mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
Merge remote-tracking branch 'origin/master' into ADO-4283
This commit is contained in:
commit
bab9ab5b28
|
|
@ -79,4 +79,5 @@ export class ListDataTableContentQueryDto extends Z.class({
|
|||
skip: paginationSchema.skip.optional(),
|
||||
filter: filterValidator.optional(),
|
||||
sortBy: sortByValidator.optional(),
|
||||
search: z.string().optional(),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
}) {}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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()`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export const getDataTableRowsApi = async (
|
|||
take?: number;
|
||||
sortBy?: string;
|
||||
filter?: string;
|
||||
search?: string;
|
||||
},
|
||||
) => {
|
||||
return await makeRestApiRequest<{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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('');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user