From 8aed72ffb13e78a676152c82dd48e4015d4d5bdf Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 11 Nov 2025 12:18:32 +0100 Subject: [PATCH 01/16] chore(core): Remove md5 usage for user id generation in postgres (#21687) --- ...2771264000-ChangeDefaultForIdInUserTable.ts | 18 ++++++++++++++++++ .../@n8n/db/src/migrations/postgresdb/index.ts | 2 ++ 2 files changed, 20 insertions(+) create mode 100644 packages/@n8n/db/src/migrations/postgresdb/1762771264000-ChangeDefaultForIdInUserTable.ts diff --git a/packages/@n8n/db/src/migrations/postgresdb/1762771264000-ChangeDefaultForIdInUserTable.ts b/packages/@n8n/db/src/migrations/postgresdb/1762771264000-ChangeDefaultForIdInUserTable.ts new file mode 100644 index 00000000000..98765da44ff --- /dev/null +++ b/packages/@n8n/db/src/migrations/postgresdb/1762771264000-ChangeDefaultForIdInUserTable.ts @@ -0,0 +1,18 @@ +import type { IrreversibleMigration, MigrationContext } from '../migration-types'; + +/** + * PostgreSQL-specific migration to change the default value for the `id` column in `user` table. + * The previous default implementation was based on MD5 hashing to produce a random UUID, but + * MD5 is not supported in FIPS compliant postgres environments. We are switching to `gen_random_uuid()` + * which is supported in versions of PostgreSQL since 13. + */ +export class ChangeDefaultForIdInUserTable1762771264000 implements IrreversibleMigration { + async up({ queryRunner, escape }: MigrationContext) { + const tableName = escape.tableName('user'); + const idColumnName = escape.columnName('id'); + + await queryRunner.query( + `ALTER TABLE ${tableName} ALTER COLUMN ${idColumnName} SET DEFAULT gen_random_uuid()`, + ); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 1db6bd2fc16..0dc1be15232 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -111,6 +111,7 @@ import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-D import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import type { Migration } from '../migration-types'; +import { ChangeDefaultForIdInUserTable1762771264000 } from './1762771264000-ChangeDefaultForIdInUserTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -225,4 +226,5 @@ export const postgresMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + ChangeDefaultForIdInUserTable1762771264000, ]; From baefd3aa352e329828b729e37f468e1ebd71c743 Mon Sep 17 00:00:00 2001 From: Daria Date: Tue, 11 Nov 2025 14:05:29 +0200 Subject: [PATCH 02/16] test: Create history for workflows with activation logic (#21724) --- .../backend-test-utils/src/db/workflows.ts | 51 +++++++++++++++++++ .../active-workflow-manager.test.ts | 23 +++++++-- .../integration/public-api/workflows.test.ts | 10 ++-- .../workflows/workflow.service.test.ts | 12 ++--- ...er-with-active-workflow-manager.ee.test.ts | 4 +- .../workflows/workflows.controller.ee.test.ts | 26 ++++++---- .../workflows/workflows.controller.test.ts | 12 +++-- 7 files changed, 107 insertions(+), 31 deletions(-) diff --git a/packages/@n8n/backend-test-utils/src/db/workflows.ts b/packages/@n8n/backend-test-utils/src/db/workflows.ts index f7d12ac5a21..3682404a47e 100644 --- a/packages/@n8n/backend-test-utils/src/db/workflows.ts +++ b/packages/@n8n/backend-test-utils/src/db/workflows.ts @@ -5,6 +5,7 @@ import { ProjectRepository, SharedWorkflowRepository, WorkflowRepository, + WorkflowHistoryRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; import type { WorkflowSharingRole } from '@n8n/permissions'; @@ -175,6 +176,41 @@ export async function createWorkflowWithTrigger( return workflow; } +/** + * Store a workflow in the DB and create its workflow history. + * @param attributes workflow attributes + * @param userOrProject user or project to assign the workflow to + */ +export async function createWorkflowWithHistory( + attributes: Partial = {}, + userOrProject?: User | Project, +) { + const workflow = await createWorkflow(attributes, userOrProject); + + // Create workflow history for the initial version + const user = userOrProject instanceof User ? userOrProject : undefined; + await createWorkflowHistory(workflow, user); + + return workflow; +} + +/** + * Store a workflow with trigger in the DB and create its workflow history. + * @param attributes workflow attributes + * @param user user to assign the workflow to + */ +export async function createWorkflowWithTriggerAndHistory( + attributes: Partial = {}, + user?: User, +) { + const workflow = await createWorkflowWithTrigger(attributes, user); + + // Create workflow history for the initial version + await createWorkflowHistory(workflow, user); + + return workflow; +} + export async function getAllWorkflows() { return await Container.get(WorkflowRepository).find(); } @@ -185,3 +221,18 @@ export async function getAllSharedWorkflows() { export const getWorkflowById = async (id: string) => await Container.get(WorkflowRepository).findOneBy({ id }); + +/** + * Create a workflow history record for a workflow + * @param workflow workflow to create history for + * @param user user who created the version (optional) + */ +export async function createWorkflowHistory(workflow: IWorkflowDb, user?: User): Promise { + await Container.get(WorkflowHistoryRepository).insert({ + workflowId: workflow.id, + versionId: workflow.versionId, + nodes: workflow.nodes, + connections: workflow.connections, + authors: user?.email ?? 'test@example.com', + }); +} diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index a3ca9fbfdaa..250b492dad7 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -1,4 +1,4 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; import type { Project, WebhookEntity } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; @@ -65,8 +65,8 @@ beforeAll(async () => { await utils.initNodeTypes(nodes); const owner = await createOwner(); - createActiveWorkflow = async () => await createWorkflow({ active: true }, owner); - createInactiveWorkflow = async () => await createWorkflow({ active: false }, owner); + createActiveWorkflow = async () => await createWorkflowWithHistory({ active: true }, owner); + createInactiveWorkflow = async () => await createWorkflowWithHistory({ active: false }, owner); Container.get(InstanceSettings).markAsLeader(); }); @@ -176,7 +176,7 @@ describe('add()', () => { ); // Create a workflow which has a form trigger - const dbWorkflow = await createWorkflow({ + const dbWorkflow = await createWorkflowWithHistory({ nodes: [ { id: 'uuid-1', @@ -193,6 +193,21 @@ describe('add()', () => { expect(updateWorkflowTriggerCountSpy).toHaveBeenCalledWith(dbWorkflow.id, 1); }); + + test('should activate an initially inactive workflow in memory', async () => { + await activeWorkflowManager.init(); + + const dbWorkflow = await createInactiveWorkflow(); + webhookService.getNodeWebhooks.mockReturnValue([]); + + // Verify it's not active in memory yet + expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(0); + + await activeWorkflowManager.add(dbWorkflow.id, 'activate'); + + expect(activeWorkflowManager.allActiveInMemory()).toHaveLength(1); + expect(activeWorkflowManager.allActiveInMemory()).toContain(dbWorkflow.id); + }); }); describe('removeAll()', () => { diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 47d09338fdb..920d8e8f3e5 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -1,7 +1,7 @@ import { createTeamProject, createWorkflow, - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, testDb, mockInstance, } from '@n8n/backend-test-utils'; @@ -624,7 +624,7 @@ describe('POST /workflows/:id/activate', () => { }); test('should set workflow as active', async () => { - const workflow = await createWorkflowWithTrigger({}, member); + const workflow = await createWorkflowWithTriggerAndHistory({}, member); const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); @@ -659,7 +659,7 @@ describe('POST /workflows/:id/activate', () => { }); test('should set non-owned workflow as active when owner', async () => { - const workflow = await createWorkflowWithTrigger({}, member); + const workflow = await createWorkflowWithTriggerAndHistory({}, member); const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`).expect(200); @@ -718,7 +718,7 @@ describe('POST /workflows/:id/deactivate', () => { }); test('should deactivate workflow', async () => { - const workflow = await createWorkflowWithTrigger({}, member); + const workflow = await createWorkflowWithTriggerAndHistory({}, member); await authMemberAgent.post(`/workflows/${workflow.id}/activate`); @@ -755,7 +755,7 @@ describe('POST /workflows/:id/deactivate', () => { }); test('should deactivate non-owned workflow when owner', async () => { - const workflow = await createWorkflowWithTrigger({}, member); + const workflow = await createWorkflowWithTriggerAndHistory({}, member); await authMemberAgent.post(`/workflows/${workflow.id}/activate`); diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 27cf019ac30..93091f1de11 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,4 +1,4 @@ -import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils'; +import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; import { SharedWorkflowRepository, type WorkflowEntity, WorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; @@ -51,7 +51,7 @@ afterEach(async () => { describe('update()', () => { test('should remove and re-add to active workflows on `active: true` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createWorkflowWithHistory({ active: true }, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); @@ -70,7 +70,7 @@ describe('update()', () => { test('should remove from active workflows on `active: false` payload', async () => { const owner = await createOwner(); - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createWorkflowWithHistory({ active: true }, owner); const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); const addSpy = jest.spyOn(activeWorkflowManager, 'add'); @@ -87,7 +87,7 @@ describe('update()', () => { test('should fetch missing connections from DB when updating nodes', async () => { const owner = await createOwner(); - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const updateData: Partial = { nodes: [ @@ -116,7 +116,7 @@ describe('update()', () => { test('should not save workflow history version when updating only active status', async () => { const owner = await createOwner(); - const workflow = await createWorkflow({ active: false }, owner); + const workflow = await createWorkflowWithHistory({ active: false }, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); @@ -132,7 +132,7 @@ describe('update()', () => { test('should save workflow history version with backfilled data when versionId changes', async () => { const owner = await createOwner(); - const workflow = await createWorkflow({ active: false }, owner); + const workflow = await createWorkflowWithHistory({ active: false }, owner); const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index 964b4dce483..a9971c3a78c 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -1,6 +1,6 @@ import { createTeamProject, - createWorkflowWithTrigger, + createWorkflowWithTriggerAndHistory, testDb, mockInstance, } from '@n8n/backend-test-utils'; @@ -39,7 +39,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflowWithTrigger({ active: true }, member); + const workflow = await createWorkflowWithTriggerAndHistory({ active: true }, member); // // ACT diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index 037a91aa496..a6886fb1720 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -3,6 +3,7 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createWorkflowWithHistory, getWorkflowSharing, shareWorkflowWithProjects, shareWorkflowWithUsers, @@ -1233,7 +1234,7 @@ describe('PATCH /workflows/:workflowId', () => { describe('workflow history', () => { test('Should always create workflow history version', async () => { - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const payload = { name: 'name updated', versionId: workflow.versionId, @@ -1270,7 +1271,7 @@ describe('PATCH /workflows/:workflowId', () => { const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); const { - data: { id }, + data: { id, versionId: updatedVersionId }, } = response.body; expect(response.statusCode).toBe(200); @@ -1278,10 +1279,11 @@ describe('PATCH /workflows/:workflowId', () => { expect(id).toBe(workflow.id); expect( await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(1); + ).toBe(2); const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ where: { workflowId: id, + versionId: updatedVersionId, }, }); expect(historyVersion).not.toBeNull(); @@ -1313,7 +1315,7 @@ describe('PATCH /workflows/:workflowId', () => { }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createWorkflowWithHistory({ active: true }, owner); const payload = { versionId: workflow.versionId, active: false, @@ -1507,7 +1509,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflow({ active: true }, member); + const workflow = await createWorkflowWithHistory({ active: true }, member); // // ACT @@ -1535,7 +1537,10 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflow({ active: true, parentFolder: folder }, member); + const workflow = await createWorkflowWithHistory( + { active: true, parentFolder: folder }, + member, + ); // // ACT @@ -1567,7 +1572,10 @@ describe('PUT /:workflowId/transfer', () => { const folder = await createFolder(destinationProject, { name: 'Test Folder' }); - const workflow = await createWorkflow({ active: true, parentFolder: folder }, member); + const workflow = await createWorkflowWithHistory( + { active: true, parentFolder: folder }, + member, + ); // // ACT @@ -1631,7 +1639,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflow({ active: true }, member); + const workflow = await createWorkflowWithHistory({ active: true }, member); activeWorkflowManager.add.mockRejectedValue(new WorkflowActivationError('Failed')); @@ -2001,7 +2009,7 @@ describe('PUT /:workflowId/transfer', () => { // const destinationProject = await createTeamProject('Team Project', member); - const workflow = await createWorkflow({ active: true }, member); + const workflow = await createWorkflowWithHistory({ active: true }, member); activeWorkflowManager.add.mockRejectedValue(new ApplicationError('Oh no!')); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 7df66459373..bee465972a8 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -3,6 +3,7 @@ import { getPersonalProject, linkUserToProject, createWorkflow, + createWorkflowWithHistory, shareWorkflowWithProjects, shareWorkflowWithUsers, randomCredentialPayload, @@ -2249,7 +2250,7 @@ describe('GET /workflows?includeFolders=true', () => { describe('PATCH /workflows/:workflowId', () => { test('should always create workflow history version', async () => { - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const payload = { name: 'name updated', versionId: workflow.versionId, @@ -2286,7 +2287,7 @@ describe('PATCH /workflows/:workflowId', () => { const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); const { - data: { id }, + data: { id, versionId: updatedVersionId }, } = response.body; expect(response.statusCode).toBe(200); @@ -2294,10 +2295,11 @@ describe('PATCH /workflows/:workflowId', () => { expect(id).toBe(workflow.id); expect( await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(1); + ).toBe(2); const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ where: { workflowId: id, + versionId: updatedVersionId, }, }); expect(historyVersion).not.toBeNull(); @@ -2326,7 +2328,7 @@ describe('PATCH /workflows/:workflowId', () => { }); test('should activate workflow without changing version ID', async () => { - const workflow = await createWorkflow({}, owner); + const workflow = await createWorkflowWithHistory({}, owner); const payload = { versionId: workflow.versionId, active: true, @@ -2347,7 +2349,7 @@ describe('PATCH /workflows/:workflowId', () => { }); test('should deactivate workflow without changing version ID', async () => { - const workflow = await createWorkflow({ active: true }, owner); + const workflow = await createWorkflowWithHistory({ active: true }, owner); const payload = { versionId: workflow.versionId, active: false, From cf9eb4e4ef77e81da844e68ba88dbfda9150e398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Tue, 11 Nov 2025 13:21:22 +0100 Subject: [PATCH 03/16] fix(editor): Make sure `Pin` action works only for pinnabe nodes (#21723) --- .../composables/useCanvasOperations.test.ts | 133 ++++++++++++++++++ .../app/composables/useCanvasOperations.ts | 12 +- 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts index 10f891b6784..745e797eeef 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.test.ts @@ -129,6 +129,29 @@ vi.mock('@/app/composables/useWorkflowState', async () => { }; }); +const canPinNodeMock = vi.fn(); +const setDataMock = vi.fn(); +const unsetDataMock = vi.fn(); +const getInputDataWithPinnedMock = vi.fn(); + +vi.mock('@/app/composables/usePinnedData', () => { + return { + usePinnedData: vi.fn(() => ({ + canPinNode: canPinNodeMock, + setData: setDataMock, + unsetData: unsetDataMock, + })), + }; +}); + +vi.mock('@/app/composables/useDataSchema', () => { + return { + useDataSchema: vi.fn(() => ({ + getInputDataWithPinned: getInputDataWithPinnedMock, + })), + }; +}); + describe('useCanvasOperations', () => { const workflowId = 'test'; const initialState = { @@ -1548,6 +1571,116 @@ describe('useCanvasOperations', () => { }); }); + describe('toggleNodesPinned', () => { + beforeEach(() => { + canPinNodeMock.mockReset(); + setDataMock.mockReset(); + unsetDataMock.mockReset(); + getInputDataWithPinnedMock.mockReset(); + }); + + it('should only pin pinnable nodes when mix of pinnable and non-pinnable nodes are selected', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const pinnableNode1 = createTestNode({ id: '1', name: 'PinnableNode1' }); + const pinnableNode2 = createTestNode({ id: '2', name: 'PinnableNode2' }); + const nonPinnableNode = createTestNode({ id: '3', name: 'NonPinnableNode' }); + + const nodes = [pinnableNode1, nonPinnableNode, pinnableNode2]; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + + // Initially, none have pinned data + workflowsStore.pinDataByNodeName = vi.fn().mockReturnValue(undefined); + + let checkIndex = 0; + const nodeOrder: string[] = []; + + // Mock canPinNode based on which node is being checked + canPinNodeMock.mockImplementation(() => { + const currentNodeIndex = checkIndex % nodes.length; + const currentNode = nodes[currentNodeIndex]; + nodeOrder.push(currentNode.id); + checkIndex++; + // Make nodes with id 1 and 2 pinnable, 3 non-pinnable + return currentNode.id !== '3'; + }); + + getInputDataWithPinnedMock.mockReturnValue([{ json: { test: 'data' } }]); + + const { toggleNodesPinned } = useCanvasOperations(); + toggleNodesPinned(['1', '2', '3'], 'pin-icon-click'); + + expect(historyStore.startRecordingUndo).toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).toHaveBeenCalled(); + expect(setDataMock).toHaveBeenCalledTimes(2); + expect(setDataMock).toHaveBeenCalledWith([{ json: { test: 'data' } }], 'pin-icon-click'); + expect(unsetDataMock).not.toHaveBeenCalled(); + }); + + it('should correctly unpin pinnable nodes when mix of pinnable and non-pinnable nodes are selected', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const pinnableNode1 = createTestNode({ id: '1', name: 'PinnableNode1' }); + const pinnableNode2 = createTestNode({ id: '2', name: 'PinnableNode2' }); + const nonPinnableNode = createTestNode({ id: '3', name: 'NonPinnableNode' }); + + const nodes = [pinnableNode1, nonPinnableNode, pinnableNode2]; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + + // Set some initial pinned data for pinnable nodes + workflowsStore.pinDataByNodeName = vi.fn().mockImplementation((nodeName: string) => { + if (nodeName === 'PinnableNode1' || nodeName === 'PinnableNode2') { + return [{ json: { pinned: 'data' } }]; + } + return undefined; + }); + + let checkIndex = 0; + + canPinNodeMock.mockImplementation(() => { + const currentNodeIndex = checkIndex % nodes.length; + const currentNode = nodes[currentNodeIndex]; + checkIndex++; + return currentNode.id !== '3'; + }); + + const { toggleNodesPinned } = useCanvasOperations(); + toggleNodesPinned(['1', '2', '3'], 'pin-icon-click'); + + expect(historyStore.startRecordingUndo).toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).toHaveBeenCalled(); + expect(unsetDataMock).toHaveBeenCalledTimes(2); + expect(unsetDataMock).toHaveBeenCalledWith('pin-icon-click'); + expect(setDataMock).not.toHaveBeenCalled(); + }); + + it('should handle case where all nodes are non-pinnable', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const historyStore = mockedStore(useHistoryStore); + + const nonPinnableNode1 = createTestNode({ id: '1', name: 'NonPinnableNode1' }); + const nonPinnableNode2 = createTestNode({ id: '2', name: 'NonPinnableNode2' }); + + const nodes = [nonPinnableNode1, nonPinnableNode2]; + workflowsStore.getNodesByIds.mockReturnValue(nodes); + + workflowsStore.pinDataByNodeName = vi.fn().mockReturnValue(undefined); + canPinNodeMock.mockReturnValue(false); + + const { toggleNodesPinned } = useCanvasOperations(); + toggleNodesPinned(['1', '2'], 'pin-icon-click'); + + expect(historyStore.startRecordingUndo).toHaveBeenCalled(); + expect(historyStore.stopRecordingUndo).toHaveBeenCalled(); + + // Verify no pinning or unpinning occurred + expect(setDataMock).not.toHaveBeenCalled(); + expect(unsetDataMock).not.toHaveBeenCalled(); + }); + }); + describe('addConnections', () => { it('should create connections between nodes', async () => { const workflowsStore = mockedStore(useWorkflowsStore); diff --git a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts index 7154ccaf43e..00fd192d4db 100644 --- a/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts +++ b/packages/frontend/editor-ui/src/app/composables/useCanvasOperations.ts @@ -653,9 +653,17 @@ export function useCanvasOperations() { } const nodes = workflowsStore.getNodesByIds(ids); - const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name)); - for (const node of nodes) { + // Filter to only pinnable nodes + const pinnableNodes = nodes.filter((node) => { + const pinnedDataForNode = usePinnedData(node); + return pinnedDataForNode.canPinNode(true); + }); + const nextStatePinned = pinnableNodes.some( + (node) => !workflowsStore.pinDataByNodeName(node.name), + ); + + for (const node of pinnableNodes) { const pinnedDataForNode = usePinnedData(node); if (nextStatePinned) { const dataToPin = useDataSchema().getInputDataWithPinned(node); From c8a29a77f26962ed932597da175cd10ea8c5e80a Mon Sep 17 00:00:00 2001 From: Michael Kret <88898367+michael-radency@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:00:35 +0200 Subject: [PATCH 04/16] feat: Expression editor - ability to preview HTML or Markdown in results pane (#21408) --- .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../components/ExpressionEditModal.test.ts | 99 +++ .../components/ExpressionEditModal.vue | 32 +- .../components/RunDataMarkdown.test.ts | 349 +++++++++++ .../runData/components/RunDataMarkdown.vue | 183 ++++++ .../ExpressionOutput.test.ts | 569 ++++++++++++++++++ .../ExpressionOutput.vue | 84 ++- 7 files changed, 1300 insertions(+), 19 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts create mode 100644 packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue create mode 100644 packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d289654b509..6685ef36bf8 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2766,6 +2766,9 @@ "ndv.search.noMatchSchema.description": "To search field values, switch to table or JSON view. {link}", "ndv.search.noMatchSchema.description.link": "Clear filter", "ndv.search.items": "{matched} of {count} item | {matched} of {count} items", + "ndv.render.text": "Text", + "ndv.render.html": "Html", + "ndv.render.markdown": "Markdown", "ndv.nodeHints.disabled": "This node is disabled, and will simply pass the input through", "ndv.nodeHints.alwaysOutputData": "This node will output an empty item if nothing would normally be returned", "ndv.nodeHints.alwaysOutputData.short": "output an empty item if nothing would normally be returned", diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts index 4e47ce1c486..ca97a820dcd 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.test.ts @@ -81,4 +81,103 @@ describe('ExpressionEditModal', () => { expect(editor).toHaveAttribute('aria-readonly', 'true'); }); }); + + describe('output render mode radio buttons', () => { + it('renders all three render mode options', async () => { + const { getByText } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: 'test', + dialogVisible: true, + }, + }); + + await waitFor(() => { + expect(getByText('Text')).toBeInTheDocument(); + expect(getByText('Html')).toBeInTheDocument(); + expect(getByText('Markdown')).toBeInTheDocument(); + }); + }); + + it('has Text as default render mode', async () => { + const { getByText } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: 'test', + dialogVisible: true, + }, + }); + + await waitFor(() => { + const textButton = getByText('Text').closest('label'); + expect(textButton).toHaveAttribute('aria-checked', 'true'); + }); + }); + + it('allows switching to Html render mode', async () => { + const { getByText } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: 'test', + dialogVisible: true, + }, + }); + + await waitFor(async () => { + const htmlButton = getByText('Html').closest('label'); + const htmlInput = htmlButton?.querySelector('input'); + + if (htmlInput) { + htmlInput.click(); + expect(htmlInput).toBeChecked(); + } + }); + }); + + it('allows switching to Markdown render mode', async () => { + const { getByText } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: 'test', + dialogVisible: true, + }, + }); + + await waitFor(async () => { + const markdownButton = getByText('Markdown').closest('label'); + const markdownInput = markdownButton?.querySelector('input'); + + if (markdownInput) { + markdownInput.click(); + expect(markdownInput).toBeChecked(); + } + }); + }); + + it('has correct values for each render mode option', async () => { + const { getByTestId } = renderModal({ + pinia, + props: { + parameter: createTestNodeProperties({ name: 'foo', type: 'string' }), + path: '', + modelValue: 'test', + dialogVisible: true, + }, + }); + + await waitFor(() => { + expect(getByTestId('radio-button-text')).toBeInTheDocument(); + expect(getByTestId('radio-button-html')).toBeInTheDocument(); + expect(getByTestId('radio-button-markdown')).toBeInTheDocument(); + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue index 2022bd26e9c..bc87044ea80 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue @@ -25,7 +25,14 @@ import { APP_MODALS_ELEMENT_ID } from '@/app/constants'; import { useThrottleFn } from '@vueuse/core'; import { ElDialog } from 'element-plus'; -import { N8nIcon, N8nInput, N8nResizeWrapper, N8nText, type ResizeData } from '@n8n/design-system'; +import { + N8nIcon, + N8nInput, + N8nRadioButtons, + N8nResizeWrapper, + N8nText, + type ResizeData, +} from '@n8n/design-system'; const DEFAULT_LEFT_SIDEBAR_WIDTH = 360; type Props = { @@ -64,6 +71,7 @@ const sidebarWidth = ref(DEFAULT_LEFT_SIDEBAR_WIDTH); const expressionInputRef = ref>(); const expressionResultRef = ref>(); const theme = outputTheme(); +const outputRenderMode = ref<'text' | 'html' | 'markdown'>('text'); const activeNode = computed(() => ndvStore.activeNode); const inputEditor = computed(() => expressionInputRef.value?.editor); @@ -216,7 +224,18 @@ const onResizeThrottle = useThrottleFn(onResize, 10); {{ i18n.baseText('parameterInput.result') }} - +
+ + +
@@ -225,6 +244,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10); :class="$style.editor" :segments="segments" :extensions="theme" + :render="outputRenderMode" data-test-id="expression-modal-output" />
@@ -318,6 +338,14 @@ const onResizeThrottle = useThrottleFn(onResize, 10); gap: var(--spacing--5xs); } +.headerControls { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-right: var(--spacing--4xs); +} + .tip { min-height: 22px; } diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts new file mode 100644 index 00000000000..094f6051ff1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.test.ts @@ -0,0 +1,349 @@ +import { createTestingPinia } from '@pinia/testing'; +import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue'; +import { renderComponent } from '@/__tests__/render'; + +describe('RunDataMarkdown.vue', () => { + it('should render markdown content correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '# Hello World\n\nThis is a test.', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + expect(markdownContainer?.textContent).toContain('Hello World'); + expect(markdownContainer?.textContent).toContain('This is a test.'); + }); + + it('should render headers correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + + const h1 = markdownContainer?.querySelector('h1'); + const h2 = markdownContainer?.querySelector('h2'); + const h3 = markdownContainer?.querySelector('h3'); + const h4 = markdownContainer?.querySelector('h4'); + const h5 = markdownContainer?.querySelector('h5'); + const h6 = markdownContainer?.querySelector('h6'); + + expect(h1?.textContent).toBe('H1'); + expect(h2?.textContent).toBe('H2'); + expect(h3?.textContent).toBe('H3'); + expect(h4?.textContent).toBe('H4'); + expect(h5?.textContent).toBe('H5'); + expect(h6?.textContent).toBe('H6'); + }); + + it('should render bold and italic text', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '**bold text** and *italic text*', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + + const strong = markdownContainer?.querySelector('strong'); + const em = markdownContainer?.querySelector('em'); + + expect(strong?.textContent).toBe('bold text'); + expect(em?.textContent).toBe('italic text'); + }); + + it('should render links correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '[Click here](https://example.com)', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const link = markdownContainer?.querySelector('a'); + + expect(link).toBeInTheDocument(); + expect(link?.textContent).toBe('Click here'); + expect(link?.getAttribute('href')).toBe('https://example.com'); + }); + + it('should render code blocks correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '```javascript\nconst x = 42;\n```', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const pre = markdownContainer?.querySelector('pre'); + const code = pre?.querySelector('code'); + + expect(pre).toBeInTheDocument(); + expect(code).toBeInTheDocument(); + expect(code?.textContent).toContain('const x = 42;'); + }); + + it('should render inline code correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: 'Use `console.log()` for debugging', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const code = markdownContainer?.querySelector('code'); + + expect(code).toBeInTheDocument(); + expect(code?.textContent).toBe('console.log()'); + }); + + it('should render unordered lists correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '- Item 1\n- Item 2\n- Item 3', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const ul = markdownContainer?.querySelector('ul'); + const listItems = ul?.querySelectorAll('li'); + + expect(ul).toBeInTheDocument(); + expect(listItems?.length).toBe(3); + expect(listItems?.[0].textContent).toBe('Item 1'); + expect(listItems?.[1].textContent).toBe('Item 2'); + expect(listItems?.[2].textContent).toBe('Item 3'); + }); + + it('should render ordered lists correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '1. First\n2. Second\n3. Third', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const ol = markdownContainer?.querySelector('ol'); + const listItems = ol?.querySelectorAll('li'); + + expect(ol).toBeInTheDocument(); + expect(listItems?.length).toBe(3); + expect(listItems?.[0].textContent).toBe('First'); + expect(listItems?.[1].textContent).toBe('Second'); + expect(listItems?.[2].textContent).toBe('Third'); + }); + + it('should render blockquotes correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '> This is a quote\n> with multiple lines', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const blockquote = markdownContainer?.querySelector('blockquote'); + + expect(blockquote).toBeInTheDocument(); + expect(blockquote?.textContent).toContain('This is a quote'); + expect(blockquote?.textContent).toContain('with multiple lines'); + }); + + it('should render tables correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const table = markdownContainer?.querySelector('table'); + const headers = table?.querySelectorAll('th'); + const cells = table?.querySelectorAll('td'); + + expect(table).toBeInTheDocument(); + expect(headers?.length).toBe(2); + expect(headers?.[0].textContent).toBe('Header 1'); + expect(headers?.[1].textContent).toBe('Header 2'); + expect(cells?.length).toBe(2); + expect(cells?.[0].textContent).toContain('Cell 1'); + expect(cells?.[1].textContent).toContain('Cell 2'); + }); + + it('should render horizontal rules correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: 'Before\n\n---\n\nAfter', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const hr = markdownContainer?.querySelector('hr'); + + expect(hr).toBeInTheDocument(); + }); + + it('should render empty string', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + }); + + it('should render plain text without markdown syntax', () => { + const plainText = 'This is just plain text without any markdown'; + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: plainText, + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + expect(markdownContainer?.textContent).toContain(plainText); + }); + + it('should handle complex mixed markdown content', () => { + const complexMarkdown = `# Main Title + +This is a paragraph with **bold** and *italic* text. + +## Subsection + +- List item 1 +- List item 2 + - Nested item + +\`\`\`javascript +const code = "example"; +\`\`\` + +> A quote + +[Link](https://example.com)`; + + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: complexMarkdown, + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + + expect(markdownContainer?.querySelector('h1')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('h2')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('strong')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('em')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('ul')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('pre')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('blockquote')).toBeInTheDocument(); + expect(markdownContainer?.querySelector('a')).toBeInTheDocument(); + }); + + it('should apply markdown CSS module class', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '# Test', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + expect(markdownContainer?.className).toContain('markdown'); + }); + + it('should handle markdown with special characters', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: 'Text with < > & " special characters', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + expect(markdownContainer?.textContent).toContain('special characters'); + }); + + it('should render markdown with newlines correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: 'Line 1\n\nLine 2\n\nLine 3', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const paragraphs = markdownContainer?.querySelectorAll('p'); + + expect(markdownContainer).toBeInTheDocument(); + expect(paragraphs?.length).toBeGreaterThan(0); + }); + + it('should handle image markdown syntax', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '![Alt text](https://example.com/image.png)', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const img = markdownContainer?.querySelector('img'); + + expect(img).toBeInTheDocument(); + expect(img?.getAttribute('alt')).toBe('Alt text'); + expect(img?.getAttribute('src')).toBe('https://example.com/image.png'); + }); + + it('should render nested lists correctly', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '- Item 1\n - Nested 1\n - Nested 2\n- Item 2', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + const lists = markdownContainer?.querySelectorAll('ul'); + + expect(lists && lists.length > 0).toBe(true); + }); + + it('should render strikethrough text if supported', () => { + const { container } = renderComponent(RunDataMarkdown, { + pinia: createTestingPinia(), + props: { + inputMarkdown: '~~strikethrough~~', + }, + }); + + const markdownContainer = container.querySelector('[class*="markdown"]'); + expect(markdownContainer).toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue new file mode 100644 index 00000000000..d5b46411cc7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/RunDataMarkdown.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts new file mode 100644 index 00000000000..0c0f445777b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.test.ts @@ -0,0 +1,569 @@ +import { renderComponent } from '@/__tests__/render'; +import { createTestingPinia } from '@pinia/testing'; +import { waitFor } from '@testing-library/vue'; +import type { Extension } from '@codemirror/state'; +import ExpressionOutput from './ExpressionOutput.vue'; +import type { Segment } from '../../../../../app/types/expressions'; + +describe('ExpressionOutput.vue', () => { + const basicSegments: Segment[] = [ + { + kind: 'plaintext', + from: 0, + to: 6, + plaintext: 'Hello ', + }, + { + kind: 'resolvable', + from: 6, + to: 16, + resolvable: '{{ $json.name }}', + resolved: 'World', + state: 'valid', + error: null, + }, + ]; + + describe('render mode: text', () => { + it('should render text output by default', () => { + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + expect(output?.textContent).toContain('Hello World'); + }); + + it('should render empty string message when segments are empty', () => { + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: [], + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('[empty]'); + }); + + it('should render plaintext segments correctly', () => { + const plaintextSegments: Segment[] = [ + { + kind: 'plaintext', + from: 0, + to: 5, + plaintext: 'Test ', + }, + { + kind: 'plaintext', + from: 5, + to: 10, + plaintext: 'Value', + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: plaintextSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('Test Value'); + }); + + it('should render resolvable segments with resolved values', () => { + const resolvableSegments: Segment[] = [ + { + kind: 'resolvable', + from: 0, + to: 10, + resolvable: '{{ 1 + 1 }}', + resolved: 2, + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: resolvableSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('2'); + }); + + it('should handle boolean resolved values', () => { + const booleanSegments: Segment[] = [ + { + kind: 'resolvable', + from: 0, + to: 10, + resolvable: '{{ true }}', + resolved: true, + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: booleanSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('true'); + }); + + it('should skip duplicate segments', () => { + const duplicateSegments: Segment[] = [ + { + from: 0, + to: 5, + plaintext: '[1,2]', + kind: 'plaintext', + }, + { + from: 0, + to: 1, + plaintext: '[', + kind: 'plaintext', + }, + { + from: 1, + to: 2, + plaintext: '1', + kind: 'plaintext', + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: duplicateSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('[1,2]'); + }); + }); + + describe('render mode: html', () => { + it('should render HTML content when render mode is html', () => { + const htmlSegments: Segment[] = [ + { + kind: 'resolvable', + from: 0, + to: 10, + resolvable: '{{ $json.html }}', + resolved: '

Hello

World

', + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: htmlSegments, + render: 'html', + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + expect(output?.tagName).toBe('IFRAME'); + }); + + it('should not render CodeMirror editor in html mode', () => { + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'html', + }, + }); + + const cmEditor = container.querySelector('.cm-editor'); + expect(cmEditor).not.toBeInTheDocument(); + }); + }); + + describe('render mode: markdown', () => { + it('should render Markdown content when render mode is markdown', () => { + const markdownSegments: Segment[] = [ + { + kind: 'resolvable', + from: 0, + to: 10, + resolvable: '{{ $json.markdown }}', + resolved: '# Hello\n\nThis is **bold** text', + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: markdownSegments, + render: 'markdown', + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + expect(output).toHaveClass('markdown'); + }); + + it('should not render CodeMirror editor in markdown mode', () => { + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'markdown', + }, + }); + + const cmEditor = container.querySelector('.cm-editor'); + expect(cmEditor).not.toBeInTheDocument(); + }); + }); + + describe('switching render modes', () => { + it('should switch from text to html mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'text', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + + await rerender({ + segments: basicSegments, + render: 'html', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).toBe('IFRAME'); + }); + + const cmEditor = container.querySelector('.cm-editor'); + expect(cmEditor).not.toBeInTheDocument(); + }); + + it('should switch from text to markdown mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'text', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + + await rerender({ + segments: basicSegments, + render: 'markdown', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toHaveClass('markdown'); + }); + + const cmEditor = container.querySelector('.cm-editor'); + expect(cmEditor).not.toBeInTheDocument(); + }); + + it('should switch from html to text mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'html', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).toBe('IFRAME'); + + await rerender({ + segments: basicSegments, + render: 'text', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toContain('Hello World'); + }); + + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).not.toBe('IFRAME'); + }); + + it('should switch from markdown to text mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'markdown', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toHaveClass('markdown'); + + await rerender({ + segments: basicSegments, + render: 'text', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toContain('Hello World'); + }); + + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).not.toHaveClass('markdown'); + }); + + it('should switch from html to markdown mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'html', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).toBe('IFRAME'); + + await rerender({ + segments: basicSegments, + render: 'markdown', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toHaveClass('markdown'); + }); + + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).not.toBe('IFRAME'); + }); + + it('should switch from markdown to html mode', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'markdown', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toHaveClass('markdown'); + + await rerender({ + segments: basicSegments, + render: 'html', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.tagName).toBe('IFRAME'); + }); + + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).not.toHaveClass('markdown'); + }); + + it('should update segments when in text mode', async () => { + const initialSegments = [ + { + kind: 'plaintext', + from: 0, + to: 5, + plaintext: 'First', + }, + ] as Segment[]; + + const updatedSegments = [ + { + kind: 'plaintext', + from: 0, + to: 6, + plaintext: 'Second', + }, + ] as Segment[]; + + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: initialSegments, + render: 'text', + }, + }); + + let output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('First'); + + await rerender({ + segments: updatedSegments, + render: 'text', + }); + + await waitFor(() => { + output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('Second'); + }); + }); + + it('should handle rapid mode switching', async () => { + const { container, rerender } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + render: 'text', + }, + }); + + await rerender({ segments: basicSegments, render: 'html' }); + await rerender({ segments: basicSegments, render: 'markdown' }); + await rerender({ segments: basicSegments, render: 'text' }); + + await waitFor(() => { + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toContain('Hello World'); + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + expect(container.querySelector('iframe')).not.toBeInTheDocument(); + }); + }); + + describe('getValue expose method', () => { + it('should render output correctly for getValue usage', () => { + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + expect(output?.textContent).toContain('Hello World'); + }); + }); + + describe('edge cases', () => { + it('should handle segments with null resolved value', () => { + const segmentsWithNull: Segment[] = [ + { + kind: 'resolvable', + from: 0, + to: 10, + resolvable: '{{ $json.missing }}', + resolved: null, + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: segmentsWithNull, + render: 'text', + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + }); + + it('should handle mixed plaintext and resolvable segments', () => { + const mixedSegments: Segment[] = [ + { + kind: 'plaintext', + from: 0, + to: 6, + plaintext: 'Hello ', + }, + { + kind: 'resolvable', + from: 6, + to: 16, + resolvable: '{{ $json.name }}', + resolved: 'John', + state: 'valid', + error: null, + }, + { + kind: 'plaintext', + from: 16, + to: 23, + plaintext: ', age: ', + }, + { + kind: 'resolvable', + from: 23, + to: 33, + resolvable: '{{ $json.age }}', + resolved: 25, + state: 'valid', + error: null, + }, + ]; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: mixedSegments, + render: 'text', + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output?.textContent).toBe('Hello John, age: 25'); + }); + + it('should handle custom extensions in text mode', () => { + const customExtensions: Extension[] = []; + + const { container } = renderComponent(ExpressionOutput, { + pinia: createTestingPinia(), + props: { + segments: basicSegments, + extensions: customExtensions, + render: 'text', + }, + }); + + const output = container.querySelector('[data-test-id="expression-output"]'); + expect(output).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue index 8a5bc54cf8c..cb7982db17a 100644 --- a/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue +++ b/packages/frontend/editor-ui/src/features/shared/editors/components/InlineExpressionEditor/ExpressionOutput.vue @@ -4,16 +4,23 @@ import { EditorView } from '@codemirror/view'; import { useI18n } from '@n8n/i18n'; import { highlighter } from '../../plugins/codemirror/resolvableHighlighter'; + import type { Plaintext, Resolved, Segment } from '@/app/types/expressions'; -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { forceParse } from '@/app/utils/forceParse'; +import RunDataHtml from '@/features/ndv/runData/components/RunDataHtml.vue'; +import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue'; interface ExpressionOutputProps { segments: Segment[]; extensions?: Extension[]; + render?: 'text' | 'html' | 'markdown'; } -const props = withDefaults(defineProps(), { extensions: () => [] }); +const props = withDefaults(defineProps(), { + extensions: () => [], + render: 'text', +}); const i18n = useI18n(); @@ -75,21 +82,9 @@ const resolvedSegments = computed(() => { .filter((segment): segment is Resolved => segment.kind === 'resolvable'); }); -watch( - () => props.segments, - () => { - if (!editor.value) return; +function initializeEditor() { + if (!root.value) return; - editor.value.dispatch({ - changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value }, - }); - - highlighter.addColor(editor.value as EditorView, resolvedSegments.value); - highlighter.removeColor(editor.value as EditorView, plaintextSegments.value); - }, -); - -onMounted(() => { editor.value = new EditorView({ parent: root.value as HTMLElement, state: EditorState.create({ @@ -105,6 +100,38 @@ onMounted(() => { highlighter.addColor(editor.value as EditorView, resolvedSegments.value); highlighter.removeColor(editor.value as EditorView, plaintextSegments.value); +} + +watch( + () => props.segments, + () => { + if (props.render !== 'text' || !editor.value) return; + + editor.value.dispatch({ + changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value }, + }); + + highlighter.addColor(editor.value as EditorView, resolvedSegments.value); + highlighter.removeColor(editor.value as EditorView, plaintextSegments.value); + }, +); + +watch( + () => props.render, + async (newMode) => { + if (newMode === 'text' && !editor.value) { + await nextTick(); + initializeEditor(); + } else if ((newMode === 'html' || newMode === 'markdown') && editor.value) { + editor.value.destroy(); + editor.value = null; + } + }, +); + +onMounted(() => { + if (props.render !== 'text') return; + initializeEditor(); }); onBeforeUnmount(() => { @@ -115,5 +142,28 @@ defineExpose({ getValue: () => '=' + resolvedExpression.value }); + + From bc61f94274bef8f4f64c5d014bedef690d218a84 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Tue, 11 Nov 2025 11:11:02 -0500 Subject: [PATCH 05/16] feat(core): Add ACR parameter to OIDC settings (#20974) --- .../@n8n/api-types/src/dto/oidc/config.dto.ts | 1 + .../oidc/__tests__/oidc.service.ee.test.ts | 21 +++++++++++ .../cli/src/sso.ee/oidc/oidc.service.ee.ts | 7 +++- .../integration/oidc/oidc.service.ee.test.ts | 11 ++++++ .../settings/sso/views/SettingsSso.vue | 36 ++++++++++++++++++- 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts index 2ce3f705de7..52435e8548d 100644 --- a/packages/@n8n/api-types/src/dto/oidc/config.dto.ts +++ b/packages/@n8n/api-types/src/dto/oidc/config.dto.ts @@ -10,4 +10,5 @@ export class OidcConfigDto extends Z.class({ .enum(['none', 'login', 'consent', 'select_account', 'create']) .optional() .default('select_account'), + authenticationContextClassReference: z.array(z.string()).default([]), }) {} diff --git a/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts index 510d9f4a6de..9af0e2e96ac 100644 --- a/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts @@ -188,6 +188,26 @@ describe('OidcService', () => { loginEnabled: mockOidcConfig.loginEnabled, prompt: 'select_account', discoveryEndpoint: expect.any(URL), + authenticationContextClassReference: expect.any(Array), + }); + }); + + it('should fill out optional authenticationContextClassReference parameter with default value', async () => { + settingsRepository.findByKey = jest.fn().mockResolvedValue({ + key: OIDC_PREFERENCES_DB_KEY, + value: JSON.stringify(mockOidcConfig), + loadOnStartup: true, + }); + + const result = await oidcService.loadConfigurationFromDatabase(); + + expect(result).toEqual({ + clientId: mockOidcConfig.clientId, + clientSecret: mockOidcConfig.clientSecret, + loginEnabled: mockOidcConfig.loginEnabled, + prompt: 'select_account', + discoveryEndpoint: expect.any(URL), + authenticationContextClassReference: [], }); }); @@ -281,6 +301,7 @@ describe('OidcService', () => { loginEnabled: mockOidcConfig.loginEnabled, prompt: 'select_account', discoveryEndpoint: expect.any(URL), + authenticationContextClassReference: expect.any(Array), }); expect(logger.warn).not.toHaveBeenCalled(); }); diff --git a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts index 6f932257d3e..b7133c97ca8 100644 --- a/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts +++ b/packages/cli/src/sso.ee/oidc/oidc.service.ee.ts @@ -40,11 +40,12 @@ const DEFAULT_OIDC_CONFIG: OidcConfigDto = { discoveryEndpoint: '', loginEnabled: false, prompt: 'select_account', + authenticationContextClassReference: [], }; type OidcRuntimeConfig = Pick< OidcConfigDto, - 'clientId' | 'clientSecret' | 'loginEnabled' | 'prompt' + 'clientId' | 'clientSecret' | 'loginEnabled' | 'prompt' | 'authenticationContextClassReference' > & { discoveryEndpoint: URL; }; @@ -178,6 +179,7 @@ export class OidcService { const nonce = this.generateNonce(); const prompt = this.oidcConfig.prompt; + const authenticationContextClassReference = this.oidcConfig.authenticationContextClassReference; const provisioningConfig = await this.provisioningService.getConfig(); const provisioningEnabled = @@ -196,6 +198,9 @@ export class OidcService { prompt, state: state.plaintext, nonce: nonce.plaintext, + ...(authenticationContextClassReference.length > 0 && { + acr_values: authenticationContextClassReference.join(' '), + }), }); return { url: authorizationURL, state: state.signed, nonce: nonce.signed }; diff --git a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts index 5ee40a09deb..25899bdcc18 100644 --- a/packages/cli/test/integration/oidc/oidc.service.ee.test.ts +++ b/packages/cli/test/integration/oidc/oidc.service.ee.test.ts @@ -57,6 +57,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'http://n8n.io/not-set', loginEnabled: false, prompt: 'select_account', + authenticationContextClassReference: [], }); }); @@ -68,6 +69,7 @@ describe('OIDC service', () => { discoveryEndpoint: new URL('http://n8n.io/not-set'), loginEnabled: false, prompt: 'select_account', + authenticationContextClassReference: [], }); }); @@ -78,6 +80,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await oidcService.updateConfig(newConfig); @@ -100,6 +103,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await oidcService.updateConfig(newConfig); @@ -121,6 +125,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'Not an url', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await expect(oidcService.updateConfig(newConfig)).rejects.toThrowError(UserError); @@ -133,6 +138,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await oidcService.updateConfig(newConfig); @@ -155,6 +161,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; discoveryMock.mockRejectedValueOnce(new Error('Discovery failed')); @@ -175,6 +182,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; const mockConfiguration = new real_odic_client.Configuration( @@ -205,6 +213,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://newprovider.example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'select_account', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; const newMockConfiguration = new real_odic_client.Configuration( @@ -256,6 +265,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'consent', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await oidcService.updateConfig(initialConfig); @@ -299,6 +309,7 @@ describe('OIDC service', () => { discoveryEndpoint: 'https://example.com/.well-known/openid-configuration', loginEnabled: true, prompt: 'consent', + authenticationContextClassReference: ['mfa', 'phrh', 'pwd'], }; await oidcService.updateConfig(initialConfig); diff --git a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue index d3b70de8c8b..82da078fc79 100644 --- a/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue +++ b/packages/frontend/editor-ui/src/features/settings/sso/views/SettingsSso.vue @@ -99,6 +99,8 @@ const promptDescriptions: PromptDescription[] = [ const authProtocol = ref(SupportedProtocols.SAML); +const authenticationContextClassReference = ref(''); + const ipsOptions = ref([ { label: i18n.baseText('settings.sso.settings.ips.options.url'), @@ -278,6 +280,8 @@ const getOidcConfig = async () => { clientSecret.value = config.clientSecret; discoveryEndpoint.value = config.discoveryEndpoint; prompt.value = config.prompt ?? 'select_account'; + authenticationContextClassReference.value = + config.authenticationContextClassReference?.join(',') || ''; }; async function loadOidcConfig() { @@ -296,12 +300,22 @@ function onAuthProtocolUpdated(value: SupportedProtocolType) { } const cannotSaveOidcSettings = computed(() => { + const currentAcrString = authenticationContextClassReference.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(','); + + const storedAcrString = ssoStore.oidcConfig?.authenticationContextClassReference?.join(',') || ''; + return ( ssoStore.oidcConfig?.clientId === clientId.value && ssoStore.oidcConfig?.clientSecret === clientSecret.value && ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value && ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled && - ssoStore.oidcConfig?.prompt === prompt.value + ssoStore.oidcConfig?.prompt === prompt.value && + storedAcrString === authenticationContextClassReference.value && + currentAcrString === storedAcrString ); }); @@ -322,6 +336,11 @@ async function onOidcSettingsSave() { if (confirmAction !== MODAL_CONFIRM) return; } + const acrArray = authenticationContextClassReference.value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + try { const newConfig = await ssoStore.saveOidcConfig({ clientId: clientId.value, @@ -329,6 +348,7 @@ async function onOidcSettingsSave() { discoveryEndpoint: discoveryEndpoint.value, prompt: prompt.value, loginEnabled: ssoStore.isOidcLoginEnabled, + authenticationContextClassReference: acrArray, }); // Update store with saved protocol selection @@ -548,6 +568,20 @@ async function onOidcSettingsSave() { The prompt parameter to use when authenticating with the OIDC provider +
+ + + ACR values to include in the authorization request (acr_values parameter), separated by + commas in order of preference. +
Date: Wed, 12 Nov 2025 00:16:39 +0200 Subject: [PATCH 06/16] fix: Save workflow history on archive and unarchive (no-changelog) (#21749) --- .../cli/src/workflows/workflow.service.ts | 18 ++-- .../workflows/workflows.controller.test.ts | 89 +++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index b82d9a69399..901e444e881 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -493,19 +493,21 @@ export class WorkflowService { } const versionId = uuid(); + workflow.versionId = versionId; + workflow.isArchived = true; + workflow.active = false; + await this.workflowRepository.update(workflowId, { isArchived: true, active: false, versionId, }); + await this.workflowHistoryService.saveVersion(user, workflow, workflowId); + this.eventService.emit('workflow-archived', { user, workflowId, publicApi: false }); await this.externalHooks.run('workflow.afterArchive', [workflowId]); - workflow.isArchived = true; - workflow.active = false; - workflow.versionId = versionId; - return workflow; } @@ -523,14 +525,16 @@ export class WorkflowService { } const versionId = uuid(); + workflow.versionId = versionId; + workflow.isArchived = false; + await this.workflowRepository.update(workflowId, { isArchived: false, versionId }); + await this.workflowHistoryService.saveVersion(user, workflow, workflowId); + this.eventService.emit('workflow-unarchived', { user, workflowId, publicApi: false }); await this.externalHooks.run('workflow.afterUnarchive', [workflowId]); - workflow.isArchived = false; - workflow.versionId = versionId; - return workflow; } diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index bee465972a8..557cb325f95 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -2587,6 +2587,33 @@ describe('POST /workflows/:workflowId/archive', () => { expect(workflowsInDb!.isArchived).toBe(true); expect(sharedWorkflowsInDb).toHaveLength(1); }); + + test('should save workflow history', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const initialVersionId = workflow.versionId; + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(200); + + const { + data: { versionId: newVersionId }, + } = response.body; + + expect(newVersionId).not.toBe(initialVersionId); + + const historyRecord = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: workflow.id, + versionId: newVersionId, + }, + }); + + expect(historyRecord).not.toBeNull(); + expect(historyRecord!.nodes).toEqual(workflow.nodes); + expect(historyRecord!.connections).toEqual(workflow.connections); + }); }); describe('POST /workflows/:workflowId/unarchive', () => { @@ -2668,6 +2695,68 @@ describe('POST /workflows/:workflowId/unarchive', () => { expect(workflowsInDb!.isArchived).toBe(false); expect(sharedWorkflowsInDb).toHaveLength(1); }); + + test('should save workflow history', async () => { + const workflow = await createWorkflowWithHistory({ isArchived: true }, owner); + const initialVersionId = workflow.versionId; + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/unarchive`) + .send() + .expect(200); + + const { + data: { versionId: newVersionId }, + } = response.body; + + expect(newVersionId).not.toBe(initialVersionId); + + const historyRecord = await Container.get(WorkflowHistoryRepository).findOne({ + where: { + workflowId: workflow.id, + versionId: newVersionId, + }, + }); + + expect(historyRecord).not.toBeNull(); + expect(historyRecord!.nodes).toEqual(workflow.nodes); + expect(historyRecord!.connections).toEqual(workflow.connections); + }); + + test('should be able to activate workflow after unarchiving', async () => { + const workflow = await createWorkflowWithHistory( + { + nodes: [ + { + id: 'trigger-1', + parameters: {}, + name: 'Schedule Trigger', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + }, + owner, + ); + + await authOwnerAgent.post(`/workflows/${workflow.id}/archive`).send().expect(200); + + const unarchiveResponse = await authOwnerAgent + .post(`/workflows/${workflow.id}/unarchive`) + .send() + .expect(200); + + const { data: unarchivedWorkflow } = unarchiveResponse.body; + + const activateResponse = await authOwnerAgent + .patch(`/workflows/${workflow.id}`) + .send({ active: true, versionId: unarchivedWorkflow.versionId }) + .expect(200); + + expect(activateResponse.body.data.active).toBe(true); + }); }); describe('DELETE /workflows/:workflowId', () => { From 86f6da38333848dc2c54aedaffda9591a12f66c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 12 Nov 2025 10:16:56 +0100 Subject: [PATCH 07/16] fix(editor): Fix command bar keyboard events handing (#21759) --- .../src/components/N8nCommandBar/CommandBar.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue index eadaf3e0813..092805f3e0c 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue @@ -196,6 +196,8 @@ const handleKeydown = (event: KeyboardEvent) => { if (!isOpen.value) return; + event.stopPropagation(); + switch (event.key) { case 'Escape': event.preventDefault(); @@ -228,7 +230,6 @@ const handleKeydown = (event: KeyboardEvent) => { break; case 'Enter': event.preventDefault(); - event.stopPropagation(); if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) { void selectItem(flattenedItems.value[selectedIndex.value]); } @@ -250,12 +251,12 @@ watch(inputValue, (newValue) => { }); onMounted(() => { - document.addEventListener('keydown', handleKeydown); + document.addEventListener('keydown', handleKeydown, { capture: true }); document.addEventListener('click', handleClickOutside); }); onUnmounted(() => { - document.removeEventListener('keydown', handleKeydown); + document.removeEventListener('keydown', handleKeydown, { capture: true }); document.removeEventListener('click', handleClickOutside); }); From 6fd8ca9021c33883c3d594e962f006059da5a834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 12 Nov 2025 10:50:28 +0100 Subject: [PATCH 08/16] fix(editor): Fix node name tooltip in NDV header (#21760) --- .../frontend/editor-ui/src/app/components/NodeTitle.vue | 1 + .../src/features/ndv/panel/components/NDVHeader.vue | 9 ++++++++- .../features/ndv/settings/components/NodeSettings.vue | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/app/components/NodeTitle.vue b/packages/frontend/editor-ui/src/app/components/NodeTitle.vue index 4fc63a605e0..63b0e64852d 100644 --- a/packages/frontend/editor-ui/src/app/components/NodeTitle.vue +++ b/packages/frontend/editor-ui/src/app/components/NodeTitle.vue @@ -47,6 +47,7 @@ const { width } = useElementSize(wrapperRef);
- +
Date: Wed, 12 Nov 2025 10:52:33 +0100 Subject: [PATCH 09/16] fix(editor): Previous nodes' outputs aren't available in expression editor for sub-nodes (#21730) --- .../src/app/stores/workflows.store.test.ts | 131 +++++++++++++++++- .../src/app/stores/workflows.store.ts | 16 +++ .../ndv/panel/components/InputPanel.vue | 18 +-- .../components/ExpressionEditModal.vue | 13 +- 4 files changed, 159 insertions(+), 19 deletions(-) diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts index cb723c0c39d..53c4d047dbe 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.test.ts @@ -12,7 +12,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store'; import type { INodeUi, IWorkflowDb, IWorkflowSettings } from '@/Interface'; import type { IExecutionResponse } from '@/features/execution/executions/executions.types'; -import { deepCopy, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; +import { deepCopy, NodeConnectionTypes, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import type { IPinData, IConnection, @@ -519,6 +519,135 @@ describe('useWorkflowsStore', () => { }); }); + describe('findRootWithMainConnection()', () => { + it('returns children connected via ai tool when they also have a main parent', () => { + const toolNode = createTestNode({ name: 'ToolNode' }); + const upstreamParentNode = createTestNode({ name: 'UpstreamNode' }); + const rootNode = createTestNode({ name: 'RootNode' }); + + workflowsStore.setNodes([toolNode, upstreamParentNode, rootNode]); + + workflowsStore.setConnections({ + [toolNode.name]: { + [NodeConnectionTypes.AiTool]: [ + [ + { + node: rootNode.name, + type: NodeConnectionTypes.AiTool, + index: 0, + }, + ], + ], + }, + [upstreamParentNode.name]: { + main: [ + [ + { + node: rootNode.name, + type: NodeConnectionTypes.Main, + index: 0, + }, + ], + ], + }, + }); + + const result = workflowsStore.findRootWithMainConnection(toolNode.name); + + expect(result).toBe(rootNode.name); + }); + + it('finds the root for a deeply nested vector tool chain', () => { + const embeddingsNode = createTestNode({ name: 'EmbeddingsNode' }); + const vectorStoreNode = createTestNode({ name: 'VectorStoreNode' }); + const vectorToolNode = createTestNode({ name: 'VectorToolNode' }); + const agentNode = createTestNode({ name: 'AI Agent' }); + const setNode = createTestNode({ name: 'SetNode' }); + + workflowsStore.setNodes([ + embeddingsNode, + vectorStoreNode, + vectorToolNode, + agentNode, + setNode, + ]); + + workflowsStore.setConnections({ + [embeddingsNode.name]: { + [NodeConnectionTypes.AiEmbedding]: [ + [ + { + node: vectorStoreNode.name, + type: NodeConnectionTypes.AiEmbedding, + index: 0, + }, + ], + ], + }, + [vectorStoreNode.name]: { + [NodeConnectionTypes.AiVectorStore]: [ + [ + { + node: vectorToolNode.name, + type: NodeConnectionTypes.AiVectorStore, + index: 0, + }, + ], + ], + }, + [vectorToolNode.name]: { + [NodeConnectionTypes.AiTool]: [ + [ + { + node: agentNode.name, + type: NodeConnectionTypes.AiTool, + index: 0, + }, + ], + ], + }, + [setNode.name]: { + main: [ + [ + { + node: agentNode.name, + type: NodeConnectionTypes.Main, + index: 0, + }, + ], + ], + }, + }); + + expect(workflowsStore.findRootWithMainConnection(embeddingsNode.name)).toBe(agentNode.name); + }); + + it('returns null when no child has a main input connection', () => { + const parent = createTestNode({ name: 'ParentNode' }); + const aiChild = createTestNode({ name: 'AiChild' }); + + workflowsStore.setNodes([parent, aiChild]); + + workflowsStore.setConnections({ + [parent.name]: { + [NodeConnectionTypes.AiTool]: [ + [ + { + node: aiChild.name, + type: NodeConnectionTypes.AiTool, + index: 0, + }, + ], + ], + }, + }); + + const result = workflowsStore.findRootWithMainConnection(parent.name); + + expect(result).toBeNull(); + }); + }); + describe('getPinDataSize()', () => { it('returns zero when pinData is empty', () => { const pinData = {}; diff --git a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts index c38a5403995..5be5bc07b8c 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflows.store.ts @@ -433,6 +433,21 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return workflow.value.nodes.find((node) => node.id === nodeId); } + function findRootWithMainConnection(nodeName: string): string | null { + const children = workflowObject.value.getChildNodes(nodeName, 'ALL'); + + for (let i = children.length - 1; i >= 0; i--) { + const childName = children[i]; + const parentNodes = workflowObject.value.getParentNodes(childName, NodeConnectionTypes.Main); + + if (parentNodes.length > 0) { + return childName; + } + } + + return null; + } + // Finds the full id for a given partial id for a node, relying on order for uniqueness in edge cases function findNodeByPartialId(partialId: string): INodeUi | undefined { return workflow.value.nodes.find((node) => node.id.startsWith(partialId)); @@ -1869,6 +1884,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { isNodeInOutgoingNodeConnections, getWorkflowById, getNodeByName, + findRootWithMainConnection, getNodeById, getNodesByIds, getParametersLastUpdate, diff --git a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue index 44a84e2d184..ccf745e221e 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/InputPanel.vue @@ -114,23 +114,7 @@ const activeNode = computed(() => workflowsStore.getNodeByName(props.activeNodeN const rootNode = computed(() => { if (!activeNode.value) return null; - // Find the first child that has a main input connection to account for nested subnodes - const findRootWithMainConnection = (nodeName: string): string | null => { - const children = props.workflowObject.getChildNodes(nodeName, 'ALL'); - - for (let i = children.length - 1; i >= 0; i--) { - const childName = children[i]; - // Check if this child has main input connections - const parentNodes = props.workflowObject.getParentNodes(childName, NodeConnectionTypes.Main); - if (parentNodes.length > 0) { - return childName; - } - } - - return null; - }; - - return findRootWithMainConnection(activeNode.value.name); + return workflowsStore.findRootWithMainConnection(activeNode.value.name); }); const hasRootNodeRun = computed(() => { diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue index bc87044ea80..4ba20b5b5e1 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ExpressionEditModal.vue @@ -83,6 +83,17 @@ const parentNodes = computed(() => { return nodes.filter(({ name }) => name !== node.name); }); +const rootNode = computed(() => { + if (!activeNode.value) return null; + + return workflowsStore.findRootWithMainConnection(activeNode.value.name); +}); + +const rootNodesParents = computed(() => { + if (!rootNode.value) return []; + return workflowsStore.workflowObject.getParentNodesByDepth(rootNode.value); +}); + watch( () => props.dialogVisible, (newValue) => { @@ -177,7 +188,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10); Date: Wed, 12 Nov 2025 11:29:53 +0100 Subject: [PATCH 10/16] fix(editor): Fix empty variables notice indent in schema view (no-changelog) (#21761) --- .../src/features/ndv/runData/components/VirtualSchema.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/features/ndv/runData/components/VirtualSchema.vue b/packages/frontend/editor-ui/src/features/ndv/runData/components/VirtualSchema.vue index 766e677e7b6..932f408ce72 100644 --- a/packages/frontend/editor-ui/src/features/ndv/runData/components/VirtualSchema.vue +++ b/packages/frontend/editor-ui/src/features/ndv/runData/components/VirtualSchema.vue @@ -259,7 +259,8 @@ const contextItems = computed(() => { const variablesEmptyNotice: RenderNotice = { type: 'notice', id: 'notice-variablesEmpty', - level: renderItem.level ?? 0, + // Increase level to indent under $vars + level: (renderItem.level ?? 0) + 1, message: i18n.baseText('dataMapping.schemaView.variablesEmpty'), }; return [renderItem, variablesEmptyNotice]; From 343413dbd9b35a1602a17ef8c4c432c0fe534b71 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Wed, 12 Nov 2025 14:23:12 +0200 Subject: [PATCH 11/16] fix(core): Capture stoppedAt timestamp and improve fullRunData handling (#21290) --- .../__tests__/workflow-execute.test.ts | 52 +++++++++++++++++ .../src/execution-engine/workflow-execute.ts | 58 ++++++++++++------- 2 files changed, 88 insertions(+), 22 deletions(-) diff --git a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 060634051f0..74f37645a20 100644 --- a/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -1863,6 +1863,58 @@ describe('WorkflowExecute', () => { await mockCleanupPromise; expect(cleanupCalled).toBe(true); }); + + test('should capture stoppedAt timestamp after all processing completes', async () => { + const startedAt = new Date('2023-01-01T00:00:00.000Z'); + + let cleanupExecuted = false; + let cleanupTimestamp: Date | null = null; + const closeFunction = new Promise((resolve) => { + setTimeout(() => { + cleanupExecuted = true; + cleanupTimestamp = new Date(); + resolve(); + }, 50); + }); + + const result = await workflowExecute.processSuccessExecution( + startedAt, + workflow, + undefined, + closeFunction, + ); + + // Verify cleanup was executed + expect(cleanupExecuted).toBe(true); + // Verify stoppedAt is after cleanup completed + expect(result.stoppedAt!.getTime()).toBeGreaterThanOrEqual(cleanupTimestamp!.getTime()); + // Verify stoppedAt is after startedAt + expect(result.stoppedAt!.getTime()).toBeGreaterThan(result.startedAt.getTime()); + }); + + test('should use explicit stoppedAt when provided to getFullRunData', () => { + const startedAt = new Date('2023-01-01T00:00:00.000Z'); + const explicitStoppedAt = new Date('2023-01-01T00:00:05.500Z'); + + const result = workflowExecute.getFullRunData(startedAt, explicitStoppedAt); + + expect(result.startedAt).toEqual(startedAt); + expect(result.stoppedAt).toEqual(explicitStoppedAt); + expect(result.stoppedAt!.getTime() - result.startedAt.getTime()).toBe(5500); + }); + + test('should default to current time when stoppedAt not provided to getFullRunData', () => { + const startedAt = new Date('2023-01-01T00:00:00.000Z'); + const currentTime = new Date('2023-01-01T00:00:03.250Z'); + jest.useFakeTimers().setSystemTime(currentTime); + + const result = workflowExecute.getFullRunData(startedAt); + + expect(result.startedAt).toEqual(startedAt); + expect(result.stoppedAt).toEqual(currentTime); + + jest.useRealTimers(); + }); }); describe('assignPairedItems', () => { diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index c3c63e8ac08..750f3e080a4 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -2396,32 +2396,29 @@ export class WorkflowExecute { executionError?: ExecutionBaseError, closeFunction?: Promise, ): Promise { - const fullRunData = this.getFullRunData(startedAt); - + // Set status before creating fullRunData if (executionError !== undefined) { Logger.debug('Workflow execution finished with error', { error: executionError, workflowId: workflow.id, }); - fullRunData.data.resultData.error = { - ...executionError, - message: executionError.message, - stack: executionError.stack, - } as ExecutionBaseError; - if (executionError.message?.includes('canceled')) { - fullRunData.status = 'canceled'; + if ( + executionError.message?.includes('canceled') || + executionError.name?.includes('Cancelled') + ) { + this.status = 'canceled'; + } else { + this.status = 'error'; } } else if (this.runExecutionData.waitTill) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions Logger.debug(`Workflow execution will wait until ${this.runExecutionData.waitTill}`, { workflowId: workflow.id, }); - fullRunData.waitTill = this.runExecutionData.waitTill; - fullRunData.status = 'waiting'; + this.status = 'waiting'; } else { Logger.debug('Workflow execution finished successfully', { workflowId: workflow.id }); - fullRunData.finished = true; - fullRunData.status = 'success'; + this.status = 'success'; } // Check if static data changed @@ -2433,13 +2430,6 @@ export class WorkflowExecute { } this.moveNodeMetadata(); - // Prevent from running the hook if the error is an abort error as it was already handled - if (!this.isCancelled) { - await this.additionalData.hooks?.runHook('workflowExecuteAfter', [ - fullRunData, - newStaticData, - ]); - } if (closeFunction) { try { @@ -2454,15 +2444,39 @@ export class WorkflowExecute { } } + // Capture stoppedAt timestamp after all processing is complete + const stoppedAt = new Date(); + const fullRunData = this.getFullRunData(startedAt, stoppedAt); + + if (executionError !== undefined) { + fullRunData.data.resultData.error = { + ...executionError, + message: executionError.message, + stack: executionError.stack, + } satisfies ExecutionBaseError; + } else if (this.runExecutionData.waitTill) { + fullRunData.waitTill = this.runExecutionData.waitTill; + } else { + fullRunData.finished = true; + } + + // Prevent from running the hook if the error is an abort error as it was already handled + if (!this.isCancelled) { + await this.additionalData.hooks?.runHook('workflowExecuteAfter', [ + fullRunData, + newStaticData, + ]); + } + return fullRunData; } - getFullRunData(startedAt: Date): IRun { + getFullRunData(startedAt: Date, stoppedAt?: Date): IRun { return { data: this.runExecutionData, mode: this.mode, startedAt, - stoppedAt: new Date(), + stoppedAt: stoppedAt ?? new Date(), status: this.status, }; } From 35488e7acc99b0aa8e25b3b499a3cb7d22c9ae5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 12 Nov 2025 15:23:11 +0100 Subject: [PATCH 12/16] feat(core): Improve workflows text search (#21738) Co-authored-by: Ricardo Espinoza --- .../__tests__/workflow.repository.test.ts | 266 ++++++++++++++++++ .../src/repositories/workflow.repository.ts | 61 +++- 2 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts new file mode 100644 index 00000000000..ea8afb8ba6a --- /dev/null +++ b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts @@ -0,0 +1,266 @@ +import { GlobalConfig } from '@n8n/config'; +import type { SelectQueryBuilder } from '@n8n/typeorm'; +import { mock } from 'jest-mock-extended'; + +import { WorkflowEntity } from '../../entities'; +import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; +import { mockInstance } from '../../utils/test-utils/mock-instance'; +import { FolderRepository } from '../folder.repository'; +import { WorkflowRepository } from '../workflow.repository'; + +describe('WorkflowRepository', () => { + const entityManager = mockEntityManager(WorkflowEntity); + const globalConfig = mockInstance(GlobalConfig, { + database: { type: 'postgresdb' }, + }); + const folderRepository = mockInstance(FolderRepository); + const workflowRepository = new WorkflowRepository( + entityManager.connection, + globalConfig, + folderRepository, + ); + + let queryBuilder: jest.Mocked>; + + beforeEach(() => { + jest.resetAllMocks(); + + queryBuilder = mock>(); + queryBuilder.where.mockReturnThis(); + queryBuilder.andWhere.mockReturnThis(); + queryBuilder.orWhere.mockReturnThis(); + queryBuilder.select.mockReturnThis(); + queryBuilder.addSelect.mockReturnThis(); + queryBuilder.leftJoin.mockReturnThis(); + queryBuilder.innerJoin.mockReturnThis(); + queryBuilder.orderBy.mockReturnThis(); + queryBuilder.addOrderBy.mockReturnThis(); + queryBuilder.skip.mockReturnThis(); + queryBuilder.take.mockReturnThis(); + queryBuilder.getMany.mockResolvedValue([]); + queryBuilder.getManyAndCount.mockResolvedValue([[], 0]); + + Object.defineProperty(queryBuilder, 'expressionMap', { + value: { + aliases: [], + }, + writable: true, + }); + + jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder); + }); + + describe('applyNameFilter', () => { + it('should search for workflows containing any word from the query', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'Users database' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('OR'), + expect.objectContaining({ + searchWord0: '%users%', + searchWord1: '%database%', + }), + ); + }); + + it('should handle single word searches', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'workflow' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + searchWord0: '%workflow%', + }), + ); + }); + + it('should handle queries with extra whitespace', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: ' Users database ' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + // Should still result in just two words + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + searchWord0: '%users%', + searchWord1: '%database%', + }), + ); + }); + + it('should not apply filter when query is empty', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: '' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + // andWhere should not be called for name filter + const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) => + call[0]?.includes('workflow.name'), + ); + expect(nameFilterCalls).toHaveLength(0); + }); + + it('should not apply filter when query is only whitespace', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: ' ' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + // andWhere should not be called for name filter + const nameFilterCalls = (queryBuilder.andWhere as jest.Mock).mock.calls.filter((call) => + call[0]?.includes('workflow.name'), + ); + expect(nameFilterCalls).toHaveLength(0); + }); + + it('should use SQLite concatenation syntax for SQLite database', async () => { + // Create a new repository instance with SQLite config + const sqliteConfig = mockInstance(GlobalConfig, { + database: { type: 'sqlite' }, + }); + const sqliteWorkflowRepository = new WorkflowRepository( + entityManager.connection, + sqliteConfig, + folderRepository, + ); + jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder); + + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'test search' }, + }; + + await sqliteWorkflowRepository.getMany(workflowIds, options); + + // Check for SQLite-specific concatenation syntax (||) + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining("workflow.name || ' ' || COALESCE"), + expect.any(Object), + ); + }); + + it('should use CONCAT syntax for non-SQLite databases', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'test search' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + // Check for CONCAT syntax + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('CONCAT(workflow.name'), + expect.any(Object), + ); + }); + + it('should search in both name and description fields', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'automation' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + const andWhereCall = (queryBuilder.andWhere as jest.Mock).mock.calls.find((call) => + call[0]?.includes('workflow.name'), + ); + + expect(andWhereCall).toBeDefined(); + expect(andWhereCall[0]).toContain('workflow.name'); + expect(andWhereCall[0]).toContain('workflow.description'); + }); + + it('should handle special characters in search query', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'test% _query' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + searchWord0: '%test%%', + searchWord1: '%_query%', + }), + ); + }); + + it('should be case-insensitive', async () => { + const workflowIds = ['workflow1']; + const options = { + filter: { query: 'USERS Database' }, + }; + + await workflowRepository.getMany(workflowIds, options); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('LOWER'), + expect.objectContaining({ + searchWord0: '%users%', + searchWord1: '%database%', + }), + ); + }); + }); + + describe('getMany', () => { + it('should apply multiple filters together', async () => { + const workflowIds = ['workflow1', 'workflow2']; + const options = { + filter: { + query: 'automation task', + active: true, + projectId: 'project1', + }, + take: 10, + skip: 0, + }; + + await workflowRepository.getMany(workflowIds, options); + + // Check that filters were applied + expect(queryBuilder.andWhere).toHaveBeenCalledWith( + expect.stringContaining('workflow.name'), + expect.objectContaining({ + searchWord0: '%automation%', + searchWord1: '%task%', + }), + ); + + expect(queryBuilder.andWhere).toHaveBeenCalledWith('workflow.active = :active', { + active: true, + }); + + expect(queryBuilder.innerJoin).toHaveBeenCalledWith('workflow.shared', 'shared'); + expect(queryBuilder.andWhere).toHaveBeenCalledWith('shared.projectId = :projectId', { + projectId: 'project1', + }); + + // Check pagination + expect(queryBuilder.skip).toHaveBeenCalledWith(0); + expect(queryBuilder.take).toHaveBeenCalledWith(10); + }); + }); +}); diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index a07e3f8efec..1d34d5c5626 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -497,18 +497,65 @@ export class WorkflowRepository extends Repository { } } + /** + * Parses and normalizes the search query into individual words + */ + private parseSearchWords(searchValue: unknown): string[] { + if (typeof searchValue !== 'string' || searchValue === '') { + return []; + } + + return searchValue + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); + } + + /** + * Returns the database-specific SQL expression to concatenate workflow name and description + */ + private getFieldConcatExpression(): string { + const dbType = this.globalConfig.database.type; + + return dbType === 'sqlite' + ? "LOWER(workflow.name || ' ' || COALESCE(workflow.description, ''))" + : "LOWER(CONCAT(workflow.name, ' ', COALESCE(workflow.description, '')))"; + } + + /** + * Builds search conditions and parameters for matching any of the search words + */ + private buildSearchConditions(searchWords: string[]): { + conditions: string[]; + parameters: Record; + } { + const concatExpression = this.getFieldConcatExpression(); + + const conditions = searchWords.map((_, index) => { + return `${concatExpression} LIKE :searchWord${index}`; + }); + + const parameters: Record = {}; + searchWords.forEach((word, index) => { + parameters[`searchWord${index}`] = `%${word}%`; + }); + + return { conditions, parameters }; + } + + /** + * Applies a name or description filter to the query builder. + * We are supporting searching by multiple words, where any of the words can match + */ private applyNameFilter( qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], ): void { - const searchValue = filter?.query; + const searchWords = this.parseSearchWords(filter?.query); - if (typeof searchValue === 'string' && searchValue !== '') { - const searchTerm = `%${searchValue.toLowerCase()}%`; - qb.andWhere( - "(LOWER(workflow.name) LIKE :searchTerm OR LOWER(COALESCE(workflow.description, '')) LIKE :searchTerm)", - { searchTerm }, - ); + if (searchWords.length > 0) { + const { conditions, parameters } = this.buildSearchConditions(searchWords); + qb.andWhere(`(${conditions.join(' OR ')})`, parameters); } } From 7bb3fdc270291e5fe08f673c2e278bf95f199003 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Wed, 12 Nov 2025 17:48:28 +0100 Subject: [PATCH 13/16] fix(Code Node): Revert node icon back to old icon (#21777) Co-authored-by: krisn0x <10799186+krisn0x@users.noreply.github.com> --- packages/nodes-base/nodes/Code/Code.node.ts | 14 +------------- packages/nodes-base/nodes/Code/js.svg | 4 ---- packages/nodes-base/nodes/Code/python.svg | 3 --- 3 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 packages/nodes-base/nodes/Code/js.svg delete mode 100644 packages/nodes-base/nodes/Code/python.svg diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index 8ce5f346389..a9eb3d2f28e 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -35,23 +35,11 @@ class PythonDisabledError extends UserError { } } -function iconForLanguage(lang: CodeNodeLanguageOption): string { - switch (lang) { - case 'python': - case 'pythonNative': - return 'file:python.svg'; - case 'javaScript': - return 'file:js.svg'; - default: - return 'file:code.svg'; - } -} - export class Code implements INodeType { description: INodeTypeDescription = { displayName: 'Code', name: 'code', - icon: `={{(${iconForLanguage})($parameter.language)}}`, + icon: 'file:code.svg', group: ['transform'], version: [1, 2], defaultVersion: 2, diff --git a/packages/nodes-base/nodes/Code/js.svg b/packages/nodes-base/nodes/Code/js.svg deleted file mode 100644 index 6f2a19ee967..00000000000 --- a/packages/nodes-base/nodes/Code/js.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/packages/nodes-base/nodes/Code/python.svg b/packages/nodes-base/nodes/Code/python.svg deleted file mode 100644 index 00318468394..00000000000 --- a/packages/nodes-base/nodes/Code/python.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file From 5031e088eb949513427af60bcf63362bbc1a12d5 Mon Sep 17 00:00:00 2001 From: Daria Date: Thu, 13 Nov 2025 10:04:10 +0200 Subject: [PATCH 14/16] feat: Improve backfill missing workflow history records migration (no-changelog) (#21743) --- ...4-BackfillMissingWorkflowHistoryRecords.ts | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts b/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts index 6a4480603f0..acb78dfcc45 100644 --- a/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts +++ b/packages/@n8n/db/src/migrations/common/1762763704614-BackfillMissingWorkflowHistoryRecords.ts @@ -2,7 +2,10 @@ import type { IrreversibleMigration, MigrationContext } from '../migration-types export class BackfillMissingWorkflowHistoryRecords1762763704614 implements IrreversibleMigration { /** - * 1. Generate versionIds for workflows with NULL versionId (only possible for manual inserts) + * 1. Generate/regenerate versionIds for workflows that need them: + * - NULL/empty versionId + * - duplicate versionIds that do not own the history record + * (i.e., no history record with matching versionId AND workflowId) * 2. Create workflow_history records for all workflows missing them * 3. Make versionId NOT NULL to ensure data consistency */ @@ -18,15 +21,35 @@ export class BackfillMissingWorkflowHistoryRecords1762763704614 implements Irrev const createdAtColumn = escape.columnName('createdAt'); const updatedAtColumn = escape.columnName('updatedAt'); - // Step 1: Generate versionIds for workflows that have NULL or empty versionId - const workflowsWithoutVersionId = await runQuery>(` - SELECT ${idColumn} as id - FROM ${workflowTable} - WHERE ${versionIdColumn} IS NULL OR ${versionIdColumn} = '' + // Step 1: Generate versionIds that do not exist in workflow history + const workflowsNeedingNewVersionId = await runQuery>(` + -- Find duplicate versionIds (appear in more than one workflow) + WITH dup_version AS ( + SELECT ${versionIdColumn} + FROM ${workflowTable} + WHERE ${versionIdColumn} IS NOT NULL AND ${versionIdColumn} <> '' + GROUP BY ${versionIdColumn} + HAVING COUNT(*) > 1 + ) + SELECT w.${idColumn} AS id + FROM ${workflowTable} w + LEFT JOIN ${historyTable} wh + ON wh.${versionIdColumn} = w.${versionIdColumn} + AND wh.${workflowIdColumn} = w.${idColumn} + LEFT JOIN dup_version d + ON d.${versionIdColumn} = w.${versionIdColumn} + WHERE + -- missing or empty versionId + w.${versionIdColumn} IS NULL OR w.${versionIdColumn} = '' + -- duplicate versionId without matching history entry by both versionId and workflowId + OR ( + d.${versionIdColumn} IS NOT NULL + AND wh.${workflowIdColumn} IS NULL + ); `); // Running in a loop to avoid using DB-specific syntax for generating UUIDs - for (const workflow of workflowsWithoutVersionId) { + for (const workflow of workflowsNeedingNewVersionId) { const versionId = crypto.randomUUID(); await runQuery( ` From 95ab79f3f8b1c72d8399c93e19bd6df35d859251 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Thu, 13 Nov 2025 11:10:41 +0200 Subject: [PATCH 15/16] feat(editor): Global row search on data table details view (#21447) --- .../list-data-table-content-query.dto.ts | 1 + .../data-table-filters.integration.test.ts | 343 ++++++++++++++++++ .../data-table.controller.integration.test.ts | 92 ++++- .../data-table.service.integration.test.ts | 16 +- .../data-table/data-table-rows.repository.ts | 52 ++- .../modules/data-table/data-table.service.ts | 5 +- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../core/dataTable/DataTableDetailsView.vue | 48 ++- .../dataGrid/DataTableTable.test.ts | 24 +- .../components/dataGrid/DataTableTable.vue | 16 + .../useDataTableOperations.test.ts | 2 + .../composables/useDataTableOperations.ts | 3 + .../features/core/dataTable/dataTable.api.ts | 1 + .../core/dataTable/dataTable.store.ts | 2 + .../playwright/pages/DataTableDetails.ts | 16 + .../tests/ui/data-table-details.spec.ts | 72 ++++ 16 files changed, 670 insertions(+), 25 deletions(-) diff --git a/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts b/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts index 2229de7df39..d9994c840d9 100644 --- a/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/list-data-table-content-query.dto.ts @@ -79,4 +79,5 @@ export class ListDataTableContentQueryDto extends Z.class({ skip: paginationSchema.skip.optional(), filter: filterValidator.optional(), sortBy: sortByValidator.optional(), + search: z.string().optional(), }) {} diff --git a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts index 08566d71332..e2e0b9f1f5b 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table-filters.integration.test.ts @@ -2362,4 +2362,347 @@ describe('dataTable filters', () => { }); }); }); + + describe('search query', () => { + it('should search across all columns', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'email', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'John Doe', email: 'john@example.com', age: 30 }, + { name: 'Jane Smith', email: 'jane@example.com', age: 25 }, + { name: 'Bob Johnson', email: 'bob@test.com', age: 35 }, + ]); + + // ACT - Search for 'john' should match name and email columns + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'john', + }); + + // ASSERT + expect(result.count).toEqual(2); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe' }), + expect.objectContaining({ name: 'Bob Johnson' }), + ]), + ); + }); + + it('should perform case-insensitive search', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'name', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'Alice' }, + { name: 'ALICE' }, + { name: 'alice' }, + { name: 'Bob' }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'ALICE', + }); + + // ASSERT + expect(result.count).toEqual(3); + expect( + result.data.every((row) => (row.name as string)?.toLowerCase().includes('alice')), + ).toBe(true); + }); + + it('should search across number columns', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'John', age: 25 }, + { name: 'Jane', age: 30 }, + { name: 'Bob', age: 250 }, + ]); + + // ACT - Search for '25' should match age column + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: '25', + }); + + // ASSERT - Should match at least these 2 rows (might also match system columns) + expect(result.count).toBeGreaterThanOrEqual(2); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John', age: 25 }), + expect.objectContaining({ name: 'Bob', age: 250 }), + ]), + ); + }); + + it('should return empty result when search has no matches', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'name', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'Alice' }, + { name: 'Bob' }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'xyz123notfound', + }); + + // ASSERT + expect(result.count).toEqual(0); + expect(result.data).toEqual([]); + }); + + it('should combine search with filters', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + { name: 'isActive', type: 'boolean' }, + ], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'John Doe', age: 25, isActive: true }, + { name: 'John Smith', age: 30, isActive: false }, + { name: 'Jane Doe', age: 35, isActive: true }, + { name: 'Bob Johnson', age: 40, isActive: true }, + ]); + + // ACT - Search for 'john' AND filter by isActive = true + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'john', + filter: { + type: 'and', + filters: [{ columnName: 'isActive', condition: 'eq', value: true }], + }, + }); + + // ASSERT + expect(result.count).toEqual(2); + expect(result.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'John Doe', isActive: true }), + expect.objectContaining({ name: 'Bob Johnson', isActive: true }), + ]), + ); + }); + + it('should combine search with sorting', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'Alice', age: 30 }, + { name: 'Charlie', age: 25 }, + { name: 'Bob', age: 35 }, + ]); + + // ACT - Search for 'a' (matches Alice and Charlie) and sort by age DESC + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'a', + sortBy: ['age', 'DESC'], + }); + + // ASSERT + expect(result.count).toEqual(2); + expect(result.data[0].name).toBe('Alice'); + expect(result.data[0].age).toBe(30); + expect(result.data[1].name).toBe('Charlie'); + expect(result.data[1].age).toBe(25); + }); + + it('should work with pagination', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'name', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'User 1' }, + { name: 'User 2' }, + { name: 'User 3' }, + { name: 'User 4' }, + { name: 'User 5' }, + { name: 'Different' }, + ]); + + // ACT - Search for 'user' with pagination + const page1 = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'user', + take: 2, + skip: 0, + }); + + const page2 = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'user', + take: 2, + skip: 2, + }); + + // ASSERT + expect(page1.count).toEqual(5); + expect(page1.data).toHaveLength(2); + + expect(page2.count).toEqual(5); + expect(page2.data).toHaveLength(2); + }); + + it('should handle empty search string', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'name', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'Alice' }, + { name: 'Bob' }, + { name: 'Charlie' }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: '', + }); + + // ASSERT - Empty search should return all rows + expect(result.count).toEqual(3); + expect(result.data).toHaveLength(3); + }); + + it('should handle whitespace-only search string', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'name', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'Alice' }, + { name: 'Bob' }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: ' ', + }); + + // ASSERT - Whitespace-only search should return all rows + expect(result.count).toEqual(2); + expect(result.data).toHaveLength(2); + }); + + it('should search with special characters', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [{ name: 'text', type: 'string' }], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { text: 'test_data' }, + { text: 'test-data' }, + { text: 'test.data' }, + { text: 'normal' }, + ]); + + // ACT - Search for underscore + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'test_', + }); + + // ASSERT + expect(result.count).toEqual(1); + expect(result.data).toEqual([expect.objectContaining({ text: 'test_data' })]); + }); + + it('should search and find rows with null values in other columns', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'description', type: 'string' }, + ], + }); + + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'John', description: null }, + { name: 'Jane', description: 'Developer' }, + { name: 'Bob', description: null }, + ]); + + // ACT + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'John', + }); + + // ASSERT + expect(result.count).toEqual(1); + expect(result.data).toEqual([expect.objectContaining({ name: 'John', description: null })]); + }); + + it('should work with all column types together', async () => { + // ARRANGE + const { id: dataTableId } = await dataTableService.createDataTable(project.id, { + name: 'dataTable', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + { name: 'isActive', type: 'boolean' }, + { name: 'birthday', type: 'date' }, + ], + }); + + const birthday = new Date('1990-05-15'); + await dataTableService.insertRows(dataTableId, project.id, [ + { name: 'John', age: 30, isActive: true, birthday }, + { name: 'Jane', age: 25, isActive: false, birthday: new Date('1995-08-20') }, + { name: 'Bob', age: 35, isActive: true, birthday: new Date('1988-12-05') }, + ]); + + // ACT - Search for partial name + const result = await dataTableService.getManyRowsAndCount(dataTableId, project.id, { + search: 'jo', + }); + + // ASSERT + expect(result.count).toEqual(1); + expect(result.data).toEqual([ + expect.objectContaining({ name: 'John', age: 30, isActive: true }), + ]); + }); + }); }); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.controller.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.controller.integration.test.ts index b1824eb9b8f..a51050ed80c 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table.controller.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table.controller.integration.test.ts @@ -780,7 +780,7 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId', () => { }); expect(dataTableColumnInDb).toBeNull(); - await expect(dataTableRowsRepository.getManyAndCount(dataTable.id, {})).rejects.toThrow( + await expect(dataTableRowsRepository.getManyAndCount(dataTable.id, {}, [])).rejects.toThrow( QueryFailedError, ); }); @@ -2026,7 +2026,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/insert', () => { data: [{ id: 1 }], }); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data[0]); }); @@ -2067,7 +2071,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/insert', () => { data: [{ id: 1 }], }); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data[0]); }); @@ -2105,7 +2113,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/insert', () => { data: [{ id: 1 }], }); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data[0]); }); @@ -2162,7 +2174,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/insert', () => { ], }); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(2); expect(rowsInDb.data[0]).toMatchObject(payload.data[0]); }); @@ -2197,7 +2213,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/insert', () => { .expect(400); expect(response.body.message).toContain('unknown column'); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(0); }); @@ -2593,7 +2613,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(403); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); }); @@ -2629,7 +2653,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(403); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); }); @@ -2677,7 +2705,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject({ first: 'test value 2', @@ -2722,7 +2754,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject({ first: 'test value 1', @@ -2766,7 +2802,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data.map((r) => r.first)).toEqual(['test value 1']); }); @@ -2809,7 +2849,11 @@ describe('DELETE /projects/:projectId/data-tables/:dataTableId/rows', () => { }) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(2); expect(rowsInDb.data.map((r) => r.first).sort()).toEqual(['test value 1', 'test value 3']); }); @@ -2969,7 +3013,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/upsert', () => { .send(payload) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data); }); @@ -3001,7 +3049,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/upsert', () => { .send(payload) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data); }); @@ -3030,7 +3082,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/upsert', () => { .send(payload) .expect(200); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(1); expect(rowsInDb.data[0]).toMatchObject(payload.data); }); @@ -3060,7 +3116,11 @@ describe('POST /projects/:projectId/data-tables/:dataTableId/upsert', () => { .expect(400); expect(response.body.message).toContain('unknown column'); - const rowsInDb = await dataTableRowsRepository.getManyAndCount(dataTable.id, {}); + const rowsInDb = await dataTableRowsRepository.getManyAndCount( + dataTable.id, + {}, + dataTable.columns, + ); expect(rowsInDb.count).toBe(0); }); diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts index 9cfe7c526b4..5115df7b8a2 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table.service.integration.test.ts @@ -998,7 +998,7 @@ describe('dataTable', () => { it('inserts a row even if it matches with the existing one', async () => { // ARRANGE - const { id: dataTableId } = await dataTableService.createDataTable(project1.id, { + const { id: dataTableId, columns } = await dataTableService.createDataTable(project1.id, { name: 'myDataTable', columns: [ { name: 'c1', type: 'number' }, @@ -1026,7 +1026,11 @@ describe('dataTable', () => { // ASSERT expect(result).toEqual([{ id: 2 }]); - const { count, data } = await dataTableRowsRepository.getManyAndCount(dataTableId, {}); + const { count, data } = await dataTableRowsRepository.getManyAndCount( + dataTableId, + {}, + columns, + ); expect(count).toEqual(2); expect(data).toEqual([ @@ -1045,7 +1049,7 @@ describe('dataTable', () => { it('return correct IDs even after deletions', async () => { // ARRANGE - const { id: dataTableId } = await dataTableService.createDataTable(project1.id, { + const { id: dataTableId, columns } = await dataTableService.createDataTable(project1.id, { name: 'myDataTable', columns: [ { name: 'c1', type: 'number' }, @@ -1086,7 +1090,11 @@ describe('dataTable', () => { // ASSERT expect(result).toEqual([{ id: 3 }, { id: 4 }]); - const { count, data } = await dataTableRowsRepository.getManyAndCount(dataTableId, {}); + const { count, data } = await dataTableRowsRepository.getManyAndCount( + dataTableId, + {}, + columns, + ); expect(count).toEqual(3); expect(data).toEqual([ diff --git a/packages/cli/src/modules/data-table/data-table-rows.repository.ts b/packages/cli/src/modules/data-table/data-table-rows.repository.ts index 65681ccf096..3eb02c44115 100644 --- a/packages/cli/src/modules/data-table/data-table-rows.repository.ts +++ b/packages/cli/src/modules/data-table/data-table-rows.repository.ts @@ -561,13 +561,14 @@ export class DataTableRowsRepository { async getManyAndCount( dataTableId: string, dto: ListDataTableContentQueryDto, + columns: DataTableColumn[], trx?: EntityManager, ) { return await withTransaction( this.dataSource.manager, trx, async (em) => { - const [countQuery, query] = this.getManyQuery(dataTableId, dto, em); + const [countQuery, query] = this.getManyQuery(dataTableId, dto, columns, em); const data: DataTableRowsReturn = await query.select('*').getRawMany(); const countResult = await countQuery.select('COUNT(*) as count').getRawOne<{ count: number | string | null; @@ -619,6 +620,7 @@ export class DataTableRowsRepository { private getManyQuery( dataTableId: string, dto: ListDataTableContentQueryDto, + columns: DataTableColumn[], em: EntityManager, ): [QueryBuilder, QueryBuilder] { const query = em.createQueryBuilder(); @@ -628,6 +630,11 @@ export class DataTableRowsRepository { if (dto.filter) { this.applyFilters(query, dto.filter, tableReference); } + + if (dto.search && dto.search.trim().length > 0) { + this.applySearch(query, dto.search, tableReference, columns); + } + const countQuery = query.clone().select('COUNT(*)'); this.applySorting(query, dto); this.applyPagination(query, dto); @@ -635,6 +642,49 @@ export class DataTableRowsRepository { return [countQuery, query]; } + private applySearch( + query: QueryBuilder, + rawSearch: string, + tableReference: string, + columns: DataTableColumn[], + ) { + const dbType = this.dataSource.options.type; + const searchTerm = rawSearch.includes('%') ? rawSearch : `%${rawSearch}%`; + const isSqlite = ['sqlite', 'sqlite-pooled'].includes(dbType); + const isMy = ['mysql', 'mariadb'].includes(dbType); + const isPg = dbType === 'postgres'; + + const allColumnNames: string[] = columns.map((c) => c.name); + if (allColumnNames.length === 0) return; + + const tableRefQuoted = quoteIdentifier(tableReference, dbType); + const conditions: string[] = []; + + for (const col of allColumnNames) { + const colRef = `${tableRefQuoted}.${quoteIdentifier(col, dbType)}`; + if (isSqlite) { + conditions.push(`UPPER(CAST(${colRef} AS TEXT)) LIKE UPPER(:search) ESCAPE '\\'`); + continue; + } + + if (isMy) { + conditions.push(`UPPER(CAST(${colRef} AS CHAR)) LIKE UPPER(:search) ESCAPE '\\\\'`); + continue; + } + + if (isPg) { + conditions.push(`CAST(${colRef} AS TEXT) ILIKE :search ESCAPE '\\'`); + continue; + } + + conditions.push(`UPPER(CAST(${colRef} AS TEXT)) LIKE UPPER(:search)`); + } + + if (conditions.length === 0) return; + const whereClause = `(${conditions.join(' OR ')})`; + query.andWhere(whereClause, { search: escapeLikeSpecials(searchTerm) }); + } + private applyFilters( query: SelectQueryBuilder | UpdateQueryBuilder | DeleteQueryBuilder, filter: DataTableFilter, diff --git a/packages/cli/src/modules/data-table/data-table.service.ts b/packages/cli/src/modules/data-table/data-table.service.ts index cab418f614a..1de5cb7326d 100644 --- a/packages/cli/src/modules/data-table/data-table.service.ts +++ b/packages/cli/src/modules/data-table/data-table.service.ts @@ -28,8 +28,6 @@ import type { } from 'n8n-workflow'; import { DATA_TABLE_SYSTEM_COLUMN_TYPE_MAP, validateFieldType } from 'n8n-workflow'; -import { RoleService } from '@/services/role.service'; - import { DataTableColumn } from './data-table-column.entity'; import { DataTableColumnRepository } from './data-table-column.repository'; import { DataTableRowsRepository } from './data-table-rows.repository'; @@ -42,6 +40,8 @@ import { DataTableNotFoundError } from './errors/data-table-not-found.error'; import { DataTableValidationError } from './errors/data-table-validation.error'; import { normalizeRows } from './utils/sql-utils'; +import { RoleService } from '@/services/role.service'; + @Service() export class DataTableService { constructor( @@ -161,6 +161,7 @@ export class DataTableService { const result = await this.dataTableRowsRepository.getManyAndCount( dataTableId, transformedDto, + columns, em, ); return { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 6685ef36bf8..10c65379bd3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -134,6 +134,7 @@ "generic.projects": "Projects", "generic.your": "Your", "generic.apiKey": "API Key", + "generic.search": "Search", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -3231,6 +3232,7 @@ "dataTable.addColumn.systemColumnDescription": "This is a system column, choose a different name", "dataTable.addColumn.alreadyExistsDescription": "Column name already exists, choose a different name", "dataTable.addColumn.testingColumnDescription": "This column is used for testing, choose a different name", + "dataTable.search.dateSearchInfo": "Date searches use UTC format, while the table displays dates in your local timezone", "settings.ldap": "LDAP", "settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.", "settings.ldap.infoTip": "Learn more about LDAP in the Docs", diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableDetailsView.vue b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableDetailsView.vue index da45dbc0093..0240692da6a 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/DataTableDetailsView.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/DataTableDetailsView.vue @@ -15,8 +15,16 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import DataTableTable from './components/dataGrid/DataTableTable.vue'; import { useDebounce } from '@/app/composables/useDebounce'; import AddColumnButton from './components/dataGrid/AddColumnButton.vue'; +import { + N8nButton, + N8nInput, + N8nLoading, + N8nSpinner, + N8nText, + N8nIcon, + N8nTooltip, +} from '@n8n/design-system'; -import { N8nButton, N8nLoading, N8nSpinner, N8nText } from '@n8n/design-system'; type Props = { id: string; projectId: string; @@ -35,6 +43,7 @@ const loading = ref(false); const saving = ref(false); const dataTable = ref(null); const dataTableTableRef = ref>(); +const searchQuery = ref(''); const { debounce } = useDebounce(); @@ -123,6 +132,27 @@ onMounted(async () => { {{ i18n.baseText('generic.saving') }}...
+ + + + {
@@ -185,4 +216,19 @@ onMounted(async () => { gap: var(--spacing--3xs); margin-left: auto; } + +.search { + max-width: 196px; +} + +.infoIcon { + display: inline-flex; + align-items: center; + color: var(--color--text--tint-2); + cursor: help; + + &:hover { + color: var(--color--primary); + } +} diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.test.ts b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.test.ts index 5f0a69a8103..c39a23201a7 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.test.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.test.ts @@ -1,3 +1,4 @@ +import { waitFor } from '@testing-library/vue'; import { createComponentRenderer } from '@/__tests__/render'; import DataTableTable from '@/features/core/dataTable/components/dataGrid/DataTableTable.vue'; import { createPinia, setActivePinia } from 'pinia'; @@ -78,13 +79,14 @@ vi.mock('@/app/composables/useToast', () => ({ }), })); +const setCurrentPageMock = vi.fn(); vi.mock('@/features/core/dataTable/composables/useDataTablePagination', () => ({ useDataTablePagination: () => ({ totalItems: 0, setTotalItems: vi.fn(), ensureItemOnPage: vi.fn(), currentPage: 1, - setCurrentPage: vi.fn(), + setCurrentPage: setCurrentPageMock, }), })); @@ -182,4 +184,24 @@ describe('DataTableTable', () => { expect(getByTestId('ag-grid-vue')).toBeInTheDocument(); }); }); + + describe('Search behavior', () => { + it('resets to first page when search changes', async () => { + const { rerender } = renderComponent({ + props: { + dataTable: mockDataTable, + search: '', + }, + }); + + await rerender({ + dataTable: mockDataTable, + search: 'john', + }); + + await waitFor(() => { + expect(setCurrentPageMock).toHaveBeenCalledWith(1); + }); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue index a20a511462b..de58e51104c 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue +++ b/packages/frontend/editor-ui/src/features/core/dataTable/components/dataGrid/DataTableTable.vue @@ -24,12 +24,14 @@ import { useDataTableOperations } from '@/features/core/dataTable/composables/us import { useDataTableColumnFilters } from '@/features/core/dataTable/composables/useDataTableColumnFilters'; import { useI18n } from '@n8n/i18n'; import { GRID_FILTER_CONFIG } from '@/features/core/dataTable/utils/filterMappings'; +import { useDebounce } from '@/app/composables/useDebounce'; import { ElPagination } from 'element-plus'; registerAgGridModulesOnce(); type Props = { dataTable: DataTable; + search?: string; }; const props = defineProps(); @@ -41,6 +43,7 @@ const emit = defineEmits<{ const gridContainerRef = useTemplateRef('gridContainerRef'); const i18n = useI18n(); +const { debounce } = useDebounce(); const rowData = ref([]); const hasRecords = computed(() => rowData.value.length > 0); @@ -102,6 +105,7 @@ const dataTableOperations = useDataTableOperations({ selectedRowIds: selection.selectedRowIds, handleCopyFocusedCell: agGrid.handleCopyFocusedCell, currentFilterJSON, + searchQuery: computed(() => props.search), }); async function onDeleteColumnFunction(columnId: string) { @@ -137,6 +141,18 @@ watch(currentFilterJSON, async () => { await setCurrentPage(1); }); +const onSearchChange = async () => { + await setCurrentPage(1); +}; +const debouncedOnSearchChange = debounce(onSearchChange, { debounceTime: 250, trailing: true }); + +watch( + () => props.search, + () => { + void debouncedOnSearchChange(); + }, +); + defineExpose({ addRow: dataTableOperations.onAddRowClick, addColumn: dataTableOperations.onAddColumn, diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.test.ts b/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.test.ts index d545cb48558..42edaec9bb6 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.test.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.test.ts @@ -671,6 +671,7 @@ describe('useDataTableOperations', () => { 20, 'name:asc', '{"status":"active"}', + undefined, ); expect(rowData.value).toEqual(fetchedData.data); expect(params.setTotalItems).toHaveBeenCalledWith(10); @@ -713,6 +714,7 @@ describe('useDataTableOperations', () => { 10, 'id:desc', undefined, + undefined, ); }); diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.ts b/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.ts index c958e427da3..544a024cfd0 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/composables/useDataTableOperations.ts @@ -47,6 +47,7 @@ export type UseDataTableOperationsParams = { currentSortBy: Ref; currentSortOrder: Ref; currentFilterJSON?: Ref; + searchQuery?: Ref; handleClearSelection: () => void; selectedRowIds: Ref>; handleCopyFocusedCell: (params: CellKeyDownEvent) => Promise; @@ -73,6 +74,7 @@ export const useDataTableOperations = ({ currentSortBy, currentSortOrder, currentFilterJSON, + searchQuery, handleClearSelection, selectedRowIds, handleCopyFocusedCell, @@ -279,6 +281,7 @@ export const useDataTableOperations = ({ pageSize.value, `${currentSortBy.value}:${currentSortOrder.value}`, currentFilterJSON?.value, + searchQuery?.value, ); rowData.value = fetchedRows.data; setTotalItems(fetchedRows.count); diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts index c579112548c..70a91391b13 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.api.ts @@ -138,6 +138,7 @@ export const getDataTableRowsApi = async ( take?: number; sortBy?: string; filter?: string; + search?: string; }, ) => { return await makeRestApiRequest<{ diff --git a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts index 8bcd8e1ff0a..315f349f317 100644 --- a/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts +++ b/packages/frontend/editor-ui/src/features/core/dataTable/dataTable.store.ts @@ -194,12 +194,14 @@ export const useDataTableStore = defineStore(DATA_TABLE_STORE, () => { pageSize: number, sortBy: string, filter?: string, + search?: string, ) => { return await getDataTableRowsApi(rootStore.restApiContext, dataTableId, projectId, { skip: (page - 1) * pageSize, take: pageSize, sortBy, filter, + search, }); }; diff --git a/packages/testing/playwright/pages/DataTableDetails.ts b/packages/testing/playwright/pages/DataTableDetails.ts index cdf27707cd1..30d504e363e 100644 --- a/packages/testing/playwright/pages/DataTableDetails.ts +++ b/packages/testing/playwright/pages/DataTableDetails.ts @@ -383,4 +383,20 @@ export class DataTableDetails extends BasePage { await sourceColumn.dragTo(targetColumn); } + + getSearchInput() { + return this.page.getByTestId('data-table-search-input'); + } + + async search(query: string) { + const searchInput = this.getSearchInput(); + await searchInput.fill(query); + // Wait for debounce + await this.page.waitForTimeout(300); + await this.page.getByText('Loading...').waitFor({ state: 'hidden' }); + } + + async clearSearch() { + await this.search(''); + } } diff --git a/packages/testing/playwright/tests/ui/data-table-details.spec.ts b/packages/testing/playwright/tests/ui/data-table-details.spec.ts index b6bf5bfa835..f55a4d255cc 100644 --- a/packages/testing/playwright/tests/ui/data-table-details.spec.ts +++ b/packages/testing/playwright/tests/ui/data-table-details.spec.ts @@ -480,4 +480,76 @@ test.describe('Data Table details view', () => { expect(birthdayFinalIndex).toBeLessThan(nameFinalIndex); }); + + test('Should search and filter rows globally', async ({ n8n }) => { + await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible(); + + await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header'); + await n8n.dataTableDetails.addColumn(COLUMN_NAMES.age, 'number', 'header'); + const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name); + const ageColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.age); + + const testData = [ + { name: 'Alice Johnson', age: '25' }, + { name: 'Bob Smith', age: '30' }, + { name: 'Charlie Brown', age: '35' }, + { name: 'Diana Prince', age: '28' }, + { name: 'Eve Adams', age: '32' }, + { name: 'Frank Miller', age: '29' }, + ]; + + for (let i = 0; i < testData.length; i++) { + await n8n.dataTableDetails.addRow(); + await n8n.dataTableDetails.setCellValue(i, nameColumn, testData[i].name, 'string', { + skipDoubleClick: true, + }); + await n8n.dataTableDetails.setCellValue(i, ageColumn, testData[i].age, 'number'); + } + + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6); + + // Test search for partial name match + await n8n.dataTableDetails.search('Alice'); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1); + const aliceValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string'); + expect(aliceValue).toContain('Alice Johnson'); + + // Test search for last name + await n8n.dataTableDetails.search('Smith'); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1); + const bobValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string'); + expect(bobValue).toContain('Bob Smith'); + + // Test search across all columns (search by age) + await n8n.dataTableDetails.search('30'); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1); + const bobAgeValue = await n8n.dataTableDetails.getCellValue(0, ageColumn, 'number'); + expect(bobAgeValue).toContain('30'); + + // Test search with multiple results + await n8n.dataTableDetails.search('a'); + const multipleResultsCount = await n8n.dataTableDetails.getDataRows().count(); + expect(multipleResultsCount).toBeGreaterThan(1); + + // Clear search and verify all rows are shown + await n8n.dataTableDetails.clearSearch(); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6); + + // Test case-insensitive search + await n8n.dataTableDetails.search('ALICE'); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1); + const aliceCaseValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string'); + expect(aliceCaseValue).toContain('Alice Johnson'); + + // Clear search for next test + await n8n.dataTableDetails.clearSearch(); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6); + + // test search combined with column filter + await n8n.dataTableDetails.setNumberFilter(COLUMN_NAMES.age, '29', 'greaterThan'); + await n8n.dataTableDetails.search('Adams'); + await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1); + const adamValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string'); + expect(adamValue).toContain('Eve Adams'); + }); }); From e45a4b1073d85d04d6455a4f18482838a7840124 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Thu, 13 Nov 2025 10:34:19 +0100 Subject: [PATCH 16/16] feat(core): Add name, autosaved, description columns to workflow history (no-changelog) (#21541) --- ...206508-AddWorkflowHistoryAutoSaveFields.ts | 20 +++++++++++++++++++ .../@n8n/db/src/migrations/mysqldb/index.ts | 4 +++- .../db/src/migrations/postgresdb/index.ts | 2 ++ .../@n8n/db/src/migrations/sqlite/index.ts | 2 ++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 packages/@n8n/db/src/migrations/common/1762847206508-AddWorkflowHistoryAutoSaveFields.ts diff --git a/packages/@n8n/db/src/migrations/common/1762847206508-AddWorkflowHistoryAutoSaveFields.ts b/packages/@n8n/db/src/migrations/common/1762847206508-AddWorkflowHistoryAutoSaveFields.ts new file mode 100644 index 00000000000..cd689e74687 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1762847206508-AddWorkflowHistoryAutoSaveFields.ts @@ -0,0 +1,20 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const tableName = 'workflow_history'; +const name = 'name'; +const autosaved = 'autosaved'; +const description = 'description'; + +export class AddWorkflowHistoryAutoSaveFields1762847206508 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns(tableName, [ + column(name).varchar(128), + column(autosaved).bool.notNull.default(false), + column(description).text, + ]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(tableName, [name, autosaved, description]); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index c05e60a1d79..7d6f4983aa8 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -1,3 +1,5 @@ +import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from './../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; import { InitialMigration1588157391238 } from './1588157391238-InitialMigration'; import { WebhookModel1592447867632 } from './1592447867632-WebhookModel'; import { CreateIndexStoppedAt1594902918301 } from './1594902918301-CreateIndexStoppedAt'; @@ -52,7 +54,6 @@ import { ChangeDependencyInfoToJson1761655473000 } from './1761655473000-ChangeD import { CreateLdapEntities1674509946020 } from '../common/1674509946020-CreateLdapEntities'; import { PurgeInvalidWorkflowConnections1675940580449 } from '../common/1675940580449-PurgeInvalidWorkflowConnections'; import { RemoveResetPasswordColumns1690000000030 } from '../common/1690000000030-RemoveResetPasswordColumns'; -import { AddMfaColumns1690000000030 } from '../common/1690000000040-AddMfaColumns'; import { CreateWorkflowNameIndex1691088862123 } from '../common/1691088862123-CreateWorkflowNameIndex'; import { CreateWorkflowHistoryTable1692967111175 } from '../common/1692967111175-CreateWorkflowHistoryTable'; import { ExecutionSoftDelete1693491613982 } from '../common/1693491613982-ExecutionSoftDelete'; @@ -227,4 +228,5 @@ export const mysqlMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowHistoryAutoSaveFields1762847206508, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 0dc1be15232..7ce461953b3 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -110,6 +110,7 @@ import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000 import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; import type { Migration } from '../migration-types'; import { ChangeDefaultForIdInUserTable1762771264000 } from './1762771264000-ChangeDefaultForIdInUserTable'; @@ -227,4 +228,5 @@ export const postgresMigrations: Migration[] = [ CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, ChangeDefaultForIdInUserTable1762771264000, + AddWorkflowHistoryAutoSaveFields1762847206508, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 3210a41227a..152476e5bfa 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -107,6 +107,7 @@ import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000 import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; import type { Migration } from '../migration-types'; const sqliteMigrations: Migration[] = [ @@ -219,6 +220,7 @@ const sqliteMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowHistoryAutoSaveFields1762847206508, ]; export { sqliteMigrations };