refactor(editor): Migrate getNewWorkflowData to the workflows API (#31556)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Grozav 2026-06-02 16:12:48 +03:00 committed by GitHub
parent 700b1cd227
commit a724624b1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 156 additions and 71 deletions

View File

@ -1,4 +1,5 @@
import { getLastSuccessfulExecution } from './workflows';
import { getLastSuccessfulExecution, getNewWorkflowData } from './workflows';
import { DEFAULT_NEW_WORKFLOW_NAME, DEFAULT_SETTINGS } from '@/app/constants/workflows';
import * as apiUtils from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { vi, describe, it, beforeEach, afterEach, expect } from 'vitest';
@ -63,4 +64,75 @@ describe('API: workflows', () => {
expect(result).toBeNull();
});
});
describe('getNewWorkflowData', () => {
let mockContext: IRestApiContext;
let makeRestApiRequestSpy: MockInstance;
beforeEach(() => {
mockContext = {
baseUrl: 'http://test-base-url',
sessionId: 'test-session',
pushRef: 'test-ref',
} as IRestApiContext;
makeRestApiRequestSpy = vi.spyOn(apiUtils, 'makeRestApiRequest');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should request new workflow data and map the response', async () => {
const serverSettings = { executionOrder: 'v1' };
makeRestApiRequestSpy.mockResolvedValue({
name: 'Server Name',
defaultSettings: serverSettings,
});
const result = await getNewWorkflowData(mockContext, 'My Name', 'project-1', 'folder-1');
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(mockContext, 'GET', '/workflows/new', {
name: 'My Name',
projectId: 'project-1',
parentFolderId: 'folder-1',
});
expect(result).toEqual({ name: 'Server Name', settings: serverSettings });
});
it('should send no payload when all parameters are empty', async () => {
makeRestApiRequestSpy.mockResolvedValue({
name: 'Server Name',
defaultSettings: {},
});
await getNewWorkflowData(mockContext);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
mockContext,
'GET',
'/workflows/new',
undefined,
);
});
it('should fall back to the provided name and default settings on error', async () => {
makeRestApiRequestSpy.mockRejectedValue(new Error('request failed'));
const result = await getNewWorkflowData(mockContext, 'My Name');
expect(result).toEqual({ name: 'My Name', settings: { ...DEFAULT_SETTINGS } });
});
it('should fall back to the default workflow name when none is provided and the request fails', async () => {
makeRestApiRequestSpy.mockRejectedValue(new Error('request failed'));
const result = await getNewWorkflowData(mockContext);
expect(result).toEqual({
name: DEFAULT_NEW_WORKFLOW_NAME,
settings: { ...DEFAULT_SETTINGS },
});
});
});
});

View File

