mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(editor): Gate autosave on document hydration (#36966)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<void>;
|
||||
renderWithComposable((init) => {
|
||||
initializeWorkspaceForNewWorkflow = init.initializeWorkspaceForNewWorkflow;
|
||||
});
|
||||
|
||||
await initializeWorkspaceForNewWorkflow();
|
||||
|
||||
expect(mockWorkflowDocumentStore.setHydrated).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof useWorkflowSaving> | 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<EditorEnabledFeatures>(() => EDITABLE_FEATURES),
|
||||
);
|
||||
return () => h(AutosaveProbe);
|
||||
},
|
||||
});
|
||||
|
||||
function takeProbe(): ReturnType<typeof useWorkflowSaving> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user