mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
refactor(editor): Unify template import initialization with the new workflow init path
This commit is contained in:
parent
80731ca347
commit
e1b73a2e36
|
|
@ -6858,14 +6858,11 @@ describe('useCanvasOperations', () => {
|
|||
},
|
||||
};
|
||||
|
||||
const getNewWorkflowData = vi.mocked(workflowsApi.getNewWorkflowData);
|
||||
const setConnectionsSpy = vi.spyOn(workflowDocumentStoreInstance, 'setConnections');
|
||||
|
||||
const { importTemplate } = useCanvasOperations();
|
||||
|
||||
const templateName = 'template name';
|
||||
await importTemplate({
|
||||
name: templateName,
|
||||
workflow,
|
||||
});
|
||||
|
||||
|
|
@ -6882,11 +6879,6 @@ describe('useCanvasOperations', () => {
|
|||
disabled: false,
|
||||
});
|
||||
expect(workflowDocumentStoreInstance.setNodePristine).toHaveBeenCalledWith(nodeB.name, true);
|
||||
expect(getNewWorkflowData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
templateName,
|
||||
projectsStore.currentProjectId,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('replaceNodeParameters', () => {
|
||||
|
|
@ -7785,6 +7777,7 @@ describe('useCanvasOperations', () => {
|
|||
expect.anything(),
|
||||
'Template Name',
|
||||
'test-project-id',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User inserted workflow template', {
|
||||
|
|
@ -7850,6 +7843,7 @@ describe('useCanvasOperations', () => {
|
|||
expect.anything(),
|
||||
'Template Name',
|
||||
'test-project-id',
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(router.replace).toHaveBeenCalledWith({
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgents
|
|||
import { type PinDataSource, usePinnedData } from '@/app/composables/usePinnedData';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useNewWorkflowDocument } from '@/app/composables/useNewWorkflowDocument';
|
||||
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
|
||||
import { useWorkflowNormalization } from '@/app/composables/useWorkflowNormalization';
|
||||
import { getExecutionErrorToastConfiguration } from '@/features/execution/executions/executions.utils';
|
||||
|
|
@ -58,7 +59,6 @@ import {
|
|||
ReplaceNodeParametersCommand,
|
||||
UpdateNodeGroupCommand,
|
||||
} 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 { getAutoSelectedCredential } from '@/features/credentials/credentials.utils';
|
||||
|
|
@ -261,6 +261,7 @@ export function useCanvasOperations() {
|
|||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const workflowHelpers = useWorkflowHelpers();
|
||||
const { initializeNewWorkflowDocument } = useNewWorkflowDocument();
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
const aiSimulatedDataGuard = useAiSimulatedDataGuard();
|
||||
const {
|
||||
|
|
@ -3585,10 +3586,8 @@ export function useCanvasOperations() {
|
|||
}
|
||||
|
||||
async function importTemplate({
|
||||
name,
|
||||
workflow,
|
||||
}: {
|
||||
name?: string;
|
||||
workflow: IWorkflowTemplate['workflow'] | WorkflowDataWithTemplateId;
|
||||
}) {
|
||||
let convertedNodes = workflow.nodes?.map(workflowsStore.convertTemplateNodeToNodeUi);
|
||||
|
|
@ -3601,13 +3600,6 @@ export function useCanvasOperations() {
|
|||
workflowDocumentStore.value.setConnections(workflow.connections);
|
||||
}
|
||||
await addNodes(convertedNodes ?? [], { keepPristine: true });
|
||||
const workflowData = await workflowsApi.getNewWorkflowData(
|
||||
rootStore.restApiContext,
|
||||
name,
|
||||
projectsStore.currentProjectId,
|
||||
);
|
||||
workflowDocumentStore.value.setName(workflowData.name);
|
||||
workflowDocumentStore.value.setHydrated(true);
|
||||
}
|
||||
|
||||
async function tryToOpenSubworkflowInNewTab(nodeId: string): Promise<boolean> {
|
||||
|
|
@ -3817,7 +3809,8 @@ export function useCanvasOperations() {
|
|||
workflowsStore.setWorkflowId(route.params.workflowId);
|
||||
}
|
||||
|
||||
await importTemplate({ name: data.name, workflow: data.workflow });
|
||||
await initializeNewWorkflowDocument({ name: data.name });
|
||||
await importTemplate({ workflow: data.workflow });
|
||||
|
||||
workflowDocumentStore.value.addToMeta({ templateId: `${templateId}` });
|
||||
|
||||
|
|
@ -3870,10 +3863,8 @@ export function useCanvasOperations() {
|
|||
workflowsStore.setWorkflowId(route.params.workflowId);
|
||||
}
|
||||
|
||||
await importTemplate({
|
||||
name: workflow.name,
|
||||
workflow,
|
||||
});
|
||||
await initializeNewWorkflowDocument({ name: workflow.name, parentFolderId });
|
||||
await importTemplate({ workflow });
|
||||
|
||||
workflowDocumentStore.value.addToMeta({ templateId: `${templateId}` });
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,216 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { shallowRef, defineComponent, h } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { render } from '@testing-library/vue';
|
||||
|
||||
import { useNewWorkflowDocument } from './useNewWorkflowDocument';
|
||||
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import type { Project } from '@/features/collaboration/projects/projects.types';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
|
||||
const mockSetDocumentTitle = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/app/composables/useDocumentTitle', () => ({
|
||||
useDocumentTitle: vi.fn(() => ({
|
||||
setDocumentTitle: mockSetDocumentTitle,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockGetNewWorkflowData = vi.hoisted(() =>
|
||||
vi.fn().mockResolvedValue({ name: 'New Workflow', settings: {} }),
|
||||
);
|
||||
vi.mock('@/app/api/workflows', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/api/workflows')>()),
|
||||
getNewWorkflowData: mockGetNewWorkflowData,
|
||||
}));
|
||||
|
||||
const mockFetchParentFolder = vi.hoisted(() => vi.fn().mockResolvedValue(null));
|
||||
vi.mock('@/features/core/folders/composables/useParentFolder', () => ({
|
||||
useParentFolder: vi.fn(() => ({
|
||||
fetchParentFolder: mockFetchParentFolder,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/app/composables/useWorkflowId', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/composables/useWorkflowId')>()),
|
||||
useWorkflowId: vi.fn(() => shallowRef('wf-1')),
|
||||
}));
|
||||
|
||||
const mockRoute = vi.hoisted(() => ({
|
||||
name: 'workflow' as string,
|
||||
params: {} as Record<string, unknown>,
|
||||
query: {} as Record<string, unknown>,
|
||||
meta: {} as Record<string, unknown>,
|
||||
}));
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as object;
|
||||
return {
|
||||
...actual,
|
||||
useRoute: vi.fn(() => mockRoute),
|
||||
useRouter: vi.fn(() => ({
|
||||
replace: vi.fn().mockResolvedValue(undefined),
|
||||
push: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const mockWorkflowDocumentStore = vi.hoisted(() => ({
|
||||
workflowId: 'wf-1',
|
||||
name: 'New Workflow',
|
||||
setName: vi.fn(),
|
||||
setHomeProject: vi.fn(),
|
||||
setScopes: vi.fn(),
|
||||
setParentFolder: vi.fn(),
|
||||
setHydrated: vi.fn(),
|
||||
onNameChange: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/app/stores/workflowDocument.store')>()),
|
||||
useWorkflowDocumentStore: vi.fn(() => mockWorkflowDocumentStore),
|
||||
createWorkflowDocumentId: vi.fn((id: string) => id),
|
||||
}));
|
||||
|
||||
function renderWithComposable(
|
||||
callback: (init: ReturnType<typeof useNewWorkflowDocument>) => void,
|
||||
workflowDocumentStoreRef = shallowRef<typeof mockWorkflowDocumentStore | null>(
|
||||
mockWorkflowDocumentStore,
|
||||
),
|
||||
) {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
callback(useNewWorkflowDocument());
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
return render(TestComponent, {
|
||||
global: {
|
||||
provide: {
|
||||
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('useNewWorkflowDocument', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
vi.clearAllMocks();
|
||||
mockGetNewWorkflowData.mockResolvedValue({ name: 'New Workflow', settings: {} });
|
||||
});
|
||||
|
||||
function initialize(options?: { name?: string; parentFolderId?: string }) {
|
||||
let initializeNewWorkflowDocument!: ReturnType<
|
||||
typeof useNewWorkflowDocument
|
||||
>['initializeNewWorkflowDocument'];
|
||||
renderWithComposable((composable) => {
|
||||
initializeNewWorkflowDocument = composable.initializeNewWorkflowDocument;
|
||||
});
|
||||
return initializeNewWorkflowDocument(options);
|
||||
}
|
||||
|
||||
it('sets the name from getNewWorkflowData and passes the requested name through', async () => {
|
||||
mockGetNewWorkflowData.mockResolvedValue({ name: 'Template Name 2', settings: {} });
|
||||
|
||||
await initialize({ name: 'Template Name' });
|
||||
|
||||
expect(mockGetNewWorkflowData).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Template Name',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockWorkflowDocumentStore.setName).toHaveBeenCalledWith('Template Name 2');
|
||||
expect(mockSetDocumentTitle).toHaveBeenCalledWith('Template Name 2', 'IDLE');
|
||||
});
|
||||
|
||||
it('populates scopes and home project from the personal project', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update', 'workflow:publish'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'personal-project-id' }),
|
||||
);
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith([
|
||||
'workflow:update',
|
||||
'workflow:publish',
|
||||
]);
|
||||
expect(mockWorkflowDocumentStore.setHydrated).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('prefers the project that refreshCurrentProject resolves', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:read'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
projectsStore.refreshCurrentProject.mockImplementation(async () => {
|
||||
projectsStore.currentProject = {
|
||||
id: 'team-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
});
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'team-project-id' }),
|
||||
);
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith(['workflow:update']);
|
||||
});
|
||||
|
||||
it('falls back to the loaded projects when the project refresh fails', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
projectsStore.refreshCurrentProject.mockRejectedValue(new Error('network error'));
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith(['workflow:update']);
|
||||
});
|
||||
|
||||
it('does not populate a store that was replaced during the project refresh', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
const storeRef = shallowRef<typeof mockWorkflowDocumentStore | null>(mockWorkflowDocumentStore);
|
||||
projectsStore.refreshCurrentProject.mockImplementation(async () => {
|
||||
storeRef.value = null;
|
||||
});
|
||||
|
||||
let initializeNewWorkflowDocument!: ReturnType<
|
||||
typeof useNewWorkflowDocument
|
||||
>['initializeNewWorkflowDocument'];
|
||||
renderWithComposable((composable) => {
|
||||
initializeNewWorkflowDocument = composable.initializeNewWorkflowDocument;
|
||||
}, storeRef);
|
||||
await initializeNewWorkflowDocument();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).not.toHaveBeenCalled();
|
||||
expect(mockWorkflowDocumentStore.setScopes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the node view initialized', async () => {
|
||||
const uiStore = mockedStore(useUIStore);
|
||||
uiStore.nodeViewInitialized = false;
|
||||
|
||||
await initialize();
|
||||
|
||||
expect(uiStore.nodeViewInitialized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { hasInjectionContext, inject } from 'vue';
|
||||
import * as workflowsApi from '@/app/api/workflows';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useWorkflowId } from '@/app/composables/useWorkflowId';
|
||||
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
|
||||
import {
|
||||
useWorkflowDocumentStore,
|
||||
createWorkflowDocumentId,
|
||||
type WorkflowDocumentStore,
|
||||
} from '@/app/stores/workflowDocument.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
import { useParentFolder } from '@/features/core/folders/composables/useParentFolder';
|
||||
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
/**
|
||||
* Populates a freshly created workflow document store with everything the
|
||||
* editor derives state from: name, home project, scopes, parent folder,
|
||||
* hydration, and the name → list cache sync.
|
||||
*
|
||||
* The new-workflow path and the template import path must both run this, so
|
||||
* a side effect added here reaches both. ADO-5844 came from the template
|
||||
* path hand-copying only part of this list.
|
||||
*/
|
||||
export function useNewWorkflowDocument() {
|
||||
const documentTitle = useDocumentTitle();
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const workflowsListStore = useWorkflowsListStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const workflowId = useWorkflowId();
|
||||
const { fetchParentFolder } = useParentFolder();
|
||||
// The app-level writable ref (provided by App.vue). Layouts gate rendering
|
||||
// on it, so it must point at the store this initialization creates.
|
||||
const currentWorkflowDocumentStore = hasInjectionContext()
|
||||
? inject(WorkflowDocumentStoreKey, null)
|
||||
: null;
|
||||
|
||||
async function initializeNewWorkflowDocument({
|
||||
name,
|
||||
parentFolderId,
|
||||
}: { name?: string; parentFolderId?: string } = {}): Promise<WorkflowDocumentStore> {
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(
|
||||
createWorkflowDocumentId(workflowId.value),
|
||||
);
|
||||
if (currentWorkflowDocumentStore) {
|
||||
currentWorkflowDocumentStore.value = workflowDocumentStore;
|
||||
}
|
||||
|
||||
// Sync document store name → list cache (mirrors initializeWorkflowDocument)
|
||||
workflowDocumentStore.onNameChange(({ payload }) => {
|
||||
workflowsListStore.updateWorkflowInCache(workflowId.value, { name: payload.name });
|
||||
});
|
||||
|
||||
const workflowData = await workflowsApi.getNewWorkflowData(
|
||||
rootStore.restApiContext,
|
||||
name,
|
||||
projectsStore.currentProjectId,
|
||||
parentFolderId,
|
||||
);
|
||||
workflowDocumentStore.setName(workflowData.name);
|
||||
documentTitle.setDocumentTitle(workflowData.name, 'IDLE');
|
||||
|
||||
try {
|
||||
await projectsStore.refreshCurrentProject();
|
||||
} catch (error) {
|
||||
// A stale project is recoverable; a rejection here would strand the
|
||||
// caller's loading state.
|
||||
console.error('Failed to refresh current project for a new workflow document', { error });
|
||||
}
|
||||
|
||||
// Navigation during the async work can dispose or replace the store.
|
||||
// Only populate the store this call created.
|
||||
if (
|
||||
currentWorkflowDocumentStore &&
|
||||
currentWorkflowDocumentStore.value !== workflowDocumentStore
|
||||
) {
|
||||
return workflowDocumentStore;
|
||||
}
|
||||
|
||||
const { currentProject, personalProject } = projectsStore;
|
||||
// Must read the project after the refresh: a `?projectId=` deep link isn't fetched
|
||||
// into the store until then, so an earlier read stamps personal as the owner.
|
||||
workflowDocumentStore.setHomeProject(currentProject ?? personalProject ?? null);
|
||||
workflowDocumentStore.setScopes(currentProject?.scopes ?? personalProject?.scopes ?? []);
|
||||
|
||||
const parentFolder = await fetchParentFolder(parentFolderId);
|
||||
workflowDocumentStore.setParentFolder(parentFolder);
|
||||
workflowDocumentStore.setHydrated(true);
|
||||
|
||||
uiStore.nodeViewInitialized = true;
|
||||
|
||||
return workflowDocumentStore;
|
||||
}
|
||||
|
||||
return { initializeNewWorkflowDocument };
|
||||
}
|
||||
|
|
@ -143,9 +143,6 @@ vi.mock('vue-router', async (importOriginal) => {
|
|||
|
||||
function renderWithComposable(
|
||||
callback: (init: ReturnType<typeof useWorkflowInitialization>) => void,
|
||||
workflowDocumentStoreRef = shallowRef<typeof mockWorkflowDocumentStore | null>(
|
||||
mockWorkflowDocumentStore,
|
||||
),
|
||||
) {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
|
|
@ -158,7 +155,7 @@ function renderWithComposable(
|
|||
return render(TestComponent, {
|
||||
global: {
|
||||
provide: {
|
||||
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
|
||||
[WorkflowDocumentStoreKey as symbol]: shallowRef(mockWorkflowDocumentStore),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -262,8 +259,8 @@ describe('useWorkflowInitialization', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('template import permissions', () => {
|
||||
function importTemplate() {
|
||||
describe('template import route', () => {
|
||||
it('delegates the import to openWorkflowTemplate and reports it handled', async () => {
|
||||
mockRoute.name = VIEWS.TEMPLATE_IMPORT;
|
||||
mockRoute.params = { id: '11204' };
|
||||
|
||||
|
|
@ -271,91 +268,10 @@ describe('useWorkflowInitialization', () => {
|
|||
renderWithComposable((init) => {
|
||||
handleTemplateImportRoute = init.handleTemplateImportRoute;
|
||||
});
|
||||
return handleTemplateImportRoute();
|
||||
}
|
||||
|
||||
it('populates scopes and home project on the document store', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update', 'workflow:publish'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
|
||||
const handled = await importTemplate();
|
||||
const handled = await handleTemplateImportRoute();
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockOpenWorkflowTemplate).toHaveBeenCalledWith('11204');
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'personal-project-id' }),
|
||||
);
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith([
|
||||
'workflow:update',
|
||||
'workflow:publish',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the project that refreshCurrentProject resolves', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:read'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
projectsStore.refreshCurrentProject.mockImplementation(async () => {
|
||||
projectsStore.currentProject = {
|
||||
id: 'team-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
});
|
||||
|
||||
await importTemplate();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'team-project-id' }),
|
||||
);
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith(['workflow:update']);
|
||||
});
|
||||
|
||||
it('falls back to the loaded projects when the project refresh fails', async () => {
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
projectsStore.currentProject = null;
|
||||
projectsStore.refreshCurrentProject.mockRejectedValue(new Error('network error'));
|
||||
|
||||
const handled = await importTemplate();
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(mockWorkflowDocumentStore.setScopes).toHaveBeenCalledWith(['workflow:update']);
|
||||
});
|
||||
|
||||
it('does not stamp a store that was replaced during the project refresh', async () => {
|
||||
mockRoute.name = VIEWS.TEMPLATE_IMPORT;
|
||||
mockRoute.params = { id: '11204' };
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.personalProject = {
|
||||
id: 'personal-project-id',
|
||||
scopes: ['workflow:update'],
|
||||
} as Project;
|
||||
const storeRef = shallowRef<typeof mockWorkflowDocumentStore | null>(
|
||||
mockWorkflowDocumentStore,
|
||||
);
|
||||
projectsStore.refreshCurrentProject.mockImplementation(async () => {
|
||||
storeRef.value = null;
|
||||
});
|
||||
|
||||
let handleTemplateImportRoute!: () => Promise<boolean>;
|
||||
renderWithComposable((init) => {
|
||||
handleTemplateImportRoute = init.handleTemplateImportRoute;
|
||||
}, storeRef);
|
||||
await handleTemplateImportRoute();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHomeProject).not.toHaveBeenCalled();
|
||||
expect(mockWorkflowDocumentStore.setScopes).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import { useToast } from '@n8n/composables/useToast';
|
|||
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 { useNewWorkflowDocument } from '@/app/composables/useNewWorkflowDocument';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store';
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
|
|
@ -46,7 +44,6 @@ export function useWorkflowInitialization() {
|
|||
const documentTitle = useDocumentTitle();
|
||||
const externalHooks = useExternalHooks();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const workflowsListStore = useWorkflowsListStore();
|
||||
const uiStore = useUIStore();
|
||||
|
|
@ -72,13 +69,12 @@ export function useWorkflowInitialization() {
|
|||
openWorkflowTemplate,
|
||||
openWorkflowTemplateFromJSON,
|
||||
} = useCanvasOperations();
|
||||
const { initializeNewWorkflowDocument } = useNewWorkflowDocument();
|
||||
const { applyExecutionData } = useExecutionDebugging();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const initializedWorkflowId = ref<string | undefined>();
|
||||
|
||||
const { fetchParentFolder } = useParentFolder();
|
||||
|
||||
function disposeCurrentWorkflowDocumentStore() {
|
||||
const workflowDocumentStore = currentWorkflowDocumentStore.value;
|
||||
|
||||
|
|
@ -170,35 +166,6 @@ export function useWorkflowInitialization() {
|
|||
await openWorkflowTemplate(templateId.toString());
|
||||
}
|
||||
|
||||
// Create document store for template workflow (empty tags initially)
|
||||
// The workflow ID was set during the template import
|
||||
const currentWorkflowId = workflowId.value;
|
||||
if (currentWorkflowId) {
|
||||
const workflowDocumentId = createWorkflowDocumentId(currentWorkflowId);
|
||||
const workflowDocumentStore = useWorkflowDocumentStore(workflowDocumentId);
|
||||
currentWorkflowDocumentStore.value = workflowDocumentStore;
|
||||
documentTitle.setDocumentTitle(workflowDocumentStore.name, 'IDLE');
|
||||
|
||||
// The header derives every permission from the document store scopes.
|
||||
// Without them, the first save turns the whole header read-only
|
||||
// (Publish and Save hidden, actions menu disabled).
|
||||
try {
|
||||
await projectsStore.refreshCurrentProject();
|
||||
} catch (error) {
|
||||
// A stale project is recoverable; a rejection here would leave the
|
||||
// route handler's caller with isLoading stuck on the loading view.
|
||||
console.error('Failed to refresh current project during template import', { error });
|
||||
}
|
||||
|
||||
// Navigation during the refresh can dispose or replace the store.
|
||||
// Only stamp the store this import created.
|
||||
if (currentWorkflowDocumentStore.value === workflowDocumentStore) {
|
||||
const { currentProject, personalProject } = projectsStore;
|
||||
workflowDocumentStore.setHomeProject(currentProject ?? personalProject ?? null);
|
||||
workflowDocumentStore.setScopes(currentProject?.scopes ?? personalProject?.scopes ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -327,38 +294,8 @@ export function useWorkflowInitialization() {
|
|||
|
||||
workflowsStore.setWorkflowId(workflowId.value);
|
||||
|
||||
const workflowDocumentId = createWorkflowDocumentId(workflowId.value);
|
||||
currentWorkflowDocumentStore.value = useWorkflowDocumentStore(workflowDocumentId);
|
||||
await initializeNewWorkflowDocument({ parentFolderId });
|
||||
|
||||
// Sync document store name → list cache (mirrors initializeWorkflowDocument)
|
||||
currentWorkflowDocumentStore.value.onNameChange(({ payload }) => {
|
||||
workflowsListStore.updateWorkflowInCache(workflowId.value, { name: payload.name });
|
||||
});
|
||||
|
||||
const workflowData = await workflowsApi.getNewWorkflowData(
|
||||
rootStore.restApiContext,
|
||||
undefined,
|
||||
projectsStore.currentProjectId,
|
||||
parentFolderId,
|
||||
);
|
||||
currentWorkflowDocumentStore.value.setName(workflowData.name);
|
||||
documentTitle.setDocumentTitle(workflowData.name, 'IDLE');
|
||||
|
||||
await projectsStore.refreshCurrentProject();
|
||||
|
||||
const { currentProject, personalProject } = projectsStore;
|
||||
// Must read the project after the refresh: a `?projectId=` deep link isn't fetched
|
||||
// into the store until then, so an earlier read stamps personal as the owner.
|
||||
currentWorkflowDocumentStore.value.setHomeProject(currentProject ?? personalProject ?? null);
|
||||
currentWorkflowDocumentStore.value.setScopes(
|
||||
currentProject?.scopes ?? personalProject?.scopes ?? [],
|
||||
);
|
||||
|
||||
const parentFolder = await fetchParentFolder(parentFolderId);
|
||||
currentWorkflowDocumentStore.value?.setParentFolder(parentFolder);
|
||||
currentWorkflowDocumentStore.value.setHydrated(true);
|
||||
|
||||
uiStore.nodeViewInitialized = true;
|
||||
initializedWorkflowId.value = workflowId.value;
|
||||
|
||||
fitView();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user