From f0b453aa0121a67fa7792a583fc980bce55b3f25 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Thu, 28 May 2026 11:34:55 +0530 Subject: [PATCH 1/2] feat: add Workflows and ProjectTemplates API resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds project-level Workflows (with WorkflowStates and WorkflowTransitions sub-resources) and ProjectTemplates (WorkItem and Page templates) to the Node SDK, matching INFRA-395 parity with the Python SDK. - src/api/Workflows/ — list, create, update; states attach/detach; transitions list, create, update, del - src/api/ProjectTemplates/ — work item templates and page templates CRUD - src/models/Workflow.ts, ProjectTemplate.ts — TypeScript interfaces and DTOs - Registered on PlaneClient and exported from index - Unit tests for both resources - README API resources section updated Co-authored-by: Plane AI --- README.md | 4 + src/api/ProjectTemplates/Pages.ts | 52 ++++++++ src/api/ProjectTemplates/WorkItems.ts | 52 ++++++++ src/api/ProjectTemplates/index.ts | 19 +++ src/api/Workflows/States.ts | 34 ++++++ src/api/Workflows/Transitions.ts | 75 ++++++++++++ src/api/Workflows/index.ts | 44 +++++++ src/client/plane-client.ts | 6 + src/index.ts | 6 + src/models/ProjectTemplate.ts | 46 +++++++ src/models/Workflow.ts | 56 +++++++++ src/models/index.ts | 2 + tests/unit/project-templates.test.ts | 135 +++++++++++++++++++++ tests/unit/workflows/workflow.test.ts | 167 ++++++++++++++++++++++++++ 14 files changed, 698 insertions(+) create mode 100644 src/api/ProjectTemplates/Pages.ts create mode 100644 src/api/ProjectTemplates/WorkItems.ts create mode 100644 src/api/ProjectTemplates/index.ts create mode 100644 src/api/Workflows/States.ts create mode 100644 src/api/Workflows/Transitions.ts create mode 100644 src/api/Workflows/index.ts create mode 100644 src/models/ProjectTemplate.ts create mode 100644 src/models/Workflow.ts create mode 100644 tests/unit/project-templates.test.ts create mode 100644 tests/unit/workflows/workflow.test.ts diff --git a/README.md b/README.md index 3fe460d..7d370c6 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,11 @@ const project = await client.projects.create("workspace-slug", { - **Intake**: Intake form and request management - **Stickies**: Stickies management - **Teamspaces**: Teamspace management +- **Milestones**: Milestone tracking and management - **Initiatives**: Initiative management +- **AgentRuns**: AI agent run orchestration and activity tracking +- **Workflows**: Project workflow management with state attachments and transitions +- **ProjectTemplates**: Work item and page template management per project - **Features**: Workspace and project features management ## Development diff --git a/src/api/ProjectTemplates/Pages.ts b/src/api/ProjectTemplates/Pages.ts new file mode 100644 index 0000000..f4e7d15 --- /dev/null +++ b/src/api/ProjectTemplates/Pages.ts @@ -0,0 +1,52 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { CreatePageTemplate, PageTemplate, UpdatePageTemplate } from "../../models/ProjectTemplate"; + +/** + * ProjectPageTemplates sub-resource + * Manages page templates within a project + */ +export class Pages extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List all page templates for a project + */ + async list(workspaceSlug: string, projectId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/pages/templates/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Create a new page template for a project + */ + async create(workspaceSlug: string, projectId: string, data: CreatePageTemplate): Promise { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/pages/templates/`, data); + } + + /** + * Update a page template by ID + */ + async update( + workspaceSlug: string, + projectId: string, + templateId: string, + data: UpdatePageTemplate + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/projects/${projectId}/pages/templates/${templateId}/`, + data + ); + } + + /** + * Delete a page template by ID + */ + async del(workspaceSlug: string, projectId: string, templateId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/pages/templates/${templateId}/`); + } +} diff --git a/src/api/ProjectTemplates/WorkItems.ts b/src/api/ProjectTemplates/WorkItems.ts new file mode 100644 index 0000000..3558791 --- /dev/null +++ b/src/api/ProjectTemplates/WorkItems.ts @@ -0,0 +1,52 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { CreateWorkItemTemplate, UpdateWorkItemTemplate, WorkItemTemplate } from "../../models/ProjectTemplate"; + +/** + * ProjectWorkItemTemplates sub-resource + * Manages work item templates within a project + */ +export class WorkItems extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List all work item templates for a project + */ + async list(workspaceSlug: string, projectId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workitems/templates/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Create a new work item template for a project + */ + async create(workspaceSlug: string, projectId: string, data: CreateWorkItemTemplate): Promise { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/workitems/templates/`, data); + } + + /** + * Update a work item template by ID + */ + async update( + workspaceSlug: string, + projectId: string, + templateId: string, + data: UpdateWorkItemTemplate + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/projects/${projectId}/workitems/templates/${templateId}/`, + data + ); + } + + /** + * Delete a work item template by ID + */ + async del(workspaceSlug: string, projectId: string, templateId: string): Promise { + return this.httpDelete(`/workspaces/${workspaceSlug}/projects/${projectId}/workitems/templates/${templateId}/`); + } +} diff --git a/src/api/ProjectTemplates/index.ts b/src/api/ProjectTemplates/index.ts new file mode 100644 index 0000000..84a6e8a --- /dev/null +++ b/src/api/ProjectTemplates/index.ts @@ -0,0 +1,19 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { WorkItems } from "./WorkItems"; +import { Pages } from "./Pages"; + +/** + * ProjectTemplates API resource + * Container for work item and page template sub-resources + */ +export class ProjectTemplates extends BaseResource { + public workItems: WorkItems; + public pages: Pages; + + constructor(config: Configuration) { + super(config); + this.workItems = new WorkItems(config); + this.pages = new Pages(config); + } +} diff --git a/src/api/Workflows/States.ts b/src/api/Workflows/States.ts new file mode 100644 index 0000000..5684846 --- /dev/null +++ b/src/api/Workflows/States.ts @@ -0,0 +1,34 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { AttachWorkflowStates } from "../../models/Workflow"; + +/** + * WorkflowStates sub-resource + * Manages state attachments on a workflow + */ +export class States extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * Attach states to a workflow + */ + async attach( + workspaceSlug: string, + projectId: string, + workflowId: string, + data: AttachWorkflowStates + ): Promise { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/`, data); + } + + /** + * Detach a state from a workflow + */ + async detach(workspaceSlug: string, projectId: string, workflowId: string, stateId: string): Promise { + return this.httpDelete( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/states/${stateId}/` + ); + } +} diff --git a/src/api/Workflows/Transitions.ts b/src/api/Workflows/Transitions.ts new file mode 100644 index 0000000..f8c5248 --- /dev/null +++ b/src/api/Workflows/Transitions.ts @@ -0,0 +1,75 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { HttpError } from "../../errors"; +import { CreateWorkflowTransition, UpdateWorkflowTransition, WorkflowTransition } from "../../models/Workflow"; + +/** + * WorkflowTransitions sub-resource + * Manages state transitions within a workflow + */ +export class Transitions extends BaseResource { + constructor(config: Configuration) { + super(config); + } + + /** + * List all state transitions for a workflow + */ + async list(workspaceSlug: string, projectId: string, workflowId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/state-transitions/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Create a state transition for a workflow. + * Returns null if the transition already exists (HTTP 400 "already exists"). + */ + async create( + workspaceSlug: string, + projectId: string, + workflowId: string, + data: CreateWorkflowTransition + ): Promise { + try { + return await this.post( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/state-transitions/`, + data + ); + } catch (error) { + if (error instanceof HttpError && error.statusCode === 400) { + const body = JSON.stringify(error.response ?? "").toLowerCase(); + if (body.includes("already exists")) { + return null; + } + } + throw error; + } + } + + /** + * Update a workflow state transition + */ + async update( + workspaceSlug: string, + projectId: string, + workflowId: string, + transitionId: string, + data: UpdateWorkflowTransition + ): Promise { + return this.patch( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/state-transitions/${transitionId}/`, + data + ); + } + + /** + * Delete a workflow state transition + */ + async del(workspaceSlug: string, projectId: string, workflowId: string, transitionId: string): Promise { + return this.httpDelete( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/state-transitions/${transitionId}/` + ); + } +} diff --git a/src/api/Workflows/index.ts b/src/api/Workflows/index.ts new file mode 100644 index 0000000..2675b4c --- /dev/null +++ b/src/api/Workflows/index.ts @@ -0,0 +1,44 @@ +import { BaseResource } from "../BaseResource"; +import { Configuration } from "../../Configuration"; +import { CreateWorkflow, UpdateWorkflow, Workflow } from "../../models/Workflow"; +import { States } from "./States"; +import { Transitions } from "./Transitions"; + +/** + * Workflows API resource + * Handles project workflow operations and exposes states/transitions sub-resources + */ +export class Workflows extends BaseResource { + public states: States; + public transitions: Transitions; + + constructor(config: Configuration) { + super(config); + this.states = new States(config); + this.transitions = new Transitions(config); + } + + /** + * List all workflows for a project + */ + async list(workspaceSlug: string, projectId: string): Promise { + const data = await this.get( + `/workspaces/${workspaceSlug}/projects/${projectId}/workflows/` + ); + return Array.isArray(data) ? data : data.results; + } + + /** + * Create a new workflow for a project + */ + async create(workspaceSlug: string, projectId: string, data: CreateWorkflow): Promise { + return this.post(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/`, data); + } + + /** + * Update a workflow by ID + */ + async update(workspaceSlug: string, projectId: string, workflowId: string, data: UpdateWorkflow): Promise { + return this.patch(`/workspaces/${workspaceSlug}/projects/${projectId}/workflows/${workflowId}/`, data); + } +} diff --git a/src/client/plane-client.ts b/src/client/plane-client.ts index 8245222..a57bf18 100644 --- a/src/client/plane-client.ts +++ b/src/client/plane-client.ts @@ -20,6 +20,8 @@ import { Teamspaces } from "../api/Teamspaces"; import { Initiatives } from "../api/Initiatives"; import { Milestones } from "../api/Milestones"; import { AgentRuns } from "../api/AgentRuns"; +import { Workflows } from "../api/Workflows"; +import { ProjectTemplates } from "../api/ProjectTemplates"; /** * Main Plane Client class @@ -48,6 +50,8 @@ export class PlaneClient { public milestones: Milestones; public initiatives: Initiatives; public agentRuns: AgentRuns; + public workflows: Workflows; + public projectTemplates: ProjectTemplates; constructor(config: { baseUrl?: string; apiKey?: string; accessToken?: string; enableLogging?: boolean }) { this.config = new Configuration({ @@ -82,5 +86,7 @@ export class PlaneClient { this.milestones = new Milestones(this.config); this.initiatives = new Initiatives(this.config); this.agentRuns = new AgentRuns(this.config); + this.workflows = new Workflows(this.config); + this.projectTemplates = new ProjectTemplates(this.config); } } diff --git a/src/index.ts b/src/index.ts index 4b17201..b5d71c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export { Teamspaces } from "./api/Teamspaces"; export { Milestones } from "./api/Milestones"; export { Initiatives } from "./api/Initiatives"; export { AgentRuns } from "./api/AgentRuns"; +export { Workflows } from "./api/Workflows"; +export { ProjectTemplates } from "./api/ProjectTemplates"; // Sub-resources export { Relations as WorkItemRelations } from "./api/WorkItems/Relations"; @@ -49,6 +51,10 @@ export { Labels as InitiativeLabels } from "./api/Initiatives/Labels"; export { Projects as InitiativeProjects } from "./api/Initiatives/Projects"; export { Epics as InitiativeEpics } from "./api/Initiatives/Epics"; export { Activities as AgentRunActivities } from "./api/AgentRuns/Activities"; +export { States as WorkflowStates } from "./api/Workflows/States"; +export { Transitions as WorkflowTransitions } from "./api/Workflows/Transitions"; +export { WorkItems as ProjectWorkItemTemplates } from "./api/ProjectTemplates/WorkItems"; +export { Pages as ProjectPageTemplates } from "./api/ProjectTemplates/Pages"; // Models export * from "./models"; diff --git a/src/models/ProjectTemplate.ts b/src/models/ProjectTemplate.ts new file mode 100644 index 0000000..0280634 --- /dev/null +++ b/src/models/ProjectTemplate.ts @@ -0,0 +1,46 @@ +import { BaseModel } from "./common"; + +/** + * Project template model interfaces + */ +export interface WorkItemTemplate extends BaseModel { + name: string; + short_description?: string; + template_data?: Record; + template_type?: string; + project: string; + workspace: string; +} + +export interface CreateWorkItemTemplate { + name: string; + short_description?: string; + template_data?: Record; +} + +export interface UpdateWorkItemTemplate { + name?: string; + short_description?: string; + template_data?: Record; +} + +export interface PageTemplate extends BaseModel { + name: string; + short_description?: string; + template_data?: Record; + template_type?: string; + project: string; + workspace: string; +} + +export interface CreatePageTemplate { + name: string; + short_description?: string; + template_data?: Record; +} + +export interface UpdatePageTemplate { + name?: string; + short_description?: string; + template_data?: Record; +} diff --git a/src/models/Workflow.ts b/src/models/Workflow.ts new file mode 100644 index 0000000..a478871 --- /dev/null +++ b/src/models/Workflow.ts @@ -0,0 +1,56 @@ +import { BaseModel } from "./common"; + +/** + * Workflow model interfaces + */ +export interface Workflow extends BaseModel { + name: string; + description?: string; + is_active?: boolean; + is_default?: boolean; + work_item_type_ids?: string[]; + project: string; + workspace: string; +} + +export interface CreateWorkflow { + name: string; + description?: string; + is_active?: boolean; + work_item_type_ids?: string[]; +} + +export interface UpdateWorkflow { + name?: string; + description?: string; + is_active?: boolean; + work_item_type_ids?: string[]; +} + +export interface AttachWorkflowStates { + state_ids: string[]; +} + +export interface WorkflowTransition extends BaseModel { + state_id?: string; + transition_state_id?: string; + type?: string; + member_ids?: string[]; + pre_rules?: Record[]; + post_rules?: Record[]; + workflow_state_id?: string; +} + +export interface CreateWorkflowTransition { + state_id: string; + transition_state_id: string; + type?: string; + member_ids?: string[]; + pre_rules?: Record[]; + post_rules?: Record[]; +} + +export interface UpdateWorkflowTransition { + pre_rules?: Record[]; + post_rules?: Record[]; +} diff --git a/src/models/index.ts b/src/models/index.ts index ce61a01..831ae04 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -26,3 +26,5 @@ export * from "./WorkItemRelation"; export * from "./WorkItemType"; export * from "./WorkLog"; export * from "./WorkspaceFeatures"; +export * from "./Workflow"; +export * from "./ProjectTemplate"; diff --git a/tests/unit/project-templates.test.ts b/tests/unit/project-templates.test.ts new file mode 100644 index 0000000..fec9b9f --- /dev/null +++ b/tests/unit/project-templates.test.ts @@ -0,0 +1,135 @@ +import { PlaneClient } from "../../src/client/plane-client"; +import { WorkItemTemplate, PageTemplate } from "../../src/models"; +import { config } from "./constants"; +import { createTestClient, randomizeName } from "../helpers/test-utils"; +import { describeIf as describe } from "../helpers/conditional-tests"; + +describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let projectId: string; + + // Work item template under test + let workItemTemplate: WorkItemTemplate; + + // Page template under test + let pageTemplate: PageTemplate; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + projectId = config.projectId; + }); + + afterAll(async () => { + if (workItemTemplate?.id) { + try { + await client.projectTemplates.workItems.del(workspaceSlug, projectId, workItemTemplate.id); + } catch (error) { + console.warn("Failed to delete work item template:", error); + } + } + + if (pageTemplate?.id) { + try { + await client.projectTemplates.pages.del(workspaceSlug, projectId, pageTemplate.id); + } catch (error) { + console.warn("Failed to delete page template:", error); + } + } + }); + + // ─── Work Item Templates ───────────────────────────────────────────────────── + + it("should create a work item template", async () => { + workItemTemplate = await client.projectTemplates.workItems.create(workspaceSlug, projectId, { + name: randomizeName("Test WI Template"), + short_description: "Created by test suite", + }); + + expect(workItemTemplate).toBeDefined(); + expect(workItemTemplate.id).toBeDefined(); + expect(workItemTemplate.name).toContain("Test WI Template"); + expect(workItemTemplate.short_description).toBe("Created by test suite"); + expect(workItemTemplate.project).toBe(projectId); + }); + + it("should list work item templates", async () => { + const templates = await client.projectTemplates.workItems.list(workspaceSlug, projectId); + + expect(templates).toBeDefined(); + expect(Array.isArray(templates)).toBe(true); + expect(templates.length).toBeGreaterThan(0); + + const found = templates.find((t) => t.id === workItemTemplate.id); + expect(found).toBeDefined(); + expect(found?.name).toBe(workItemTemplate.name); + }); + + it("should update a work item template", async () => { + const updated = await client.projectTemplates.workItems.update(workspaceSlug, projectId, workItemTemplate.id!, { + name: randomizeName("Updated WI Template"), + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(workItemTemplate.id); + expect(updated.name).toContain("Updated WI Template"); + + workItemTemplate = updated; + }); + + it("should delete a work item template", async () => { + await expect( + client.projectTemplates.workItems.del(workspaceSlug, projectId, workItemTemplate.id!) + ).resolves.not.toThrow(); + + // Mark as deleted so afterAll won't attempt again + workItemTemplate = { ...workItemTemplate, id: undefined } as unknown as WorkItemTemplate; + }); + + // ─── Page Templates ────────────────────────────────────────────────────────── + + it("should create a page template", async () => { + pageTemplate = await client.projectTemplates.pages.create(workspaceSlug, projectId, { + name: randomizeName("Test Page Template"), + short_description: "Created by test suite", + }); + + expect(pageTemplate).toBeDefined(); + expect(pageTemplate.id).toBeDefined(); + expect(pageTemplate.name).toContain("Test Page Template"); + expect(pageTemplate.short_description).toBe("Created by test suite"); + expect(pageTemplate.project).toBe(projectId); + }); + + it("should list page templates", async () => { + const templates = await client.projectTemplates.pages.list(workspaceSlug, projectId); + + expect(templates).toBeDefined(); + expect(Array.isArray(templates)).toBe(true); + expect(templates.length).toBeGreaterThan(0); + + const found = templates.find((t) => t.id === pageTemplate.id); + expect(found).toBeDefined(); + expect(found?.name).toBe(pageTemplate.name); + }); + + it("should update a page template", async () => { + const updated = await client.projectTemplates.pages.update(workspaceSlug, projectId, pageTemplate.id!, { + name: randomizeName("Updated Page Template"), + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(pageTemplate.id); + expect(updated.name).toContain("Updated Page Template"); + + pageTemplate = updated; + }); + + it("should delete a page template", async () => { + await expect(client.projectTemplates.pages.del(workspaceSlug, projectId, pageTemplate.id!)).resolves.not.toThrow(); + + // Mark as deleted so afterAll won't attempt again + pageTemplate = { ...pageTemplate, id: undefined } as unknown as PageTemplate; + }); +}); diff --git a/tests/unit/workflows/workflow.test.ts b/tests/unit/workflows/workflow.test.ts new file mode 100644 index 0000000..09beb03 --- /dev/null +++ b/tests/unit/workflows/workflow.test.ts @@ -0,0 +1,167 @@ +import { PlaneClient } from "../../../src/client/plane-client"; +import { Workflow, WorkflowTransition } from "../../../src/models"; +import { State } from "../../../src/models/State"; +import { config } from "../constants"; +import { createTestClient, randomizeName } from "../../helpers/test-utils"; +import { describeIf as describe } from "../../helpers/conditional-tests"; + +describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () => { + let client: PlaneClient; + let workspaceSlug: string; + let projectId: string; + let workflow: Workflow; + let stateA: State; + let stateB: State; + let transition: WorkflowTransition; + + beforeAll(async () => { + client = createTestClient(); + workspaceSlug = config.workspaceSlug; + projectId = config.projectId; + + // Create two states to use for workflow state/transition operations + stateA = await client.states.create(workspaceSlug, projectId, { + name: randomizeName("WF State A"), + group: "started", + color: "#9AA4BC", + }); + + stateB = await client.states.create(workspaceSlug, projectId, { + name: randomizeName("WF State B"), + group: "started", + color: "#A4BC9A", + }); + }); + + afterAll(async () => { + if (workflow?.id) { + // Detach stateA from workflow if it was attached + if (stateA?.id) { + try { + await client.workflows.states.detach(workspaceSlug, projectId, workflow.id, stateA.id); + } catch { + // ignore — already detached or never attached + } + } + } + + if (stateA?.id) { + try { + await client.states.delete(workspaceSlug, projectId, stateA.id); + } catch (error) { + console.warn("Failed to delete stateA:", error); + } + } + + if (stateB?.id) { + try { + await client.states.delete(workspaceSlug, projectId, stateB.id); + } catch (error) { + console.warn("Failed to delete stateB:", error); + } + } + }); + + it("should create a workflow", async () => { + workflow = await client.workflows.create(workspaceSlug, projectId, { + name: randomizeName("Test Workflow"), + }); + + expect(workflow).toBeDefined(); + expect(workflow.id).toBeDefined(); + expect(workflow.name).toContain("Test Workflow"); + expect(workflow.project).toBe(projectId); + }); + + it("should list workflows", async () => { + const workflows = await client.workflows.list(workspaceSlug, projectId); + + expect(workflows).toBeDefined(); + expect(Array.isArray(workflows)).toBe(true); + expect(workflows.length).toBeGreaterThan(0); + + const found = workflows.find((w) => w.id === workflow.id); + expect(found).toBeDefined(); + expect(found?.name).toBe(workflow.name); + }); + + it("should update a workflow", async () => { + const updated = await client.workflows.update(workspaceSlug, projectId, workflow.id!, { + name: randomizeName("Updated Workflow"), + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(workflow.id); + expect(updated.name).toContain("Updated Workflow"); + + // Keep local reference current + workflow = updated; + }); + + it("should attach a state to a workflow", async () => { + await expect( + client.workflows.states.attach(workspaceSlug, projectId, workflow.id!, { + state_ids: [stateA.id!], + }) + ).resolves.not.toThrow(); + }); + + it("should list transitions (initially empty for the workflow)", async () => { + const transitions = await client.workflows.transitions.list(workspaceSlug, projectId, workflow.id!); + + expect(transitions).toBeDefined(); + expect(Array.isArray(transitions)).toBe(true); + }); + + it("should create a workflow transition", async () => { + const result = await client.workflows.transitions.create(workspaceSlug, projectId, workflow.id!, { + state_id: stateA.id!, + transition_state_id: stateB.id!, + }); + + // May return null if transition already exists + if (result !== null) { + expect(result.id).toBeDefined(); + transition = result; + } else { + // Fetch the existing transition + const transitions = await client.workflows.transitions.list(workspaceSlug, projectId, workflow.id!); + const found = transitions.find((t) => t.state_id === stateA.id && t.transition_state_id === stateB.id); + expect(found).toBeDefined(); + transition = found!; + } + }); + + it("should list transitions (find newly created)", async () => { + const transitions = await client.workflows.transitions.list(workspaceSlug, projectId, workflow.id!); + + expect(transitions).toBeDefined(); + expect(Array.isArray(transitions)).toBe(true); + expect(transitions.length).toBeGreaterThan(0); + + const found = transitions.find((t) => t.id === transition.id); + expect(found).toBeDefined(); + }); + + it("should update a workflow transition", async () => { + const updated = await client.workflows.transitions.update(workspaceSlug, projectId, workflow.id!, transition.id!, { + pre_rules: [], + post_rules: [], + }); + + expect(updated).toBeDefined(); + expect(updated.id).toBe(transition.id); + }); + + it("should delete a workflow transition", async () => { + await expect( + client.workflows.transitions.del(workspaceSlug, projectId, workflow.id!, transition.id!) + ).resolves.not.toThrow(); + }); + + it("should detach a state from a workflow", async () => { + await expect( + client.workflows.states.detach(workspaceSlug, projectId, workflow.id!, stateA.id!) + ).resolves.not.toThrow(); + }); +}); From 1605623537266d300f0170880c2453cf2a002ef4 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Thu, 28 May 2026 11:45:43 +0530 Subject: [PATCH 2/2] fix: address CodeRabbit review on Workflows and ProjectTemplates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transitions.ts: harden error.response parsing in create() — defensive type check instead of JSON.stringify to avoid throw on non-serializable payloads - Workflow.ts: derive CreateWorkflow/UpdateWorkflow and CreateWorkflowTransition/UpdateWorkflowTransition from model interfaces via Pick/Partial/Required instead of duplicating fields - ProjectTemplate.ts: same — derive Create/Update DTOs from WorkItemTemplate and PageTemplate via Pick/Partial - Tests: replace resolves.not.toThrow() with resolves.toBeUndefined() for Promise assertions (workflow + project-templates) Co-authored-by: Plane AI --- src/api/Workflows/Transitions.ts | 11 +++++++++- src/models/ProjectTemplate.ts | 24 ++++------------------ src/models/Workflow.ts | 29 +++++---------------------- tests/unit/project-templates.test.ts | 6 ++++-- tests/unit/workflows/workflow.test.ts | 6 +++--- 5 files changed, 26 insertions(+), 50 deletions(-) diff --git a/src/api/Workflows/Transitions.ts b/src/api/Workflows/Transitions.ts index f8c5248..24978d7 100644 --- a/src/api/Workflows/Transitions.ts +++ b/src/api/Workflows/Transitions.ts @@ -39,7 +39,16 @@ export class Transitions extends BaseResource { ); } catch (error) { if (error instanceof HttpError && error.statusCode === 400) { - const body = JSON.stringify(error.response ?? "").toLowerCase(); + const response = error.response as unknown; + const body = + typeof response === "string" + ? response.toLowerCase() + : typeof response === "object" && + response !== null && + "detail" in response && + typeof (response as { detail: unknown }).detail === "string" + ? (response as { detail: string }).detail.toLowerCase() + : ""; if (body.includes("already exists")) { return null; } diff --git a/src/models/ProjectTemplate.ts b/src/models/ProjectTemplate.ts index 0280634..2482056 100644 --- a/src/models/ProjectTemplate.ts +++ b/src/models/ProjectTemplate.ts @@ -12,17 +12,9 @@ export interface WorkItemTemplate extends BaseModel { workspace: string; } -export interface CreateWorkItemTemplate { - name: string; - short_description?: string; - template_data?: Record; -} +export type CreateWorkItemTemplate = Pick; -export interface UpdateWorkItemTemplate { - name?: string; - short_description?: string; - template_data?: Record; -} +export type UpdateWorkItemTemplate = Partial; export interface PageTemplate extends BaseModel { name: string; @@ -33,14 +25,6 @@ export interface PageTemplate extends BaseModel { workspace: string; } -export interface CreatePageTemplate { - name: string; - short_description?: string; - template_data?: Record; -} +export type CreatePageTemplate = Pick; -export interface UpdatePageTemplate { - name?: string; - short_description?: string; - template_data?: Record; -} +export type UpdatePageTemplate = Partial; diff --git a/src/models/Workflow.ts b/src/models/Workflow.ts index a478871..21caa10 100644 --- a/src/models/Workflow.ts +++ b/src/models/Workflow.ts @@ -13,19 +13,9 @@ export interface Workflow extends BaseModel { workspace: string; } -export interface CreateWorkflow { - name: string; - description?: string; - is_active?: boolean; - work_item_type_ids?: string[]; -} +export type CreateWorkflow = Pick; -export interface UpdateWorkflow { - name?: string; - description?: string; - is_active?: boolean; - work_item_type_ids?: string[]; -} +export type UpdateWorkflow = Partial; export interface AttachWorkflowStates { state_ids: string[]; @@ -41,16 +31,7 @@ export interface WorkflowTransition extends BaseModel { workflow_state_id?: string; } -export interface CreateWorkflowTransition { - state_id: string; - transition_state_id: string; - type?: string; - member_ids?: string[]; - pre_rules?: Record[]; - post_rules?: Record[]; -} +export type CreateWorkflowTransition = Required> & + Partial>; -export interface UpdateWorkflowTransition { - pre_rules?: Record[]; - post_rules?: Record[]; -} +export type UpdateWorkflowTransition = Partial>; diff --git a/tests/unit/project-templates.test.ts b/tests/unit/project-templates.test.ts index fec9b9f..2417137 100644 --- a/tests/unit/project-templates.test.ts +++ b/tests/unit/project-templates.test.ts @@ -81,7 +81,7 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes it("should delete a work item template", async () => { await expect( client.projectTemplates.workItems.del(workspaceSlug, projectId, workItemTemplate.id!) - ).resolves.not.toThrow(); + ).resolves.toBeUndefined(); // Mark as deleted so afterAll won't attempt again workItemTemplate = { ...workItemTemplate, id: undefined } as unknown as WorkItemTemplate; @@ -127,7 +127,9 @@ describe(!!(config.workspaceSlug && config.projectId), "ProjectTemplates API Tes }); it("should delete a page template", async () => { - await expect(client.projectTemplates.pages.del(workspaceSlug, projectId, pageTemplate.id!)).resolves.not.toThrow(); + await expect( + client.projectTemplates.pages.del(workspaceSlug, projectId, pageTemplate.id!) + ).resolves.toBeUndefined(); // Mark as deleted so afterAll won't attempt again pageTemplate = { ...pageTemplate, id: undefined } as unknown as PageTemplate; diff --git a/tests/unit/workflows/workflow.test.ts b/tests/unit/workflows/workflow.test.ts index 09beb03..6a5e003 100644 --- a/tests/unit/workflows/workflow.test.ts +++ b/tests/unit/workflows/workflow.test.ts @@ -103,7 +103,7 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () client.workflows.states.attach(workspaceSlug, projectId, workflow.id!, { state_ids: [stateA.id!], }) - ).resolves.not.toThrow(); + ).resolves.toBeUndefined(); }); it("should list transitions (initially empty for the workflow)", async () => { @@ -156,12 +156,12 @@ describe(!!(config.workspaceSlug && config.projectId), "Workflow API Tests", () it("should delete a workflow transition", async () => { await expect( client.workflows.transitions.del(workspaceSlug, projectId, workflow.id!, transition.id!) - ).resolves.not.toThrow(); + ).resolves.toBeUndefined(); }); it("should detach a state from a workflow", async () => { await expect( client.workflows.states.detach(workspaceSlug, projectId, workflow.id!, stateA.id!) - ).resolves.not.toThrow(); + ).resolves.toBeUndefined(); }); });