@ -1,4 +1,5 @@
import type {
INewWorkflowData,
IWorkflowDb,
NewWorkflowResponse,
WorkflowListResource,
@ -9,6 +10,8 @@ import type {
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
} from '@/features/execution/executions/executions.types';
import { DEFAULT_NEW_WORKFLOW_NAME, DEFAULT_SETTINGS } from '@/app/constants';
import { isEmpty } from '@/app/utils/typesUtils';
import type { ExecutionRedactionQueryDto } from '@n8n/api-types';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type {
@ -16,6 +19,7 @@ import type {
ExecutionOptions,
ExecutionSummary,
IDataObject,
IWorkflowSettings,
} from 'n8n-workflow';
import { getFullApiResponse, makeRestApiRequest } from '@n8n/rest-api-client';
@ -32,6 +36,32 @@ export async function getNewWorkflow(context: IRestApiContext, data?: IDataObjec
};
}
export async function getNewWorkflowData(
context: IRestApiContext,
name?: string,
projectId?: string,
parentFolderId?: string,
): Promise<INewWorkflowData> {
let workflowData: { name: string; settings: IWorkflowSettings } = {
name: '',
settings: { ...DEFAULT_SETTINGS },
};
try {
const data: IDataObject = {
name,
projectId,
parentFolderId,
};
workflowData = await getNewWorkflow(context, isEmpty(data) ? undefined : data);
} catch (e) {
// in case of error, default to original name
workflowData.name = name || DEFAULT_NEW_WORKFLOW_NAME;
}
return workflowData;
}
export async function getWorkflow(context: IRestApiContext, id: string) {
return await makeRestApiRequest<IWorkflowDb>(context, 'GET', `/workflows/${id}`);
}

View File

@ -60,6 +60,7 @@ import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/
import { useTelemetry } from './useTelemetry';
import { useToast } from '@/app/composables/useToast';
import * as nodeHelpers from '@/app/composables/useNodeHelpers';
import * as workflowsApi from '@/app/api/workflows';
import {
injectWorkflowState,
useWorkflowState,
@ -93,6 +94,7 @@ vi.mock('@/app/api/workflows', async (importOriginal) => {
return {
...actual,
getNewWorkflow: vi.fn().mockResolvedValue({ name: 'New Workflow', settings: {} }),
getNewWorkflowData: vi.fn().mockResolvedValue({ name: 'New Workflow', settings: {} }),
};
});
@ -5435,7 +5437,7 @@ describe('useCanvasOperations', () => {
},
};
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const getNewWorkflowData = vi.mocked(workflowsApi.getNewWorkflowData);
const setConnectionsSpy = vi.spyOn(workflowDocumentStoreInstance, 'setConnections');
const { importTemplate } = useCanvasOperations();
@ -5459,7 +5461,11 @@ describe('useCanvasOperations', () => {
disabled: false,
});
expect(workflowDocumentStoreInstance.setNodePristine).toHaveBeenCalledWith(nodeB.name, true);
expect(getNewWorkflowData).toHaveBeenCalledWith(templateName, projectsStore.currentProjectId);
expect(getNewWorkflowData).toHaveBeenCalledWith(
expect.anything(),
templateName,
projectsStore.currentProjectId,
);
});
});
describe('replaceNodeParameters', () => {
@ -6134,13 +6140,17 @@ describe('useCanvasOperations', () => {
workflow: { nodes: [], connections: {} },
});
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const getNewWorkflowData = vi.mocked(workflowsApi.getNewWorkflowData);
const { openWorkflowTemplate } = useCanvasOperations();
await openWorkflowTemplate('template-id');
expect(templatesStore.getFixedWorkflowTemplate).toHaveBeenCalledWith('template-id');
expect(getNewWorkflowData).toHaveBeenCalledWith('Template Name', 'test-project-id');
expect(getNewWorkflowData).toHaveBeenCalledWith(
expect.anything(),
'Template Name',
'test-project-id',
);
expect(telemetry.track).toHaveBeenCalledWith('User inserted workflow template', {
source: 'workflow',
@ -6196,12 +6206,16 @@ describe('useCanvasOperations', () => {
meta: { templateId: 'template-id' },
};
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const getNewWorkflowData = vi.mocked(workflowsApi.getNewWorkflowData);
const { openWorkflowTemplateFromJSON } = useCanvasOperations();
await openWorkflowTemplateFromJSON(template);
expect(getNewWorkflowData).toHaveBeenCalledWith('Template Name', 'test-project-id');
expect(getNewWorkflowData).toHaveBeenCalledWith(
expect.anything(),
'Template Name',
'test-project-id',
);
expect(router.replace).toHaveBeenCalledWith({
name: VIEWS.NEW_WORKFLOW,

View File

@ -44,6 +44,7 @@ import {
RenameNodeCommand,
ReplaceNodeParametersCommand,
} from '@/app/models/history';
import * as workflowsApi from '@/app/api/workflows';
import { useCanvasStore } from '@/app/stores/canvas.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useExecutionsStore } from '@/features/execution/executions/executions.store';
@ -3145,7 +3146,8 @@ export function useCanvasOperations() {
workflowDocumentStore.value.setConnections(workflow.connections);
}
await addNodes(convertedNodes ?? [], { keepPristine: true });
const workflowData = await workflowState.getNewWorkflowData(
const workflowData = await workflowsApi.getNewWorkflowData(
rootStore.restApiContext,
name,
projectsStore.currentProjectId,
);

View File

@ -26,7 +26,7 @@ import {
import { ref } from 'vue';
import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { DATA_TYPE_ICON_MAP } from '@/app/constants';
import { DEFAULT_SETTINGS } from '../stores/workflowDocument/useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
export function useDataSchema() {
const workflowDocumentStore = injectWorkflowDocumentStore();

View File

@ -42,6 +42,11 @@ vi.mock('@/app/composables/useCanvasOperations', () => ({
})),
}));
vi.mock('@/app/api/workflows', async (importOriginal) => ({
...(await importOriginal<typeof import('@/app/api/workflows')>()),
getNewWorkflowData: vi.fn().mockResolvedValue({ name: 'New Workflow', settings: {} }),
}));
vi.mock('@/features/execution/executions/composables/useExecutionDebugging', () => ({
useExecutionDebugging: vi.fn(() => ({
applyExecutionData: vi.fn(),
@ -129,18 +134,12 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
function createWorkflowState(): WorkflowState {
return {
getNewWorkflowData: vi.fn().mockResolvedValue({ name: 'New Workflow', settings: {} }),
} as unknown as WorkflowState;
}
function renderWithComposable(
callback: (init: ReturnType<typeof useWorkflowInitialization>) => void,
) {
const TestComponent = defineComponent({
setup() {
const init = useWorkflowInitialization(createWorkflowState());
const init = useWorkflowInitialization({} as unknown as WorkflowState);
callback(init);
return () => h('div');
},

View File

@ -7,6 +7,8 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useParentFolder } from '@/features/core/folders/composables/useParentFolder';
import * as workflowsApi from '@/app/api/workflows';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
@ -44,6 +46,7 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
const documentTitle = useDocumentTitle();
const externalHooks = useExternalHooks();
const rootStore = useRootStore();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const uiStore = useUIStore();
@ -308,7 +311,8 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
workflowsListStore.updateWorkflowInCache(workflowId.value, { name: payload.name });
});
const workflowData = await workflowState.getNewWorkflowData(
const workflowData = await workflowsApi.getNewWorkflowData(
rootStore.restApiContext,
undefined,
projectsStore.currentProjectId,
parentFolderId,

View File

@ -1,10 +1,8 @@
import * as workflowsApi from '@/app/api/workflows';
import { DEFAULT_NEW_WORKFLOW_NAME, WorkflowStateKey } from '@/app/constants';
import { WorkflowStateKey } from '@/app/constants';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { DEFAULT_SETTINGS } from '@/app/stores/workflowDocument/useWorkflowDocumentSettings';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowStateStore } from '@/app/stores/workflowState.store';
import {
@ -12,16 +10,12 @@ import {
useWorkflowExecutionStateStore,
} from '@/app/stores/workflowExecutionState.store';
import { createExecutionDataId, useExecutionDataStore } from '@/app/stores/executionData.store';
import { isEmpty } from '@/app/utils/typesUtils';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import type {
IExecutionResponse,
IExecutionsStopData,
} from '@/features/execution/executions/executions.types';
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
import type { INewWorkflowData } from '@/Interface';
import { useRootStore } from '@n8n/stores/useRootStore';
import { type IDataObject, type IWorkflowSettings } from 'n8n-workflow';
import { inject } from 'vue';
import { useDocumentTitle } from './useDocumentTitle';
import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
@ -29,7 +23,6 @@ import { IN_PROGRESS_EXECUTION_ID } from '@/app/constants/placeholders';
export function useWorkflowState() {
const ws = useWorkflowsStore();
const workflowStateStore = useWorkflowStateStore();
const rootStore = useRootStore();
////
// Workflow editing state
@ -67,34 +60,6 @@ export function useWorkflowState() {
);
}
async function getNewWorkflowData(
name?: string,
projectId?: string,
parentFolderId?: string,
): Promise<INewWorkflowData> {
let workflowData: { name: string; settings: IWorkflowSettings } = {
name: '',
settings: { ...DEFAULT_SETTINGS },
};
try {
const data: IDataObject = {
name,
projectId,
parentFolderId,
};
workflowData = await workflowsApi.getNewWorkflow(
rootStore.restApiContext,
isEmpty(data) ? undefined : data,
);
} catch (e) {
// in case of error, default to original name
workflowData.name = name || DEFAULT_NEW_WORKFLOW_NAME;
}
return workflowData;
}
////
// Execution
////
@ -171,7 +136,6 @@ export function useWorkflowState() {
resetState,
setWorkflowExecutionData,
setActiveExecutionId,
getNewWorkflowData,
// Execution
markExecutionAsStopped,

View File

@ -1,5 +1,12 @@
import { BINARY_MODE_SEPARATE } from 'n8n-workflow';
import type { IWorkflowSettings } from 'n8n-workflow';
export const DEFAULT_NODETYPE_VERSION = 1;
export const DEFAULT_NEW_WORKFLOW_NAME = 'My workflow';
export const DEFAULT_SETTINGS = {
executionOrder: 'v1',
binaryMode: BINARY_MODE_SEPARATE,
} satisfies IWorkflowSettings;
export const MIN_WORKFLOW_NAME_LENGTH = 1;
export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';

View File

@ -35,7 +35,6 @@ vi.mock('@/app/composables/useWorkflowState', async (importOriginal) => {
return {
...actual,
useWorkflowState: vi.fn(() => ({
getNewWorkflowDataAndMakeShareable: vi.fn(),
setActiveExecutionId: vi.fn(),
resetState: vi.fn(),
})),

View File

@ -35,7 +35,6 @@ vi.mock('@/features/ai/assistant/assistant.store', () => ({
vi.mock('@/app/composables/useWorkflowState', () => {
const mockState = () => ({
getNewWorkflowData: vi.fn(),
resetState: vi.fn(),
});
return {

View File

@ -16,7 +16,7 @@ import {
createWorkflowDocumentId,
disposeWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { DEFAULT_SETTINGS } from '@/app/stores/workflowDocument/useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
import { useUIStore } from '@/app/stores/ui.store';
import { createTestNode } from '@/__tests__/mocks';
import type { INodeUi, IWorkflowDb } from '@/Interface';

View File

@ -10,10 +10,8 @@ import { useWorkflowDocumentDescription } from './workflowDocument/useWorkflowDo
import { useWorkflowDocumentMeta } from './workflowDocument/useWorkflowDocumentMeta';
import { useWorkflowDocumentPinData } from './workflowDocument/useWorkflowDocumentPinData';
import { useWorkflowDocumentScopes } from './workflowDocument/useWorkflowDocumentScopes';
import {
useWorkflowDocumentSettings,
DEFAULT_SETTINGS,
} from './workflowDocument/useWorkflowDocumentSettings';
import { useWorkflowDocumentSettings } from './workflowDocument/useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
import { useWorkflowDocumentTags } from './workflowDocument/useWorkflowDocumentTags';
import { useWorkflowDocumentIsArchived } from './workflowDocument/useWorkflowDocumentIsArchived';
import { useWorkflowDocumentTimestamps } from './workflowDocument/useWorkflowDocumentTimestamps';

View File

@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { useWorkflowDocumentSettings, DEFAULT_SETTINGS } from './useWorkflowDocumentSettings';
import { useWorkflowDocumentSettings } from './useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
vi.mock('../workflows.store', () => ({
useWorkflowsStore: vi.fn(() => ({

View File

@ -1,7 +1,8 @@
import { ref, readonly } from 'vue';
import { createEventHook } from '@vueuse/core';
import { BINARY_MODE_SEPARATE, deepCopy } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import type { IWorkflowSettings } from 'n8n-workflow';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
import { CHANGE_ACTION } from './types';
import type { ChangeAction, ChangeEvent } from './types';
@ -11,11 +12,6 @@ export type SettingsPayload = {
export type SettingsChangeEvent = ChangeEvent<SettingsPayload>;
export const DEFAULT_SETTINGS = {
executionOrder: 'v1',
binaryMode: BINARY_MODE_SEPARATE,
} satisfies IWorkflowSettings;
export interface WorkflowDocumentSettingsDeps {
syncWorkflowObject: (settings: IWorkflowSettings) => void;
}

View File

@ -6,7 +6,7 @@ import {
useWorkflowDocumentWorkflowObject,
type WorkflowDocumentWorkflowObjectDeps,
} from './useWorkflowDocumentWorkflowObject';
import { DEFAULT_SETTINGS } from './useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';

View File

@ -2,7 +2,7 @@ import type { INodeUi } from '@/Interface';
import type { IConnections, IPinData, IWorkflowSettings } from 'n8n-workflow';
import { Workflow, deepCopy } from 'n8n-workflow';
import { ref, type Ref } from 'vue';
import { DEFAULT_SETTINGS } from './useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
import { useNodeTypesStore } from '../nodeTypes.store';
export interface WorkflowDocumentWorkflowObjectDeps {

View File

@ -16,7 +16,7 @@ import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useInstallNode } from './useInstallNode';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { DEFAULT_SETTINGS } from '@/app/stores/workflowDocument/useWorkflowDocumentSettings';
import { DEFAULT_SETTINGS } from '@/app/constants/workflows';
vi.mock('@/app/composables/useCanvasOperations', () => ({
useCanvasOperations: vi.fn().mockReturnValue({