From a906d2fdfcc5481799a571b4b666eea864fa07a1 Mon Sep 17 00:00:00 2001 From: Sam Wooler Date: Fri, 16 Oct 2026 12:48:25 +0000 Subject: [PATCH] feat(API): Return the parent folder on workflow read endpoints (#40981) Co-authored-by: Claude Haiku 5.5 --- packages/@n8n/api-types/src/dto/index.ts | 3 + .../list-workflows-query.dto.test.ts | 10 + .../__tests__/workflow-public.dto.test.ts | 63 ++ .../dto/workflows/list-workflows-query.dto.ts | 7 + .../src/dto/workflows/workflow-public.dto.ts | 16 + .../dto/workflows/workflow-public.openapi.ts | 14 + .../workflows.public.controller.ts | 19 +- .../spec/paths/getWorkflow.generated.yml | 648 +++++++++++++++++- .../spec/paths/getWorkflows.generated.yml | 19 + .../src/workflows/workflow-finder.service.ts | 3 + .../integration/public-api/workflows.test.ts | 78 +++ .../workflows/workflow-finder.service.test.ts | 56 ++ 12 files changed, 932 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 69e117051b9..f11deb9ce9f 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -254,15 +254,18 @@ export { export { WorkflowPublicDto, CreatedWorkflowPublicDto, + RetrievedWorkflowPublicDto, WorkflowListPublicDto, WorkflowPublishPublicDto, workflowPublicSchema, createdWorkflowPublicSchema, + retrievedWorkflowPublicSchema, workflowPublishPublicSchema, workflowListItemPublicSchema, sharedWorkflowPublicSchema, activeWorkflowVersionPublicSchema, workflowParentFolderPublicSchema, + workflowParentFolderRefPublicSchema, DeletedWorkflowPublicDto, UpdatedWorkflowPublicDto, } from './workflows/workflow-public.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts index f0962f432b5..d3c9a9fa3bc 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/list-workflows-query.dto.test.ts @@ -9,6 +9,16 @@ describe('ListWorkflowsQueryDto', () => { expect(result.data.offset).toBe(0); expect(result.data.limit).toBe(100); expect(result.data.excludePinnedData).toBe(false); + expect(result.data.includeParentFolder).toBe(false); + } + }); + + test('parses includeParentFolder from a query string', () => { + const result = ListWorkflowsQueryDto.safeParse({ includeParentFolder: 'true' }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.includeParentFolder).toBe(true); } }); }); diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts index f94bebfa61d..79e067b6c95 100644 --- a/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/workflow-public.dto.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert'; import { CreatedWorkflowPublicDto, + RetrievedWorkflowPublicDto, + WorkflowListPublicDto, WorkflowPublicDto, WorkflowPublishPublicDto, } from '../workflow-public.dto'; @@ -165,6 +167,67 @@ describe('CreatedWorkflowPublicDto', () => { }); }); +describe('RetrievedWorkflowPublicDto', () => { + describe('parentFolder', () => { + test('accepts a null parent folder', () => { + const result = RetrievedWorkflowPublicDto.safeParse({ ...baseWorkflow, parentFolder: null }); + expect(result.success).toBe(true); + }); + + test('accepts a populated parent folder', () => { + const parentFolder = { + id: 'folder-1', + name: 'Target Folder', + parentFolderId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + const result = RetrievedWorkflowPublicDto.safeParse({ ...baseWorkflow, parentFolder }); + expect(result.success).toBe(true); + }); + }); +}); + +describe('WorkflowListPublicDto', () => { + const baseListItem = { + id: '1', + name: 'My workflow', + active: false, + activeVersionId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + isArchived: false, + versionId: 'version-1', + triggerCount: 0, + nodes: [], + connections: {}, + nodeGroups: [], + settings: null, + staticData: null, + meta: null, + shared: [ + { + role: 'workflow:owner', + workflowId: '1', + projectId: 'project-1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], + activeVersion: null, + }; + + test('accepts parentFolder as absent, null, or an id-only reference', () => { + const listWith = (item: Record) => + WorkflowListPublicDto.safeParse({ data: [item], nextCursor: null }); + + expect(listWith(baseListItem).success).toBe(true); + expect(listWith({ ...baseListItem, parentFolder: null }).success).toBe(true); + expect(listWith({ ...baseListItem, parentFolder: { id: 'folder-1' } }).success).toBe(true); + }); +}); + describe('WorkflowPublishPublicDto', () => { const { shared: _shared, ...workflowWithoutShared } = baseWorkflow; diff --git a/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts b/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts index 48178f165d4..027eab47dba 100644 --- a/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/list-workflows-query.dto.ts @@ -1,5 +1,8 @@ +import '../../openapi-extend'; + import { z } from 'zod'; +import { includeParentFolderOpenApi } from './workflow-public.openapi'; import { booleanFromString } from '../../schemas/boolean-from-string'; import { Z } from '../../zod-class'; import { publicApiPaginationSchema } from '../pagination/pagination.dto'; @@ -12,4 +15,8 @@ export class ListWorkflowsQueryDto extends Z.class({ name: z.string().optional(), projectId: z.string().optional(), excludePinnedData: booleanFromString.optional().default('false'), + includeParentFolder: booleanFromString + .optional() + .default('false') + .openapi(includeParentFolderOpenApi), }) {} diff --git a/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts b/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts index f088fffb025..261c0ecf4bb 100644 --- a/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/workflows/workflow-public.dto.ts @@ -6,9 +6,11 @@ import { z } from 'zod'; import { connectionsOpenApi, workflowCreateFieldDocs, + listParentFolderOpenApi, metaOpenApi, nodeGroupsOpenApi, nodesOpenApi, + parentFolderOpenApi, pinDataOpenApi, settingsOpenApi, staticDataOpenApi, @@ -118,6 +120,10 @@ export const workflowParentFolderPublicSchema = z.object({ updatedAt: z.string().datetime(), }); +export const workflowParentFolderRefPublicSchema = workflowParentFolderPublicSchema.pick({ + id: true, +}); + export const createdWorkflowPublicSchema = workflowPublicSchema.extend({ parentFolder: workflowParentFolderPublicSchema .nullable() @@ -126,6 +132,12 @@ export const createdWorkflowPublicSchema = workflowPublicSchema.extend({ export class CreatedWorkflowPublicDto extends Z.class(createdWorkflowPublicSchema.shape) {} +export const retrievedWorkflowPublicSchema = workflowPublicSchema.extend({ + parentFolder: workflowParentFolderPublicSchema.nullable().openapi(parentFolderOpenApi), +}); + +export class RetrievedWorkflowPublicDto extends Z.class(retrievedWorkflowPublicSchema.shape) {} + export const workflowPublishPublicSchema = workflowPublicSchema.omit({ shared: true }); export class WorkflowPublishPublicDto extends Z.class(workflowPublishPublicSchema.shape) {} @@ -168,6 +180,10 @@ export const workflowListItemPublicSchema = workflowPublicSchema .extend({ shared: z.array(workflowListItemSharedPublicSchema), activeVersion: workflowListItemActiveVersionPublicSchema.nullable(), + parentFolder: workflowParentFolderRefPublicSchema + .nullable() + .optional() + .openapi(listParentFolderOpenApi), }); export class WorkflowListPublicDto extends Z.class({ diff --git a/packages/@n8n/api-types/src/dto/workflows/workflow-public.openapi.ts b/packages/@n8n/api-types/src/dto/workflows/workflow-public.openapi.ts index d315ab22777..b0e4e13f3d3 100644 --- a/packages/@n8n/api-types/src/dto/workflows/workflow-public.openapi.ts +++ b/packages/@n8n/api-types/src/dto/workflows/workflow-public.openapi.ts @@ -299,6 +299,20 @@ export const metaOpenApi: ZodOpenAPIMetadata = alsoNullable({ }, }); +export const parentFolderOpenApi: ZodOpenAPIMetadata = { + description: 'Folder that contains the workflow, or null at the project root', +}; + +export const listParentFolderOpenApi: ZodOpenAPIMetadata = { + description: + 'Folder that contains the workflow, or null at the project root. The field is present only when `includeParentFolder` is `true`.', +}; + +export const includeParentFolderOpenApi: ZodOpenAPIMetadata = { + description: + 'Set to `true` to add `parentFolder` to each workflow. `parentFolder` holds the folder `id`, or is `null` at the project root.', +}; + /** `ZodOpenAPIMetadata` is a union, which will not assign into a nested `properties` slot. */ function nestingDescriptor(schema: Record): ZodOpenAPIMetadata { return schema; diff --git a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts index b1edbad9caa..6c633b277bb 100644 --- a/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/workflows.public.controller.ts @@ -8,6 +8,7 @@ import { ListWorkflowHistoryQueryDto, ListWorkflowsQueryDto, PublishWorkflowPublicDto, + RetrievedWorkflowPublicDto, TagIdsPublicDto, TransferWorkflowPublicDto, UpdatedWorkflowPublicDto, @@ -122,6 +123,10 @@ function toPublicFolder(folder: Folder) { }; } +function toPublicParentFolderRef(folder: Folder | null) { + return folder ? { id: folder.id } : null; +} + function toPublicSharedWorkflow(sharedWorkflow: SharedWorkflow) { return { role: sharedWorkflow.role, @@ -266,6 +271,7 @@ export class WorkflowsPublicController { includePinnedData: !query.excludePinnedData, includeTags: this.workflowTagsEnabled, includeActiveVersion: true, + includeParentFolder: query.includeParentFolder, }, ); @@ -295,6 +301,9 @@ export class WorkflowsPublicController { ...(query.excludePinnedData ? {} : { pinData: toPublicJson(workflow.pinData) }), ...(workflow.tags ? { tags: workflow.tags.map(toPublicTag) } : {}), shared: workflow.shared.map(toPublicListSharedWorkflow), + ...(query.includeParentFolder + ? { parentFolder: toPublicParentFolderRef(workflow.parentFolder) } + : {}), activeVersion: workflow.activeVersion ? toPublicActiveVersionWithoutHistory(workflow.activeVersion) : null, @@ -343,14 +352,14 @@ export class WorkflowsPublicController { @ApiSummary('Retrieve a workflow') @ApiDescription('Retrieve a workflow.') @ApiTags(['Workflow']) - @ApiResponse(200, WorkflowPublicDto) + @ApiResponse(200, RetrievedWorkflowPublicDto) @ApiErrorResponse(404) async getWorkflow( req: AuthenticatedRequest, _res: Response, @Param('workflowId', workflowIdParamSchema) workflowId: string, @Query query: GetWorkflowQueryDto, - ): Promise { + ): Promise { const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, req.user, @@ -358,6 +367,7 @@ export class WorkflowsPublicController { { includeTags: this.workflowTagsEnabled, includeActiveVersion: true, + includeParentFolder: true, }, ); @@ -370,7 +380,10 @@ export class WorkflowsPublicController { publicApi: true, }); - return this.toWorkflowPublicDto(workflow, { excludePinnedData: query.excludePinnedData }); + return { + ...this.toWorkflowPublicDto(workflow, { excludePinnedData: query.excludePinnedData }), + parentFolder: workflow.parentFolder ? toPublicFolder(workflow.parentFolder) : null, + }; } @Put('/:workflowId') diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml index 75641b8d14e..ba964612e68 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflow.generated.yml @@ -26,7 +26,653 @@ responses: content: application/json: schema: - $ref: ../../../../shared/spec/schemas/workflowPublicDto.generated.yml + type: object + properties: + id: + type: string + name: + type: string + description: + type: string + nullable: true + active: + type: boolean + description: Deprecated. Check whether `activeVersionId` is not null instead. + deprecated: true + activeVersionId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + isArchived: + type: boolean + versionId: + type: string + versionCounter: + type: number + sourceWorkflowId: + type: string + nullable: true + triggerCount: + type: number + nodes: + type: array + description: Nodes that make up the workflow + items: + type: object + properties: + id: + type: string + example: 0f5532f9-36ba-4bef-86c7-30d607400b15 + name: + type: string + example: Jira + webhookId: + type: string + disabled: + type: boolean + notesInFlow: + type: boolean + notes: + type: string + type: + type: string + example: n8n-nodes-base.jira + typeVersion: + type: number + example: 1 + executeOnce: + type: boolean + example: false + alwaysOutputData: + type: boolean + example: false + retryOnFail: + type: boolean + example: false + maxTries: + type: number + waitBetweenTries: + type: number + continueOnFail: + type: boolean + example: false + description: use onError instead + deprecated: true + onError: + type: string + example: stopWorkflow + position: + type: array + items: + type: number + example: + - -100 + - 80 + parameters: + type: object + additionalProperties: true + credentials: + type: object + example: + jiraSoftwareCloudApi: + name: jiraApi + customTelemetryTags: + type: object + properties: + tag: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + connections: + type: object + description: Connections between nodes, keyed by source node name + example: + Jira: + main: + - - node: Jira + type: main + index: 0 + nodeGroups: + type: array + description: Visual groupings of nodes shown as frames on the canvas + items: + type: object + properties: + id: + type: string + description: Unique identifier for the node group + example: 9b1c8e2a-4d3f-4a6b-8c7d-1e2f3a4b5c6d + name: + type: string + description: Display name of the node group + example: Data processing + description: + type: string + maxLength: 155 + description: Optional plain-text description of the node group + example: Cleans and normalizes incoming records + nodeIds: + type: array + description: IDs of the nodes that belong to this group + items: + type: string + required: + - id + - name + - nodeIds + settings: + type: object + description: Execution and behaviour settings for the workflow + properties: + saveExecutionProgress: + oneOf: + - type: boolean + - type: string + enum: + - DEFAULT + saveManualExecutions: + oneOf: + - type: boolean + - type: string + enum: + - DEFAULT + saveDataErrorExecution: + type: string + enum: + - DEFAULT + - all + - none + saveDataSuccessExecution: + type: string + enum: + - DEFAULT + - all + - none + executionTimeout: + type: number + example: 3600 + errorWorkflow: + type: string + example: VzqKEW0ShTXA5vPj + description: The ID of the workflow that contains the error trigger node. + timezone: + type: string + example: America/New_York + executionOrder: + type: string + example: v1 + binaryMode: + type: string + enum: + - separate + - combined + description: Controls how binary data is resolved from a node's input. This is a derived, internal setting rather than something intended to be set programmatically. It is included in workflow responses for reference, but any value sent when creating or updating a workflow is ignored. + callerPolicy: + type: string + enum: + - any + - none + - workflowsFromAList + - workflowsFromSameOwner + example: workflowsFromSameOwner + description: |- + Controls which workflows are allowed to call this workflow using the Execute Workflow node. Defaults to workflowsFromSameOwner. + + Available options: + - `any`: Any workflow can call this workflow (no restrictions) + - `none`: No other workflows can call this workflow (completely blocked) + - `workflowsFromSameOwner` (default): Only workflows owned by the same project can call this workflow + - `workflowsFromAList`: Only the workflows listed in `callerIds` can call this workflow + callerIds: + type: string + example: 14, 18, 23 + description: Comma-separated list of workflow IDs allowed to call this workflow (only used with the workflowsFromAList policy) + timeSavedMode: + type: string + enum: + - fixed + - dynamic + description: |- + Controls how the time saved per execution is calculated. + + Available options: + - `fixed`: Uses the value in `timeSavedPerExecution`. Use when the time saved is the same for every execution. + - `dynamic`: Calculates the time saved from actual execution metrics. `timeSavedPerExecution` is ignored. + timeSavedPerExecution: + type: number + description: Estimated time saved per execution in minutes + redactionPolicy: + type: string + enum: + - none + - non-manual + - manual-only + - all + example: non-manual + description: |- + Controls whether execution data is redacted for this workflow. + + Available options: + - `none` (default): No redaction. All execution data is stored. + - `non-manual`: Redact production (non-manually triggered) executions only. + - `manual-only`: Redact manually triggered executions only. + - `all`: Redact all executions, manual and production. + availableInMCP: + type: boolean + example: false + description: Controls whether this workflow is reachable over the Model Context Protocol (MCP). Defaults to false. The workflow must be active and must hold at least one active Webhook node. + customTelemetryTags: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + credentialResolverId: + type: string + description: ID of the credential resolver that resolves credentials for this workflow. This is a derived, internal setting managed through the credential resolver configuration rather than something intended to be set programmatically. It is included in workflow responses for reference, but any value sent when creating or updating a workflow is ignored. + nullable: true + staticData: + type: object + description: Data the workflow keeps between executions + example: + lastId: 1 + nullable: true + pinData: + type: object + description: Pinned sample data for nodes, keyed by node name + nullable: true + meta: + type: object + description: Workflow metadata such as template information + properties: + onboardingId: + type: string + templateId: + type: string + instanceId: + type: string + templateCredsSetupCompleted: + type: boolean + nullable: true + tags: + type: array + items: + type: object + properties: + id: + type: string + readOnly: true + example: 2tUt1wbLX592XDdX + name: + type: string + example: Production + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - name + - createdAt + - updatedAt + shared: + type: array + items: + type: object + properties: + role: + type: string + workflowId: + type: string + projectId: + type: string + project: + type: object + properties: + id: + type: string + readOnly: true + example: VmwOO9HeTEj20kxM + name: + type: string + example: Marketing + type: + type: string + enum: + - personal + - team + readOnly: true + example: team + icon: + type: object + description: Icon of the project, or null when the project has none + properties: + type: + type: string + enum: + - emoji + - icon + value: + type: string + color: + type: string + required: + - type + - value + nullable: true + description: + type: string + nullable: true + example: Workflows the marketing team owns. + customTelemetryTags: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + creatorId: + type: string + nullable: true + example: f9a2cbb8-0b1e-4b64-9c1c-0d5f5f1f2a3b + createdAt: + type: string + format: date-time + readOnly: true + updatedAt: + type: string + format: date-time + readOnly: true + required: + - id + - name + - type + - icon + - description + - customTelemetryTags + - creatorId + - createdAt + - updatedAt + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - role + - workflowId + - projectId + - project + - createdAt + - updatedAt + activeVersion: + type: object + nullable: true + properties: + versionId: + type: string + workflowId: + type: string + nodes: + type: array + description: Nodes that make up the workflow + items: + type: object + properties: + id: + type: string + example: 0f5532f9-36ba-4bef-86c7-30d607400b15 + name: + type: string + example: Jira + webhookId: + type: string + disabled: + type: boolean + notesInFlow: + type: boolean + notes: + type: string + type: + type: string + example: n8n-nodes-base.jira + typeVersion: + type: number + example: 1 + executeOnce: + type: boolean + example: false + alwaysOutputData: + type: boolean + example: false + retryOnFail: + type: boolean + example: false + maxTries: + type: number + waitBetweenTries: + type: number + continueOnFail: + type: boolean + example: false + description: use onError instead + deprecated: true + onError: + type: string + example: stopWorkflow + position: + type: array + items: + type: number + example: + - -100 + - 80 + parameters: + type: object + additionalProperties: true + credentials: + type: object + example: + jiraSoftwareCloudApi: + name: jiraApi + customTelemetryTags: + type: object + properties: + tag: + type: array + items: + type: object + properties: + key: + type: string + value: + type: string + required: + - key + - value + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + connections: + type: object + description: Connections between nodes, keyed by source node name + example: + Jira: + main: + - - node: Jira + type: main + index: 0 + nodeGroups: + type: array + description: Visual groupings of nodes shown as frames on the canvas + items: + type: object + properties: + id: + type: string + description: Unique identifier for the node group + example: 9b1c8e2a-4d3f-4a6b-8c7d-1e2f3a4b5c6d + name: + type: string + description: Display name of the node group + example: Data processing + description: + type: string + maxLength: 155 + description: Optional plain-text description of the node group + example: Cleans and normalizes incoming records + nodeIds: + type: array + description: IDs of the nodes that belong to this group + items: + type: string + required: + - id + - name + - nodeIds + authors: + type: string + name: + type: string + nullable: true + description: + type: string + nullable: true + autosaved: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + workflowPublishHistory: + type: array + items: + type: object + properties: + id: + type: number + workflowId: + type: string + versionId: + type: string + nullable: true + event: + type: string + enum: + - activated + - deactivated + userId: + type: string + nullable: true + createdAt: + type: string + format: date-time + required: + - id + - workflowId + - versionId + - event + - userId + - createdAt + required: + - versionId + - workflowId + - nodes + - connections + - nodeGroups + - authors + - name + - description + - autosaved + - createdAt + - updatedAt + - workflowPublishHistory + parentFolder: + type: object + nullable: true + properties: + id: + type: string + name: + type: string + parentFolderId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - name + - parentFolderId + - createdAt + - updatedAt + description: Folder that contains the workflow, or null at the project root + required: + - id + - name + - description + - active + - activeVersionId + - createdAt + - updatedAt + - isArchived + - versionId + - versionCounter + - sourceWorkflowId + - triggerCount + - nodes + - connections + - nodeGroups + - settings + - staticData + - meta + - shared + - activeVersion + - parentFolder '400': $ref: ../../../../shared/spec/responses/badRequest.yml '401': diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml index 04af352abb9..7facde972c3 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/getWorkflows.generated.yml @@ -40,6 +40,16 @@ parameters: required: false name: excludePinnedData in: query + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + description: Set to `true` to add `parentFolder` to each workflow. `parentFolder` holds the folder `id`, or is `null` at the project root. + required: false + name: includeParentFolder + in: query responses: '200': description: Operation successful. @@ -540,6 +550,15 @@ responses: - autosaved - createdAt - updatedAt + parentFolder: + type: object + nullable: true + properties: + id: + type: string + required: + - id + description: Folder that contains the workflow, or null at the project root. The field is present only when `includeParentFolder` is `true`. required: - id - name diff --git a/packages/cli/src/workflows/workflow-finder.service.ts b/packages/cli/src/workflows/workflow-finder.service.ts index df7b27f0be7..714f4c0dd00 100644 --- a/packages/cli/src/workflows/workflow-finder.service.ts +++ b/packages/cli/src/workflows/workflow-finder.service.ts @@ -35,6 +35,7 @@ export type FindWorkflowsForUserOptions = { includeTags?: boolean; includeProjects?: boolean; includeActiveVersion?: boolean; + includeParentFolder?: boolean; }; @Service() @@ -456,6 +457,7 @@ export class WorkflowFinderService { includeTags = false, includeProjects = false, includeActiveVersion = false, + includeParentFolder = false, } = options; const { name, active, tagNames, folderId, projectId } = filters; @@ -491,6 +493,7 @@ export class WorkflowFinderService { ...(includeActiveVersion && { activeVersion: true }), ...(includePinnedData && { pinData: true }), ...(includeTags && { tags: true }), + ...(includeParentFolder && { parentFolder: true }), }; const { workflows, count } = await this.workflowRepository.getManyAndCountWithSharingSubquery( diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 716c17ac905..bc542aa52ef 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -832,6 +832,34 @@ describe('GET /workflows', () => { ]); }); + test('should not return parentFolder unless includeParentFolder is set', async () => { + const folder = await createFolder(memberPersonalProject, { name: 'List Folder' }); + await createWorkflowWithHistory({}, member); + await createWorkflowWithHistory({ parentFolder: folder }, member); + + const response = await authMemberAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); + for (const item of response.body.data) { + expect(item).not.toHaveProperty('parentFolder'); + } + }); + + test('should return only the parent folder id when includeParentFolder is true', async () => { + const folder = await createFolder(memberPersonalProject, { name: 'List Folder' }); + const rootWorkflow = await createWorkflowWithHistory({}, member); + const folderWorkflow = await createWorkflowWithHistory({ parentFolder: folder }, member); + + const response = await authMemberAgent.get('/workflows?includeParentFolder=true'); + + expect(response.statusCode).toBe(200); + const rootItem = response.body.data.find((w: { id: string }) => w.id === rootWorkflow.id); + const folderItem = response.body.data.find((w: { id: string }) => w.id === folderWorkflow.id); + expect(rootItem.parentFolder).toBeNull(); + expect(folderItem.parentFolder).toEqual({ id: folder.id }); + }); + test('should return activeVersion when filtering by active=true', async () => { await createWorkflowWithHistory({}, member); const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); @@ -1072,6 +1100,56 @@ describe('GET /workflows/:id', () => { ).map(({ event }) => event); expect(events).toEqual(['activated', 'deactivated', 'activated']); }); + + test('should return a null parentFolder for a workflow at the project root', async () => { + const workflow = await createWorkflowWithHistory({}, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.parentFolder).toBeNull(); + }); + + test('should return the parent folder of a workflow in a folder', async () => { + const folder = await createFolder(memberPersonalProject, { name: 'Read Folder' }); + const workflow = await createWorkflowWithHistory({ parentFolder: folder }, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(Object.keys(response.body.parentFolder).sort()).toEqual([ + 'createdAt', + 'id', + 'name', + 'parentFolderId', + 'updatedAt', + ]); + expect(response.body.parentFolder).toEqual({ + id: folder.id, + name: 'Read Folder', + parentFolderId: null, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + }); + }); + + test('should return the immediate parent folder for a workflow in a subfolder', async () => { + const outerFolder = await createFolder(memberPersonalProject, { name: 'Outer Folder' }); + const innerFolder = await createFolder(memberPersonalProject, { + name: 'Inner Folder', + parentFolder: outerFolder, + }); + const workflow = await createWorkflowWithHistory({ parentFolder: innerFolder }, member); + + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.parentFolder).toMatchObject({ + id: innerFolder.id, + name: 'Inner Folder', + parentFolderId: outerFolder.id, + }); + }); }); describe('GET /workflows/:id/:versionId', () => { diff --git a/packages/cli/test/integration/workflows/workflow-finder.service.test.ts b/packages/cli/test/integration/workflows/workflow-finder.service.test.ts index 36152aae6b7..976390d5db7 100644 --- a/packages/cli/test/integration/workflows/workflow-finder.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-finder.service.test.ts @@ -137,4 +137,60 @@ describe('WorkflowFinderService', () => { expect(head).toBeNull(); }); }); + + describe('findWorkflowsForUser', () => { + const createListingFixtures = async (projectName: string) => { + const project = await createTeamProject(projectName, owner); + const folder = await createFolder(project, { name: 'Listed' }); + + const [workflowInFolder, workflowInRoot] = await Promise.all([ + createWorkflow({ parentFolder: folder }, project), + createWorkflow({}, project), + ]); + + return { folder, workflowInFolder, workflowInRoot }; + }; + + it('loads each parent folder when asked for it', async () => { + const { folder, workflowInFolder, workflowInRoot } = + await createListingFixtures('Listing with folders'); + + const { workflows } = await workflowFinderService.findWorkflowsForUser( + owner, + ['workflow:read'], + { includeParentFolder: true }, + ); + + expect(workflows).toHaveLength(2); + + const folderedWorkflow = workflows.find((w) => w.id === workflowInFolder.id); + assert(folderedWorkflow, 'Expected to find workflow in folder'); + expect(folderedWorkflow.parentFolder).toEqual( + expect.objectContaining({ id: folder.id, name: 'Listed' }), + ); + + const rootWorkflow = workflows.find((w) => w.id === workflowInRoot.id); + assert(rootWorkflow, 'Expected to find workflow in root'); + expect(rootWorkflow.parentFolder).toBeNull(); + }); + + it('does not load parent folders unless asked for them', async () => { + const { workflowInFolder, workflowInRoot } = + await createListingFixtures('Listing without folders'); + + const { workflows } = await workflowFinderService.findWorkflowsForUser(owner, [ + 'workflow:read', + ]); + + expect(workflows).toHaveLength(2); + + const folderedWorkflow = workflows.find((w) => w.id === workflowInFolder.id); + assert(folderedWorkflow, 'Expected to find workflow in folder'); + expect(folderedWorkflow.parentFolder).toBeUndefined(); + + const rootWorkflow = workflows.find((w) => w.id === workflowInRoot.id); + assert(rootWorkflow, 'Expected to find workflow in root'); + expect(rootWorkflow.parentFolder).toBeUndefined(); + }); + }); });