refactor(editor): Migrate workflow name to workflowDocument store (#27343)
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions

This commit is contained in:
Alex Grozav 2026-04-07 07:37:52 +07:00 committed by GitHub
parent 9120283009
commit 4aba06d78e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 550 additions and 289 deletions

View File

@ -6,8 +6,12 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
import { STORES } from '@n8n/stores';
import { WorkflowIdKey } from '@/app/constants/injectionKeys';
import { computed } from 'vue';
import { WorkflowIdKey, WorkflowDocumentStoreKey } from '@/app/constants/injectionKeys';
import { computed, shallowRef } from 'vue';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
vi.mock('@n8n/permissions', () => ({
getResourcePermissions: vi.fn(() => ({
@ -68,29 +72,23 @@ const initialState = {
},
};
const pinia = createTestingPinia({ initialState, stubActions: false });
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId('1'));
const renderComponent = createComponentRenderer(MainHeader, {
pinia: createTestingPinia({ initialState }),
pinia,
global: {
stubs: {
WorkflowDetails: {
props: [
'id',
'tags',
'name',
'meta',
'scopes',
'active',
'currentFolder',
'isArchived',
'description',
],
props: ['id', 'tags', 'name', 'currentFolder', 'isArchived', 'description'],
template: '<div data-test-id="workflow-details-stub"></div>',
},
GithubButton: { template: '<div></div>' },
TabBar: { template: '<div></div>' },
},
provide: {
[WorkflowIdKey]: computed(() => 'test-workflow-id'),
[WorkflowIdKey as symbol]: computed(() => 'test-workflow-id'),
[WorkflowDocumentStoreKey as symbol]: shallowRef(workflowDocumentStore),
},
},
});
@ -122,6 +120,8 @@ describe('MainHeader', () => {
meta: {},
};
workflowDocumentStore.setName('Test Workflow');
sourceControlStore.preferences.branchReadOnly = false;
vi.spyOn(collaborationStore, 'shouldBeReadOnly', 'get').mockReturnValue(false);
});

View File

@ -74,6 +74,7 @@ const hideMenuBar = computed(() =>
const workflow = computed(() => workflowsStore.workflow);
const workflowId = useInjectWorkflowId();
const workflowDocumentStore = inject(WorkflowDocumentStoreKey, null);
const workflowName = computed(() => workflowDocumentStore?.value?.name ?? '');
const workflowTags = computed(() => workflowDocumentStore?.value?.tags ?? []);
const workflowIsArchived = computed(() => workflowDocumentStore?.value?.isArchived ?? false);
const onWorkflowPage = computed(() => !!(route.meta.nodeView || route.meta.keepWorkflowAlive));
@ -288,10 +289,10 @@ async function onWorkflowDeactivated() {
>
<div v-show="!hideMenuBar && !settingsStore.isCanvasOnly" :class="$style['top-menu']">
<WorkflowDetails
v-if="workflow?.name"
v-if="workflowName"
:id="workflow.id"
:tags="workflowTags"
:name="workflow.name"
:name="workflowName"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflowIsArchived"
:description="workflow.description"

View File

@ -15,7 +15,6 @@ import { useInjectWorkflowId } from '@/app/composables/useInjectWorkflowId';
import { useMessage } from '@/app/composables/useMessage';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { nodeViewEventBus } from '@/app/event-bus';
import type { IWorkflowDb } from '@/Interface';
import type { FolderShortInfo } from '@/features/core/folders/folders.types';
@ -83,7 +82,6 @@ const message = useMessage();
const toast = useToast();
const documentTitle = useDocumentTitle();
const workflowId = useInjectWorkflowId();
const workflowState = injectWorkflowState();
const workflowDocumentStore = inject(WorkflowDocumentStoreKey, null);
const isTagsEditEnabled = ref(false);
@ -215,7 +213,8 @@ function onNameSubmit(name: string) {
}
// Update workflow name in store and mark state as dirty
workflowState.setWorkflowName({ newName, setStateDirty: true });
workflowDocumentStore?.value?.setName(newName);
uiStore.markStateDirty('metadata');
documentTitle.setDocumentTitle(newName, 'IDLE');
renameInput.value?.forceCancel();

View File

@ -1,8 +1,8 @@
import { nextTick, reactive } from 'vue';
import { reactive } from 'vue';
import { flushPromises } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import type { MockInstance } from 'vitest';
import { waitFor, within } from '@testing-library/vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import type { FrontendSettings } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
@ -71,6 +71,14 @@ const createComponent = createComponentRenderer(WorkflowSettingsVue, {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
// Stub ElSwitch to prevent spurious update:model-value emissions in jsdom.
// userEvent.click simulates pointer movement that can trigger the switch
// during mouse path traversal, toggling executionTimeout and breaking save.
ElSwitch: {
props: ['modelValue', 'disabled'],
template:
'<span :data-test-id="$attrs[\'data-test-id\']" :aria-checked="!!modelValue" role="switch" />',
},
},
},
});
@ -100,8 +108,8 @@ describe('WorkflowSettingsVue', () => {
releaseChannel: 'stable',
});
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.workflowId = '1';
workflowDocumentStore.setName('Test Workflow');
// Populate workflowsById to mark workflow as existing (not new)
const testWorkflow = createTestWorkflow({
id: '1',
@ -121,7 +129,7 @@ describe('WorkflowSettingsVue', () => {
it('should render correctly', async () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = false;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(getByTestId('workflow-settings-dialog')).toBeVisible();
});
@ -129,7 +137,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = false;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(
within(getByTestId('workflow-settings-dialog')).queryByTestId('workflow-caller-policy'),
@ -140,7 +148,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(getByTestId('workflow-caller-policy')).toBeVisible();
});
@ -149,7 +157,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('workflow-caller-policy'));
await userEvent.click(dropdownItems[2]);
@ -161,7 +169,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('error-workflow'));
// first is `- No Workflow -`, second is the workflow returned by
@ -181,7 +189,7 @@ describe('WorkflowSettingsVue', () => {
});
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('error-workflow'));
expect(dropdownItems[0]).toHaveTextContent('No Workflow');
@ -203,12 +211,21 @@ describe('WorkflowSettingsVue', () => {
});
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('error-workflow'));
// Open the error workflow dropdown
const errorWorkflowRow = getByTestId('error-workflow');
const combobox = within(errorWorkflowRow).getByRole('combobox');
await userEvent.click(combobox);
// Select "No Workflow" (first option)
await userEvent.click(dropdownItems[0]);
// Wait for dropdown to appear and select "No Workflow"
await waitFor(async () => {
const option = within(document.body as HTMLElement).getAllByRole('option');
const noWorkflow = option.find((o) => o.textContent?.includes('No Workflow'));
expect(noWorkflow).toBeTruthy();
await userEvent.click(noWorkflow!);
});
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
@ -226,12 +243,20 @@ describe('WorkflowSettingsVue', () => {
});
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('error-workflow'));
// Open the error workflow dropdown
const errorWorkflowRow = getByTestId('error-workflow');
await userEvent.click(within(errorWorkflowRow).getByRole('combobox'));
// Select the test workflow (second option)
await userEvent.click(dropdownItems[1]);
// Wait for dropdown and select the test workflow
await waitFor(async () => {
const options = within(document.body as HTMLElement).getAllByRole('option');
const testWorkflow = options.find((o) => o.textContent?.includes('Test Workflow'));
expect(testWorkflow).toBeTruthy();
await userEvent.click(testWorkflow!);
});
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
@ -251,7 +276,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('workflow-caller-policy'));
await userEvent.click(dropdownItems[2]);
@ -268,7 +293,7 @@ describe('WorkflowSettingsVue', () => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.Sharing] = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId('workflow-caller-policy'));
await userEvent.click(dropdownItems[2]);
@ -316,7 +341,7 @@ describe('WorkflowSettingsVue', () => {
async (testId, optionText, storeSetter) => {
storeSetter();
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(getByTestId(testId));
@ -327,7 +352,7 @@ describe('WorkflowSettingsVue', () => {
it('should save time saved per execution correctly', async () => {
workflowDocumentStore.setSettings({ timeSavedMode: 'fixed' });
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
});
@ -350,7 +375,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ timeSavedMode: 'fixed', timeSavedPerExecution: 10 });
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
});
@ -377,7 +402,7 @@ describe('WorkflowSettingsVue', () => {
sourceControlStore.preferences.branchReadOnly = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
});
@ -402,7 +427,7 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => readOnlyWorkflow);
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-time-saved-per-execution')).toBeVisible();
});
@ -417,7 +442,7 @@ describe('WorkflowSettingsVue', () => {
describe('Execution Order & Binary Mode', () => {
it('should render execution order dropdown with correct options', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-execution-order'),
@ -436,7 +461,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ executionOrder: 'v1', binaryMode: BINARY_MODE_COMBINED });
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-execution-order'),
@ -460,7 +485,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ executionOrder: 'v0', binaryMode: BINARY_MODE_COMBINED });
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-execution-order'),
@ -484,7 +509,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ executionOrder: 'v1', binaryMode: BINARY_MODE_COMBINED });
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-execution-order'),
@ -508,7 +533,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ executionOrder: 'v0', binaryMode: 'separate' });
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
toast.showMessage.mockClear();
@ -528,7 +553,7 @@ describe('WorkflowSettingsVue', () => {
});
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-execution-order'),
@ -541,7 +566,7 @@ describe('WorkflowSettingsVue', () => {
sourceControlStore.preferences.branchReadOnly = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const executionOrderDropdown = within(
getByTestId('workflow-settings-execution-order'),
@ -566,7 +591,7 @@ describe('WorkflowSettingsVue', () => {
}));
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const executionOrderDropdown = within(
getByTestId('workflow-settings-execution-order'),
@ -628,14 +653,14 @@ describe('WorkflowSettingsVue', () => {
it('should render credential resolver dropdown', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(getByTestId('workflow-settings-credential-resolver')).toBeVisible();
});
it('should load credential resolvers on mount', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
@ -654,7 +679,7 @@ describe('WorkflowSettingsVue', () => {
it('should show "New" button for creating a new resolver', async () => {
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-credential-resolver-create-new')).toBeInTheDocument();
@ -663,7 +688,7 @@ describe('WorkflowSettingsVue', () => {
it('should not show "Edit" button when no resolver is selected', async () => {
const { queryByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(queryByTestId('workflow-settings-credential-resolver-edit')).not.toBeInTheDocument();
@ -674,7 +699,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-1' });
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(getByTestId('workflow-settings-credential-resolver-edit')).toBeInTheDocument();
@ -685,7 +710,7 @@ describe('WorkflowSettingsVue', () => {
workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-n8n' });
const { queryByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolverTypes).toHaveBeenCalled();
@ -698,7 +723,7 @@ describe('WorkflowSettingsVue', () => {
it('should select a resolver from dropdown', async () => {
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
@ -708,30 +733,44 @@ describe('WorkflowSettingsVue', () => {
getByTestId('workflow-settings-credential-resolver'),
);
// Select "Test Resolver 1"
expect(dropdownItems).toHaveLength(3);
expect(dropdownItems[0]).toHaveTextContent('Test Resolver 1');
expect(dropdownItems[1]).toHaveTextContent('Test Resolver 2');
expect(dropdownItems[2]).toHaveTextContent('N8n Resolver');
await userEvent.click(dropdownItems[0]);
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
const callArgs = workflowsStore.updateWorkflow.mock.calls[0];
expect(callArgs[0]).toBe('1');
expect(callArgs[1].settings?.credentialResolverId).toBe('resolver-1');
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
'1',
expect.objectContaining({
settings: expect.objectContaining({ credentialResolverId: 'resolver-1' }),
}),
);
});
it('should save workflow with selected resolver', async () => {
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
});
const dropdownItems = await getDropdownItems(
getByTestId('workflow-settings-credential-resolver'),
);
// Open the credential resolver dropdown
const resolverContainer = getByTestId('workflow-settings-credential-resolver');
await userEvent.click(within(resolverContainer).getByRole('combobox'));
// Select "Test Resolver 2"
await userEvent.click(dropdownItems[1]);
// Wait for dropdown and select "Test Resolver 2"
await waitFor(async () => {
const options = within(document.body as HTMLElement).getAllByRole('option');
const resolver = options.find((o) => o.textContent?.includes('Test Resolver 2'));
expect(resolver).toBeTruthy();
await userEvent.click(resolver!);
});
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
@ -741,29 +780,55 @@ describe('WorkflowSettingsVue', () => {
});
it('should save with empty credentialResolverId when resolver is cleared', async () => {
// Element Plus clearable sets the model value to '' when the clear icon is clicked.
// The clear icon requires CSS hover state which jsdom cannot simulate,
// so we verify the save behavior when the value is already empty.
workflowDocumentStore.setSettings({ credentialResolverId: '' });
workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-1' });
const { getByRole } = createComponent({ pinia });
// flushPromises drains the full microtask queue, ensuring onMounted's
// Promise.all (loadCredentialResolvers, loadWorkflows, etc.) fully resolves
// and workflowSettings.value is initialized before we click Save.
const { getByTestId, getByRole } = createComponent({ pinia });
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
});
const dropdown = getByTestId('workflow-settings-credential-resolver');
const input = dropdown.querySelector('input') as HTMLInputElement;
// Wait for the select to display the selected resolver
await waitFor(() => {
expect(input?.value).toBe('Test Resolver 1');
});
// Hover over the select trigger to reveal the clear icon.
// Element Plus toggles the clear icon via a JS mouseenter handler on
// .select-trigger (not CSS :hover), and Vue re-renders asynchronously.
const selectTrigger = dropdown.querySelector('.select-trigger') as HTMLElement;
const arrowIcon = dropdown.querySelector('.el-icon');
selectTrigger.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }));
// Wait for Vue to swap the arrow icon for the clear icon (different VNode keys)
await waitFor(() => {
expect(dropdown.querySelector('.el-icon')).not.toBe(arrowIcon);
});
// Click the clear icon
const clearIcon = dropdown.querySelector('.el-icon') as HTMLElement;
await fireEvent.click(clearIcon);
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
const callArgs = workflowsStore.updateWorkflow.mock.calls[0];
expect(callArgs[0]).toBe('1');
expect(callArgs[1].settings?.credentialResolverId).toBe('');
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
'1',
expect.objectContaining({
settings: expect.objectContaining({ credentialResolverId: '' }),
}),
);
});
it('should disable credential resolver dropdown when environment is read-only', async () => {
sourceControlStore.preferences.branchReadOnly = true;
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownContainer = getByTestId('workflow-settings-credential-resolver');
const input = dropdownContainer.querySelector('input');
@ -786,7 +851,7 @@ describe('WorkflowSettingsVue', () => {
}));
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const dropdownContainer = getByTestId('workflow-settings-credential-resolver');
const input = dropdownContainer.querySelector('input');
@ -799,7 +864,7 @@ describe('WorkflowSettingsVue', () => {
});
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
await waitFor(() => {
expect(restApiClient.getCredentialResolvers).toHaveBeenCalled();
@ -903,6 +968,13 @@ describe('WorkflowSettingsVue', () => {
});
describe('Redaction Policy', () => {
it('should not render redaction policy when env feature flag is missing', async () => {
const { queryByTestId } = createComponent({ pinia });
await flushPromises();
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
});
it('should not render redaction policy when redaction module is inactive', async () => {
vi.spyOn(settingsStore, 'isModuleActive').mockImplementation(
(name: string) => name !== 'redaction',
@ -918,7 +990,7 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
const { queryByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
});
@ -927,7 +999,7 @@ describe('WorkflowSettingsVue', () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
const { queryByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(queryByTestId('workflow-settings-redaction-policy')).not.toBeInTheDocument();
});
@ -944,7 +1016,7 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
expect(getByTestId('workflow-settings-redaction-policy')).toBeVisible();
});
@ -962,7 +1034,7 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
const { getByTestId } = createComponent({ pinia });
await nextTick();
await flushPromises();
const productionItems = await getDropdownItems(
getByTestId('workflow-settings-redact-production-select'),
@ -981,6 +1053,7 @@ describe('WorkflowSettingsVue', () => {
it('should save redaction policy as non-manual when only production is set to redact', async () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
settingsStore.settings.envFeatureFlags.N8N_ENV_FEAT_REDACTION_POLICY = true;
const workflowWithRedactionScope = createTestWorkflow({
id: '1',
@ -992,14 +1065,24 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const productionItems = await getDropdownItems(
getByTestId('workflow-settings-redact-production-select'),
);
await userEvent.click(productionItems[1]);
// Open the production redaction dropdown
const productionSelect = getByTestId('workflow-settings-redact-production-select');
await userEvent.click(within(productionSelect).getByRole('combobox'));
// Select "Redact"
await waitFor(async () => {
const options = within(document.body as HTMLElement).getAllByRole('option');
const redactOption = options.find((o) => o.textContent?.trim() === 'Redact');
expect(redactOption).toBeTruthy();
await userEvent.click(redactOption!);
});
await flushPromises();
toast.showError.mockClear();
await userEvent.click(getByRole('button', { name: 'Save' }));
expect(toast.showError).not.toHaveBeenCalled();
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
expect.any(String),
@ -1022,17 +1105,31 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
const { getByTestId, getByRole } = createComponent({ pinia });
await nextTick();
await flushPromises();
const productionItems = await getDropdownItems(
getByTestId('workflow-settings-redact-production-select'),
);
await userEvent.click(productionItems[1]);
// Open the production redaction dropdown and select "Redact"
const productionSelect = getByTestId('workflow-settings-redact-production-select');
await userEvent.click(within(productionSelect).getByRole('combobox'));
await waitFor(async () => {
const options = within(document.body as HTMLElement).getAllByRole('option');
const redactOption = options.find((o) => o.textContent?.trim() === 'Redact');
expect(redactOption).toBeTruthy();
await userEvent.click(redactOption!);
});
await flushPromises();
const manualItems = await getDropdownItems(
getByTestId('workflow-settings-redact-manual-select'),
);
await userEvent.click(manualItems[1]);
// Open the manual redaction dropdown and select "Redact"
const manualSelect = getByTestId('workflow-settings-redact-manual-select');
await userEvent.click(within(manualSelect).getByRole('combobox'));
await waitFor(async () => {
const options = within(document.body as HTMLElement).getAllByRole('option');
const redactOption = options.find(
(o) => o.textContent?.trim() === 'Redact' && !o.classList.contains('selected'),
);
expect(redactOption).toBeTruthy();
await userEvent.click(redactOption!);
});
await flushPromises();
await userEvent.click(getByRole('button', { name: 'Save' }));
@ -1046,6 +1143,22 @@ describe('WorkflowSettingsVue', () => {
it('should disable production redaction select and force "Redact" when dynamic credentials are configured', async () => {
vi.spyOn(settingsStore, 'isModuleActive').mockReturnValue(true);
vi.mocked(restApiClient.getCredentialResolvers).mockResolvedValue([
{
id: 'resolver-1',
name: 'Test Resolver 1',
type: 'editable-type',
config: '{}',
createdAt: new Date(),
updatedAt: new Date(),
},
]);
vi.mocked(restApiClient.getCredentialResolverTypes).mockResolvedValue([
{ name: 'editable-type', displayName: 'Editable', options: [] },
]);
const rbacStore = useRBACStore();
rbacStore.addGlobalScope('credentialResolver:list');
const workflowWithRedactionScope = createTestWorkflow({
id: '1',
name: 'Test Workflow',
@ -1055,17 +1168,21 @@ describe('WorkflowSettingsVue', () => {
workflowsListStore.workflowsById = { '1': workflowWithRedactionScope };
workflowsListStore.getWorkflowById.mockImplementation(() => workflowWithRedactionScope);
workflowDocumentStore.setSettings({ credentialResolverId: 'some-resolver-id' });
workflowDocumentStore.setSettings({ credentialResolverId: 'resolver-1' });
const { getByTestId } = createComponent({ pinia });
await flushPromises();
await nextTick();
// Verify the credential resolver dropdown shows the selected resolver
await waitFor(() => {
const resolverDropdown = getByTestId('workflow-settings-credential-resolver');
const resolverInput = resolverDropdown.querySelector('input') as HTMLInputElement;
expect(resolverInput.value).toBe('Test Resolver 1');
});
// Verify the dropdown cannot be opened (disabled by workflowHasDynamicCredentials)
const productionSelect = getByTestId('workflow-settings-redact-production-select');
const dropdownItems = await getDropdownItems(productionSelect).catch(() => null);
expect(dropdownItems).toBeNull();
const input = productionSelect.querySelector('input');
expect(input).toBeDisabled();
});
});
});

View File

@ -192,7 +192,7 @@ const isMCPEnabled = computed(
const readOnlyEnv = computed(
() => sourceControlStore.preferences.branchReadOnly || collaborationStore.shouldBeReadOnly,
);
const workflowName = computed(() => workflowsStore.workflowName);
const workflowName = computed(() => workflowDocumentStore.value?.name ?? '');
const workflowId = computed(() => workflowsStore.workflowId);
const workflow = computed(() => workflowsListStore.getWorkflowById(workflowId.value));
const isSharingEnabled = computed(

View File

@ -57,16 +57,34 @@ const router = useRouter();
const route = useRoute();
const workflowSaving = useWorkflowSaving({ router });
const workflow = ref(
data.id && workflowsListStore.workflowsById[data.id]
? workflowsListStore.workflowsById[data.id]
: workflowsStore.workflow,
const workflowDocumentStore = computed(() =>
data.id ? useWorkflowDocumentStore(createWorkflowDocumentId(data.id)) : undefined,
);
const workflowListEntry = computed(() => workflowsListStore.workflowsById[data.id]);
const workflowId = computed(() => data.id);
const workflowName = computed(
() => workflowListEntry.value?.name ?? workflowDocumentStore.value?.name ?? '',
);
const workflowHomeProject = computed(
() =>
workflowListEntry.value?.homeProject ??
workflowDocumentStore.value?.homeProject ??
workflowsStore.workflow.homeProject,
);
const workflowScopes = computed(
() =>
workflowListEntry.value?.scopes ??
workflowDocumentStore.value?.scopes ??
workflowsStore.workflow.scopes,
);
const workflowSharedWithProjects = computed(
() => workflowListEntry.value?.sharedWithProjects ?? workflowsStore.workflow.sharedWithProjects,
);
const loading = ref(true);
const isDirty = ref(false);
const modalBus = createEventBus();
const sharedWithProjects = ref([
...(workflow.value.sharedWithProjects ?? []),
...(workflowSharedWithProjects.value ?? []),
] as ProjectSharingData[]);
const teamProject = ref(null as Project | null);
@ -74,12 +92,12 @@ const isSharingEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
);
const isHomeTeamProject = computed(() => workflow.value.homeProject?.type === ProjectTypes.Team);
const isHomeTeamProject = computed(() => workflowHomeProject.value?.type === ProjectTypes.Team);
const modalTitle = computed(() => {
if (isHomeTeamProject.value) {
return i18n.baseText('workflows.shareModal.title.static', {
interpolate: { projectName: workflow.value.homeProject?.name ?? '' },
interpolate: { projectName: workflowHomeProject.value?.name ?? '' },
});
}
@ -88,34 +106,31 @@ const modalTitle = computed(() => {
? (uiStore.contextBasedTranslationKeys.workflows.sharing.title as BaseTextKey)
: (uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.title as BaseTextKey),
{
interpolate: { name: workflow.value.name },
interpolate: {
name: workflowName.value,
},
},
);
});
const workflowPermissions = computed(() => {
// For existing workflows, scopes come from the API response on the workflow object.
// For new unsaved workflows, scopes are only in the workflowDocument store.
const scopes =
workflow.value?.scopes ??
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.value.id)).scopes;
return getResourcePermissions(scopes).workflow;
return getResourcePermissions(workflowScopes.value).workflow;
});
const isPersonalSpaceRestricted = computed(
() =>
workflow.value.homeProject?.type === ProjectTypes.Personal &&
workflow.value.homeProject?.id === projectsStore.personalProject?.id &&
workflowHomeProject.value?.type === ProjectTypes.Personal &&
workflowHomeProject.value?.id === projectsStore.personalProject?.id &&
!workflowPermissions.value.share,
);
const workflowOwnerName = computed(() =>
workflowsEEStore.getWorkflowOwnerName(`${workflow.value.id}`),
workflowsEEStore.getWorkflowOwnerName(`${workflowId.value}`),
);
const searchFn = useRemoteProjectSearch();
const filterFn = (project: ProjectListItem) =>
project.type === 'personal' && project.id !== workflow.value.homeProject?.id;
project.type === 'personal' && project.id !== workflowHomeProject.value?.id;
const numberOfMembersInHomeTeamProject = computed(() => teamProject.value?.relations.length ?? 0);
@ -143,21 +158,21 @@ const workflowRoles = computed(() =>
const trackTelemetry = (eventName: string, data: ITelemetryTrackProperties) => {
telemetry.track(eventName, {
workflow_id: workflow.value.id,
workflow_id: workflowId.value,
...data,
});
};
const onProjectAdded = (project: ProjectSharingData) => {
trackTelemetry('User selected sharee to add', {
project_id_sharer: workflow.value.homeProject?.id,
project_id_sharer: workflowHomeProject.value?.id,
project_id_sharee: project.id,
});
};
const onProjectRemoved = (project: ProjectSharingData) => {
trackTelemetry('User selected sharee to remove', {
project_id_sharer: workflow.value.homeProject?.id,
project_id_sharer: workflowHomeProject.value?.id,
project_id_sharee: project.id,
});
};
@ -170,7 +185,7 @@ const onSave = async () => {
loading.value = true;
const saveWorkflowPromise = async () => {
if (!workflowsStore.isWorkflowSaved[workflow.value.id]) {
if (!workflowsStore.isWorkflowSaved[workflowId.value]) {
const parentFolderId = route.query.folderId as string | undefined;
const workflowId = await workflowSaving.saveAsNewWorkflow({ parentFolderId });
if (!workflowId) {
@ -178,7 +193,7 @@ const onSave = async () => {
}
return workflowId;
} else {
return workflow.value.id;
return workflowId.value;
}
};
@ -228,12 +243,12 @@ const goToUpgrade = () => {
const initialize = async () => {
if (isSharingEnabled.value) {
// Fetch workflow if it exists and is not new
if (workflowsStore.isWorkflowSaved[workflow.value.id]) {
await workflowsListStore.fetchWorkflow(workflow.value.id);
if (workflowsStore.isWorkflowSaved[workflowId.value]) {
await workflowsListStore.fetchWorkflow(workflowId.value);
}
if (isHomeTeamProject.value && workflow.value.homeProject) {
teamProject.value = await projectsStore.fetchProject(workflow.value.homeProject.id);
if (isHomeTeamProject.value && workflowHomeProject.value) {
teamProject.value = await projectsStore.fetchProject(workflowHomeProject.value.id);
}
}
@ -289,7 +304,7 @@ watch(
<div>
<ProjectSharing
v-model="sharedWithProjects"
:home-project="workflow.homeProject"
:home-project="workflowHomeProject"
:search-fn="searchFn"
:filter-fn="filterFn"
:roles="workflowRoles"
@ -308,7 +323,7 @@ watch(
<N8nInfoTip v-if="isHomeTeamProject" :bold="false" class="mt-s">
<I18nT keypath="workflows.shareModal.info.members" tag="span" scope="global">
<template #projectName>
{{ workflow.homeProject?.name }}
{{ workflowHomeProject?.name }}
</template>
<template #members>
<strong>

View File

@ -4596,7 +4596,7 @@ describe('useCanvasOperations', () => {
renameNode: vi.fn(),
});
const setWorkflowName = vi.spyOn(workflowState, 'setWorkflowName');
const setNameSpy = vi.spyOn(workflowDocumentStoreInstance, 'setName');
const canvasOperations = useCanvasOperations();
const workflowDataWithName = {
@ -4607,10 +4607,7 @@ describe('useCanvasOperations', () => {
await canvasOperations.importWorkflowData(workflowDataWithName, 'file');
expect(setWorkflowName).toHaveBeenCalledWith({
newName: 'Test Workflow Name',
setStateDirty: true,
});
expect(setNameSpy).toHaveBeenCalledWith('Test Workflow Name');
});
it('should not crash when importing nodes that exceed maxNodes limit', async () => {
@ -6324,9 +6321,9 @@ describe('useCanvasOperations', () => {
uiStore.lastInteractedWithNodeHandle = `outputs/${NodeConnectionTypes.AiTool}/0`;
const { addNode } = useCanvasOperations();
const docStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
const addConnectionSpy = vi.spyOn(docStore, 'addConnection');
const removeConnectionSpy = vi.spyOn(docStore, 'removeConnection');
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
const addConnectionSpy = vi.spyOn(workflowDocumentStore, 'addConnection');
const removeConnectionSpy = vi.spyOn(workflowDocumentStore, 'removeConnection');
// Act: Add HITL node
await nextTick();

View File

@ -2726,7 +2726,10 @@ export function useCanvasOperations() {
}
if (workflowData.name) {
workflowState.setWorkflowName({ newName: workflowData.name, setStateDirty });
workflowDocumentStore.value?.setName(workflowData.name);
if (setStateDirty) {
uiStore.markStateDirty('metadata');
}
}
return workflowData;
@ -3005,7 +3008,11 @@ export function useCanvasOperations() {
workflowDocumentStore.value?.setConnections(workflow.connections);
}
await addNodes(convertedNodes ?? [], { keepPristine: true });
await workflowState.getNewWorkflowData(name, projectsStore.currentProjectId);
const workflowData = await workflowState.getNewWorkflowData(
name,
projectsStore.currentProjectId,
);
workflowDocumentStore.value?.setName(workflowData.name);
}
function tryToOpenSubworkflowInNewTab(nodeId: string): boolean {

View File

@ -10,14 +10,14 @@ export async function workflowActivated({ data }: WorkflowActivated) {
const { initializeWorkspace } = useCanvasOperations();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const documentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const bannersStore = useBannersStore();
const uiStore = useUIStore();
const { workflowId, activeVersionId } = data;
const workflowIsBeingViewed = workflowsStore.workflowId === workflowId;
const activeVersionChanged = documentStore?.value?.activeVersionId !== activeVersionId;
const activeVersionChanged = workflowDocumentStore?.value?.activeVersionId !== activeVersionId;
if (workflowIsBeingViewed && activeVersionChanged) {
// Only update workflow if there are no unsaved changes
if (!uiStore.stateIsDirty) {

View File

@ -9,7 +9,7 @@ import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated) {
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const documentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const { initializeWorkspace } = useCanvasOperations();
const bannersStore = useBannersStore();
const uiStore = useUIStore();
@ -26,7 +26,7 @@ export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated)
// initializeWorkspace calls initState which sets the document store
await initializeWorkspace(updatedWorkflow);
} else {
documentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
}
bannersStore.pushBannerToStack('WORKFLOW_AUTO_DEACTIVATED');

View File

@ -9,7 +9,7 @@ export async function workflowDeactivated({ data }: WorkflowDeactivated) {
const { initializeWorkspace } = useCanvasOperations();
const workflowsStore = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const documentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const uiStore = useUIStore();
if (workflowsStore.workflowId === data.workflowId) {
@ -22,7 +22,7 @@ export async function workflowDeactivated({ data }: WorkflowDeactivated) {
// initializeWorkspace calls initState which sets the document store
await initializeWorkspace(updatedWorkflow);
} else {
documentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
}
}
}

View File

@ -10,14 +10,14 @@ export async function workflowFailedToActivate(
_options: { workflowState: WorkflowState },
) {
const workflowsStore = useWorkflowsStore();
const documentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
if (workflowsStore.workflowId !== data.workflowId) {
return;
}
workflowsStore.setWorkflowInactive(data.workflowId);
documentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore?.value?.setActiveState({ activeVersionId: null, activeVersion: null });
const toast = useToast();
const i18n = useI18n();

View File

@ -6,6 +6,10 @@ import { renderComponent } from '@/__tests__/render';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { injectWorkflowState, useWorkflowState, type WorkflowState } from './useWorkflowState';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
vi.mock('@/app/composables/useWorkflowState', async () => {
const actual = await vi.importActual('@/app/composables/useWorkflowState');
@ -118,10 +122,14 @@ describe('useResolvedExpression', () => {
it('should re-resolve when workflow name changes', async () => {
const workflowsStore = useWorkflowsStore();
workflowsStore.workflow.id = 'test-workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId('test-workflow'),
);
const resolveExpressionSpy = mockResolveExpression();
resolveExpressionSpy.mockImplementation(async () => workflowsStore.workflow.name);
resolveExpressionSpy.mockImplementation(async () => workflowDocumentStore.name);
workflowState.setWorkflowName({ newName: 'Old Name', setStateDirty: false });
workflowDocumentStore.setName('Old Name');
const { resolvedExpressionString } = await renderTestComponent({
expression: '={{ $workflow.name }}',
@ -135,7 +143,7 @@ describe('useResolvedExpression', () => {
expect(toValue(resolvedExpressionString)).toBe('Old Name');
// Update name and expect re-resolution
workflowState.setWorkflowName({ newName: 'New Name', setStateDirty: false });
workflowDocumentStore.setName('New Name');
await nextTick();
vi.advanceTimersByTime(200);
await nextTick();

View File

@ -18,6 +18,10 @@ import {
watch,
} from 'vue';
import { useWorkflowHelpers, type ResolveParameterOptions } from './useWorkflowHelpers';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { ExpressionLocalResolveContextSymbol } from '@/app/constants';
import type { ExpressionLocalResolveContext } from '@/app/types/expressions';
@ -36,6 +40,11 @@ export function useResolvedExpression({
}) {
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
workflowsStore.workflowId
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
: undefined,
);
const { resolveExpression } = useWorkflowHelpers();
@ -119,7 +128,7 @@ export function useResolvedExpression({
toRef(additionalData),
() => workflowsStore.getWorkflowExecution,
() => workflowsStore.getWorkflowRunData,
() => workflowsStore.workflow.name,
() => workflowDocumentStore.value?.name,
targetItem,
],
debouncedUpdateExpression,

View File

@ -247,7 +247,6 @@ describe('useWorkflowHelpers', () => {
});
const addWorkflowSpy = vi.spyOn(workflowsListStore, 'addWorkflow');
const setWorkflowIdSpy = vi.spyOn(workflowState, 'setWorkflowId');
const setWorkflowNameSpy = vi.spyOn(workflowState, 'setWorkflowName');
const setWorkflowVersionDataSpy = vi.spyOn(workflowsStore, 'setWorkflowVersionData');
const setWorkflowSharedWithSpy = vi.spyOn(workflowsEEStore, 'setWorkflowSharedWith');
const upsertTagsSpy = vi.spyOn(tagsStore, 'upsertTags');
@ -256,10 +255,6 @@ describe('useWorkflowHelpers', () => {
expect(addWorkflowSpy).toHaveBeenCalledWith(workflowData);
expect(setWorkflowIdSpy).toHaveBeenCalledWith('1');
expect(setWorkflowNameSpy).toHaveBeenCalledWith({
newName: 'Test Workflow',
setStateDirty: false,
});
expect(setWorkflowVersionDataSpy).toHaveBeenCalledWith({
versionId: 'v1',
name: null,
@ -322,12 +317,12 @@ describe('useWorkflowHelpers', () => {
};
workflowsStore.workflowId = workflowId;
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.allNodes = [];
workflowsStore.workflow.versionId = 'v1';
const documentId = createWorkflowDocumentId(workflowId);
const workflowDocumentStore = useWorkflowDocumentStore(documentId);
Object.defineProperty(workflowDocumentStore, 'name', { value: 'Test Workflow' });
Object.defineProperty(workflowDocumentStore, 'connectionsBySourceNode', {
value: initialConnections,
configurable: true,
@ -354,7 +349,6 @@ describe('useWorkflowHelpers', () => {
const tagIds = ['tag1', 'tag2'];
workflowsStore.workflowId = workflowId;
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.allNodes = [];
workflowsStore.isWorkflowActive = false;
workflowsStore.workflow.settings = { executionOrder: 'v1' };
@ -368,7 +362,8 @@ describe('useWorkflowHelpers', () => {
configurable: true,
});
// Note: createTestingPinia() stubs actions by default, so setTags()/setSettings() won't work
// Note: createTestingPinia() stubs actions by default, so setTags()/setSettings()/setName() won't work
Object.defineProperty(workflowDocumentStore, 'name', { value: 'Test Workflow' });
Object.defineProperty(workflowDocumentStore, 'tags', {
value: tagIds,
});

View File

@ -610,7 +610,7 @@ export function useWorkflowHelpers() {
const connections = deepCopy(workflowConnections);
const data: WorkflowData = {
name: workflowsStore.workflowName,
name: workflowDocumentStore.name,
nodes,
pinData: workflowDocumentStore.getPinDataSnapshot(),
connections,
@ -889,17 +889,17 @@ export function useWorkflowHelpers() {
uiStore.markStateClean();
}
const docStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflowId));
if (workflow.activeVersion) {
workflowsStore.setWorkflowActive(workflowId, workflow.activeVersion, isCurrentWorkflow);
docStore.setActiveState({
workflowDocumentStore.setActiveState({
activeVersionId: workflow.activeVersion.versionId,
activeVersion: workflow.activeVersion,
});
} else {
workflowsStore.setWorkflowInactive(workflowId);
docStore.setActiveState({
workflowDocumentStore.setActiveState({
activeVersionId: null,
activeVersion: null,
});
@ -986,10 +986,6 @@ export function useWorkflowHelpers() {
workflowsListStore.addWorkflow(workflowData);
workflowsStore.setDescription(workflowData.description);
ws.setWorkflowId(workflowData.id);
ws.setWorkflowName({
newName: workflowData.name,
setStateDirty: uiStore.stateIsDirty,
});
workflowsStore.setWorkflowVersionData({
versionId: workflowData.versionId,
name: null,
@ -1036,7 +1032,12 @@ export function useWorkflowHelpers() {
initializedWorkflowDocumentStore.onSettingsChange(({ payload }) => {
workflowsStore.workflowObject.setSettings(payload.settings);
});
initializedWorkflowDocumentStore.onNameChange(({ payload }) => {
workflowsStore.workflowObject.name = payload.name;
workflowsListStore.updateWorkflowInCache(workflowData.id, { name: payload.name });
});
initializedWorkflowDocumentStore.setName(workflowData.name);
initializedWorkflowDocumentStore.setTags(tagIds);
initializedWorkflowDocumentStore.setActiveState({
activeVersionId: workflowData.activeVersionId,

View File

@ -179,7 +179,7 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
async function handleDebugModeRoute() {
if (!isDebugRoute.value) return;
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'DEBUG');
documentTitle.setDocumentTitle(currentWorkflowDocumentStore.value?.name ?? '', 'DEBUG');
if (!workflowsStore.isInDebugMode) {
const executionId = route.params.executionId;
@ -257,17 +257,23 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
const parentFolderId = route.query.parentFolderId as string | undefined;
await workflowState.getNewWorkflowData(
workflowState.setWorkflowId(workflowId.value);
const workflowDocumentId = createWorkflowDocumentId(workflowId.value);
currentWorkflowDocumentStore.value = useWorkflowDocumentStore(workflowDocumentId);
// Sync document store name → workflowObject + list cache (mirrors initializeWorkflowDocument)
currentWorkflowDocumentStore.value.onNameChange(({ payload }) => {
workflowsStore.workflowObject.name = payload.name;
workflowsListStore.updateWorkflowInCache(workflowId.value, { name: payload.name });
});
const workflowData = await workflowState.getNewWorkflowData(
undefined,
projectsStore.currentProjectId,
parentFolderId,
);
workflowState.setWorkflowId(workflowId.value);
// Create document store for new workflow
const workflowDocumentId = createWorkflowDocumentId(workflowId.value);
currentWorkflowDocumentStore.value = useWorkflowDocumentStore(workflowDocumentId);
currentWorkflowDocumentStore.value.setName(workflowData.name);
const homeProject = projectsStore.currentProject ?? projectsStore.personalProject ?? null;
currentWorkflowDocumentStore.value.setHomeProject(homeProject);

View File

@ -53,7 +53,6 @@ vi.mock('@n8n/permissions', () => ({
const mockWorkflowState = {
setWorkflowProperty: vi.fn(),
setWorkflowName: vi.fn(),
setActive: vi.fn(),
setWorkflowId: vi.fn(),
setNodeValue: vi.fn(),
@ -172,7 +171,6 @@ describe('useWorkflowSaving', () => {
modalConfirmSpy.mockResolvedValue(MODAL_CONFIRM);
const mockWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -589,7 +587,6 @@ describe('useWorkflowSaving', () => {
workflowDocumentStore.setTags(tagIds);
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -665,7 +662,6 @@ describe('useWorkflowSaving', () => {
const initialDirtyCount = uiStore.dirtyStateSetCount;
const mockWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -711,7 +707,6 @@ describe('useWorkflowSaving', () => {
uiStore.markStateDirty();
const mockWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -747,7 +742,6 @@ describe('useWorkflowSaving', () => {
const saveStore = useWorkflowSaveStore();
const mockWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -812,7 +806,6 @@ describe('useWorkflowSaving', () => {
workflowsStore.workflowId = workflow.id;
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -872,7 +865,6 @@ describe('useWorkflowSaving', () => {
const saveStore = useWorkflowSaveStore();
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -935,7 +927,6 @@ describe('useWorkflowSaving', () => {
const saveStore = useWorkflowSaveStore();
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -1014,7 +1005,6 @@ describe('useWorkflowSaving', () => {
const saveStore = useWorkflowSaveStore();
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -1079,7 +1069,6 @@ describe('useWorkflowSaving', () => {
const saveStore = useWorkflowSaveStore();
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};
@ -1116,7 +1105,6 @@ describe('useWorkflowSaving', () => {
const initialRetryCount = saveStore.retryCount;
const testWorkflowState: Partial<WorkflowState> = {
setWorkflowName: vi.fn(),
setWorkflowProperty: vi.fn(),
};

View File

@ -19,21 +19,6 @@ describe('useWorkflowState', () => {
workflowState = useWorkflowState();
});
describe('setWorkflowName()', () => {
it('should set the workflow name correctly', () => {
workflowState.setWorkflowName({
newName: 'New Workflow Name',
setStateDirty: false,
});
expect(workflowsStore.workflow.name).toBe('New Workflow Name');
});
it('should propagate name to workflowObject for pre-exec expressions', () => {
workflowState.setWorkflowName({ newName: 'WF Title', setStateDirty: false });
expect(workflowsStore.workflowObject.name).toBe('WF Title');
});
});
describe('markExecutionAsStopped', () => {
beforeEach(() => {
workflowsStore.workflowExecutionData = createTestWorkflowExecutionResponse({

View File

@ -12,7 +12,6 @@ import type {
} from '@/features/execution/executions/executions.types';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import { getPairedItemsMapping } from '@/app/utils/pairedItemUtils';
import {
@ -50,7 +49,6 @@ export const workflowStateEventBus = createEventBus<WorkflowStateBusEvents>();
export function useWorkflowState() {
const ws = useWorkflowsStore();
const workflowsListStore = useWorkflowsListStore();
const workflowStateStore = useWorkflowStateStore();
const uiStore = useUIStore();
const rootStore = useRootStore();
@ -60,18 +58,6 @@ export function useWorkflowState() {
// Workflow editing state
////
function setWorkflowName(data: { newName: string; setStateDirty: boolean }) {
if (data.setStateDirty) {
uiStore.markStateDirty('metadata');
}
ws.workflow.name = data.newName;
ws.workflowObject.name = data.newName;
if (ws.workflow.id && workflowsListStore.workflowsById[ws.workflow.id]) {
workflowsListStore.workflowsById[ws.workflow.id].name = data.newName;
}
}
/** @deprecated Use `workflowDocumentStore.removeAllConnections()` instead. */
function removeAllConnections(data: { setStateDirty: boolean }): void {
if (data?.setStateDirty) {
@ -161,8 +147,6 @@ export function useWorkflowState() {
workflowData.name = name || DEFAULT_NEW_WORKFLOW_NAME;
}
setWorkflowName({ newName: workflowData.name, setStateDirty: false });
return workflowData;
}
@ -176,7 +160,10 @@ export function useWorkflowState() {
setActiveExecutionId(undefined);
workflowStateStore.executingNode.clearNodeExecutionQueue();
ws.executionWaitingForWebhook = false;
documentTitle.setDocumentTitle(ws.workflowName, 'IDLE');
const workflowDocumentStore = ws.workflow.id
? useWorkflowDocumentStore(createWorkflowDocumentId(ws.workflow.id))
: undefined;
documentTitle.setDocumentTitle(workflowDocumentStore?.name ?? '', 'IDLE');
ws.workflowExecutionStartedData = undefined;
// TODO(ckolb): confirm this works across files?
@ -208,8 +195,15 @@ export function useWorkflowState() {
setWorkflowExecutionData(null);
resetAllNodesIssues();
// Reset name via document store (triggers onNameChange → updates workflowObject.name)
if (ws.workflow.id) {
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(ws.workflow.id),
);
workflowDocumentStore.setName('');
}
setWorkflowId('');
setWorkflowName({ newName: '', setStateDirty: false });
// Settings are managed by workflowDocumentStore; reset the runtime Workflow instance directly
ws.workflowObject.setSettings({ ...DEFAULT_SETTINGS });
// Note: Tags are now managed by workflowDocumentStore, which is disposed during reset
@ -424,7 +418,6 @@ export function useWorkflowState() {
setWorkflowExecutionData,
resetAllNodesIssues,
setWorkflowId,
setWorkflowName,
setWorkflowProperty,
setActiveExecutionId,
getNewWorkflowData,

View File

@ -3,7 +3,10 @@ import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useWorkflowUpdate } from './useWorkflowUpdate';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import type { useWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
@ -29,6 +32,8 @@ vi.mock('@/features/workflows/canvas/canvas.utils', () => ({
// Mock workflowDocumentStore - using hoisted for proper initialization
const mockDocumentStore = vi.hoisted(() => ({
allNodes: [] as INodeUi[],
name: '',
setName: vi.fn(),
setNodes: vi.fn(),
setConnections: vi.fn(),
resetParametersLastUpdatedAt: vi.fn(),
@ -46,7 +51,7 @@ vi.mock('@/app/stores/workflowDocument.store', () => ({
// Mock useWorkflowState - using hoisted for proper initialization
const mockWorkflowState = vi.hoisted(() => ({
setWorkflowName: vi.fn(),
resetParametersLastUpdatedAt: vi.fn(),
}));
vi.mock('@/app/composables/useWorkflowState', () => ({
injectWorkflowState: vi.fn(() => mockWorkflowState),
@ -91,6 +96,8 @@ describe('useWorkflowUpdate', () => {
// Setup default mocks
(mockDocumentStore as { allNodes: INodeUi[] }).allNodes = [];
(mockDocumentStore as { name: string }).name = '';
vi.mocked(mockDocumentStore.setName).mockClear();
vi.mocked(mockDocumentStore.setNodes).mockClear();
vi.mocked(mockDocumentStore.setConnections).mockClear();
vi.mocked(mockDocumentStore.resetParametersLastUpdatedAt).mockClear();
@ -639,7 +646,14 @@ describe('useWorkflowUpdate', () => {
describe('workflow name update', () => {
it('should update workflow name on initial generation when name starts with default', async () => {
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId('test-workflow'),
);
Object.defineProperty(workflowDocumentStore, 'name', {
value: DEFAULT_NEW_WORKFLOW_NAME,
configurable: true,
});
const setNameSpy = vi.spyOn(workflowDocumentStore, 'setName');
const { updateWorkflow } = useWorkflowUpdate();
@ -652,14 +666,18 @@ describe('useWorkflowUpdate', () => {
{ isInitialGeneration: true },
);
expect(mockWorkflowState.setWorkflowName).toHaveBeenCalledWith({
newName: 'My Generated Workflow',
setStateDirty: false,
});
expect(setNameSpy).toHaveBeenCalledWith('My Generated Workflow');
});
it('should not update workflow name when not initial generation', async () => {
workflowsStore.workflow.name = DEFAULT_NEW_WORKFLOW_NAME;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId('test-workflow'),
);
Object.defineProperty(workflowDocumentStore, 'name', {
value: DEFAULT_NEW_WORKFLOW_NAME,
configurable: true,
});
const setNameSpy = vi.spyOn(workflowDocumentStore, 'setName');
const { updateWorkflow } = useWorkflowUpdate();
@ -672,11 +690,18 @@ describe('useWorkflowUpdate', () => {
{ isInitialGeneration: false },
);
expect(mockWorkflowState.setWorkflowName).not.toHaveBeenCalled();
expect(setNameSpy).not.toHaveBeenCalled();
});
it('should not update workflow name when current name does not start with default', async () => {
workflowsStore.workflow.name = 'Custom Workflow Name';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId('test-workflow'),
);
Object.defineProperty(workflowDocumentStore, 'name', {
value: 'Custom Workflow Name',
configurable: true,
});
const setNameSpy = vi.spyOn(workflowDocumentStore, 'setName');
const { updateWorkflow } = useWorkflowUpdate();
@ -689,7 +714,7 @@ describe('useWorkflowUpdate', () => {
{ isInitialGeneration: true },
);
expect(mockWorkflowState.setWorkflowName).not.toHaveBeenCalled();
expect(setNameSpy).not.toHaveBeenCalled();
});
});

View File

@ -14,7 +14,6 @@ import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useBuilderStore } from '@/features/ai/assistant/builder.store';
import { useUIStore } from '@/app/stores/ui.store';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { computed } from 'vue';
import { useCanvasOperations } from '@/app/composables/useCanvasOperations';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
@ -44,7 +43,6 @@ export type UpdateWorkflowResult =
export function useWorkflowUpdate() {
const workflowsStore = useWorkflowsStore();
const workflowState = injectWorkflowState();
const credentialsStore = useCredentialsStore();
const nodeTypesStore = useNodeTypesStore();
const builderStore = useBuilderStore();
@ -323,12 +321,13 @@ export function useWorkflowUpdate() {
* Update workflow name if initial generation and name starts with default
*/
function updateWorkflowNameIfNeeded(name?: string, isInitialGeneration?: boolean): void {
if (
name &&
isInitialGeneration &&
workflowsStore.workflow.name.startsWith(DEFAULT_NEW_WORKFLOW_NAME)
) {
workflowState.setWorkflowName({ newName: name, setStateDirty: false });
if (!name || !isInitialGeneration || !workflowsStore.workflowId) return;
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
if (workflowDocumentStore.name.startsWith(DEFAULT_NEW_WORKFLOW_NAME)) {
workflowDocumentStore.setName(name);
}
}

View File

@ -17,6 +17,7 @@ import { useWorkflowDocumentUsedCredentials } from './workflowDocument/useWorkfl
import { useWorkflowDocumentNodes } from './workflowDocument/useWorkflowDocumentNodes';
import { useWorkflowDocumentViewport } from './workflowDocument/useWorkflowDocumentViewport';
import { useWorkflowDocumentConnections } from './workflowDocument/useWorkflowDocumentConnections';
import { useWorkflowDocumentName } from './workflowDocument/useWorkflowDocumentName';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
@ -60,6 +61,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
return defineStore(getWorkflowDocumentStoreId(id), () => {
const [workflowId, workflowVersion] = id.split('@');
const workflowDocumentName = useWorkflowDocumentName();
const workflowDocumentActive = useWorkflowDocumentActive();
const workflowDocumentHomeProject = useWorkflowDocumentHomeProject();
const workflowDocumentChecksum = useWorkflowDocumentChecksum();
@ -101,6 +103,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
return {
workflowId,
workflowVersion,
...workflowDocumentName,
...workflowDocumentActive,
...workflowDocumentHomeProject,
...workflowDocumentChecksum,

View File

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from 'vitest';
import { useWorkflowDocumentName } from './useWorkflowDocumentName';
function createName() {
return useWorkflowDocumentName();
}
describe('useWorkflowDocumentName', () => {
describe('initial state', () => {
it('should start with empty string', () => {
const { name } = createName();
expect(name.value).toBe('');
});
});
describe('setName', () => {
it('should set name and fire event hook', () => {
const { name, setName, onNameChange } = createName();
const hookSpy = vi.fn();
onNameChange(hookSpy);
setName('My Workflow');
expect(name.value).toBe('My Workflow');
expect(hookSpy).toHaveBeenCalledWith({
action: 'update',
payload: { name: 'My Workflow' },
});
});
it('should replace existing value', () => {
const { name, setName } = createName();
setName('First Name');
setName('Second Name');
expect(name.value).toBe('Second Name');
});
it('should fire event hook on every call', () => {
const { setName, onNameChange } = createName();
const hookSpy = vi.fn();
onNameChange(hookSpy);
setName('First');
setName('Second');
expect(hookSpy).toHaveBeenCalledTimes(2);
});
});
});

View File

@ -0,0 +1,31 @@
import { ref, readonly } from 'vue';
import { createEventHook } from '@vueuse/core';
import { CHANGE_ACTION } from './types';
import type { ChangeAction, ChangeEvent } from './types';
export type NamePayload = {
name: string;
};
export type NameChangeEvent = ChangeEvent<NamePayload>;
export function useWorkflowDocumentName() {
const name = ref<string>('');
const onNameChange = createEventHook<NameChangeEvent>();
function applyName(value: string, action: ChangeAction = CHANGE_ACTION.UPDATE) {
name.value = value;
void onNameChange.trigger({ action, payload: { name: value } });
}
function setName(value: string) {
applyName(value);
}
return {
name: readonly(name),
setName,
onNameChange: onNameChange.on,
};
}

View File

@ -149,8 +149,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const chatPartialExecutionDestinationNode = ref<string | null>(null);
const selectedTriggerNodeName = ref<string>();
const workflowName = computed(() => workflow.value.name);
const workflowId = computed(() => workflow.value.id);
const workflowVersionId = computed(() => workflow.value.versionId);
@ -587,7 +585,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return new Workflow({
id,
name: workflow.value.name,
name: workflowDocumentStore?.name ?? '',
nodes: copyData ? deepCopy(nodes) : nodes,
connections: copyData ? deepCopy(connections) : connections,
active: false,
@ -1740,7 +1738,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
isInDebugMode,
chatMessages,
chatPartialExecutionDestinationNode,
workflowName,
workflowId,
workflowVersionId,
isNewWorkflow,

View File

@ -121,7 +121,6 @@ let nodeTypesStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let credentialsStore: ReturnType<typeof mockedStore<typeof useCredentialsStore>>;
let pinia: ReturnType<typeof createTestingPinia>;
let setWorkflowNameSpy: Mock;
let getNodeTypeSpy: Mock;
let getCredentialsByTypeSpy: Mock;
@ -184,11 +183,6 @@ describe('AI Builder store', () => {
workflowState = useWorkflowState();
vi.mocked(injectWorkflowState).mockReturnValue(workflowState);
setWorkflowNameSpy = vi.fn().mockImplementation(({ newName }: { newName: string }) => {
workflowsStore.workflow.name = newName;
});
vi.spyOn(workflowState, 'setWorkflowName').mockImplementation(setWorkflowNameSpy);
getNodeTypeSpy = vi.fn();
vi.spyOn(nodeTypesStore, 'getNodeType', 'get').mockReturnValue(getNodeTypeSpy);
@ -3208,7 +3202,10 @@ describe('AI Builder store', () => {
describe('Page title status', () => {
it('should set title to AI_BUILDING when streaming starts', async () => {
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
// Mock the API to prevent actual calls
apiSpy.mockImplementation(() => {});
@ -3222,7 +3219,10 @@ describe('AI Builder store', () => {
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
// Start streaming first
builderStore.streaming = true;
@ -3237,7 +3237,10 @@ describe('AI Builder store', () => {
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
// Start streaming first
builderStore.streaming = true;
@ -3252,7 +3255,10 @@ describe('AI Builder store', () => {
Object.defineProperty(document, 'hidden', { value: true, configurable: true });
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
builderStore.streaming = true;
builderStore.abortStreaming();
@ -3268,7 +3274,10 @@ describe('AI Builder store', () => {
Object.defineProperty(document, 'hidden', { value: false, configurable: true });
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
setDocumentTitleMock.mockClear();
@ -3607,8 +3616,11 @@ describe('AI Builder store', () => {
const triggerSuccessfulStreamingComplete = async () => {
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.workflowId = 'test-workflow-123';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId('test-workflow-123'),
);
workflowDocumentStore.setName('Test Workflow');
workflowsStore.isNewWorkflow = false;
workflowsStore.workflowVersionId = 'version-1';
@ -3670,7 +3682,10 @@ describe('AI Builder store', () => {
mockIsNotificationsEnabled.value = true;
const builderStore = useBuilderStore();
workflowsStore.workflowName = 'Test Workflow';
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
workflowDocumentStore.setName('Test Workflow');
builderStore.streaming = true;
builderStore.abortStreaming();
@ -4030,10 +4045,10 @@ describe('AI Builder store', () => {
builderStore.storeGeneratedPinData(pinData);
expect(builderStore.hasDeferredPinData).toBe(true);
const wfDocStore = useWorkflowDocumentStore(
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
expect(wfDocStore.pinData).toEqual({});
expect(workflowDocumentStore.pinData).toEqual({});
});
it('storeGeneratedPinData merges multiple calls', () => {
@ -4058,10 +4073,10 @@ describe('AI Builder store', () => {
builderStore.storeGeneratedPinData(pinData);
builderStore.applyGeneratedPinData();
const wfDocStore = useWorkflowDocumentStore(
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
expect(wfDocStore.pinData).toEqual(pinData);
expect(workflowDocumentStore.pinData).toEqual(pinData);
expect(uiStore.stateIsDirty).toBe(true);
expect(builderStore.hasDeferredPinData).toBe(false);
expect(builderStore.pinDataApplied).toBe(true);

View File

@ -548,7 +548,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
return;
}
const workflowName = workflowsStore.workflowName;
const workflowName = workflowDocumentStore.value?.name ?? '';
const titleKey =
completionType === 'workflow-ready'
@ -641,7 +641,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// Update page title on completion. We show Done when the user is not on the page
// Browser notifications are only shown when the tab is hidden
if (document.hidden) {
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'AI_DONE');
documentTitle.setDocumentTitle(workflowDocumentStore.value?.name ?? '', 'AI_DONE');
if (!wasAborted && userMessageId) {
const completionType = hasWorkflowUpdateInCurrentBatch(userMessageId)
? 'workflow-ready'
@ -649,7 +649,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
notifyOnCompletion(completionType);
}
} else {
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'IDLE');
documentTitle.setDocumentTitle(workflowDocumentStore.value?.name ?? '', 'IDLE');
}
}
@ -726,7 +726,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
// Updates page title to show AI is building (skip for help questions)
if (!isHelpStreaming.value) {
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'AI_BUILDING');
documentTitle.setDocumentTitle(workflowDocumentStore.value?.name ?? '', 'AI_BUILDING');
}
}
@ -1347,10 +1347,10 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
posthogStore.getVariant(CODE_WORKFLOW_BUILDER_EXPERIMENT.name) ===
CODE_WORKFLOW_BUILDER_EXPERIMENT.codePinData;
if (isPinDataEnabled) {
const wfDocStore = workflowsStore.workflowId
const workflowDocumentStore = workflowsStore.workflowId
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
: undefined;
const pinData = wfDocStore?.getPinDataSnapshot();
const pinData = workflowDocumentStore?.getPinDataSnapshot();
if (pinData && Object.keys(pinData).length > 0) {
generatedPinData.value = pinData;
pinDataApplied.value = true;
@ -1658,7 +1658,7 @@ export const useBuilderStore = defineStore(STORES.BUILDER, () => {
*/
function clearDoneIndicatorTitle() {
if (documentTitle.getDocumentState() === 'AI_DONE') {
documentTitle.setDocumentTitle(workflowsStore.workflowName, 'IDLE');
documentTitle.setDocumentTitle(workflowDocumentStore.value?.name ?? '', 'IDLE');
}
}

View File

@ -72,7 +72,6 @@ vi.mock('@/app/composables/useWorkflowState', async () => {
return {
...actual,
injectWorkflowState: vi.fn(() => ({
setWorkflowName: vi.fn(),
isWorkflowRunning: ref(false),
})),
};

View File

@ -68,7 +68,7 @@ const chatTriggerNode = computed(() =>
const agentDisplayName = computed(() => {
const triggerName = chatTriggerNode.value?.parameters?.agentName;
if (typeof triggerName === 'string' && triggerName.trim()) return triggerName.trim();
return workflowsStore.workflowName || 'Workflow';
return workflowDocumentStore?.value?.name || 'Workflow';
});
const workflowAgent = computed<ChatModelDto | null>(() => {

View File

@ -3,12 +3,21 @@ import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useChatHubPanelStore } from '@/features/ai/chatHub/chatHubPanel.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { usePopOutWindow } from '@/features/execution/logs/composables/usePopOutWindow';
import CanvasChatFloatingWindow from './CanvasChatFloatingWindow.vue';
const route = useRoute();
const chatHubPanelStore = useChatHubPanelStore();
const workflowsStore = useWorkflowsStore();
const workflowDocumentStore = computed(() =>
workflowsStore.workflowId
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
: undefined,
);
const canvasChatFloatingWindowRef = ref<InstanceType<typeof CanvasChatFloatingWindow>>();
@ -36,7 +45,9 @@ watch(
{ immediate: true },
);
const popOutWindowTitle = computed(() => `Chat - ${workflowsStore.workflowName || 'Workflow'}`);
const popOutWindowTitle = computed(
() => `Chat - ${workflowDocumentStore.value?.name || 'Workflow'}`,
);
const shouldPopOut = computed(() => isPoppedOut.value && chatHubPanelStore.isOpen);
usePopOutWindow({

View File

@ -16,6 +16,10 @@ import { useLogsPanelLayout } from '@/features/execution/logs/composables/useLog
import { type KeyMap } from '@/app/composables/useKeybindings';
import LogsViewKeyboardEventListener from './LogsViewKeyboardEventListener.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { N8nResizeWrapper } from '@n8n/design-system';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
@ -28,7 +32,12 @@ const popOutContent = useTemplateRef('popOutContent');
const logsStore = useLogsStore();
const ndvStore = useNDVStore();
const workflowsStore = useWorkflowsStore();
const workflowName = computed(() => workflowsStore.workflow.name);
const workflowDocumentStore = computed(() =>
workflowsStore.workflowId
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId))
: undefined,
);
const workflowName = computed(() => workflowDocumentStore.value?.name ?? '');
const {
height,

View File

@ -26,7 +26,6 @@ describe('TriggerPanel.vue', () => {
beforeEach(async () => {
setActivePinia(createTestingPinia({ stubActions: false }));
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.workflowName = 'Test Workflow';
workflowsStore.workflowId = '1';
const node = createTestNode({ id: '0', name: 'Webhook', type: 'n8n-nodes-base.webhook' });
workflowsStore.workflow.nodes = [node];

View File

@ -37,8 +37,8 @@ vi.mock('@/features/collaboration/projects/projects.store', () => ({
currentProjectId: 'p1',
}),
}));
vi.mock('@/app/stores/workflows.store', () => ({
useWorkflowsStore: () => ({ workflow: { name: 'WF' } }),
vi.mock('@/app/stores/workflowDocument.store', () => ({
injectWorkflowDocumentStore: () => ref({ name: 'WF' }),
}));
// Command groups

View File

@ -19,7 +19,7 @@ import { useInstanceAiCommands } from './useInstanceAiCommands';
import type { CommandGroup } from '../types';
import { useI18n } from '@n8n/i18n';
import { PROJECT_DATA_TABLES, DATA_TABLE_VIEW } from '@/features/core/dataTable/constants';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import {
CHAT_CONVERSATION_VIEW,
@ -31,7 +31,7 @@ import {
export function useCommandBar() {
const nodeTypesStore = useNodeTypesStore();
const projectsStore = useProjectsStore();
const workflowStore = useWorkflowsStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const router = useRouter();
const route = useRoute();
const i18n = useI18n();
@ -220,21 +220,22 @@ export function useCommandBar() {
});
const context = computed(() => {
const workflowName = workflowDocumentStore?.value?.name ?? '';
switch (router.currentRoute.value.name) {
case VIEWS.WORKFLOW:
case VIEWS.NEW_WORKFLOW:
return workflowStore.workflow.name
? i18n.baseText('commandBar.sections.workflow') + ' ⋅ ' + workflowStore.workflow.name
return workflowName
? i18n.baseText('commandBar.sections.workflow') + ' ⋅ ' + workflowName
: '';
case VIEWS.EXECUTION_PREVIEW:
case VIEWS.EXECUTION_DEBUG:
return workflowStore.workflow.name
? i18n.baseText('commandBar.sections.execution') + ' ⋅ ' + workflowStore.workflow.name
return workflowName
? i18n.baseText('commandBar.sections.execution') + ' ⋅ ' + workflowName
: '';
case VIEWS.EVALUATION:
case VIEWS.EVALUATION_EDIT:
case VIEWS.EVALUATION_RUNS_DETAIL:
return workflowStore.workflow.name ? ' ⋅ ' + workflowStore.workflow.name : '';
return workflowName ? ' ⋅ ' + workflowName : '';
default:
return '';
}