From 4aba06d78e23cac40a662cc15cffe93fc0a51502 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Tue, 7 Apr 2026 07:37:52 +0700 Subject: [PATCH] refactor(editor): Migrate workflow name to workflowDocument store (#27343) --- .../components/MainHeader/MainHeader.test.ts | 30 +- .../app/components/MainHeader/MainHeader.vue | 5 +- .../components/MainHeader/WorkflowDetails.vue | 5 +- .../app/components/WorkflowSettings.test.ts | 289 ++++++++++++------ .../src/app/components/WorkflowSettings.vue | 2 +- .../app/components/WorkflowShareModal.ee.vue | 73 +++-- .../composables/useCanvasOperations.test.ts | 13 +- .../app/composables/useCanvasOperations.ts | 11 +- .../handlers/workflowActivated.ts | 4 +- .../handlers/workflowAutoDeactivated.ts | 4 +- .../handlers/workflowDeactivated.ts | 4 +- .../handlers/workflowFailedToActivate.ts | 4 +- .../composables/useResolvedExpression.test.ts | 14 +- .../app/composables/useResolvedExpression.ts | 11 +- .../composables/useWorkflowHelpers.test.ts | 11 +- .../src/app/composables/useWorkflowHelpers.ts | 17 +- .../composables/useWorkflowInitialization.ts | 22 +- .../app/composables/useWorkflowSaving.test.ts | 12 - .../app/composables/useWorkflowState.test.ts | 15 - .../src/app/composables/useWorkflowState.ts | 31 +- .../app/composables/useWorkflowUpdate.test.ts | 47 ++- .../src/app/composables/useWorkflowUpdate.ts | 15 +- .../src/app/stores/workflowDocument.store.ts | 3 + .../useWorkflowDocumentName.test.ts | 51 ++++ .../useWorkflowDocumentName.ts | 31 ++ .../src/app/stores/workflows.store.ts | 5 +- .../ai/assistant/builder.store.test.ts | 49 +-- .../features/ai/assistant/builder.store.ts | 14 +- .../Agent/AIBuilderDiffModal.test.ts | 1 - .../chatHub/components/CanvasChatHubPanel.vue | 2 +- .../chatHub/components/CanvasChatOverlay.vue | 13 +- .../execution/logs/components/LogsPanel.vue | 11 +- .../ndv/panel/components/TriggerPanel.test.ts | 1 - .../composables/useCommandBar.test.ts | 4 +- .../commandBar/composables/useCommandBar.ts | 15 +- 35 files changed, 550 insertions(+), 289 deletions(-) create mode 100644 packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentName.test.ts create mode 100644 packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentName.ts diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.test.ts index 8d2a5431788..7175c4c44ba 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.test.ts @@ -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: '
', }, GithubButton: { template: '
' }, TabBar: { template: '
' }, }, 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); }); diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue index 625048491f8..69ceb6bb442 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/MainHeader.vue @@ -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() { >
', }, + // 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: + '', + }, }, }, }); @@ -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(); }); }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index 3cf2502e6de..fad548c2866 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -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( diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.vue b/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.vue index a00cb173b6e..0e9914ddf25 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowShareModal.ee.vue @@ -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(