feat(editor): Update workflows list endpoint to support filter by node type (no-changelog) (#20158)

This commit is contained in:
Svetoslav Dekov 2025-09-30 10:26:38 +03:00 committed by GitHub
parent ac0e7e375f
commit 27e0320e41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 204 additions and 6 deletions

View File

@ -435,6 +435,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
this.applyTagsFilter(qb, filter);
this.applyProjectFilter(qb, filter);
this.applyParentFolderFilter(qb, filter);
this.applyNodeTypesFilter(qb, filter);
this.applyAvailableInMCPFilter(qb, filter);
}
@ -543,6 +544,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
}
private applyNodeTypesFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
): void {
const nodeTypes = isStringArray(filter?.nodeTypes) ? filter.nodeTypes : [];
if (!nodeTypes.length) return;
const { whereClause, parameters } = buildWorkflowsByNodesQuery(
nodeTypes,
this.globalConfig.database.type,
);
qb.andWhere(whereClause, parameters);
}
private applyOwnedByRelation(qb: SelectQueryBuilder<WorkflowEntity>): void {
// Check if 'shared' join already exists from project filter
if (!qb.expressionMap.aliases.find((alias) => alias.name === 'shared')) {

View File

@ -40,6 +40,12 @@ export class WorkflowFilter extends BaseFilter {
@Expose()
availableInMCP?: boolean;
@IsArray()
@IsString({ each: true })
@IsOptional()
@Expose()
nodeTypes?: string[];
static async fromString(rawFilter: string) {
return await this.toFilter(rawFilter, WorkflowFilter);
}

View File

@ -12,6 +12,7 @@ export class WorkflowSelect extends BaseSelect {
'versionId',
'ownedBy', // non-entity field
'parentFolder',
'nodes',
]);
}

View File

@ -18,16 +18,11 @@ import {
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { createFolder } from '@test-integration/db/folders';
import { DateTime } from 'luxon';
import { PROJECT_ROOT, type INode, type IPinData, type IWorkflowBase } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { License } from '@/license';
import { ProjectService } from '@/services/project.service.ee';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
import { createFolder } from '@test-integration/db/folders';
import { saveCredential } from '../shared/db/credentials';
import { assignTagToWorkflow, createTag } from '../shared/db/tags';
import { createManyUsers, createMember, createOwner } from '../shared/db/users';
@ -35,6 +30,11 @@ import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
import { makeWorkflow, MOCK_PINDATA } from '../shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { License } from '@/license';
import { ProjectService } from '@/services/project.service.ee';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
let owner: User;
let member: User;
let anotherMember: User;
@ -1014,6 +1014,126 @@ describe('GET /workflows', () => {
expect(response2.body.data).toHaveLength(1);
expect(response2.body.data[0].id).toBe(workflow2.id);
});
test('should filter workflows by nodeTypes', async () => {
const httpWorkflow = await createWorkflow(
{
name: 'HTTP Workflow',
nodes: [
{
id: uuid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
},
owner,
);
const slackWorkflow = await createWorkflow(
{
name: 'Slack Workflow',
nodes: [
{
id: uuid(),
name: 'Slack',
type: 'n8n-nodes-base.slack',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
},
owner,
);
const mixedWorkflow = await createWorkflow(
{
name: 'Mixed Workflow',
nodes: [
{
id: uuid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
{
id: uuid(),
name: 'Slack',
type: 'n8n-nodes-base.slack',
parameters: {},
typeVersion: 1,
position: [100, 0],
},
],
},
owner,
);
// Filter by single node type
const httpResponse = await authOwnerAgent
.get('/workflows')
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&select=["nodes"]')
.expect(200);
expect(httpResponse.body.data).toHaveLength(2);
const httpWorkflowIds = httpResponse.body.data.map((w: any) => w.id);
expect(httpWorkflowIds).toContain(httpWorkflow.id);
expect(httpWorkflowIds).toContain(mixedWorkflow.id);
expect(httpResponse.body.data[0].nodes).toHaveLength(1);
expect(httpResponse.body.data[1].nodes).toHaveLength(2);
// Filter by multiple node types (OR operation - returns workflows containing ANY of the specified node types)
const multipleResponse = await authOwnerAgent
.get('/workflows')
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"] }')
.expect(200);
expect(multipleResponse.body.data).toHaveLength(3);
const multipleWorkflowIds = multipleResponse.body.data.map((w: any) => w.id);
expect(multipleWorkflowIds).toContain(httpWorkflow.id);
expect(multipleWorkflowIds).toContain(slackWorkflow.id);
expect(multipleWorkflowIds).toContain(mixedWorkflow.id);
// Filter by non-existent node type
const emptyResponse = await authOwnerAgent
.get('/workflows')
.query('filter={ "nodeTypes": ["n8n-nodes-base.nonExistent"] }')
.expect(200);
expect(emptyResponse.body.data).toHaveLength(0);
});
test('should all workflows when filtering by empty nodeTypes array', async () => {
await createWorkflow(
{
name: 'Test Workflow',
nodes: [
{
id: uuid(),
name: 'Start',
type: 'n8n-nodes-base.start',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
},
owner,
);
const response = await authOwnerAgent
.get('/workflows')
.query('filter={ "nodeTypes": [] }')
.expect(200);
expect(response.body.data).toHaveLength(1); // Should return all workflows when nodeTypes is empty
});
});
describe('select', () => {
@ -1877,6 +1997,60 @@ describe('GET /workflows?includeFolders=true', () => {
expect(response.body.data[1].id).toBe(workflow.id);
expect(response.body.data[1].homeProject).not.toBeNull();
});
test('should filter workflows and folders by nodeTypes', async () => {
const pp = await getPersonalProject(owner);
const httpWorkflow = await createWorkflow(
{
name: 'HTTP Workflow',
nodes: [
{
id: uuid(),
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
},
owner,
);
await createWorkflow(
{
name: 'Slack Workflow',
nodes: [
{
id: uuid(),
name: 'Slack',
type: 'n8n-nodes-base.slack',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
},
owner,
);
const folder = await createFolder(pp, { name: 'Test Folder' });
const response = await authOwnerAgent
.get('/workflows')
.query('filter={ "nodeTypes": ["n8n-nodes-base.httpRequest"] }&includeFolders=true')
.expect(200);
expect(response.body.data).toHaveLength(2); // 1 folder + 1 matching workflow
const workflowItems = response.body.data.filter((item: any) => item.resource === 'workflow');
const folderItems = response.body.data.filter((item: any) => item.resource === 'folder');
expect(workflowItems).toHaveLength(1);
expect(workflowItems[0].id).toBe(httpWorkflow.id);
expect(folderItems).toHaveLength(1);
expect(folderItems[0].id).toBe(folder.id);
});
});
describe('sortBy', () => {