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/@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/@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/@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( ` 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 87b41d478c8..abdda0b68cf 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'; @@ -228,5 +229,6 @@ export const mysqlMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowHistoryAutoSaveFields1762847206508, AddWorkflowVersionIdToExecutionData1762858574621, ]; 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 f5d9bc4a01b..e4067fa29be 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -108,10 +108,12 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; -import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; +import { AddWorkflowHistoryAutoSaveFields1762847206508 } from '../common/1762847206508-AddWorkflowHistoryAutoSaveFields'; +import { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; +import { ChangeDefaultForIdInUserTable1762771264000 } from './1762771264000-ChangeDefaultForIdInUserTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -226,5 +228,7 @@ export const postgresMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + ChangeDefaultForIdInUserTable1762771264000, + AddWorkflowHistoryAutoSaveFields1762847206508, AddWorkflowVersionIdToExecutionData1762858574621, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 5817a728d5d..f76956b208b 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 { AddWorkflowVersionIdToExecutionData1762858574621 } from '../common/1762858574621-AddWorkflowVersionIdToExecutionData'; import type { Migration } from '../migration-types'; @@ -220,6 +221,7 @@ const sqliteMigrations: Migration[] = [ AddWorkflowDescriptionColumn1762177736257, CreateOAuthEntities1760116750277, BackfillMissingWorkflowHistoryRecords1762763704614, + AddWorkflowHistoryAutoSaveFields1762847206508, AddWorkflowVersionIdToExecutionData1762858574621, ]; 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); } } 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/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/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/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/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/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..557cb325f95 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, @@ -2585,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', () => { @@ -2666,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', () => { 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, }; } 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); }); diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d289654b509..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", @@ -2766,6 +2767,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", @@ -3228,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/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); { }; }); +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); 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/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/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/panel/components/NDVHeader.vue b/packages/frontend/editor-ui/src/features/ndv/panel/components/NDVHeader.vue index d2217d7b7ac..1e41089d4a9 100644 --- a/packages/frontend/editor-ui/src/features/ndv/panel/components/NDVHeader.vue +++ b/packages/frontend/editor-ui/src/features/ndv/panel/components/NDVHeader.vue @@ -31,7 +31,14 @@ function onRename(newNodeName: string) {