Merge remote-tracking branch 'origin/master' into ADO-4283

This commit is contained in:
Charlie Kolb 2025-11-13 10:53:24 +01:00
commit bab9ab5b28
No known key found for this signature in database
58 changed files with 3056 additions and 168 deletions

View File

@ -79,4 +79,5 @@ export class ListDataTableContentQueryDto extends Z.class({
skip: paginationSchema.skip.optional(),
filter: filterValidator.optional(),
sortBy: sortByValidator.optional(),
search: z.string().optional(),
}) {}

View File

@ -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([]),
}) {}

View File

@ -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<IWorkflowDb> = {},
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<IWorkflowDb> = {},
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<void> {
await Container.get(WorkflowHistoryRepository).insert({
workflowId: workflow.id,
versionId: workflow.versionId,
nodes: workflow.nodes,
connections: workflow.connections,
authors: user?.email ?? 'test@example.com',
});
}

View File

@ -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<Array<{ id: string }>>(`
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<Array<{ id: string }>>(`
-- 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(
`

View File

@ -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]);
}
}

View File

@ -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,
];

View File

@ -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()`,
);
}
}

View File

@ -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,
];

View File

@ -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,
];

View File

@ -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<SelectQueryBuilder<WorkflowEntity>>;
beforeEach(() => {
jest.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>();
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);
});
});
});

View File

