feat(API): Return the parent folder on workflow read endpoints (#40981)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Wooler 2026-10-16 12:48:25 +00:00 committed by GitHub
parent 683fd11259
commit a906d2fdfc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 932 additions and 4 deletions

View File

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

View File

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

View File

@ -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<string, unknown>) =>
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;

View File

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

View File

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

View File

@ -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<string, unknown>): ZodOpenAPIMetadata {
return schema;

View File

@ -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<WorkflowPublicDto> {
): Promise<RetrievedWorkflowPublicDto> {
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')

View File

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

View File

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

View File

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

View File

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

View File

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