refactor(editor): Migrate whole workflow object consumers (#29395)

This commit is contained in:
Suguru Inoue
2026-05-01 10:04:55 +00:00
committed by GitHub
parent e17b6864be
commit bdf06fa8dd
50 changed files with 476 additions and 576 deletions
@@ -14,7 +14,6 @@ import { useExecutionsStore } from '@/features/execution/executions/executions.s
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { computed, inject, onBeforeMount, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { RouteLocation, RouteLocationRaw } from 'vue-router';
@@ -35,7 +34,6 @@ const pushConnection = usePushConnection({ router });
const toast = useToast();
const ndvStore = useNDVStore();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore();
@@ -71,7 +69,6 @@ const activeNode = computed(() => ndvStore.activeNode);
const hideMenuBar = computed(() =>
Boolean(activeNode.value && activeNode.value.type !== STICKY_NODE_TYPE),
);
const workflow = computed(() => workflowsStore.workflow);
const workflowId = useInjectWorkflowId();
const workflowDocumentStore = inject(WorkflowDocumentStoreKey, null);
const workflowName = computed(() => workflowDocumentStore?.value?.name ?? '');
@@ -265,7 +262,7 @@ async function onWorkflowDeactivated() {
) {
try {
// Fetch the updated workflow to get the latest settings after backend processing
const updatedWorkflow = await workflowsListStore.fetchWorkflow(workflow.value.id);
const updatedWorkflow = await workflowsListStore.fetchWorkflow(workflowId.value);
workflowDocumentStore?.value?.hydrate(updatedWorkflow);
toast.showToast({
title: locale.baseText('mcp.workflowDeactivated.title'),
@@ -291,7 +288,7 @@ async function onWorkflowDeactivated() {
<div v-show="!hideMenuBar && !settingsStore.isCanvasOnly" :class="$style['top-menu']">
<WorkflowDetails
v-if="workflowName"
:id="workflow.id"
:id="workflowId"
:tags="workflowTags"
:name="workflowName"
:current-folder="parentFolderForBreadcrumbs"
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
</span>
<ConnectionTracker class="actions">
<WorkflowProductionChecklist v-if="!isNewWorkflow" :workflow="workflowsStore.workflow" />
<WorkflowProductionChecklist v-if="!isNewWorkflow" />
<WorkflowHeaderDraftPublishActions
:id="id"
ref="workflowHeaderActions"
@@ -161,6 +161,7 @@ describe('WorkflowProductionChecklist', () => {
createWorkflowDocumentId(mockWorkflow.id),
);
workflowDocumentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
workflowDocumentStore.setSettings(mockWorkflow.settings ?? { executionOrder: 'v1' });
workflowDocumentStoreRef.value = workflowDocumentStore;
router = {
@@ -203,16 +204,7 @@ describe('WorkflowProductionChecklist', () => {
activeVersion: null,
});
const { container } = renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
});
const { container } = renderComponent({ pinia: createTestingPinia() });
expect(
container.querySelector('[data-test-id="n8n-suggested-actions-stub"]'),
@@ -222,12 +214,7 @@ describe('WorkflowProductionChecklist', () => {
it('should not render when cache is loading', () => {
workflowsCache.isCacheLoading.value = true;
const { container } = renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
const { container } = renderComponent({ pinia: createTestingPinia() });
expect(
container.querySelector('[data-test-id="n8n-suggested-actions-stub"]'),
@@ -244,15 +231,11 @@ describe('WorkflowProductionChecklist', () => {
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [createTestNode({ type: 'ai-node', typeVersion: 1 })],
},
},
pinia,
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: 'ai-node', typeVersion: 1 }),
]);
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -292,15 +275,11 @@ describe('WorkflowProductionChecklist', () => {
.fn()
.mockReturnValue(mockNonAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [createTestNode({ type: 'regular-node', typeVersion: 1 })],
},
},
pinia,
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: 'regular-node', typeVersion: 1 }),
]);
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -323,12 +302,7 @@ describe('WorkflowProductionChecklist', () => {
});
it('should show error workflow action and time saved when not ignored', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -359,12 +333,7 @@ describe('WorkflowProductionChecklist', () => {
},
});
const { container } = renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
const { container } = renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(
@@ -374,15 +343,11 @@ describe('WorkflowProductionChecklist', () => {
});
it('should not show error workflow action when workflow contains an enabled Error Trigger node', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [createTestNode({ type: ERROR_TRIGGER_NODE_TYPE, typeVersion: 1 })],
},
},
pinia: createTestingPinia(),
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: ERROR_TRIGGER_NODE_TYPE, typeVersion: 1 }),
]);
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -398,17 +363,11 @@ describe('WorkflowProductionChecklist', () => {
});
it('should show error workflow action when workflow contains a disabled Error Trigger node', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [
createTestNode({ type: ERROR_TRIGGER_NODE_TYPE, typeVersion: 1, disabled: true }),
],
},
},
pinia: createTestingPinia(),
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: ERROR_TRIGGER_NODE_TYPE, typeVersion: 1, disabled: true }),
]);
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -441,15 +400,11 @@ describe('WorkflowProductionChecklist', () => {
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [createTestNode({ type: 'ai-node', typeVersion: 1 })],
},
},
pinia,
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: 'ai-node', typeVersion: 1 }),
]);
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -470,12 +425,7 @@ describe('WorkflowProductionChecklist', () => {
uiStore = useUIStore(pinia);
const openModalSpy = vi.spyOn(uiStore, 'openModal');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -493,12 +443,7 @@ describe('WorkflowProductionChecklist', () => {
uiStore = useUIStore(pinia);
const openModalSpy = vi.spyOn(uiStore, 'openModal');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -512,12 +457,7 @@ describe('WorkflowProductionChecklist', () => {
});
it('should ignore specific action when ignore is clicked', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -537,12 +477,7 @@ describe('WorkflowProductionChecklist', () => {
});
it('should ignore all actions after confirmation', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -568,12 +503,7 @@ describe('WorkflowProductionChecklist', () => {
it('should not ignore all actions if confirmation is cancelled', async () => {
message.confirm = vi.fn().mockResolvedValue('cancel');
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -593,12 +523,7 @@ describe('WorkflowProductionChecklist', () => {
describe('Popover behavior', () => {
it('should track when popover is opened via update:open event', async () => {
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -622,16 +547,7 @@ describe('WorkflowProductionChecklist', () => {
activeVersion: null,
});
renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
workflowDocumentStoreRef.value?.setActiveState({
activeVersionId: 'v1',
@@ -658,16 +574,7 @@ describe('WorkflowProductionChecklist', () => {
activeVersion: null,
});
renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await flushPromises();
@@ -703,16 +610,7 @@ describe('WorkflowProductionChecklist', () => {
activeVersion: null,
});
renderComponent({
props: {
workflow: {
...mockWorkflow,
active: false,
activeVersionId: null,
},
},
pinia,
});
renderComponent({ pinia });
await flushPromises();
@@ -740,12 +638,7 @@ describe('WorkflowProductionChecklist', () => {
writable: true,
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -768,15 +661,11 @@ describe('WorkflowProductionChecklist', () => {
// @ts-expect-error - mocking readonly property
nodeTypesStore.getNodeType = vi.fn().mockReturnValue(mockAINodeType as INodeTypeDescription);
renderComponent({
props: {
workflow: {
...mockWorkflow,
nodes: [createTestNode({ type: 'ai-node', typeVersion: 1 })],
},
},
pinia,
});
workflowDocumentStoreRef.value?.setNodes([
createTestNode({ type: 'ai-node', typeVersion: 1 }),
]);
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -806,19 +695,13 @@ describe('WorkflowProductionChecklist', () => {
});
it('should mark error workflow as completed when error workflow is set', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
errorWorkflow: 'error-workflow-id',
},
},
},
pinia: createTestingPinia(),
workflowDocumentStoreRef.value?.setSettings({
executionOrder: 'v1',
errorWorkflow: 'error-workflow-id',
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
@@ -840,19 +723,13 @@ describe('WorkflowProductionChecklist', () => {
});
it('should mark time saved as completed when time saved is set', async () => {
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
timeSavedPerExecution: 10,
},
},
},
pinia: createTestingPinia(),
workflowDocumentStoreRef.value?.setSettings({
executionOrder: 'v1',
timeSavedPerExecution: 10,
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
{
@@ -885,17 +762,7 @@ describe('WorkflowProductionChecklist', () => {
});
workflowDocumentStoreRef.value = workflowDocumentStore;
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
},
},
},
pinia: createTestingPinia(),
});
renderComponent({ pinia: createTestingPinia() });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -924,12 +791,7 @@ describe('WorkflowProductionChecklist', () => {
settingsStore = useSettingsStore(pinia);
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(false);
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toEqual([
@@ -962,12 +824,7 @@ describe('WorkflowProductionChecklist', () => {
});
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true);
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toContainEqual({
@@ -992,12 +849,7 @@ describe('WorkflowProductionChecklist', () => {
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(false);
vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(false);
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
const actions = mockN8nSuggestedActionsProps.actions;
@@ -1023,12 +875,7 @@ describe('WorkflowProductionChecklist', () => {
},
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
const actions = mockN8nSuggestedActionsProps.actions;
@@ -1046,12 +893,7 @@ describe('WorkflowProductionChecklist', () => {
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toContainEqual({
@@ -1073,19 +915,13 @@ describe('WorkflowProductionChecklist', () => {
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
});
renderComponent({
props: {
workflow: {
...mockWorkflow,
settings: {
executionOrder: 'v1',
availableInMCP: true,
},
},
},
pinia,
workflowDocumentStoreRef.value?.setSettings({
executionOrder: 'v1',
availableInMCP: true,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toContainEqual({
id: 'workflow-mcp-access',
@@ -1112,12 +948,7 @@ describe('WorkflowProductionChecklist', () => {
},
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
const actions = mockN8nSuggestedActionsProps.actions;
@@ -1137,12 +968,7 @@ describe('WorkflowProductionChecklist', () => {
});
vi.spyOn(usersStore, 'isAdmin', 'get').mockReturnValue(true);
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -1166,12 +992,7 @@ describe('WorkflowProductionChecklist', () => {
mcp: { mcpAccessEnabled: true, mcpManagedByEnv: false },
});
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -1194,12 +1015,7 @@ describe('WorkflowProductionChecklist', () => {
branchReadOnly: true,
} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -1217,12 +1033,7 @@ describe('WorkflowProductionChecklist', () => {
branchReadOnly: false,
} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -1236,12 +1047,7 @@ describe('WorkflowProductionChecklist', () => {
sourceControlStore.preferences = {} as SourceControlPreferences;
renderComponent({
props: {
workflow: mockWorkflow,
},
pinia,
});
renderComponent({ pinia });
await vi.waitFor(() => {
expect(mockN8nSuggestedActionsProps.actions).toBeDefined();
@@ -7,7 +7,6 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ActionType, WorkflowSettings } from '@/app/composables/useWorkflowsCache';
import { useWorkflowSettingsCache } from '@/app/composables/useWorkflowsCache';
import { useUIStore } from '@/app/stores/ui.store';
import type { IWorkflowDb } from '@/Interface';
import {
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
@@ -29,10 +28,6 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
const props = defineProps<{
workflow: IWorkflowDb;
}>();
const i18n = useI18n();
const router = useRouter();
const evaluationStore = useEvaluationStore();
@@ -49,9 +44,9 @@ const workflowDocumentStore = inject(WorkflowDocumentStoreKey, null);
const isPopoverOpen = ref(false);
const cachedSettings = ref<WorkflowSettings | null>(null);
const nodes = computed(() => workflowDocumentStore?.value?.allNodes ?? []);
const hasAINode = computed(() => {
const nodes = props.workflow.nodes;
return nodes.some((node) => {
return nodes.value.some((node) => {
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
return nodeType?.codex?.categories?.includes('AI');
});
@@ -62,28 +57,22 @@ const hasEvaluationSetOutputsNode = computed((): boolean => {
});
const hasErrorWorkflow = computed(() => {
const errorWorkflow =
workflowDocumentStore?.value?.settings?.errorWorkflow ?? props.workflow.settings?.errorWorkflow;
const errorWorkflow = workflowDocumentStore?.value?.settings?.errorWorkflow;
return !!errorWorkflow;
});
const isErrorWorkflow = computed(() => {
return props.workflow.nodes.some(
return nodes.value.some(
(node) => node.type === ERROR_TRIGGER_NODE_TYPE && node.disabled !== true,
);
});
const hasSavedTimeNodes = computed(() => {
if (!props.workflow?.nodes) return false;
return props.workflow.nodes.some(
(node) => node.type === TIME_SAVED_NODE_TYPE && node.disabled !== true,
);
return nodes.value.some((node) => node.type === TIME_SAVED_NODE_TYPE && node.disabled !== true);
});
const hasTimeSaved = computed(() => {
const timeSavedPerExecution =
workflowDocumentStore?.value?.settings?.timeSavedPerExecution ??
props.workflow.settings?.timeSavedPerExecution;
const timeSavedPerExecution = workflowDocumentStore?.value?.settings?.timeSavedPerExecution;
return timeSavedPerExecution !== undefined || hasSavedTimeNodes.value;
});
@@ -214,18 +203,17 @@ const availableActions = computed(() => {
...baseAction,
id: 'workflow-mcp-access',
description: i18n.baseText('mcp.productionChecklist.workflow.description'),
completed:
workflowDocumentStore?.value?.settings?.availableInMCP ??
props.workflow.settings?.availableInMCP ??
false,
completed: workflowDocumentStore?.value?.settings?.availableInMCP ?? false,
};
}
});
async function loadWorkflowSettings() {
if (props.workflow.id) {
if (workflowDocumentStore?.value?.workflowId) {
// todo add global config
cachedSettings.value = await workflowsCache.getMergedWorkflowSettings(props.workflow.id);
cachedSettings.value = await workflowsCache.getMergedWorkflowSettings(
workflowDocumentStore?.value.workflowId,
);
}
}
@@ -235,7 +223,7 @@ async function handleActionClick(actionId: string) {
// Navigate to evaluations
await router.push({
name: VIEWS.EVALUATION_EDIT,
params: { workflowId: props.workflow.id },
params: { workflowId: workflowDocumentStore?.value?.workflowId },
});
break;
case 'errorWorkflow':
@@ -269,7 +257,10 @@ async function handleIgnoreClick(actionId: string) {
return;
}
await workflowsCache.ignoreSuggestedAction(props.workflow.id, actionId);
await workflowsCache.ignoreSuggestedAction(
workflowDocumentStore?.value?.workflowId ?? '',
actionId,
);
await loadWorkflowSettings();
telemetry.track('user clicked ignore suggested action', {
@@ -326,7 +317,7 @@ watch(
}
// Update firstActivatedAt after opening popover
await workflowsCache.updateFirstActivatedAt(props.workflow.id);
await workflowsCache.updateFirstActivatedAt(workflowDocumentStore?.value?.workflowId ?? '');
}
},
);
@@ -17,6 +17,18 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsEEStore } from '@/app/stores/workflows.ee.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useRolesStore } from '@/app/stores/roles.store';
import type { ProjectSharingData } from '@/features/collaboration/projects/projects.types';
const mockWorkflowDocumentState = reactive({
homeProject: null as ProjectSharingData | null,
scopes: [] as string[],
sharedWithProjects: [] as ProjectSharingData[],
name: '',
});
vi.mock('@/app/stores/workflowDocument.store', () => ({
useWorkflowDocumentStore: () => mockWorkflowDocumentState,
createWorkflowDocumentId: (id: string) => `${id}@latest`,
}));
const mockRouteQuery = reactive<Record<string, string>>({});
vi.mock('vue-router', async (importOriginal) => {
@@ -73,7 +85,6 @@ let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let workflowsEEStore: MockedStore<typeof useWorkflowsEEStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
let rolesStore: MockedStore<typeof useRolesStore>;
describe('WorkflowShareModal.ee.vue', () => {
beforeEach(() => {
settingsStore = mockedStore(useSettingsStore);
@@ -84,6 +95,10 @@ describe('WorkflowShareModal.ee.vue', () => {
// Reset route query
Object.keys(mockRouteQuery).forEach((key) => delete mockRouteQuery[key]);
mockWorkflowDocumentState.homeProject = null;
mockWorkflowDocumentState.sharedWithProjects = [];
mockWorkflowDocumentState.scopes = [];
mockWorkflowDocumentState.name = '';
// Set up default store state
settingsStore.settings.enterprise = { sharing: true } as FrontendSettings['enterprise'];
@@ -121,6 +136,15 @@ describe('WorkflowShareModal.ee.vue', () => {
// Set route query to indicate new workflow
mockRouteQuery.new = 'true';
const homeProject: ProjectSharingData = {
id: 'personal-project-id',
name: 'Personal Project',
type: ProjectTypes.Personal,
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
workflowsStore.workflow = {
id: '',
name: 'My workflow',
@@ -133,8 +157,11 @@ describe('WorkflowShareModal.ee.vue', () => {
scopes: [],
nodes: [],
connections: {},
homeProject,
};
mockWorkflowDocumentState.homeProject = homeProject;
const saveWorkflowSharedWithSpy = vi.spyOn(workflowsEEStore, 'saveWorkflowSharedWith');
const props = {
@@ -174,6 +201,15 @@ describe('WorkflowShareModal.ee.vue', () => {
type: ProjectTypes.Personal,
});
const homeProject: ProjectSharingData = {
id: 'personal-project-id',
name: 'Personal Project',
type: ProjectTypes.Personal,
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
workflowsStore.workflow = {
id: 'workflow-1',
name: 'My workflow',
@@ -186,16 +222,11 @@ describe('WorkflowShareModal.ee.vue', () => {
scopes: [],
nodes: [],
connections: {},
homeProject: {
id: 'personal-project-id',
name: 'Personal Project',
type: ProjectTypes.Personal,
icon: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
homeProject,
};
mockWorkflowDocumentState.homeProject = homeProject;
const props = {
data: { id: 'workflow-1' },
};
@@ -66,10 +66,7 @@ const workflowName = computed(
() => workflowListEntry.value?.name ?? workflowDocumentStore.value.name,
);
const workflowHomeProject = computed(
() =>
workflowListEntry.value?.homeProject ??
workflowDocumentStore.value.homeProject ??
workflowsStore.workflow.homeProject,
() => workflowListEntry.value?.homeProject ?? workflowDocumentStore.value.homeProject,
);
const workflowScopes = computed(
() => workflowListEntry.value?.scopes ?? workflowDocumentStore.value.scopes,
@@ -304,6 +301,7 @@ watch(
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]" :class="$style.content">
<div>
<ProjectSharing
v-if="workflowHomeProject"
v-model="sharedWithProjects"
:home-project="workflowHomeProject"
:search-fn="searchFn"
@@ -221,7 +221,7 @@ describe('useCanvasOperations', () => {
// Tests that need custom behavior can override via vi.spyOn.
vi.mocked(workflowDocumentStoreInstance.getParentNodesByDepth).mockReturnValue([]);
vi.mocked(workflowDocumentStoreInstance.getConnectedNodes).mockReturnValue([]);
vi.mocked(workflowDocumentStoreInstance.getSnapshot).mockReturnValue({
vi.mocked(workflowDocumentStoreInstance.getWorkflowObjectAccessorSnapshot).mockReturnValue({
id: workflowDocumentStoreInstance.workflowId,
connectionsBySourceNode: workflowDocumentStoreInstance.connectionsBySourceNode,
pinData: workflowDocumentStoreInstance.pinData as IPinData,
@@ -211,10 +211,10 @@ export function useCanvasOperations() {
const preventOpeningNDV = !!localStorage.getItem('NodeView.preventOpeningNDV');
const editableWorkflow = computed<IWorkflowDb>(() => workflowsStore.workflow);
const editableWorkflowObject = computed(() =>
workflowDocumentStore.value ? workflowDocumentStore.value.getSnapshot() : undefined,
workflowDocumentStore.value
? workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot()
: undefined,
);
const triggerNodes = computed<INodeUi[]>(() => {
@@ -2708,12 +2708,16 @@ export function useCanvasOperations() {
// the user
workflowHelpers.updateNodePositions(
workflowData,
NodeViewUtils.getNewNodePosition(editableWorkflow.value.nodes, lastClickPosition.value, {
...(workflowData.nodes && workflowData.nodes.length > 1
? { size: getNodesGroupSize(workflowData.nodes) }
: {}),
viewport,
}),
NodeViewUtils.getNewNodePosition(
workflowDocumentStore.value.allNodes,
lastClickPosition.value,
{
...(workflowData.nodes && workflowData.nodes.length > 1
? { size: getNodesGroupSize(workflowData.nodes) }
: {}),
viewport,
},
),
);
await addImportedNodesToWorkflow(workflowData, {
@@ -3076,10 +3080,11 @@ export function useCanvasOperations() {
telemetry: true,
});
const offsetIndex = editableWorkflow.value.nodes.length - nodes.length;
const allNodes = workflowDocumentStore.value.allNodes;
const offsetIndex = allNodes.length - nodes.length;
const connections: CanvasConnectionCreateData[] = addedConnections.map(({ from, to }) => {
const fromNode = editableWorkflow.value.nodes[offsetIndex + from.nodeIndex];
const toNode = editableWorkflow.value.nodes[offsetIndex + to.nodeIndex];
const fromNode = allNodes[offsetIndex + from.nodeIndex];
const toNode = allNodes[offsetIndex + to.nodeIndex];
const type = from.type ?? to.type ?? NodeConnectionTypes.Main;
return {
@@ -3251,7 +3256,6 @@ export function useCanvasOperations() {
return {
lastClickPosition,
editableWorkflow,
editableWorkflowObject,
triggerNodes,
requireNodeTypeDescription,
@@ -15,6 +15,12 @@ import type { JSONSchema7 } from 'json-schema';
import { mock } from 'vitest-mock-extended';
vi.mock('@/app/stores/workflows.store');
vi.mock('@/app/stores/workflowDocument.store', () => ({
createWorkflowDocumentId: vi.fn(() => 'test'),
useWorkflowDocumentStore: vi.fn(() => ({
getSettingsSnapshot: () => ({ binaryMode: undefined }),
})),
}));
describe('useDataSchema', () => {
const getSchema = useDataSchema().getSchema;
@@ -26,9 +26,10 @@ import {
type ITaskDataConnections,
NodeConnectionTypes,
} from 'n8n-workflow';
import { ref } from 'vue';
import { computed, 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';
export function useDataSchema() {
function getSchema(
@@ -554,6 +555,11 @@ export const useFlattenSchema = () => {
return acc;
}
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
acc = acc.concat(
flattenSchema({
isDataEmpty: item.isDataEmpty,
@@ -567,7 +573,9 @@ export const useFlattenSchema = () => {
expressionPrefix: getNodeParentExpression({
nodeName: item.node.name,
distanceFromActive: item.depth,
binaryMode: useWorkflowsStore().workflow.settings?.binaryMode,
binaryMode:
workflowDocumentStore.value.getSettingsSnapshot().binaryMode ??
DEFAULT_SETTINGS.binaryMode,
}),
}),
);
@@ -288,7 +288,7 @@ export function useNodeHelpers() {
}
const nodeInputIssues = getNodeInputIssues(
workflowDocumentStore.value.getSnapshot(),
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
node,
nodeType,
);
@@ -178,7 +178,8 @@ export function usePinnedData(
if (typeof data === 'object') data = JSON.stringify(data);
const { pinData: _pinData, ...workflowObjectWithoutPinData } = workflowsStore.workflow;
const { pinData: _pinData, ...workflowObjectWithoutPinData } =
workflowDocumentStore.value?.getSnapshot() ?? {};
const currentPinData = (workflowDocumentStore.value?.pinData ?? {}) as IPinData;
const workflowJson = jsonStringify(workflowObjectWithoutPinData, { replaceCircularRefs: true });
@@ -207,6 +207,10 @@ export async function fetchExecutionData(
executionId: string,
): Promise<SimplifiedExecution | undefined> {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
try {
const executionResponse = await workflowsStore.fetchExecutionDataById(executionId);
if (!executionResponse?.data) {
@@ -216,7 +220,7 @@ export async function fetchExecutionData(
return {
id: executionId,
workflowId: executionResponse.workflowId,
workflowData: workflowsStore.workflow,
workflowData: workflowDocumentStore.getSnapshot(),
data: executionResponse.data,
status: executionResponse.status,
startedAt: workflowsStore.workflowExecutionData?.startedAt as Date,
@@ -38,10 +38,10 @@ export async function executionStarted(
// Initialize or reinitialize workflowExecutionData to clear previous execution's
// node status (e.g. DemoLayout iframe receiving push events for a new execution).
if (!workflowsStore.workflowExecutionData?.data || needsInit) {
const wf = workflowsStore.workflow;
const workflowDocumentStore = workflowsStore.workflowId
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
: undefined;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
options.workflowState.setWorkflowExecutionData({
id: data.executionId,
finished: false,
@@ -49,18 +49,7 @@ export async function executionStarted(
status: 'running',
createdAt: new Date(),
startedAt: new Date(),
workflowData: {
id: wf.id,
name: workflowDocumentStore?.name ?? '',
active: wf.active,
isArchived: wf.isArchived,
nodes: wf.nodes,
connections: wf.connections,
createdAt: wf.createdAt,
updatedAt: wf.updatedAt,
versionId: wf.versionId ?? '',
activeVersionId: wf.activeVersionId ?? null,
},
workflowData: workflowDocumentStore.getSnapshot(),
data: createRunExecutionData(),
});
}
@@ -20,7 +20,7 @@ import {
} from '@/app/composables/useWorkflowState';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useChat } from '@n8n/chat/composables';
import type { IStartRunData } from '@/Interface';
import type { INodeUi, IStartRunData } from '@/Interface';
import type { IExecutionResponse } from '@/features/execution/executions/executions.types';
import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@@ -43,39 +43,45 @@ import {
CHAT_HITL_TOOL_NODE_TYPE,
} from '../constants';
import type { WorkflowObjectAccessors } from '../types';
import type { useWorkflowDocumentStore } from '../stores/workflowDocument.store';
import type { Mocked } from 'vitest';
type Writable<T> = { -readonly [K in keyof T]: T[K] };
const { mockDocumentStore } = vi.hoisted(() => {
const store = {
workflowId: '123',
name: 'Test Workflow',
allNodes: [] as unknown[],
allNodes: [],
getNodeByName: vi.fn(),
getParentNodes: vi.fn().mockReturnValue([]),
getChildNodes: vi.fn().mockReturnValue([]),
getStartNode: vi.fn(),
checkIfNodeHasChatParent: vi.fn(),
checkIfToolNodeHasChatParent: vi.fn(),
connectionsBySourceNode: {} as Record<string, unknown>,
pinData: {} as Record<string, unknown>,
connectionsBySourceNode: {},
pinData: {},
incomingConnectionsByNodeName: vi.fn().mockReturnValue({}),
outgoingConnectionsByNodeName: vi.fn().mockReturnValue({}),
nodesIssuesExist: false,
getParametersLastUpdate: vi.fn(),
getPinnedDataLastUpdate: vi.fn(),
getPinnedDataLastRemovedAt: vi.fn(),
getSnapshot: vi.fn(),
getWorkflowObjectAccessorSnapshot: vi.fn(),
hasNodeValidationIssues: false,
nodeValidationIssues: [],
serialize: vi.fn(),
};
store.getSnapshot.mockReturnValue({
} as Partial<Mocked<Writable<ReturnType<typeof useWorkflowDocumentStore>>>> as Mocked<
Writable<ReturnType<typeof useWorkflowDocumentStore>>
>;
store.getWorkflowObjectAccessorSnapshot.mockReturnValue({
id: store.workflowId,
getNode: store.getNodeByName,
getParentNodes: store.getParentNodes,
getChildNodes: store.getChildNodes,
connectionsBySourceNode: store.connectionsBySourceNode,
pinData: store.pinData,
});
pinData: store.pinData as IPinData,
} as Partial<WorkflowObjectAccessors> as WorkflowObjectAccessors);
return { mockDocumentStore: store };
});
@@ -433,7 +439,7 @@ describe('useRunWorkflow({ router })', () => {
return [];
});
vi.mocked(mockDocumentStore.getNodeByName).mockImplementation((name: string) => {
const nodes: Record<string, unknown> = {
const nodes: Record<string, INodeUi> = {
[parentNodeName]: createTestNode({ name: parentNodeName }),
[destinationNodeName]: createTestNode({ name: destinationNodeName }),
};
@@ -568,9 +574,7 @@ describe('useRunWorkflow({ router })', () => {
const composable = useRunWorkflow({ router });
const triggerNode = 'Chat Trigger';
const nodeData = mock<ITaskData>();
vi.mocked(mockDocumentStore.getChildNodes).mockReturnValue([
{ name: 'Child node', type: 'nodes.child' },
]);
vi.mocked(mockDocumentStore.getChildNodes).mockReturnValue(['Child node']);
mockDocumentStore.serialize.mockReturnValue(mock<WorkflowData>({ nodes: [] }));
const { runWorkflow } = composable;
@@ -587,10 +591,7 @@ describe('useRunWorkflow({ router })', () => {
},
startNodes: [
{
name: {
name: 'Child node',
type: 'nodes.child',
},
name: 'Child node',
sourceData: null,
},
],
@@ -670,7 +671,7 @@ describe('useRunWorkflow({ router })', () => {
};
vi.mocked(mockDocumentStore.getNodeByName).mockImplementation((name: string) =>
name === 'Test node' ? { id: 'Test id', name: 'Test node' } : undefined,
name === 'Test node' ? createTestNode({ id: 'Test id', name: 'Test node' }) : null,
);
vi.mocked(pushConnectionStore).isConnected = true;
@@ -1280,7 +1281,7 @@ describe('useRunWorkflow({ router })', () => {
if (name === topNode) return getNodeUi(topNode, [100, 50]);
if (name === middleNode) return getNodeUi(middleNode, [200, 200]);
if (name === bottomNode) return getNodeUi(bottomNode, [150, 350]);
return undefined;
return null;
});
// Test with different order of input nodes
@@ -189,7 +189,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
directParentNodes,
runData,
workflowData.pinData,
workflowDocumentStore.value.getSnapshot(),
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
);
const { startNodeNames } = consolidatedData;
@@ -539,7 +539,7 @@ export function useRunWorkflow(useRunWorkflowOpts: {
// execution finished before it could be stopped
const executedData = {
data: execution.data,
workflowData: workflowsStore.workflow,
workflowData: workflowDocumentStore.value.getSnapshot(),
finished: execution.finished,
mode: execution.mode,
startedAt: execution.startedAt,
@@ -407,7 +407,7 @@ export function useWorkflowExtraction() {
);
for (const node of selectionChildNodes) {
const currentNode = workflowsStore.workflow.nodes.find((x) => x.id === node.id);
const currentNode = workflowDocumentStore?.value?.allNodes.find((x) => x.id === node.id);
if (isEqual(node, currentNode)) continue;
@@ -461,7 +461,7 @@ export function useWorkflowExtraction() {
) {
const { start, end } = selection;
const allNodeNames = workflowsStore.workflow.nodes.map((x) => x.name);
const allNodeNames = workflowDocumentStore?.value?.allNodes.map((x) => x.name) ?? [];
let startNodeName = 'Start';
const subGraphNames = subGraph.map((x) => x.name);
@@ -98,7 +98,7 @@ export async function resolveParameter<T = IDataObject>(
return await resolveParameterImpl(
parameter,
workflowDocumentStore.getSnapshot(),
workflowDocumentStore.getWorkflowObjectAccessorSnapshot(),
workflowDocumentStore.connectionsBySourceNode,
useEnvironmentsStore().variablesAsObject,
useNDVStore().activeNode,
@@ -84,9 +84,7 @@ export function useWorkflowState() {
setActiveExecutionId(undefined);
workflowStateStore.executingNode.clearNodeExecutionQueue();
ws.executionWaitingForWebhook = false;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(ws.workflow.id),
);
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(ws.workflowId));
documentTitle.setDocumentTitle(workflowDocumentStore.name, 'IDLE');
ws.workflowExecutionStartedData = undefined;
@@ -35,9 +35,11 @@ import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { serializeNode } from '@/app/utils/nodes/nodeTransforms';
import type { WorkflowObjectAccessors } from '../types';
import type { IWorkflowDb } from '@/Interface';
import type { INode, IPinData } from 'n8n-workflow';
import type { INode, IPinData, ProjectSharingData } from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import type { WorkflowData } from '@n8n/rest-api-client/api/workflows';
import type { Scope } from '@n8n/permissions';
import type { IUsedCredential } from '@/features/credentials/credentials.types';
export {
getPinDataSize,
@@ -318,7 +320,10 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
});
}
function getSnapshot(): WorkflowObjectAccessors {
/**
* @deprecated use individual method or `getSnapshot()`
*/
function getWorkflowObjectAccessorSnapshot(): WorkflowObjectAccessors {
return {
id: workflowId,
connectionsBySourceNode: workflowDocumentConnections.connectionsBySourceNode.value,
@@ -333,6 +338,35 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
};
}
function getSnapshot(): IWorkflowDb {
return {
id: workflowId,
name: workflowDocumentName.name.value,
description: workflowDocumentDescription.description.value,
active: workflowDocumentActive.active.value,
activeVersionId: workflowDocumentActive.activeVersionId.value,
isArchived: workflowDocumentIsArchived.isArchived.value,
createdAt: workflowDocumentTimestamps.createdAt.value,
updatedAt: workflowDocumentTimestamps.updatedAt.value,
nodes: workflowDocumentNodes.allNodes.value,
connections: workflowDocumentConnections.connectionsBySourceNode.value,
settings: { ...DEFAULT_SETTINGS, ...workflowDocumentSettings.settings.value },
tags: [...workflowDocumentTags.tags.value],
pinData: workflowDocumentPinData.pinData.value as IPinData,
sharedWithProjects: (workflowDocumentSharedWithProjects.sharedWithProjects.value ??
[]) as ProjectSharingData[],
homeProject: workflowDocumentHomeProject.homeProject.value ?? undefined,
scopes: workflowDocumentScopes.scopes.value as Scope[],
versionId: workflowDocumentVersionData.versionId.value,
usedCredentials: Object.values(
workflowDocumentUsedCredentials.usedCredentials.value,
) as IUsedCredential[],
meta: workflowDocumentMeta.meta.value,
parentFolder: workflowDocumentParentFolder.parentFolder.value ?? undefined,
checksum: workflowDocumentChecksum.checksum.value,
};
}
return {
workflowId,
workflowVersion,
@@ -363,6 +397,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
hydrate,
reset,
getSnapshot,
getWorkflowObjectAccessorSnapshot,
serialize,
cloneWorkflowObject,
createWorkflowObject,
@@ -1044,6 +1044,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
);
return {
/**
* @deprecated use granular methods or getSnapshot() in workflow document store.
*/
workflow,
currentWorkflowExecutions,
workflowExecutionData,
@@ -246,8 +246,6 @@ const {
fetchWorkflowDataFromUrl,
resetWorkspace,
initializeWorkspace,
editableWorkflow,
editableWorkflowObject,
lastClickPosition,
startChat,
addNodesAndConnections,
@@ -397,7 +395,11 @@ async function openWorkflow(data: IWorkflowDb) {
*/
const triggerNodes = computed(() => {
return editableWorkflow.value.nodes.filter((node) => nodeTypesStore.isTriggerNode(node.type));
return (
workflowDocumentStore?.value?.allNodes.filter((node) =>
nodeTypesStore.isTriggerNode(node.type),
) ?? []
);
});
const containsTriggerNodes = computed(() => triggerNodes.value.length > 0);
@@ -1209,13 +1211,15 @@ function onRunWorkflowButtonMouseLeave() {
*/
const chatTriggerNode = computed(() => {
return editableWorkflow.value.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
return workflowDocumentStore?.value?.allNodes.find(
(node) => node.type === CHAT_TRIGGER_NODE_TYPE,
);
});
const containsChatTriggerNodes = computed(() => {
return (
!isExecutionWaitingForWebhook.value &&
!!editableWorkflow.value.nodes.find(
!!workflowDocumentStore?.value?.allNodes.find(
(node) =>
[MANUAL_CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(node.type) &&
node.disabled !== true,
@@ -1277,7 +1281,9 @@ function onToggleChat() {
* Evaluation
*/
const evaluationTriggerNode = computed(() => {
return editableWorkflow.value.nodes.find((node) => node.type === EVALUATION_TRIGGER_NODE_TYPE);
return workflowDocumentStore?.value?.allNodes.find(
(node) => node.type === EVALUATION_TRIGGER_NODE_TYPE,
);
});
/**
@@ -1572,7 +1578,11 @@ watch([() => route.name, () => route.params.workflowId], () => {
watch(
() => {
return isLoading.value || isCanvasReadOnly.value || editableWorkflow.value.nodes.length !== 0;
return (
isLoading.value ||
isCanvasReadOnly.value ||
(workflowDocumentStore?.value?.allNodes ?? []).length !== 0
);
},
(isReadOnlyOrLoading) => {
if (isReadOnlyOrLoading) {
@@ -1785,11 +1795,9 @@ onBeforeUnmount(() => {
<template>
<div :class="$style.wrapper">
<WorkflowCanvas
v-if="editableWorkflow && editableWorkflowObject && !isLoading"
:id="editableWorkflow.id"
v-if="!isLoading"
:id="workflowDocumentStore?.workflowId"
ref="canvas"
:workflow="editableWorkflow"
:workflow-object="editableWorkflowObject"
:fallback-nodes="fallbackNodes"
:show-fallback-nodes="showFallbackNodes"
:event-bus="canvasEventBus"
@@ -1935,7 +1943,6 @@ onBeforeUnmount(() => {
<Suspense>
<LazyNodeDetailsView
v-if="!isNDVV2"
:workflow-object="editableWorkflowObject"
:read-only="isCanvasReadOnly"
:is-production-execution-preview="nodeHelpers.isProductionExecutionPreview.value"
:renaming="false"
@@ -1948,7 +1955,6 @@ onBeforeUnmount(() => {
<Suspense>
<LazyNodeDetailsViewV2
v-if="isNDVV2"
:workflow-object="editableWorkflowObject"
:read-only="isCanvasReadOnly"
:is-production-execution-preview="nodeHelpers.isProductionExecutionPreview.value"
@rename-node="onRenameNode"
@@ -32,6 +32,7 @@ const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
settings: {},
getPinDataSnapshot: vi.fn().mockReturnValue({}),
getNodeByName: vi.fn().mockReturnValue(null),
getSnapshot: vi.fn().mockReturnValue({}),
},
}));
@@ -388,9 +388,12 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
}
: undefined,
currentWorkflow: workflowDataStale.value
? await assistantHelpers.simplifyWorkflowForAssistant(workflowsStore.workflow, {
excludeParameterValues: !allowSendingParameterValues.value,
})
? await assistantHelpers.simplifyWorkflowForAssistant(
workflowDocumentStore.value.getSnapshot(),
{
excludeParameterValues: !allowSendingParameterValues.value,
},
)
: undefined,
executionData:
workflowExecutionDataStale.value && executionResult
@@ -1004,7 +1004,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
const payload = await createBuilderPayload(text, userMessageId, {
workflowId: workflowsStore.workflowId,
quickReplyType,
workflow: workflowsStore.workflow,
workflow: workflowDocumentStore.value.getSnapshot(),
executionData: executionResult,
nodesForSchema: Object.keys(workflowDocumentStore.value.nodesByName),
mode: modeForPayload,
@@ -35,7 +35,7 @@ export function useBuilderExecution(isReady: ComputedRef<boolean>) {
const { runWorkflow } = useRunWorkflow({ router });
const triggerNodes = computed(() =>
workflowsStore.workflow.nodes.filter((node) => nodeTypesStore.isTriggerNode(node.type)),
workflowDocumentStore.value.allNodes.filter((node) => nodeTypesStore.isTriggerNode(node.type)),
);
// Empty until ready — prevents trigger selection in the execute button while setup is pending
@@ -84,6 +84,7 @@ const mockWorkflowDocumentStore = reactive({
get allNodes() {
return mockWorkflowsStore.workflow.nodes;
},
getSnapshot: vi.fn().mockReturnValue({}),
});
vi.mock('@/features/ai/assistant/builder.store', () => ({
@@ -351,12 +351,12 @@ export function useReviewChanges() {
}
const sourceWorkflow = {
...workflowsStore.workflow,
...workflowDocumentStore.value.getSnapshot(),
nodes: sourceNodes,
connections: sourceConnections,
};
const targetWorkflow = {
...workflowsStore.workflow,
...workflowDocumentStore.value.getSnapshot(),
nodes: targetCached.nodes,
connections: targetCached.connections,
};
@@ -563,6 +563,9 @@ export const useChatStore = defineStore(STORES.CHAT_HUB, () => {
*/
function initManualExecutionScaffold() {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowsStore.workflowExecutionData = {
id: IN_PROGRESS_EXECUTION_ID,
@@ -576,7 +579,7 @@ export const useChatStore = defineStore(STORES.CHAT_HUB, () => {
data: createRunExecutionData({
resultData: { runData: {} },
}),
workflowData: workflowsStore.workflow,
workflowData: workflowDocumentStore.getSnapshot(),
};
// Signal canvas that an execution is pending (null = waiting for execution ID)
@@ -402,7 +402,7 @@ onMounted(async () => {
node.parameters = resolved ?? {};
}
previousWorkflow = { ...workflowsStore.workflow };
previousWorkflow = { ...workflowDocumentStore.value.getSnapshot() };
const targetDocStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowData.id));
targetDocStore.hydrate(workflowData);
} catch (error) {
@@ -16,9 +16,16 @@ import type { ExecutionSummary } from 'n8n-workflow';
import { useDebounce } from '@/app/composables/useDebounce';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { executionRetryMessage } from '../executions.utils';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
const executionsStore = useExecutionsStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const workflowsListStore = useWorkflowsListStore();
const i18n = useI18n();
const telemetry = useTelemetry();
@@ -149,12 +156,13 @@ async function initializeRoute() {
function fetchWorkflow() {
// Skip fetching if it's a new workflow that hasn't been saved yet
if (isNewWorkflowRoute.value || !workflowId.value) {
workflow.value = workflowsStore.workflow;
workflow.value = workflowDocumentStore.value.getSnapshot();
return;
}
// Use the workflow from the list store (already loaded by WorkflowLayout)
workflow.value = workflowsListStore.workflowsById[workflowId.value] ?? workflowsStore.workflow;
workflow.value =
workflowsListStore.workflowsById[workflowId.value] ?? workflowDocumentStore.value.getSnapshot();
}
async function onAutoRefreshToggle(value: boolean) {
@@ -16,12 +16,13 @@ import {
import { usePushConnectionStore } from '@/app/stores/pushConnection.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { createLogTree, flattenLogEntries } from '../logs.utils';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
const { mockDocumentStore } = vi.hoisted(() => ({
mockDocumentStore: {
workflowId: 'test-workflow-id',
name: 'Test Workflow',
allNodes: [] as unknown[],
allNodes: [],
getNodeByName: vi.fn(),
getParentNodes: vi.fn().mockReturnValue([]),
getChildNodes: vi.fn().mockReturnValue([]),
@@ -29,7 +30,7 @@ const { mockDocumentStore } = vi.hoisted(() => ({
checkIfNodeHasChatParent: vi.fn().mockReturnValue(false),
checkIfToolNodeHasChatParent: vi.fn().mockReturnValue(false),
getExpressionHandler: vi.fn().mockReturnValue(null),
getSnapshot: vi.fn().mockReturnValue({
getWorkflowObjectAccessorSnapshot: vi.fn().mockReturnValue({
id: 'test-workflow-id',
connectionsBySourceNode: {},
pinData: {},
@@ -41,8 +42,8 @@ const { mockDocumentStore } = vi.hoisted(() => ({
getChildNodes: vi.fn().mockReturnValue([]),
getParentNodesByDepth: vi.fn().mockReturnValue([]),
}),
connectionsBySourceNode: {} as Record<string, unknown>,
pinData: {} as Record<string, unknown>,
connectionsBySourceNode: {},
pinData: {},
incomingConnectionsByNodeName: vi.fn().mockReturnValue({}),
outgoingConnectionsByNodeName: vi.fn().mockReturnValue({}),
settings: {},
@@ -59,7 +60,7 @@ const { mockDocumentStore } = vi.hoisted(() => ({
versionId: '',
meta: {},
}),
},
} satisfies Partial<ReturnType<typeof useWorkflowDocumentStore>>,
}));
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
@@ -1,22 +1,27 @@
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { computed } from 'vue';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useRoute } from 'vue-router';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
export function useClearExecutionButtonVisible() {
const route = useRoute();
const sourceControlStore = useSourceControlStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const workflowExecutionData = computed(() => workflowsStore.workflowExecutionData);
const isWorkflowRunning = computed(() => workflowsStore.isWorkflowRunning);
const isReadOnlyRoute = computed(() => !!route?.meta?.readOnlyCanvas);
const { editableWorkflow } = useCanvasOperations();
const nodeTypesStore = useNodeTypesStore();
const isReadOnlyEnvironment = computed(() => sourceControlStore.preferences.branchReadOnly);
const allTriggerNodesDisabled = computed(() =>
editableWorkflow.value.nodes
workflowDocumentStore.value.allNodes
.filter((node) => nodeTypesStore.isTriggerNode(node.type))
.every((node) => node.disabled),
);
@@ -157,9 +157,7 @@ describe('SettingsSourceControl', () => {
it('should show SSH-specific fields when SSH protocol is selected', async () => {
await nextTick();
const { container, getByTestId } = renderComponent({
pinia,
});
const { container, getByTestId } = renderComponent({ pinia });
await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual(''));
@@ -179,9 +177,7 @@ describe('SettingsSourceControl', () => {
it('should show HTTPS-specific fields when HTTPS protocol is selected', async () => {
await nextTick();
const { container, queryByTestId } = renderComponent({
pinia,
});
const { container, queryByTestId } = renderComponent({ pinia });
await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual(''));
@@ -64,7 +64,9 @@ const nodeData = computed(
const ndvStore = useNDVStore();
const workflowObjectAccessors = computed(() =>
workflowDocumentStore?.value ? workflowDocumentStore.value.getSnapshot() : undefined,
workflowDocumentStore?.value
? workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot()
: undefined,
);
const nodeInputIssues = computed(() => {
@@ -63,7 +63,7 @@ const emit = defineEmits<{
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflow.id)),
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const telemetry = useTelemetry();
@@ -289,6 +289,8 @@ describe('VirtualSchema.vue', () => {
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflow.id),
);
workflowDocumentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
workflowDocumentStore.setName(workflowsStore.workflow.name);
renderComponent = createComponentRenderer(VirtualSchema, {
global: {
@@ -20,7 +20,6 @@ import { useTelemetry } from '@/app/composables/useTelemetry';
import { useCalloutHelpers } from '@/app/composables/useCalloutHelpers';
import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { executionDataToJson } from '@/app/utils/nodeTypesUtils';
import {
@@ -46,7 +45,6 @@ import { useSettingsStore } from '@/app/stores/settings.store';
import { isEmpty } from '@/app/utils/typesUtils';
import { asyncComputed } from '@vueuse/core';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import pick from 'lodash/pick';
import { DateTime } from 'luxon';
import NodeExecuteButton from '@/app/components/NodeExecuteButton.vue';
import { I18nT } from 'vue-i18n';
@@ -87,7 +85,6 @@ const telemetryContext = useTelemetryContext();
const i18n = useI18n();
const ndvStore = useNDVStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const schemaPreviewStore = useSchemaPreviewStore();
const environmentsStore = useEnvironmentsStore();
@@ -221,7 +218,11 @@ const contextSchema = computed(() => {
mode: 'test',
resumeUrl: i18n.baseText('dataMapping.schemaView.execution.resumeUrl'),
},
$workflow: pick(workflowsStore.workflow, ['id', 'name', 'active']),
$workflow: {
id: workflowDocumentStore?.value?.workflowId ?? '',
name: workflowDocumentStore?.value?.name ?? '',
active: workflowDocumentStore?.value?.active ?? false,
},
};
return filterSchema(getSchema(schemaSource), props.search);
@@ -15,14 +15,9 @@ import {
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import {
createTestWorkflow,
createTestWorkflowObject,
defaultNodeDescriptions,
mockNodes,
} from '@/__tests__/mocks';
import { computed } from 'vue';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { createTestWorkflow, defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { computed, shallowRef } from 'vue';
import { WorkflowDocumentStoreKey, WorkflowIdKey } from '@/app/constants/injectionKeys';
vi.mock('vue-router', () => {
return {
@@ -50,8 +45,13 @@ async function createPiniaStore(isActiveNode: boolean) {
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.setNodes(workflow.nodes);
workflowDocumentStore.setConnections(workflow.connections);
workflowDocumentStore.setSettings(workflow.settings ?? { executionOrder: 'v1' });
workflowDocumentStore.initPristineNodeMetadata(node.name);
const workflowDocumentStoreRef = shallowRef(workflowDocumentStore);
if (isActiveNode) {
ndvStore.setActiveNodeName(node.name, 'other');
}
@@ -62,7 +62,7 @@ async function createPiniaStore(isActiveNode: boolean) {
return {
pinia,
workflow,
workflowObject: createTestWorkflowObject(workflow),
workflowDocumentStoreRef,
nodeName: node.name,
};
}
@@ -83,15 +83,13 @@ describe('NodeDetailsView', () => {
});
it('should render correctly', async () => {
const { pinia, workflow, workflowObject } = await createPiniaStore(true);
const { pinia, workflow, workflowDocumentStoreRef } = await createPiniaStore(true);
const renderComponent = createComponentRenderer(NodeDetailsView, {
props: {
workflowObject,
},
global: {
provide: {
[WorkflowIdKey as unknown as string]: computed(() => workflow.id),
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
},
mocks: {
$route: {
@@ -110,15 +108,13 @@ describe('NodeDetailsView', () => {
describe('keyboard listener', () => {
test('should register and unregister keydown listener based on modal open state', async () => {
const { pinia, workflow, workflowObject } = await createPiniaStore(true);
const { pinia, workflow, workflowDocumentStoreRef } = await createPiniaStore(true);
const renderComponent = createComponentRenderer(NodeDetailsView, {
props: {
workflowObject,
},
global: {
provide: {
[WorkflowIdKey as unknown as string]: computed(() => workflow.id),
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
},
mocks: {
$route: {
@@ -128,9 +124,7 @@ describe('NodeDetailsView', () => {
},
});
const { getByTestId, queryByTestId, unmount } = renderComponent({
pinia,
});
const { getByTestId, queryByTestId, unmount } = renderComponent({ pinia });
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
@@ -154,16 +148,14 @@ describe('NodeDetailsView', () => {
});
test('should unregister keydown listener on unmount', async () => {
const { pinia, workflow, workflowObject, nodeName } = await createPiniaStore(false);
const { pinia, workflow, workflowDocumentStoreRef, nodeName } = await createPiniaStore(false);
const ndvStore = useNDVStore(pinia);
const renderComponent = createComponentRenderer(NodeDetailsView, {
props: {
workflowObject,
},
global: {
provide: {
[WorkflowIdKey as unknown as string]: computed(() => workflow.id),
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
},
mocks: {
$route: {
@@ -173,9 +165,7 @@ describe('NodeDetailsView', () => {
},
});
const { getByTestId, queryByTestId, unmount } = renderComponent({
pinia,
});
const { getByTestId, queryByTestId, unmount } = renderComponent({ pinia });
ndvStore.setActiveNodeName(nodeName, 'other');
@@ -5,7 +5,6 @@ import type { IRunData, NodeConnectionType, IConnectedNode } from 'n8n-workflow'
import { jsonParse, NodeHelpers, NodeConnectionTypes } from 'n8n-workflow';
import type { IRunDataDisplayMode, IUpdateInformation, TargetItem } from '@/Interface';
import type { NodePanelType } from '@/features/ndv/shared/ndv.types';
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import NodeSettings from '@/features/ndv/settings/components/NodeSettings.vue';
import NDVDraggablePanels from '../../panel/components/NDVDraggablePanels.vue';
@@ -54,7 +53,6 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
workflowObject: WorkflowObjectAccessors;
readOnly?: boolean;
renaming?: boolean;
isProductionExecutionPreview?: boolean;
@@ -100,6 +98,10 @@ const isPairedItemHoveringEnabled = ref(true);
const pushRef = computed(() => ndvStore.pushRef);
const workflowObject = computed(() =>
workflowDocumentStore?.value?.getWorkflowObjectAccessorSnapshot(),
);
const activeNodeType = computed(() => {
if (activeNode.value) {
return nodeTypesStore.getNodeType(activeNode.value.type, activeNode.value.typeVersion);
@@ -132,7 +134,7 @@ const workflowRunData = computed(() => {
const parentNodes = computed(() => {
if (activeNode.value) {
return props.workflowObject.getParentNodesByDepth(activeNode.value.name, 1);
return workflowObject.value?.getParentNodesByDepth(activeNode.value.name, 1) ?? [];
}
return [];
});
@@ -152,8 +154,8 @@ const parentNode = computed<IConnectedNode | undefined>(() => {
const inputNodeName = computed<string | undefined>(() => {
const nodeOutputs =
activeNode.value && activeNodeType.value
? NodeHelpers.getNodeOutputs(props.workflowObject, activeNode.value, activeNodeType.value)
activeNode.value && activeNodeType.value && workflowObject.value
? NodeHelpers.getNodeOutputs(workflowObject.value, activeNode.value, activeNodeType.value)
: [];
const nonMainOutputs = nodeOutputs.filter((output) => {
@@ -168,7 +170,7 @@ const inputNodeName = computed<string | undefined>(() => {
// For sub-nodes, we need to get their connected output node to determine the input
// because sub-nodes use specialized outputs (e.g. NodeConnectionTypes.AiTool)
// instead of the standard Main output type
const connectedOutputNode = props.workflowObject.getChildNodes(
const connectedOutputNode = workflowObject.value?.getChildNodes(
activeNode.value.name,
'ALL_NON_MAIN',
)?.[0];
@@ -245,14 +247,14 @@ const maxInputRun = computed(() => {
return 0;
}
const workflowNode = props.workflowObject.getNode(activeNode.value.name);
const workflowNode = workflowObject.value?.getNode(activeNode.value.name);
if (!workflowNode || !activeNodeType.value) {
if (!workflowNode || !activeNodeType.value || !workflowObject.value) {
return 0;
}
const outputs = NodeHelpers.getNodeOutputs(
props.workflowObject,
workflowObject.value,
workflowNode,
activeNodeType.value,
);
@@ -605,12 +607,12 @@ watch(
setTimeout(() => ndvStore.setNDVPushRef(), 0);
if (!activeNodeType.value) {
if (!activeNodeType.value || !workflowObject.value) {
return;
}
void externalHooks.run('dataDisplay.nodeTypeChanged', {
nodeSubtitle: nodeHelpers.getNodeSubtitle(node, activeNodeType.value, props.workflowObject),
nodeSubtitle: nodeHelpers.getNodeSubtitle(node, activeNodeType.value, workflowObject.value),
});
setTimeout(() => {
@@ -744,7 +746,7 @@ onBeforeUnmount(() => {
@activate="onWorkflowActivate"
/>
<InputPanel
v-else-if="!isTriggerNode"
v-else-if="!isTriggerNode && workflowObject"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="inputRun"
@@ -773,6 +775,7 @@ onBeforeUnmount(() => {
</template>
<template #output>
<OutputPanel
v-if="workflowObject"
data-test-id="output-panel"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
@@ -14,15 +14,9 @@ import {
} from '@/app/stores/workflowDocument.store';
import { createComponentRenderer } from '@/__tests__/render';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
defaultNodeDescriptions,
} from '@/__tests__/mocks';
import type { Workflow } from 'n8n-workflow';
import { computed } from 'vue';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { createTestNode, createTestWorkflow, defaultNodeDescriptions } from '@/__tests__/mocks';
import { computed, shallowRef } from 'vue';
import { WorkflowDocumentStoreKey, WorkflowIdKey } from '@/app/constants/injectionKeys';
vi.mock('vue-router', () => ({
useRouter: () => ({}),
@@ -48,21 +42,24 @@ const setupStore = (nodes: Array<ReturnType<typeof createTestNode>>) => {
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.hydrate(workflow);
workflowDocumentStore.setAllNodeMetadata(
nodes.reduce((acc, node) => ({ ...acc, [node.name]: { pristine: true } }), {}),
);
const workflowDocumentStoreRef = shallowRef(workflowDocumentStore);
return {
pinia,
workflow,
workflowObject: createTestWorkflowObject(workflow),
workflowDocumentStoreRef,
};
};
describe('NodeDetailsViewV2', () => {
let pinia: ReturnType<typeof createTestingPinia>;
let workflowId: string;
let workflowObject: Workflow;
let workflowDocumentStoreRef: ReturnType<typeof setupStore>['workflowDocumentStoreRef'];
const manualTriggerNode = createTestNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
@@ -82,12 +79,12 @@ describe('NodeDetailsViewV2', () => {
const render = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
...componentProps,
},
global: {
provide: {
[WorkflowIdKey as unknown as string]: computed(() => workflowId),
[WorkflowDocumentStoreKey as symbol]: workflowDocumentStoreRef,
},
mocks: {
$route: {
@@ -123,7 +120,7 @@ describe('NodeDetailsViewV2', () => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowId = store.workflow.id;
workflowObject = store.workflowObject;
workflowDocumentStoreRef = store.workflowDocumentStoreRef;
});
test('should not render when no node is active', () => {
@@ -167,7 +164,7 @@ describe('NodeDetailsViewV2', () => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowId = store.workflow.id;
workflowObject = store.workflowObject;
workflowDocumentStoreRef = store.workflowDocumentStoreRef;
});
test('should register keydown listener on mount', async () => {
@@ -201,7 +198,7 @@ describe('NodeDetailsViewV2', () => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowId = store.workflow.id;
workflowObject = store.workflowObject;
workflowDocumentStoreRef = store.workflowDocumentStoreRef;
});
test('should open dialog on mount', async () => {
@@ -238,7 +235,7 @@ describe('NodeDetailsViewV2', () => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowId = store.workflow.id;
workflowObject = store.workflowObject;
workflowDocumentStoreRef = store.workflowDocumentStoreRef;
});
test('should close NDV when close button is clicked', async () => {
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { IRunDataDisplayMode, IUpdateInformation, TargetItem } from '@/Interface';
import type { MainPanelType, NodePanelType } from '../ndv.types';
import type { WorkflowObjectAccessors } from '@/app/types/workflow';
import { createEventBus } from '@n8n/utils/event-bus';
import type { IRunData, NodeConnectionType } from 'n8n-workflow';
import { jsonParse, NodeConnectionTypes, NodeHelpers } from 'n8n-workflow';
@@ -56,7 +55,6 @@ const emit = defineEmits<{
const props = withDefaults(
defineProps<{
workflowObject: WorkflowObjectAccessors;
readOnly?: boolean;
isProductionExecutionPreview?: boolean;
}>(),
@@ -104,6 +102,10 @@ const mainPanelRef = useTemplateRef('mainPanelRef');
// computed
const pushRef = computed(() => ndvStore.pushRef);
const workflowObject = computed(() =>
workflowDocumentStore?.value?.getWorkflowObjectAccessorSnapshot(),
);
const activeNodeType = computed(() => {
if (activeNode.value) {
return nodeTypesStore.getNodeType(activeNode.value.type, activeNode.value.typeVersion);
@@ -132,8 +134,8 @@ const workflowRunData = computed(() => {
const parentNodes = computed(() => {
if (activeNode.value) {
return (
props.workflowObject
.getParentNodesByDepth(activeNode.value.name, 1)
workflowObject.value
?.getParentNodesByDepth(activeNode.value.name, 1)
.map(({ name }) => name) || []
);
} else {
@@ -156,8 +158,8 @@ const parentNode = computed(() => {
const inputNodeName = computed<string | undefined>(() => {
const nodeOutputs =
activeNode.value && activeNodeType.value
? NodeHelpers.getNodeOutputs(props.workflowObject, activeNode.value, activeNodeType.value)
activeNode.value && activeNodeType.value && workflowObject.value
? NodeHelpers.getNodeOutputs(workflowObject.value, activeNode.value, activeNodeType.value)
: [];
const nonMainOutputs = nodeOutputs.filter((output) => {
@@ -172,7 +174,7 @@ const inputNodeName = computed<string | undefined>(() => {
// For sub-nodes, we need to get their connected output node to determine the input
// because sub-nodes use specialized outputs (e.g. NodeConnectionTypes.AiTool)
// instead of the standard Main output type
const connectedOutputNode = props.workflowObject.getChildNodes(
const connectedOutputNode = workflowObject.value?.getChildNodes(
activeNode.value.name,
'ALL_NON_MAIN',
)?.[0];
@@ -249,14 +251,14 @@ const maxInputRun = computed(() => {
return 0;
}
const workflowNode = props.workflowObject.getNode(activeNode.value.name);
const workflowNode = workflowObject.value?.getNode(activeNode.value.name);
if (!workflowNode || !activeNodeType.value) {
if (!workflowNode || !activeNodeType.value || !workflowObject.value) {
return 0;
}
const outputs = NodeHelpers.getNodeOutputs(
props.workflowObject,
workflowObject.value,
workflowNode,
activeNodeType.value,
);
@@ -599,12 +601,12 @@ watch(
setTimeout(() => ndvStore.setNDVPushRef(), 0);
if (!activeNodeType.value) {
if (!activeNodeType.value || !workflowObject.value) {
return;
}
void externalHooks.run('dataDisplay.nodeTypeChanged', {
nodeSubtitle: nodeHelpers.getNodeSubtitle(node, activeNodeType.value, props.workflowObject),
nodeSubtitle: nodeHelpers.getNodeSubtitle(node, activeNodeType.value, workflowObject.value),
});
setTimeout(() => {
@@ -756,7 +758,7 @@ onBeforeUnmount(() => {
@activate="onWorkflowActivate"
/>
<InputPanel
v-else-if="!isTriggerNode"
v-else-if="!isTriggerNode && workflowObject"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
:run-index="inputRun"
@@ -827,6 +829,7 @@ onBeforeUnmount(() => {
:style="{ width: `${panelWidthPercentage.right}%` }"
>
<OutputPanel
v-if="workflowObject"
data-test-id="output-panel"
:workflow-object="workflowObject"
:can-link-runs="canLinkRuns"
@@ -14,23 +14,12 @@ import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
const mockEditableWorkflow = {
value: {
nodes: [] as Array<{
id: string;
name: string;
type: string;
typeVersion: number;
}>,
},
};
import { createTestNode } from '@/__tests__/mocks';
vi.mock('@/app/composables/useCanvasOperations', () => ({
useCanvasOperations: () => ({
addNodes: vi.fn(),
setNodeActive: vi.fn(),
editableWorkflow: mockEditableWorkflow,
}),
}));
@@ -40,11 +29,6 @@ vi.mock('@/features/workflows/canvas/canvas.eventBus', () => ({
},
}));
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
...(await importOriginal()),
injectWorkflowDocumentStore: vi.fn().mockReturnValue(null),
}));
const mockGenerateMergedNodesAndActionsFn = vi.fn().mockReturnValue({ mergedNodes: [] });
vi.mock('@/features/shared/nodeCreator/composables/useActionsGeneration', () => ({
@@ -133,8 +117,6 @@ describe('useNodeCommands', () => {
});
mockAddNodes.mockResolvedValue([{ id: 'node-1' }]);
mockEditableWorkflow.value.nodes = [];
});
describe('add node command', () => {
@@ -245,15 +227,21 @@ describe('useNodeCommands', () => {
});
it('should populate open node children with workflow nodes', () => {
mockEditableWorkflow.value.nodes = [
{ id: 'node-1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1 },
{
const store = useWorkflowDocumentStore(createWorkflowDocumentId('123'));
store.setNodes([
createTestNode({
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
}),
createTestNode({
id: 'node-2',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
},
];
}),
]);
const { commands } = useNodeCommands({
lastQuery: ref(''),
@@ -320,9 +308,15 @@ describe('useNodeCommands', () => {
describe('root open node items', () => {
beforeEach(() => {
mockEditableWorkflow.value.nodes = [
{ id: 'node-1', name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1 },
];
const store = useWorkflowDocumentStore(createWorkflowDocumentId('123'));
store.setNodes([
createTestNode({
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
}),
]);
});
it('should not show root open node items when query is too short', () => {
@@ -33,7 +33,7 @@ export function useNodeCommands(options: {
const i18n = useI18n();
const { lastQuery } = options;
const { addNodes, setNodeActive, editableWorkflow } = useCanvasOperations();
const { addNodes, setNodeActive } = useCanvasOperations();
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const sourceControlStore = useSourceControlStore();
@@ -139,7 +139,7 @@ export function useNodeCommands(options: {
};
const openNodeCommands = computed<CommandBarItem[]>(() => {
return editableWorkflow.value.nodes.map((node) => buildOpenNodeCommand(node, false));
return workflowDocumentStore.value.allNodes.map((node) => buildOpenNodeCommand(node, false));
});
const rootOpenNodeCommandItems = computed<CommandBarItem[]>(() => {
@@ -147,7 +147,7 @@ export function useNodeCommands(options: {
return [];
}
return editableWorkflow.value.nodes.map((node) => buildOpenNodeCommand(node, true));
return workflowDocumentStore.value.allNodes.map((node) => buildOpenNodeCommand(node, true));
});
const nodeCommands = computed<CommandBarItem[]>(() => {
@@ -1,7 +1,6 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach, type MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useWorkflowCommands } from './useWorkflowCommands';
import * as useCanvasOperations from '@/app/composables/useCanvasOperations';
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
@@ -21,7 +20,6 @@ import { shallowRef, type Ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
vi.mock('@/app/composables/useCanvasOperations');
vi.mock('@/app/composables/useWorkflowHelpers');
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
...(await importOriginal()),
@@ -109,6 +107,9 @@ describe('useWorkflowCommands', () => {
createWorkflowDocumentId(mockWorkflow.value.id),
);
mockWorkflowDocumentStore.setScopes(mockWorkflow.value.scopes ?? []);
mockWorkflowDocumentStore.setName(mockWorkflow.value.name);
mockWorkflowDocumentStore.setTags(mockWorkflow.value.tags ?? []);
mockWorkflowDocumentStore.setNodes(mockWorkflow.value.nodes);
vi.spyOn(mockWorkflowDocumentStore, 'serialize').mockReturnValue(
mockWorkflow.value as unknown as WorkflowData,
);
@@ -133,9 +134,6 @@ describe('useWorkflowCommands', () => {
mockSourceControlStore.preferences.branchReadOnly = false;
const canvasOperationsMock: MockInstance = vi.spyOn(useCanvasOperations, 'useCanvasOperations');
canvasOperationsMock.mockReturnValue({ editableWorkflow: mockWorkflow });
canvasEventBus.emit = vi.fn();
nodeViewEventBus.emit = vi.fn();
});
@@ -149,7 +147,7 @@ describe('useWorkflowCommands', () => {
});
it('should return credential commands when credentials exist', () => {
mockWorkflow.value.nodes = [
const nodes = [
{
id: 'node1',
name: 'node1',
@@ -161,6 +159,8 @@ describe('useWorkflowCommands', () => {
},
} as unknown as INodeUi,
];
mockWorkflow.value.nodes = nodes;
mockWorkflowDocumentStore.setNodes(nodes);
const { commands } = useWorkflowCommands();
const credentialCommand = commands.value.find((cmd) => cmd.id === 'open-credential');
@@ -171,7 +171,7 @@ describe('useWorkflowCommands', () => {
});
it('should handle credential click', async () => {
mockWorkflow.value.nodes = [
const nodes = [
{
id: 'node1',
name: 'node1',
@@ -183,6 +183,8 @@ describe('useWorkflowCommands', () => {
},
} as unknown as INodeUi,
];
mockWorkflow.value.nodes = nodes;
mockWorkflowDocumentStore.setNodes(nodes);
const { commands } = useWorkflowCommands();
const credentialCommand = commands.value.find((cmd) => cmd.id === 'open-credential');
@@ -259,6 +261,7 @@ describe('useWorkflowCommands', () => {
it('should handle duplicate workflow', async () => {
mockWorkflow.value.tags = ['tag1'];
mockWorkflowDocumentStore.setTags(['tag1']);
const { commands } = useWorkflowCommands();
const duplicateCommand = commands.value.find((cmd) => cmd.id === 'duplicate-workflow');
@@ -323,8 +326,6 @@ describe('useWorkflowCommands', () => {
describe('subworkflow commands', () => {
it('should return empty array when no subworkflows exist', () => {
mockWorkflow.value.nodes = [];
const { commands } = useWorkflowCommands();
const subworkflowCommand = commands.value.find((cmd) => cmd.id === 'open-sub-workflow');
@@ -332,7 +333,7 @@ describe('useWorkflowCommands', () => {
});
it('should return subworkflow commands when Execute Workflow nodes exist', () => {
mockWorkflow.value.nodes = [
const nodes = [
{
id: 'node1',
name: 'node1',
@@ -348,6 +349,8 @@ describe('useWorkflowCommands', () => {
},
} as unknown as INodeUi,
];
mockWorkflow.value.nodes = nodes;
mockWorkflowDocumentStore.setNodes(nodes);
const { commands } = useWorkflowCommands();
const subworkflowCommand = commands.value.find((cmd) => cmd.id === 'open-sub-workflow');
@@ -9,7 +9,6 @@ import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useRunWorkflow } from '@/app/composables/useRunWorkflow';
import {
@@ -54,7 +53,6 @@ const ITEM_ID = {
export function useWorkflowCommands(): CommandGroup {
const i18n = useI18n();
const { editableWorkflow } = useCanvasOperations();
const rootStore = useRootStore();
const uiStore = useUIStore();
const tagsStore = useTagsStore();
@@ -87,7 +85,9 @@ export function useWorkflowCommands(): CommandGroup {
const credentialCommands = computed<CommandBarItem[]>(() => {
const credentials = uniqBy(
editableWorkflow.value.nodes.map((node) => Object.values(node.credentials ?? {})).flat(),
workflowDocumentStore.value.allNodes
.map((node) => Object.values(node.credentials ?? {}))
.flat(),
(cred) => cred.id,
);
if (credentials.length === 0) {
@@ -286,8 +286,8 @@ export function useWorkflowCommands(): CommandGroup {
name: DUPLICATE_MODAL_KEY,
data: {
id: workflowsStore.workflowId,
name: editableWorkflow.value.name,
tags: editableWorkflow.value.tags,
name: workflowDocumentStore.value.name,
tags: workflowDocumentStore.value.tags,
},
});
},
@@ -303,7 +303,7 @@ export function useWorkflowCommands(): CommandGroup {
]);
const subworkflowCommands = computed<CommandBarItem[]>(() => {
const subworkflows = editableWorkflow.value.nodes
const subworkflows = workflowDocumentStore.value.allNodes
.filter((node) => node.type === EXECUTE_WORKFLOW_NODE_TYPE)
.map((node) => node?.parameters?.workflowId)
.filter(
@@ -366,7 +366,7 @@ export function useWorkflowCommands(): CommandGroup {
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let name = editableWorkflow.value.name || 'unsaved_workflow';
let name = workflowDocumentStore.value.name || 'unsaved_workflow';
name = name.replace(/[^a-z0-9]/gi, '_');
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
saveAs(blob, name + '.json');
@@ -248,8 +248,14 @@ export const hasActiveNode = (targetNodeParameterContext?: TargetNodeParameterCo
return workflowDocumentStore.getNodeByName(targetNodeParameterContext.nodeName) !== null;
};
export const isSplitInBatchesAbsent = () =>
!useWorkflowsStore().workflow.nodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
export const isSplitInBatchesAbsent = () => {
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
return !workflowDocumentStore.allNodes.some((node) => node.type === SPLIT_IN_BATCHES_NODE_TYPE);
};
export function autocompletableNodeNames(targetNodeParameterContext?: TargetNodeParameterContext) {
const workflowsStore = useWorkflowsStore();
@@ -2,17 +2,16 @@ import { waitFor } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import WorkflowCanvas from './WorkflowCanvas.vue';
import { createEventBus } from '@n8n/utils/event-bus';
import type { Workflow } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import { STICKY_NODE_TYPE } from '@/app/constants';
import { CanvasNodeRenderType } from '../canvas.types';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
defaultNodeDescriptions,
} from '@/__tests__/mocks';
import { createTestNode, createTestWorkflow, defaultNodeDescriptions } from '@/__tests__/mocks';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import type { IWorkflowDb } from '@/Interface';
import * as vueuse from '@vueuse/core';
vi.mock('@vueuse/core', async () => {
@@ -23,16 +22,14 @@ vi.mock('@vueuse/core', async () => {
};
});
function setupWorkflow(workflow: IWorkflowDb) {
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.hydrate(workflow);
}
const renderComponent = createComponentRenderer(WorkflowCanvas, {
props: {
id: 'canvas',
workflow: createTestWorkflow({
id: '1',
name: 'Test Workflow',
nodes: [],
connections: {},
}),
workflowObject: {} as Workflow,
eventBus: createEventBus(),
},
});
@@ -43,6 +40,15 @@ beforeEach(() => {
const nodeTypesStore = useNodeTypesStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
setupWorkflow(
createTestWorkflow({
id: '1',
name: 'Test Workflow',
nodes: [],
connections: {},
}),
);
});
afterEach(() => {
@@ -64,12 +70,9 @@ describe('WorkflowCanvas', () => {
],
connections: { 'Node 1': { main: [[{ node: 'Node 2', type: 'main', index: 0 }]] } },
});
const { container } = renderComponent({
props: {
workflow,
workflowObject: createTestWorkflowObject(workflow),
},
});
setupWorkflow(workflow);
const { container } = renderComponent();
await waitFor(() => expect(container.querySelectorAll('.vue-flow__node')).toHaveLength(2));
@@ -103,13 +106,10 @@ describe('WorkflowCanvas', () => {
nodes: [...stickyNodes],
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
setupWorkflow(workflow);
const { container } = renderComponent({
props: {
workflow,
workflowObject,
fallbackNodes,
showFallbackNodes: true,
},
@@ -137,13 +137,10 @@ describe('WorkflowCanvas', () => {
nodes,
connections: {},
});
const workflowObject = createTestWorkflowObject(workflow);
setupWorkflow(workflow);
const { container } = renderComponent({
props: {
workflow,
workflowObject,
fallbackNodes,
showFallbackNodes: false,
},
@@ -6,11 +6,15 @@ import { createEventBus } from '@n8n/utils/event-bus';
import type { ViewportTransform } from '@vue-flow/core';
import { getRectOfNodes, useVueFlow } from '@vue-flow/core';
import { throttledRef } from '@vueuse/core';
import { computed, ref, toRef, useCssModule, useTemplateRef } from 'vue';
import { computed, ref, useCssModule, useTemplateRef } from 'vue';
import type { CanvasEventBusEvents } from '../canvas.types';
import { useCanvasMapping } from '../composables/useCanvasMapping';
import Canvas from './Canvas.vue';
import type { WorkflowObjectAccessors } from '@/app/types';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
createWorkflowDocumentId,
useWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
defineOptions({
inheritAttrs: false,
@@ -19,8 +23,6 @@ defineOptions({
const props = withDefaults(
defineProps<{
id?: string;
workflow: IWorkflowDb;
workflowObject: WorkflowObjectAccessors;
fallbackNodes?: IWorkflowDb['nodes'];
showFallbackNodes?: boolean;
eventBus?: EventBus<CanvasEventBusEvents>;
@@ -41,18 +43,23 @@ const props = withDefaults(
const canvasRef = useTemplateRef('canvas');
const $style = useCssModule();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const { onNodesInitialized, viewport, viewportRef, getNodes, fitBounds } = useVueFlow(props.id);
const workflow = toRef(props, 'workflow');
const workflowObject = toRef(props, 'workflowObject');
const workflowObject = computed(() =>
workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
);
const nodes = computed(() => {
return props.showFallbackNodes
? [...props.workflow.nodes, ...props.fallbackNodes]
: props.workflow.nodes;
? [...workflowDocumentStore.value.allNodes, ...props.fallbackNodes]
: workflowDocumentStore.value.allNodes;
});
const connections = computed(() => props.workflow.connections);
const connections = computed(() => workflowDocumentStore.value.connectionsBySourceNode);
const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping({
nodes,
@@ -148,7 +155,6 @@ defineExpose({
<div :class="$style.wrapper" data-test-id="canvas-wrapper">
<div id="canvas" :class="$style.canvas">
<Canvas
v-if="workflow"
:id="id"
ref="canvas"
:nodes="executing ? mappedNodesThrottled : mappedNodes"
@@ -53,7 +53,7 @@ export function useExpressionResolveCtx(node: ComputedRef<INodeUi | null | undef
return {
localResolve: true,
envVars: environmentsStore.variablesAsObject,
workflow: workflowDocumentStore.value.getSnapshot(),
workflow: workflowDocumentStore.value.getWorkflowObjectAccessorSnapshot(),
execution,
nodeName,
additionalKeys: {},