@ -497,18 +497,65 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
}
/**
* 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<string, string>;
} {
const concatExpression = this.getFieldConcatExpression();
const conditions = searchWords.map((_, index) => {
return `${concatExpression} LIKE :searchWord${index}`;
});
const parameters: Record<string, string> = {};
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<WorkflowEntity>,
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);
}
}

View File

@ -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 }),
]);
});
});
});

View File

@ -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);
});

View File

@ -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([

View File

@ -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<T extends ObjectLiteral>(
query: SelectQueryBuilder<T> | UpdateQueryBuilder<T> | DeleteQueryBuilder<T>,
filter: DataTableFilter,

View File

@ -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 {

View File

@ -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();
});

View File

@ -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 };

View File

@ -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;
}

View File

@ -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()', () => {

View File

@ -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);

View File

@ -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`);

View File

@ -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<WorkflowEntity> = {
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');

View File

@ -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

View File

@ -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!'));

View File

@ -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', () => {

View File

@ -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<void>((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', () => {

View File

@ -2396,32 +2396,29 @@ export class WorkflowExecute {
executionError?: ExecutionBaseError,
closeFunction?: Promise<void>,
): Promise<IRun> {
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,
};
}

View File

@ -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);
});
</script>

View File

@ -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 <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View File

@ -47,6 +47,7 @@ const { width } = useElementSize(wrapperRef);
<NodeIcon
v-else
:icon-source="iconSource"
:node-type="nodeType"
:size="18"
:show-tooltip="true"
tooltip-position="left"

View File

@ -129,6 +129,29 @@ vi.mock('@/app/composables/useWorkflowState', async () => {
};
});
const canPinNodeMock = vi.fn();
const setDataMock = vi.fn();
const unsetDataMock = vi.fn();
const getInputDataWithPinnedMock = vi.fn();
vi.mock('@/app/composables/usePinnedData', () => {
return {
usePinnedData: vi.fn(() => ({
canPinNode: canPinNodeMock,
setData: setDataMock,
unsetData: unsetDataMock,
})),
};
});
vi.mock('@/app/composables/useDataSchema', () => {
return {
useDataSchema: vi.fn(() => ({
getInputDataWithPinned: getInputDataWithPinnedMock,
})),
};
});
describe('useCanvasOperations', () => {
const workflowId = 'test';
const initialState = {
@ -1548,6 +1571,116 @@ describe('useCanvasOperations', () => {
});
});
describe('toggleNodesPinned', () => {
beforeEach(() => {
canPinNodeMock.mockReset();
setDataMock.mockReset();
unsetDataMock.mockReset();
getInputDataWithPinnedMock.mockReset();
});
it('should only pin pinnable nodes when mix of pinnable and non-pinnable nodes are selected', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const historyStore = mockedStore(useHistoryStore);
const pinnableNode1 = createTestNode({ id: '1', name: 'PinnableNode1' });
const pinnableNode2 = createTestNode({ id: '2', name: 'PinnableNode2' });
const nonPinnableNode = createTestNode({ id: '3', name: 'NonPinnableNode' });
const nodes = [pinnableNode1, nonPinnableNode, pinnableNode2];
workflowsStore.getNodesByIds.mockReturnValue(nodes);
// Initially, none have pinned data
workflowsStore.pinDataByNodeName = vi.fn().mockReturnValue(undefined);
let checkIndex = 0;
const nodeOrder: string[] = [];
// Mock canPinNode based on which node is being checked
canPinNodeMock.mockImplementation(() => {
const currentNodeIndex = checkIndex % nodes.length;
const currentNode = nodes[currentNodeIndex];
nodeOrder.push(currentNode.id);
checkIndex++;
// Make nodes with id 1 and 2 pinnable, 3 non-pinnable
return currentNode.id !== '3';
});
getInputDataWithPinnedMock.mockReturnValue([{ json: { test: 'data' } }]);
const { toggleNodesPinned } = useCanvasOperations();
toggleNodesPinned(['1', '2', '3'], 'pin-icon-click');
expect(historyStore.startRecordingUndo).toHaveBeenCalled();
expect(historyStore.stopRecordingUndo).toHaveBeenCalled();
expect(setDataMock).toHaveBeenCalledTimes(2);
expect(setDataMock).toHaveBeenCalledWith([{ json: { test: 'data' } }], 'pin-icon-click');
expect(unsetDataMock).not.toHaveBeenCalled();
});
it('should correctly unpin pinnable nodes when mix of pinnable and non-pinnable nodes are selected', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const historyStore = mockedStore(useHistoryStore);
const pinnableNode1 = createTestNode({ id: '1', name: 'PinnableNode1' });
const pinnableNode2 = createTestNode({ id: '2', name: 'PinnableNode2' });
const nonPinnableNode = createTestNode({ id: '3', name: 'NonPinnableNode' });
const nodes = [pinnableNode1, nonPinnableNode, pinnableNode2];
workflowsStore.getNodesByIds.mockReturnValue(nodes);
// Set some initial pinned data for pinnable nodes
workflowsStore.pinDataByNodeName = vi.fn().mockImplementation((nodeName: string) => {
if (nodeName === 'PinnableNode1' || nodeName === 'PinnableNode2') {
return [{ json: { pinned: 'data' } }];
}
return undefined;
});
let checkIndex = 0;
canPinNodeMock.mockImplementation(() => {
const currentNodeIndex = checkIndex % nodes.length;
const currentNode = nodes[currentNodeIndex];
checkIndex++;
return currentNode.id !== '3';
});
const { toggleNodesPinned } = useCanvasOperations();
toggleNodesPinned(['1', '2', '3'], 'pin-icon-click');
expect(historyStore.startRecordingUndo).toHaveBeenCalled();
expect(historyStore.stopRecordingUndo).toHaveBeenCalled();
expect(unsetDataMock).toHaveBeenCalledTimes(2);
expect(unsetDataMock).toHaveBeenCalledWith('pin-icon-click');
expect(setDataMock).not.toHaveBeenCalled();
});
it('should handle case where all nodes are non-pinnable', () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const historyStore = mockedStore(useHistoryStore);
const nonPinnableNode1 = createTestNode({ id: '1', name: 'NonPinnableNode1' });
const nonPinnableNode2 = createTestNode({ id: '2', name: 'NonPinnableNode2' });
const nodes = [nonPinnableNode1, nonPinnableNode2];
workflowsStore.getNodesByIds.mockReturnValue(nodes);
workflowsStore.pinDataByNodeName = vi.fn().mockReturnValue(undefined);
canPinNodeMock.mockReturnValue(false);
const { toggleNodesPinned } = useCanvasOperations();
toggleNodesPinned(['1', '2'], 'pin-icon-click');
expect(historyStore.startRecordingUndo).toHaveBeenCalled();
expect(historyStore.stopRecordingUndo).toHaveBeenCalled();
// Verify no pinning or unpinning occurred
expect(setDataMock).not.toHaveBeenCalled();
expect(unsetDataMock).not.toHaveBeenCalled();
});
});
describe('addConnections', () => {
it('should create connections between nodes', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);

View File

@ -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);

View File

@ -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 = {};

View File

@ -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,

View File

@ -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<DataTable | null>(null);
const dataTableTableRef = ref<InstanceType<typeof DataTableTable>>();
const searchQuery = ref('');
const { debounce } = useDebounce();
@ -123,6 +132,27 @@ onMounted(async () => {
<N8nText>{{ i18n.baseText('generic.saving') }}...</N8nText>
</div>
<div :class="$style.actions">
<N8nInput
v-model="searchQuery"
data-test-id="data-table-search-input"
size="small"
:class="$style.search"
:placeholder="i18n.baseText('generic.search')"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
<template #suffix>
<N8nTooltip placement="bottom">
<template #content>
{{ i18n.baseText('dataTable.search.dateSearchInfo') }}
</template>
<span :class="$style.infoIcon">
<N8nIcon icon="info" size="small" />
</span>
</N8nTooltip>
</template>
</N8nInput>
<N8nButton
data-test-id="data-table-header-add-row-button"
@click="dataTableTableRef?.addRow"
@ -139,6 +169,7 @@ onMounted(async () => {
<DataTableTable
ref="dataTableTableRef"
:data-table="dataTable"
:search="searchQuery"
@toggle-save="onToggleSave"
/>
</div>
@ -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);
}
}
</style>

View File

@ -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);
});
});
});
});

View File

@ -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<Props>();
@ -41,6 +43,7 @@ const emit = defineEmits<{
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
const i18n = useI18n();
const { debounce } = useDebounce();
const rowData = ref<DataTableRow[]>([]);
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,

View File

@ -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,
);
});

View File

@ -47,6 +47,7 @@ export type UseDataTableOperationsParams = {
currentSortBy: Ref<string>;
currentSortOrder: Ref<string | null>;
currentFilterJSON?: Ref<string | undefined>;
searchQuery?: Ref<string | undefined>;
handleClearSelection: () => void;
selectedRowIds: Ref<Set<number>>;
handleCopyFocusedCell: (params: CellKeyDownEvent<DataTableRow>) => Promise<void>;
@ -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);

View File

@ -138,6 +138,7 @@ export const getDataTableRowsApi = async (
take?: number;
sortBy?: string;
filter?: string;
search?: string;
},
) => {
return await makeRestApiRequest<{

View File

@ -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,
});
};

View File

@ -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(() => {

View File

@ -31,7 +31,14 @@ function onRename(newNodeName: string) {
<template>
<header :class="$style.ndvHeader">
<div :class="$style.content">
<NodeIcon v-if="icon" :class="$style.icon" :size="20" :icon-source="icon" />
<NodeIcon
v-if="icon"
:class="$style.icon"
:size="20"
:icon-source="icon"
:node-name="props.nodeTypeName"
:show-tooltip="true"
/>
<div :class="$style.title">
<N8nInlineTextEdit
:model-value="nodeName"

View File

@ -81,4 +81,103 @@ describe('ExpressionEditModal', () => {
expect(editor).toHaveAttribute('aria-readonly', 'true');
});
});
describe('output render mode radio buttons', () => {
it('renders all three render mode options', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
expect(getByText('Text')).toBeInTheDocument();
expect(getByText('Html')).toBeInTheDocument();
expect(getByText('Markdown')).toBeInTheDocument();
});
});
it('has Text as default render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
const textButton = getByText('Text').closest('label');
expect(textButton).toHaveAttribute('aria-checked', 'true');
});
});
it('allows switching to Html render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(async () => {
const htmlButton = getByText('Html').closest('label');
const htmlInput = htmlButton?.querySelector('input');
if (htmlInput) {
htmlInput.click();
expect(htmlInput).toBeChecked();
}
});
});
it('allows switching to Markdown render mode', async () => {
const { getByText } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(async () => {
const markdownButton = getByText('Markdown').closest('label');
const markdownInput = markdownButton?.querySelector('input');
if (markdownInput) {
markdownInput.click();
expect(markdownInput).toBeChecked();
}
});
});
it('has correct values for each render mode option', async () => {
const { getByTestId } = renderModal({
pinia,
props: {
parameter: createTestNodeProperties({ name: 'foo', type: 'string' }),
path: '',
modelValue: 'test',
dialogVisible: true,
},
});
await waitFor(() => {
expect(getByTestId('radio-button-text')).toBeInTheDocument();
expect(getByTestId('radio-button-html')).toBeInTheDocument();
expect(getByTestId('radio-button-markdown')).toBeInTheDocument();
});
});
});
});

View File

@ -25,7 +25,14 @@ import { APP_MODALS_ELEMENT_ID } from '@/app/constants';
import { useThrottleFn } from '@vueuse/core';
import { ElDialog } from 'element-plus';
import { N8nIcon, N8nInput, N8nResizeWrapper, N8nText, type ResizeData } from '@n8n/design-system';
import {
N8nIcon,
N8nInput,
N8nRadioButtons,
N8nResizeWrapper,
N8nText,
type ResizeData,
} from '@n8n/design-system';
const DEFAULT_LEFT_SIDEBAR_WIDTH = 360;
type Props = {
@ -64,6 +71,7 @@ const sidebarWidth = ref(DEFAULT_LEFT_SIDEBAR_WIDTH);
const expressionInputRef = ref<InstanceType<typeof ExpressionEditorModalInput>>();
const expressionResultRef = ref<InstanceType<typeof ExpressionOutput>>();
const theme = outputTheme();
const outputRenderMode = ref<'text' | 'html' | 'markdown'>('text');
const activeNode = computed(() => ndvStore.activeNode);
const inputEditor = computed(() => expressionInputRef.value?.editor);
@ -75,6 +83,17 @@ const parentNodes = computed(() => {
return nodes.filter(({ name }) => name !== node.name);
});
const rootNode = computed(() => {
if (!activeNode.value) return null;
return workflowsStore.findRootWithMainConnection(activeNode.value.name);
});
const rootNodesParents = computed(() => {
if (!rootNode.value) return [];
return workflowsStore.workflowObject.getParentNodesByDepth(rootNode.value);
});
watch(
() => props.dialogVisible,
(newValue) => {
@ -169,7 +188,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<VirtualSchema
:class="$style.schema"
:search="appliedSearch"
:nodes="parentNodes"
:nodes="parentNodes.length > 0 ? parentNodes : rootNodesParents"
:mapping-enabled="!isReadOnly"
:connection-type="NodeConnectionTypes.Main"
pane-type="input"
@ -216,7 +235,18 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
<N8nText bold size="large">
{{ i18n.baseText('parameterInput.result') }}
</N8nText>
<OutputItemSelect />
<div :class="$style.headerControls">
<OutputItemSelect />
<N8nRadioButtons
v-model="outputRenderMode"
size="small"
:options="[
{ label: i18n.baseText('ndv.render.text'), value: 'text' },
{ label: i18n.baseText('ndv.render.html'), value: 'html' },
{ label: i18n.baseText('ndv.render.markdown'), value: 'markdown' },
]"
/>
</div>
</div>
<div :class="[$style.editorContainer, { 'ph-no-capture': redactValues }]">
@ -225,6 +255,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
:class="$style.editor"
:segments="segments"
:extensions="theme"
:render="outputRenderMode"
data-test-id="expression-modal-output"
/>
</div>
@ -318,6 +349,14 @@ const onResizeThrottle = useThrottleFn(onResize, 10);
gap: var(--spacing--5xs);
}
.headerControls {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: var(--spacing--4xs);
}
.tip {
min-height: 22px;
}

View File

@ -0,0 +1,349 @@
import { createTestingPinia } from '@pinia/testing';
import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue';
import { renderComponent } from '@/__tests__/render';
describe('RunDataMarkdown.vue', () => {
it('should render markdown content correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# Hello World\n\nThis is a test.',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain('Hello World');
expect(markdownContainer?.textContent).toContain('This is a test.');
});
it('should render headers correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
const h1 = markdownContainer?.querySelector('h1');
const h2 = markdownContainer?.querySelector('h2');
const h3 = markdownContainer?.querySelector('h3');
const h4 = markdownContainer?.querySelector('h4');
const h5 = markdownContainer?.querySelector('h5');
const h6 = markdownContainer?.querySelector('h6');
expect(h1?.textContent).toBe('H1');
expect(h2?.textContent).toBe('H2');
expect(h3?.textContent).toBe('H3');
expect(h4?.textContent).toBe('H4');
expect(h5?.textContent).toBe('H5');
expect(h6?.textContent).toBe('H6');
});
it('should render bold and italic text', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '**bold text** and *italic text*',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
const strong = markdownContainer?.querySelector('strong');
const em = markdownContainer?.querySelector('em');
expect(strong?.textContent).toBe('bold text');
expect(em?.textContent).toBe('italic text');
});
it('should render links correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '[Click here](https://example.com)',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const link = markdownContainer?.querySelector('a');
expect(link).toBeInTheDocument();
expect(link?.textContent).toBe('Click here');
expect(link?.getAttribute('href')).toBe('https://example.com');
});
it('should render code blocks correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '```javascript\nconst x = 42;\n```',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const pre = markdownContainer?.querySelector('pre');
const code = pre?.querySelector('code');
expect(pre).toBeInTheDocument();
expect(code).toBeInTheDocument();
expect(code?.textContent).toContain('const x = 42;');
});
it('should render inline code correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Use `console.log()` for debugging',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const code = markdownContainer?.querySelector('code');
expect(code).toBeInTheDocument();
expect(code?.textContent).toBe('console.log()');
});
it('should render unordered lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '- Item 1\n- Item 2\n- Item 3',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const ul = markdownContainer?.querySelector('ul');
const listItems = ul?.querySelectorAll('li');
expect(ul).toBeInTheDocument();
expect(listItems?.length).toBe(3);
expect(listItems?.[0].textContent).toBe('Item 1');
expect(listItems?.[1].textContent).toBe('Item 2');
expect(listItems?.[2].textContent).toBe('Item 3');
});
it('should render ordered lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '1. First\n2. Second\n3. Third',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const ol = markdownContainer?.querySelector('ol');
const listItems = ol?.querySelectorAll('li');
expect(ol).toBeInTheDocument();
expect(listItems?.length).toBe(3);
expect(listItems?.[0].textContent).toBe('First');
expect(listItems?.[1].textContent).toBe('Second');
expect(listItems?.[2].textContent).toBe('Third');
});
it('should render blockquotes correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '> This is a quote\n> with multiple lines',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const blockquote = markdownContainer?.querySelector('blockquote');
expect(blockquote).toBeInTheDocument();
expect(blockquote?.textContent).toContain('This is a quote');
expect(blockquote?.textContent).toContain('with multiple lines');
});
it('should render tables correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const table = markdownContainer?.querySelector('table');
const headers = table?.querySelectorAll('th');
const cells = table?.querySelectorAll('td');
expect(table).toBeInTheDocument();
expect(headers?.length).toBe(2);
expect(headers?.[0].textContent).toBe('Header 1');
expect(headers?.[1].textContent).toBe('Header 2');
expect(cells?.length).toBe(2);
expect(cells?.[0].textContent).toContain('Cell 1');
expect(cells?.[1].textContent).toContain('Cell 2');
});
it('should render horizontal rules correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Before\n\n---\n\nAfter',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const hr = markdownContainer?.querySelector('hr');
expect(hr).toBeInTheDocument();
});
it('should render empty string', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
});
it('should render plain text without markdown syntax', () => {
const plainText = 'This is just plain text without any markdown';
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: plainText,
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain(plainText);
});
it('should handle complex mixed markdown content', () => {
const complexMarkdown = `# Main Title
This is a paragraph with **bold** and *italic* text.
## Subsection
- List item 1
- List item 2
- Nested item
\`\`\`javascript
const code = "example";
\`\`\`
> A quote
[Link](https://example.com)`;
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: complexMarkdown,
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.querySelector('h1')).toBeInTheDocument();
expect(markdownContainer?.querySelector('h2')).toBeInTheDocument();
expect(markdownContainer?.querySelector('strong')).toBeInTheDocument();
expect(markdownContainer?.querySelector('em')).toBeInTheDocument();
expect(markdownContainer?.querySelector('ul')).toBeInTheDocument();
expect(markdownContainer?.querySelector('pre')).toBeInTheDocument();
expect(markdownContainer?.querySelector('blockquote')).toBeInTheDocument();
expect(markdownContainer?.querySelector('a')).toBeInTheDocument();
});
it('should apply markdown CSS module class', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '# Test',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.className).toContain('markdown');
});
it('should handle markdown with special characters', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Text with < > & " special characters',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
expect(markdownContainer?.textContent).toContain('special characters');
});
it('should render markdown with newlines correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: 'Line 1\n\nLine 2\n\nLine 3',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const paragraphs = markdownContainer?.querySelectorAll('p');
expect(markdownContainer).toBeInTheDocument();
expect(paragraphs?.length).toBeGreaterThan(0);
});
it('should handle image markdown syntax', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '![Alt text](https://example.com/image.png)',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const img = markdownContainer?.querySelector('img');
expect(img).toBeInTheDocument();
expect(img?.getAttribute('alt')).toBe('Alt text');
expect(img?.getAttribute('src')).toBe('https://example.com/image.png');
});
it('should render nested lists correctly', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '- Item 1\n - Nested 1\n - Nested 2\n- Item 2',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
const lists = markdownContainer?.querySelectorAll('ul');
expect(lists && lists.length > 0).toBe(true);
});
it('should render strikethrough text if supported', () => {
const { container } = renderComponent(RunDataMarkdown, {
pinia: createTestingPinia(),
props: {
inputMarkdown: '~~strikethrough~~',
},
});
const markdownContainer = container.querySelector('[class*="markdown"]');
expect(markdownContainer).toBeInTheDocument();
});
});

View File

@ -0,0 +1,183 @@
<script lang="ts">
import { defineComponent } from 'vue';
import VueMarkdownRender from 'vue-markdown-render';
export default defineComponent({
name: 'RunDataMarkdown',
components: {
VueMarkdownRender,
},
props: {
inputMarkdown: {
type: String,
required: true,
},
},
});
</script>
<template>
<div :class="$style.markdown">
<VueMarkdownRender :source="inputMarkdown" />
</div>
</template>
<style lang="scss" module>
.markdown {
width: 100%;
height: 100%;
overflow: auto;
padding: var(--spacing--sm) var(--spacing--md);
border: var(--border);
border-radius: var(--radius);
background-color: var(--color--background--light-3);
color: var(--color--text);
font-family: var(--font-family);
line-height: var(--line-height--xl);
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: var(--spacing--lg);
margin-bottom: var(--spacing--sm);
font-weight: var(--font-weight--bold);
line-height: var(--line-height--lg);
border-bottom: var(--border-width) solid var(--border-color--light);
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: var(--font-size--2xl);
}
h2 {
font-size: var(--font-size--xl);
}
h3 {
font-size: var(--font-size--lg);
}
h4 {
font-size: var(--font-size--md);
}
h5 {
font-size: var(--font-size--sm);
}
h6 {
font-size: var(--font-size--xs);
color: var(--color--text--tint-1);
}
p {
margin: var(--spacing--sm) 0;
}
a {
color: var(--color--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
color: var(--color--primary--shade-1);
}
}
strong {
font-weight: var(--font-weight--bold);
}
em {
font-style: italic;
}
blockquote {
padding: var(--spacing--xs) var(--spacing--sm);
margin: var(--spacing--sm) 0;
color: var(--color--text--tint-1);
border-left: 0.25em solid var(--border-color);
background-color: var(--color--background--light-1);
border-radius: var(--radius--sm);
}
code,
pre {
font-family: var(--font-family--monospace);
font-size: var(--font-size--sm);
}
code {
background-color: var(--color--background--light-1);
padding: 0.2em 0.4em;
border-radius: var(--radius--sm);
color: var(--code--color--foreground);
}
pre {
background-color: var(--color--background--light-2);
padding: var(--spacing--sm);
border-radius: var(--radius--lg);
overflow-x: auto;
border: var(--border-width) solid var(--border-color--light);
code {
background: none;
padding: 0;
color: inherit;
}
}
ul,
ol {
margin: var(--spacing--sm) 0;
padding-left: var(--spacing--lg);
}
table {
border-collapse: collapse;
width: 100%;
margin: var(--spacing--sm) 0;
font-size: var(--font-size--sm);
th,
td {
border: var(--border-width) solid var(--border-color--light);
padding: var(--spacing--2xs) var(--spacing--xs);
text-align: left;
}
th {
background-color: var(--table--header--color--background);
font-weight: var(--font-weight--medium);
color: var(--color--text--shade-1);
}
tr:nth-child(even) {
background-color: var(--table--row--color--background--even);
}
tr:hover {
background-color: var(--table--row--color--background--hover);
}
}
hr {
border: 0;
border-top: var(--border-width) solid var(--border-color--light);
margin: var(--spacing--lg) 0;
}
img {
max-width: 100%;
border-radius: var(--radius--sm);
box-shadow: var(--shadow--light);
}
blockquote > :last-child {
margin-bottom: 0;
}
}
</style>

View File

@ -259,7 +259,8 @@ const contextItems = computed(() => {
const variablesEmptyNotice: RenderNotice = {
type: 'notice',
id: 'notice-variablesEmpty',
level: renderItem.level ?? 0,
// Increase level to indent under $vars
level: (renderItem.level ?? 0) + 1,
message: i18n.baseText('dataMapping.schemaView.variablesEmpty'),
};
return [renderItem, variablesEmptyNotice];

View File

@ -632,6 +632,7 @@ function handleSelectAction(params: INodeParameters) {
:model-value="node.name"
:icon-source="iconSource"
:read-only="isReadOnly"
:node-type="nodeType"
@update:model-value="nameChanged"
/>
<NodeExecuteButton

View File

@ -99,6 +99,8 @@ const promptDescriptions: PromptDescription[] = [
const authProtocol = ref<SupportedProtocolType>(SupportedProtocols.SAML);
const authenticationContextClassReference = ref('');
const ipsOptions = ref([
{
label: i18n.baseText('settings.sso.settings.ips.options.url'),
@ -278,6 +280,8 @@ const getOidcConfig = async () => {
clientSecret.value = config.clientSecret;
discoveryEndpoint.value = config.discoveryEndpoint;
prompt.value = config.prompt ?? 'select_account';
authenticationContextClassReference.value =
config.authenticationContextClassReference?.join(',') || '';
};
async function loadOidcConfig() {
@ -296,12 +300,22 @@ function onAuthProtocolUpdated(value: SupportedProtocolType) {
}
const cannotSaveOidcSettings = computed(() => {
const currentAcrString = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.join(',');
const storedAcrString = ssoStore.oidcConfig?.authenticationContextClassReference?.join(',') || '';
return (
ssoStore.oidcConfig?.clientId === clientId.value &&
ssoStore.oidcConfig?.clientSecret === clientSecret.value &&
ssoStore.oidcConfig?.discoveryEndpoint === discoveryEndpoint.value &&
ssoStore.oidcConfig?.loginEnabled === ssoStore.isOidcLoginEnabled &&
ssoStore.oidcConfig?.prompt === prompt.value
ssoStore.oidcConfig?.prompt === prompt.value &&
storedAcrString === authenticationContextClassReference.value &&
currentAcrString === storedAcrString
);
});
@ -322,6 +336,11 @@ async function onOidcSettingsSave() {
if (confirmAction !== MODAL_CONFIRM) return;
}
const acrArray = authenticationContextClassReference.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
try {
const newConfig = await ssoStore.saveOidcConfig({
clientId: clientId.value,
@ -329,6 +348,7 @@ async function onOidcSettingsSave() {
discoveryEndpoint: discoveryEndpoint.value,
prompt: prompt.value,
loginEnabled: ssoStore.isOidcLoginEnabled,
authenticationContextClassReference: acrArray,
});
// Update store with saved protocol selection
@ -548,6 +568,20 @@ async function onOidcSettingsSave() {
</N8nSelect>
<small>The prompt parameter to use when authenticating with the OIDC provider</small>
</div>
<div :class="$style.group">
<label>Authentication Context Class Reference</label>
<N8nInput
:model-value="authenticationContextClassReference"
type="textarea"
data-test-id="oidc-authentication-context-class-reference"
placeholder="mfa, phrh, pwd"
@update:model-value="(v: string) => (authenticationContextClassReference = v)"
/>
<small
>ACR values to include in the authorization request (acr_values parameter), separated by
commas in order of preference.</small
>
</div>
<div :class="$style.group">
<ElSwitch
v-model="ssoStore.isOidcLoginEnabled"

View File

@ -0,0 +1,569 @@
import { renderComponent } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import type { Extension } from '@codemirror/state';
import ExpressionOutput from './ExpressionOutput.vue';
import type { Segment } from '../../../../../app/types/expressions';
describe('ExpressionOutput.vue', () => {
const basicSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Hello ',
},
{
kind: 'resolvable',
from: 6,
to: 16,
resolvable: '{{ $json.name }}',
resolved: 'World',
state: 'valid',
error: null,
},
];
describe('render mode: text', () => {
it('should render text output by default', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.textContent).toContain('Hello World');
});
it('should render empty string message when segments are empty', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: [],
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('[empty]');
});
it('should render plaintext segments correctly', () => {
const plaintextSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 5,
plaintext: 'Test ',
},
{
kind: 'plaintext',
from: 5,
to: 10,
plaintext: 'Value',
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: plaintextSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Test Value');
});
it('should render resolvable segments with resolved values', () => {
const resolvableSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ 1 + 1 }}',
resolved: 2,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: resolvableSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('2');
});
it('should handle boolean resolved values', () => {
const booleanSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ true }}',
resolved: true,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: booleanSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('true');
});
it('should skip duplicate segments', () => {
const duplicateSegments: Segment[] = [
{
from: 0,
to: 5,
plaintext: '[1,2]',
kind: 'plaintext',
},
{
from: 0,
to: 1,
plaintext: '[',
kind: 'plaintext',
},
{
from: 1,
to: 2,
plaintext: '1',
kind: 'plaintext',
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: duplicateSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('[1,2]');
});
});
describe('render mode: html', () => {
it('should render HTML content when render mode is html', () => {
const htmlSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.html }}',
resolved: '<h1>Hello</h1><p>World</p>',
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: htmlSegments,
render: 'html',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.tagName).toBe('IFRAME');
});
it('should not render CodeMirror editor in html mode', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
});
describe('render mode: markdown', () => {
it('should render Markdown content when render mode is markdown', () => {
const markdownSegments: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.markdown }}',
resolved: '# Hello\n\nThis is **bold** text',
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: markdownSegments,
render: 'markdown',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output).toHaveClass('markdown');
});
it('should not render CodeMirror editor in markdown mode', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
});
describe('switching render modes', () => {
it('should switch from text to html mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
await rerender({
segments: basicSegments,
render: 'html',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
it('should switch from text to markdown mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
await rerender({
segments: basicSegments,
render: 'markdown',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
});
const cmEditor = container.querySelector('.cm-editor');
expect(cmEditor).not.toBeInTheDocument();
});
it('should switch from html to text mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
await rerender({
segments: basicSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).not.toBe('IFRAME');
});
it('should switch from markdown to text mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
await rerender({
segments: basicSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).not.toHaveClass('markdown');
});
it('should switch from html to markdown mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'html',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
await rerender({
segments: basicSegments,
render: 'markdown',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).not.toBe('IFRAME');
});
it('should switch from markdown to html mode', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'markdown',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toHaveClass('markdown');
await rerender({
segments: basicSegments,
render: 'html',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.tagName).toBe('IFRAME');
});
output = container.querySelector('[data-test-id="expression-output"]');
expect(output).not.toHaveClass('markdown');
});
it('should update segments when in text mode', async () => {
const initialSegments = [
{
kind: 'plaintext',
from: 0,
to: 5,
plaintext: 'First',
},
] as Segment[];
const updatedSegments = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Second',
},
] as Segment[];
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: initialSegments,
render: 'text',
},
});
let output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('First');
await rerender({
segments: updatedSegments,
render: 'text',
});
await waitFor(() => {
output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Second');
});
});
it('should handle rapid mode switching', async () => {
const { container, rerender } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
render: 'text',
},
});
await rerender({ segments: basicSegments, render: 'html' });
await rerender({ segments: basicSegments, render: 'markdown' });
await rerender({ segments: basicSegments, render: 'text' });
await waitFor(() => {
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toContain('Hello World');
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(container.querySelector('iframe')).not.toBeInTheDocument();
});
});
describe('getValue expose method', () => {
it('should render output correctly for getValue usage', () => {
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
expect(output?.textContent).toContain('Hello World');
});
});
describe('edge cases', () => {
it('should handle segments with null resolved value', () => {
const segmentsWithNull: Segment[] = [
{
kind: 'resolvable',
from: 0,
to: 10,
resolvable: '{{ $json.missing }}',
resolved: null,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: segmentsWithNull,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
});
it('should handle mixed plaintext and resolvable segments', () => {
const mixedSegments: Segment[] = [
{
kind: 'plaintext',
from: 0,
to: 6,
plaintext: 'Hello ',
},
{
kind: 'resolvable',
from: 6,
to: 16,
resolvable: '{{ $json.name }}',
resolved: 'John',
state: 'valid',
error: null,
},
{
kind: 'plaintext',
from: 16,
to: 23,
plaintext: ', age: ',
},
{
kind: 'resolvable',
from: 23,
to: 33,
resolvable: '{{ $json.age }}',
resolved: 25,
state: 'valid',
error: null,
},
];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: mixedSegments,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output?.textContent).toBe('Hello John, age: 25');
});
it('should handle custom extensions in text mode', () => {
const customExtensions: Extension[] = [];
const { container } = renderComponent(ExpressionOutput, {
pinia: createTestingPinia(),
props: {
segments: basicSegments,
extensions: customExtensions,
render: 'text',
},
});
const output = container.querySelector('[data-test-id="expression-output"]');
expect(output).toBeInTheDocument();
});
});
});

View File

@ -4,16 +4,23 @@ import { EditorView } from '@codemirror/view';
import { useI18n } from '@n8n/i18n';
import { highlighter } from '../../plugins/codemirror/resolvableHighlighter';
import type { Plaintext, Resolved, Segment } from '@/app/types/expressions';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { forceParse } from '@/app/utils/forceParse';
import RunDataHtml from '@/features/ndv/runData/components/RunDataHtml.vue';
import RunDataMarkdown from '@/features/ndv/runData/components/RunDataMarkdown.vue';
interface ExpressionOutputProps {
segments: Segment[];
extensions?: Extension[];
render?: 'text' | 'html' | 'markdown';
}
const props = withDefaults(defineProps<ExpressionOutputProps>(), { extensions: () => [] });
const props = withDefaults(defineProps<ExpressionOutputProps>(), {
extensions: () => [],
render: 'text',
});
const i18n = useI18n();
@ -75,21 +82,9 @@ const resolvedSegments = computed<Resolved[]>(() => {
.filter((segment): segment is Resolved => segment.kind === 'resolvable');
});
watch(
() => props.segments,
() => {
if (!editor.value) return;
function initializeEditor() {
if (!root.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
);
onMounted(() => {
editor.value = new EditorView({
parent: root.value as HTMLElement,
state: EditorState.create({
@ -105,6 +100,38 @@ onMounted(() => {
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
}
watch(
() => props.segments,
() => {
if (props.render !== 'text' || !editor.value) return;
editor.value.dispatch({
changes: { from: 0, to: editor.value.state.doc.length, insert: resolvedExpression.value },
});
highlighter.addColor(editor.value as EditorView, resolvedSegments.value);
highlighter.removeColor(editor.value as EditorView, plaintextSegments.value);
},
);
watch(
() => props.render,
async (newMode) => {
if (newMode === 'text' && !editor.value) {
await nextTick();
initializeEditor();
} else if ((newMode === 'html' || newMode === 'markdown') && editor.value) {
editor.value.destroy();
editor.value = null;
}
},
);
onMounted(() => {
if (props.render !== 'text') return;
initializeEditor();
});
onBeforeUnmount(() => {
@ -115,5 +142,28 @@ defineExpose({ getValue: () => '=' + resolvedExpression.value });
</script>
<template>
<div ref="root" data-test-id="expression-output"></div>
<div v-if="render === 'text'" ref="root" data-test-id="expression-output"></div>
<RunDataHtml
v-else-if="render === 'html'"
data-test-id="expression-output"
:input-html="resolvedExpression"
/>
<RunDataMarkdown
v-else-if="render === 'markdown'"
data-test-id="expression-output"
:input-markdown="resolvedExpression"
/>
</template>
<style lang="scss">
.__html-display {
border: 2px solid var(--border-color);
padding: var(--spacing--xs);
border-width: var(--border-width);
border-style: var(--input--border-style, var(--border-style));
border-color: var(--input--border-color, var(--border-color));
border-radius: var(--input--radius, var(--radius));
}
</style>

View File

@ -35,23 +35,11 @@ class PythonDisabledError extends UserError {
}
}
function iconForLanguage(lang: CodeNodeLanguageOption): string {
switch (lang) {
case 'python':
case 'pythonNative':
return 'file:python.svg';
case 'javaScript':
return 'file:js.svg';
default:
return 'file:code.svg';
}
}
export class Code implements INodeType {
description: INodeTypeDescription = {
displayName: 'Code',
name: 'code',
icon: `={{(${iconForLanguage})($parameter.language)}}`,
icon: 'file:code.svg',
group: ['transform'],
version: [1, 2],
defaultVersion: 2,

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 630 630"><script xmlns=""/>
<rect width="630" height="630" fill="#f7df1e"/>
<path d="m423.2 492.19c12.69 20.72 29.2 35.95 58.4 35.95 24.53 0 40.2-12.26 40.2-29.2 0-20.3-16.1-27.49-43.1-39.3l-14.8-6.35c-42.72-18.2-71.1-41-71.1-89.2 0-44.4 33.83-78.2 86.7-78.2 37.64 0 64.7 13.1 84.2 47.4l-46.1 29.6c-10.15-18.2-21.1-25.37-38.1-25.37-17.34 0-28.33 11-28.33 25.37 0 17.76 11 24.95 36.4 35.95l14.8 6.34c50.3 21.57 78.7 43.56 78.7 93 0 53.3-41.87 82.5-98.1 82.5-54.98 0-90.5-26.2-107.88-60.54zm-209.13 5.13c9.3 16.5 17.76 30.45 38.1 30.45 19.45 0 31.72-7.61 31.72-37.2v-201.3h59.2v202.1c0 61.3-35.94 89.2-88.4 89.2-47.4 0-74.85-24.53-88.81-54.075z"/>
<script xmlns=""/></svg>

Before

Width:  |  Height:  |  Size: 723 B

View File

@ -1,3 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://web.resource.org/cc/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="110px" height="110px" viewBox="0.21 -0.077 110 110" enable-background="new 0.21 -0.077 110 110" xml:space="preserve"><script xmlns=""/><linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="63.8159" y1="56.6829" x2="118.4934" y2="1.8225" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" style="stop-color:#387EB8"/> <stop offset="1" style="stop-color:#366994"/></linearGradient><path fill="url(#SVGID_1_)" d="M55.023-0.077c-25.971,0-26.25,10.081-26.25,12.156c0,3.148,0,12.594,0,12.594h26.75v3.781 c0,0-27.852,0-37.375,0c-7.949,0-17.938,4.833-17.938,26.25c0,19.673,7.792,27.281,15.656,27.281c2.335,0,9.344,0,9.344,0 s0-9.765,0-13.125c0-5.491,2.721-15.656,15.406-15.656c15.91,0,19.971,0,26.531,0c3.902,0,14.906-1.696,14.906-14.406 c0-13.452,0-17.89,0-24.219C82.054,11.426,81.515-0.077,55.023-0.077z M40.273,8.392c2.662,0,4.813,2.15,4.813,4.813 c0,2.661-2.151,4.813-4.813,4.813s-4.813-2.151-4.813-4.813C35.46,10.542,37.611,8.392,40.273,8.392z"/><linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="97.0444" y1="21.6321" x2="155.6665" y2="-34.5308" gradientTransform="matrix(1 0 0 -1 -53.2974 66.4321)"> <stop offset="0" style="stop-color:#FFE052"/> <stop offset="1" style="stop-color:#FFC331"/></linearGradient><path fill="url(#SVGID_2_)" d="M55.397,109.923c25.959,0,26.282-10.271,26.282-12.156c0-3.148,0-12.594,0-12.594H54.897v-3.781 c0,0,28.032,0,37.375,0c8.009,0,17.938-4.954,17.938-26.25c0-23.322-10.538-27.281-15.656-27.281c-2.336,0-9.344,0-9.344,0 s0,10.216,0,13.125c0,5.491-2.631,15.656-15.406,15.656c-15.91,0-19.476,0-26.532,0c-3.892,0-14.906,1.896-14.906,14.406 c0,14.475,0,18.265,0,24.219C28.366,100.497,31.562,109.923,55.397,109.923z M70.148,101.454c-2.662,0-4.813-2.151-4.813-4.813 s2.15-4.813,4.813-4.813c2.661,0,4.813,2.151,4.813,4.813S72.809,101.454,70.148,101.454z"/><script xmlns=""/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -383,4 +383,20 @@ export class DataTableDetails extends BasePage {
await sourceColumn.dragTo(targetColumn);
}
getSearchInput() {
return this.page.getByTestId('data-table-search-input');
}
async search(query: string) {
const searchInput = this.getSearchInput();
await searchInput.fill(query);
// Wait for debounce
await this.page.waitForTimeout(300);
await this.page.getByText('Loading...').waitFor({ state: 'hidden' });
}
async clearSearch() {
await this.search('');
}
}

View File

@ -480,4 +480,76 @@ test.describe('Data Table details view', () => {
expect(birthdayFinalIndex).toBeLessThan(nameFinalIndex);
});
test('Should search and filter rows globally', async ({ n8n }) => {
await expect(n8n.dataTableDetails.getPageWrapper()).toBeVisible();
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.name, 'string', 'header');
await n8n.dataTableDetails.addColumn(COLUMN_NAMES.age, 'number', 'header');
const nameColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.name);
const ageColumn = await n8n.dataTableDetails.getColumnIdByName(COLUMN_NAMES.age);
const testData = [
{ name: 'Alice Johnson', age: '25' },
{ name: 'Bob Smith', age: '30' },
{ name: 'Charlie Brown', age: '35' },
{ name: 'Diana Prince', age: '28' },
{ name: 'Eve Adams', age: '32' },
{ name: 'Frank Miller', age: '29' },
];
for (let i = 0; i < testData.length; i++) {
await n8n.dataTableDetails.addRow();
await n8n.dataTableDetails.setCellValue(i, nameColumn, testData[i].name, 'string', {
skipDoubleClick: true,
});
await n8n.dataTableDetails.setCellValue(i, ageColumn, testData[i].age, 'number');
}
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// Test search for partial name match
await n8n.dataTableDetails.search('Alice');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const aliceValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(aliceValue).toContain('Alice Johnson');
// Test search for last name
await n8n.dataTableDetails.search('Smith');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const bobValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(bobValue).toContain('Bob Smith');
// Test search across all columns (search by age)
await n8n.dataTableDetails.search('30');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const bobAgeValue = await n8n.dataTableDetails.getCellValue(0, ageColumn, 'number');
expect(bobAgeValue).toContain('30');
// Test search with multiple results
await n8n.dataTableDetails.search('a');
const multipleResultsCount = await n8n.dataTableDetails.getDataRows().count();
expect(multipleResultsCount).toBeGreaterThan(1);
// Clear search and verify all rows are shown
await n8n.dataTableDetails.clearSearch();
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// Test case-insensitive search
await n8n.dataTableDetails.search('ALICE');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const aliceCaseValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(aliceCaseValue).toContain('Alice Johnson');
// Clear search for next test
await n8n.dataTableDetails.clearSearch();
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(6);
// test search combined with column filter
await n8n.dataTableDetails.setNumberFilter(COLUMN_NAMES.age, '29', 'greaterThan');
await n8n.dataTableDetails.search('Adams');
await expect(n8n.dataTableDetails.getDataRows()).toHaveCount(1);
const adamValue = await n8n.dataTableDetails.getCellValue(0, nameColumn, 'string');
expect(adamValue).toContain('Eve Adams');
});
});