diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 05e679d441e..98407885efc 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -4665,6 +4665,29 @@ describe('useCanvasOperations', () => { expect(workflowDocumentStoreInstance.setConnections).toHaveBeenCalled(); }); + it('marks the document hydrated after nodes and connections are set', async () => { + const workflow = createTestWorkflow({ + id: workflowId, + nodes: [createTestNode()], + connections: {}, + }); + + const setNodesSpy = vi.spyOn(workflowDocumentStoreInstance, 'setNodes'); + const setConnectionsSpy = vi.spyOn(workflowDocumentStoreInstance, 'setConnections'); + const setHydratedSpy = vi.spyOn(workflowDocumentStoreInstance, 'setHydrated'); + const { initializeWorkspace } = useCanvasOperations(); + + await initializeWorkspace(workflow); + + expect(setHydratedSpy).toHaveBeenCalledWith(true); + expect(setHydratedSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + setNodesSpy.mock.invocationCallOrder[0], + ); + expect(setHydratedSpy.mock.invocationCallOrder[0]).toBeGreaterThan( + setConnectionsSpy.mock.invocationCallOrder[0], + ); + }); + it('should set connections even when workflowId is initially empty', async () => { const workflowsStore = useWorkflowsStore(); // Simulate the state after resetWorkspace() — workflowId is cleared diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 7d98d04b0e9..583b51ac5ae 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -2712,6 +2712,7 @@ export function useCanvasOperations() { initializedDocumentStore.setNodes(nodes); initializedDocumentStore.setConnections(connections); + initializedDocumentStore.setHydrated(true); return { workflowDocumentStore: initializedDocumentStore }; } @@ -3530,6 +3531,7 @@ export function useCanvasOperations() { projectsStore.currentProjectId, ); workflowDocumentStore.value.setName(workflowData.name); + workflowDocumentStore.value.setHydrated(true); } async function tryToOpenSubworkflowInNewTab(nodeId: string): Promise { diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.test.ts index 535dfac235d..28cfc9dbe68 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.test.ts @@ -107,6 +107,7 @@ const mockWorkflowDocumentStore = vi.hoisted(() => ({ setHomeProject: vi.fn(), setScopes: vi.fn(), setParentFolder: vi.fn(), + setHydrated: vi.fn(), onNameChange: vi.fn(), })); vi.mock('@/app/stores/workflowDocument.store', () => ({ @@ -200,5 +201,16 @@ describe('useWorkflowInitialization', () => { expect(mockSetDocumentTitle).toHaveBeenCalledWith('New Workflow', 'IDLE'); }); + + it('marks a fresh workflow document hydrated after initialization', async () => { + let initializeWorkspaceForNewWorkflow!: () => Promise; + renderWithComposable((init) => { + initializeWorkspaceForNewWorkflow = init.initializeWorkspaceForNewWorkflow; + }); + + await initializeWorkspaceForNewWorkflow(); + + expect(mockWorkflowDocumentStore.setHydrated).toHaveBeenCalledWith(true); + }); }); }); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts index c13b13a6adc..4d495df58eb 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowInitialization.ts @@ -334,6 +334,7 @@ export function useWorkflowInitialization() { const parentFolder = await fetchParentFolder(parentFolderId); currentWorkflowDocumentStore.value?.setParentFolder(parentFolder); + currentWorkflowDocumentStore.value.setHydrated(true); uiStore.nodeViewInitialized = true; initializedWorkflowId.value = workflowId.value; diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts index 5200de65fba..aa01455dc80 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.test.ts @@ -153,6 +153,23 @@ describe('useWorkflowSaving', () => { backendConnectionStore.setOnline(true); }); + function prepareHydratedWorkflow(workflowId: string) { + const workflow = createTestWorkflow({ + id: workflowId, + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + mockRoute.params = { workflowId }; + workflowsStore.setWorkflowId(workflowId); + workflowsListStore.workflowsById = { + ...workflowsListStore.workflowsById, + [workflowId]: workflow, + }; + const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId)); + documentStore.hydrate(workflow); + + return { workflow, documentStore }; + } + describe('promptSaveUnsavedWorkflowChanges', () => { it('should prompt the user to save changes and proceed if confirmed', async () => { const workflow = createTestWorkflow({ @@ -239,6 +256,43 @@ describe('useWorkflowSaving', () => { expect(next).toHaveBeenCalledWith(); }); + it('cancels a scheduled autosave when the user discards changes', async () => { + vi.useFakeTimers(); + try { + const { workflow } = prepareHydratedWorkflow('w-discard-cancel-autosave'); + const updateSpy = vi + .spyOn(workflowsStore, 'updateWorkflow') + .mockResolvedValue({ ...workflow, checksum: 'test-checksum' }); + const next = vi.fn(); + const confirm = vi.fn(); + const cancel = vi.fn(); + const uiStore = useUIStore(); + const saveStore = useWorkflowSaveStore(); + + uiStore.markStateDirty(); + modalConfirmSpy.mockResolvedValue(MODAL_CANCEL); + + const workflowSaving = useWorkflowSaving({ router, ownsAutoSave: true }); + workflowSaving.autoSaveWorkflow(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled); + + await workflowSaving.promptSaveUnsavedWorkflowChanges(next, { confirm, cancel }); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle); + expect(uiStore.stateIsDirty).toBe(false); + + await vi.advanceTimersByTimeAsync( + getDebounceTime(DEBOUNCE_TIME.API.AUTOSAVE_MAX_WAIT) + 1000, + ); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + } finally { + vi.useRealTimers(); + } + }); + it('should restore the route if the modal is closed and the workflow is not new', async () => { const next = vi.fn(); const confirm = vi.fn(); @@ -598,6 +652,38 @@ describe('useWorkflowSaving', () => { ); }); + it('allows a manual save before the document is marked hydrated', async () => { + const workflow = createTestWorkflow({ + id: 'w-manual-unhydrated', + name: 'Manual unhydrated workflow', + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + + vi.spyOn(workflowsStore, 'updateWorkflow').mockResolvedValue({ + ...workflow, + checksum: 'test-checksum', + }); + + workflowsStore.setWorkflowId(workflow.id); + workflowsListStore.workflowsById = { [workflow.id]: workflow }; + const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)); + documentStore.setName(workflow.name); + documentStore.setNodes(workflow.nodes); + documentStore.setConnections(workflow.connections); + + expect(documentStore.hydrated).toBe(false); + + const { saveCurrentWorkflow } = useWorkflowSaving({ router }); + const saved = await saveCurrentWorkflow({ id: workflow.id }, true, false, false); + + expect(saved).toBe(true); + expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith( + workflow.id, + expect.objectContaining({ id: workflow.id, name: workflow.name }), + false, + ); + }); + it('should not include active=false in the request if the workflow has no activatable trigger node', async () => { const workflow = createTestWorkflow({ id: 'w1', @@ -808,6 +894,7 @@ describe('useWorkflowSaving', () => { describe('autoSaveWorkflow', () => { it('should not schedule autosave if a save is already in progress', () => { + prepareHydratedWorkflow('w-pending-save'); const saveStore = useWorkflowSaveStore(); // Simulate an ongoing save by setting pendingSave @@ -824,6 +911,7 @@ describe('useWorkflowSaving', () => { }); it('should schedule autosave when state is Idle', () => { + prepareHydratedWorkflow('w-schedule'); const saveStore = useWorkflowSaveStore(); // Ensure state is Idle @@ -929,6 +1017,7 @@ describe('useWorkflowSaving', () => { }); workflowsStore.setWorkflowId(workflow.id); + mockRoute.params = { workflowId: workflow.id }; useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow); workflowsListStore.workflowsById = { [workflow.id]: workflow }; @@ -1332,6 +1421,7 @@ describe('useWorkflowSaving', () => { }); it('should not schedule autosave when network is offline', () => { + prepareHydratedWorkflow('w-offline'); const saveStore = useWorkflowSaveStore(); backendConnectionStore.setOnline(false); @@ -1345,6 +1435,7 @@ describe('useWorkflowSaving', () => { }); it('should not schedule autosave when autosave is disabled via environment variable', () => { + prepareHydratedWorkflow('w-disabled'); const autosaveStore = useWorkflowSaveStore(); const settingsStore = mockedStore(useSettingsStore); @@ -1364,6 +1455,7 @@ describe('useWorkflowSaving', () => { }); it('should schedule autosave when autosave is enabled via environment variable', () => { + prepareHydratedWorkflow('w-enabled'); const autosaveStore = useWorkflowSaveStore(); const settingsStore = mockedStore(useSettingsStore); @@ -1383,6 +1475,233 @@ describe('useWorkflowSaving', () => { }); }); + describe('autosave document hydration gate', () => { + const EDITABLE_FEATURES: EditorEnabledFeatures = { + readOnly: false, + expandGroups: 'all', + aiAssistant: false, + aiBuilder: false, + askAi: false, + executionSuccessToasts: false, + executionErrorToasts: false, + }; + + const probe: { current: ReturnType | null } = { current: null }; + + const AutosaveProbe = defineComponent({ + name: 'AutosaveHydrationProbe', + setup() { + probe.current = useWorkflowSaving({ router, ownsAutoSave: true }); + return () => h('div'); + }, + }); + + const AutosaveHostStub = defineComponent({ + name: 'AutosaveHydrationHostStub', + props: { + workflowId: { type: String, required: true }, + }, + setup(props) { + provide( + WorkflowIdKey, + computed(() => props.workflowId), + ); + provide( + EditorEnabledFeaturesKey, + computed(() => EDITABLE_FEATURES), + ); + return () => h(AutosaveProbe); + }, + }); + + function takeProbe(): ReturnType { + const saving = probe.current; + if (!saving) throw new Error('AutosaveHydrationProbe did not initialise'); + return saving; + } + + function mountAutosaveHost(workflowId: string) { + probe.current = null; + const wrapper = mount(AutosaveHostStub, { props: { workflowId } }); + return { wrapper, saving: takeProbe() }; + } + + async function flushAutoSave() { + await vi.advanceTimersByTimeAsync( + getDebounceTime(DEBOUNCE_TIME.API.AUTOSAVE_MAX_WAIT) + 1000, + ); + } + + beforeEach(() => { + vi.useFakeTimers(); + mockedStore(useSettingsStore).isAutosaveEnabled = true; + useWorkflowSaveStore().reset(); + useUIStore().markStateDirty(); + }); + + afterEach(() => { + vi.useRealTimers(); + probe.current = null; + }); + + it('defers scheduling while the current workflow document is not hydrated', async () => { + const workflow = createTestWorkflow({ + id: 'w-hydration-pending', + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + workflowsListStore.workflowsById = { [workflow.id]: workflow }; + const updateSpy = vi + .spyOn(workflowsStore, 'updateWorkflow') + .mockResolvedValue({ ...workflow, checksum: 'test-checksum' }); + const createSpy = vi + .spyOn(workflowsStore, 'createNewWorkflow') + .mockResolvedValue(createTestWorkflow({ id: 'created' })); + const saveStore = useWorkflowSaveStore(); + const uiStore = useUIStore(); + + const { saving } = mountAutosaveHost(workflow.id); + + saving.autoSaveWorkflow(); + await flushAutoSave(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle); + expect(saveStore.retryCount).toBe(0); + expect(uiStore.stateIsDirty).toBe(true); + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('drops a scheduled autosave if navigation lands on an unhydrated document before it fires', async () => { + const workflow = createTestWorkflow({ + id: 'w-hydrated-first', + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + workflowsListStore.workflowsById = { [workflow.id]: workflow }; + useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow); + const updateSpy = vi + .spyOn(workflowsStore, 'updateWorkflow') + .mockResolvedValue({ ...workflow, checksum: 'test-checksum' }); + const createSpy = vi + .spyOn(workflowsStore, 'createNewWorkflow') + .mockResolvedValue(createTestWorkflow({ id: 'created' })); + const saveStore = useWorkflowSaveStore(); + + const { wrapper, saving } = mountAutosaveHost(workflow.id); + + saving.autoSaveWorkflow(); + expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled); + + await wrapper.setProps({ workflowId: 'w-unhydrated-next' }); + await flushAutoSave(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle); + expect(saveStore.retryCount).toBe(0); + expect(useUIStore().stateIsDirty).toBe(true); + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('drops a scheduled autosave if state becomes clean before it fires', async () => { + const workflow = createTestWorkflow({ + id: 'w-clean-before-fire', + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + workflowsListStore.workflowsById = { [workflow.id]: workflow }; + useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow); + const updateSpy = vi + .spyOn(workflowsStore, 'updateWorkflow') + .mockResolvedValue({ ...workflow, checksum: 'test-checksum' }); + const createSpy = vi + .spyOn(workflowsStore, 'createNewWorkflow') + .mockResolvedValue(createTestWorkflow({ id: 'created' })); + const saveStore = useWorkflowSaveStore(); + const uiStore = useUIStore(); + + const { saving } = mountAutosaveHost(workflow.id); + + saving.autoSaveWorkflow(); + expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled); + + uiStore.markStateClean(); + await flushAutoSave(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle); + expect(saveStore.retryCount).toBe(0); + expect(uiStore.stateIsDirty).toBe(false); + expect(updateSpy).not.toHaveBeenCalled(); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('re-arms autosave when an existing workflow document becomes hydrated while dirty', async () => { + const workflow = createTestWorkflow({ + id: 'w-hydration-rearm', + name: 'Hydrated workflow', + nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: false })], + }); + workflowsListStore.workflowsById = { [workflow.id]: workflow }; + const updateSpy = vi + .spyOn(workflowsStore, 'updateWorkflow') + .mockResolvedValue({ ...workflow, checksum: 'test-checksum' }); + const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)); + const saveStore = useWorkflowSaveStore(); + + const { saving } = mountAutosaveHost(workflow.id); + + saving.autoSaveWorkflow(); + expect(saveStore.autoSaveState).toBe(AutoSaveState.Idle); + + documentStore.hydrate(workflow); + await nextTick(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled); + + await flushAutoSave(); + + expect(updateSpy).toHaveBeenCalledWith( + workflow.id, + expect.objectContaining({ + name: workflow.name, + nodes: expect.arrayContaining([ + expect.objectContaining({ name: workflow.nodes[0].name }), + ]), + }), + false, + ); + }); + + it('creates a new workflow by autosave only after the new document is hydrated', async () => { + const newWorkflowId = 'w-new-hydration'; + const createdWorkflow = createTestWorkflow({ + id: 'w-created-from-autosave', + name: 'Named new workflow', + }); + const createSpy = vi + .spyOn(workflowsStore, 'createNewWorkflow') + .mockResolvedValue(createdWorkflow); + const documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(newWorkflowId)); + const saveStore = useWorkflowSaveStore(); + + const { saving } = mountAutosaveHost(newWorkflowId); + + saving.autoSaveWorkflow(); + await flushAutoSave(); + + expect(createSpy).not.toHaveBeenCalled(); + + documentStore.setName('Named new workflow'); + documentStore.setHydrated(true); + await nextTick(); + + expect(saveStore.autoSaveState).toBe(AutoSaveState.Scheduled); + + await flushAutoSave(); + + expect(createSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Named new workflow', autosaved: true }), + ); + }); + }); + describe('autosave on a read-only preview canvas', () => { // Preview hosts (template, workflow history, execution) mount the real // NodeView and supersede the editor context with `readOnly: true`. Opening a @@ -1541,9 +1860,11 @@ describe('useWorkflowSaving', () => { // Instance AI locks the canvas while its agent edits and then releases // it; without this the agent's changes would sit unsaved. const saveStore = useWorkflowSaveStore(); + const workflow = createTestWorkflow({ id: 'w-rearm' }); + useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id)).hydrate(workflow); const wrapper = mount(PreviewHostStub, { - props: { workflowId: 'w-rearm', readOnly: true }, + props: { workflowId: workflow.id, readOnly: true }, }); useUIStore().markStateDirty(); diff --git a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts index 0fe8a1ef43d..018f34f958c 100644 --- a/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts +++ b/packages/frontend/editor-ui/src/app/composables/useWorkflowSaving.ts @@ -87,6 +87,44 @@ export function useWorkflowSaving({ // for the out-of-tree callers, the way `useRunWorkflow` does for the same key. const editorContext = getCurrentInstance() ? useEditorContext() : undefined; const canAutoSave = computed(() => ownsAutoSave && editorContext?.readOnly.value !== true); + const currentWorkflowDocumentStore = computed(() => + useWorkflowDocumentStore(createWorkflowDocumentId(workflowId.value)), + ); + const canScheduleAutoSave = computed(() => { + // Don't schedule from a read-only canvas, or from an instance that doesn't + // own one. Every autosave entry point funnels through here, so a preview + // never writes whatever marked it dirty. + if (!canAutoSave.value) { + return false; + } + + // Don't schedule if autosave is disabled via environment variable + if (!settingsStore.isAutosaveEnabled) { + return false; + } + + // Don't schedule if a save is already in progress - the finally block + // will reschedule if there are pending changes + if (saveStore.pendingSave) { + return false; + } + + // Don't schedule if we're waiting for retry backoff to complete + if (saveStore.isRetrying) { + return false; + } + + // Don't schedule if we're offline + if (!backendConnectionStore.isOnline) { + return false; + } + + if (!currentWorkflowDocumentStore.value.hydrated) { + return false; + } + + return true; + }); async function promptSaveUnsavedWorkflowChanges( next: NavigationGuardNext, @@ -141,6 +179,7 @@ export function useWorkflowSaving({ await cancel(); uiStore.markStateClean(); + cancelAutoSave(); next(); return; @@ -600,8 +639,8 @@ export function useWorkflowSaving({ return; } - // Check if another save is already in progress - if (saveStore.pendingSave) { + if (!uiStore.stateIsDirty || !canScheduleAutoSave.value) { + saveStore.setAutoSaveState(AutoSaveState.Idle); return; } @@ -615,7 +654,7 @@ export function useWorkflowSaving({ saveStore.setAutoSaveState(AutoSaveState.Idle); } // If changes were made during save, reschedule autosave - if (uiStore.stateIsDirty && !saveStore.isRetrying) { + if (uiStore.stateIsDirty && canScheduleAutoSave.value) { saveStore.setAutoSaveState(AutoSaveState.Scheduled); void autoSaveWorkflowDebounced(); } @@ -627,31 +666,7 @@ export function useWorkflowSaving({ ); const scheduleAutoSave = () => { - // Don't schedule from a read-only canvas, or from an instance that doesn't - // own one. Every autosave entry point funnels through here, so a preview - // never writes whatever marked it dirty. - if (!canAutoSave.value) { - return; - } - - // Don't schedule if autosave is disabled via environment variable - if (!settingsStore.isAutosaveEnabled) { - return; - } - - // Don't schedule if a save is already in progress - the finally block - // will reschedule if there are pending changes - if (saveStore.pendingSave) { - return; - } - - // Don't schedule if we're waiting for retry backoff to complete - if (saveStore.isRetrying) { - return; - } - - // Don't schedule if we're offline - if (!backendConnectionStore.isOnline) { + if (!canScheduleAutoSave.value) { return; } @@ -676,23 +691,13 @@ export function useWorkflowSaving({ // Instance AI preview locks the canvas while its agent edits, and nothing // else would save what the agent wrote. Mirrors the AI-builder re-arm in // NodeView. - watch(canAutoSave, (allowed, wasAllowed) => { + // Watch for network coming back online, and for other autosave eligibility + // returning after retry backoff, save completion, or document hydration. + watch(canScheduleAutoSave, (allowed, wasAllowed) => { if (allowed && !wasAllowed && uiStore.stateIsDirty) { scheduleAutoSave(); } }); - - // Watch for network coming back online - watch( - () => backendConnectionStore.isOnline, - (isOnline, wasOnline) => { - if (isOnline && !wasOnline) { - if (uiStore.stateIsDirty) { - scheduleAutoSave(); - } - } - }, - ); } return { diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.test.ts index 0e88c44fbb5..a3e75cc430c 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.test.ts @@ -361,8 +361,11 @@ describe('workflowDocument.store orchestration', () => { const store = useWorkflowDocumentStore(createWorkflowDocumentId('wf-1')); const workflow = buildFullWorkflow(); + expect(store.hydrated).toBe(false); + store.hydrate(workflow); + expect(store.hydrated).toBe(true); expect(store.name).toBe('My Workflow'); expect(store.description).toBe('Sample description'); expect(store.activeVersionId).toBe('ver-123'); @@ -435,6 +438,15 @@ describe('workflowDocument.store orchestration', () => { expect(store.pinnedDataByNodeName).toEqual({}); }); + it('clears hydrated state on reset', () => { + const store = useWorkflowDocumentStore(createWorkflowDocumentId('wf-1')); + + store.hydrate(buildFullWorkflow()); + store.reset(); + + expect(store.hydrated).toBe(false); + }); + it('derives homeProject from the shared owner relation when homeProject is absent', () => { // `GET /workflows/:id` omits the assembled `homeProject` when the sharing // license is inactive, returning the raw `shared` relation instead. diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts index 2bc248f6392..2ac8b012ee5 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument.store.ts @@ -1,6 +1,6 @@ import { defineStore, getActivePinia } from 'pinia'; import { STORES } from '@n8n/stores'; -import { computed, inject, provide, shallowRef, watchEffect, type ShallowRef } from 'vue'; +import { computed, inject, provide, ref, shallowRef, watchEffect, type ShallowRef } from 'vue'; import { WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys'; import { useWorkflowDocumentActive } from './workflowDocument/useWorkflowDocumentActive'; import { useWorkflowDocumentHomeProject } from './workflowDocument/useWorkflowDocumentHomeProject'; @@ -176,6 +176,7 @@ export function getWorkflowDocumentStoreId(id: string) { export function useWorkflowDocumentStore(id: WorkflowDocumentId) { return defineStore(getWorkflowDocumentStoreId(id), () => { const [workflowId, workflowVersion] = id.split('@'); + const hydrated = ref(false); const nodeTypesStore = useNodeTypesStore(); @@ -298,6 +299,10 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { return data; } + function setHydrated(value: boolean) { + hydrated.value = value; + } + function hydrate(workflow: IWorkflowDb) { if (workflow.id !== workflowId) { throw new Error( @@ -356,9 +361,11 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { settings: workflow.settings ?? { ...DEFAULT_SETTINGS }, pinData: workflow.pinData ?? {}, }); + setHydrated(true); } function reset() { + setHydrated(false); workflowDocumentName.setName(''); workflowDocumentDescription.setDescription(''); workflowDocumentActive.setActiveState({ activeVersionId: null, activeVersion: null }); @@ -448,6 +455,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { documentId: id, workflowId, workflowVersion, + hydrated, ...workflowDocumentName, ...workflowDocumentActive, ...workflowDocumentPublicationStatus, @@ -474,6 +482,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) { ...workflowDocumentNodesIssues, ...workflowDocumentNodeGroups, removeAllNodes, + setHydrated, hydrate, reset, getSnapshot,