From 66427fbb372c3113583364d17e24fab4412c680e Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Tue, 26 May 2026 14:28:47 +0530 Subject: [PATCH 1/2] feat: rich filters + pql on work-item list endpoints, add listWorkspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the new external API's structured filtering capability on every work-item list method in the SDK. - ListWorkItemsParams now accepts `filters?: Record` (the existing `pql?: string` field is unchanged) plus the standard pagination/ordering fields. AdvancedSearchWorkItem also gains `pql`. - New `prepareWorkItemParams` helper JSON-encodes `filters` into the `filters=` query parameter the API expects; everything else passes through unchanged. - New `WorkItems.listWorkspace(workspaceSlug, params)` calling GET /workspaces//work-items/ — paginated, total_results, spans every project the caller can view. - `WorkItems.list`, `Cycles.listWorkItemsInCycle`, and `Modules.listWorkItemsInModule` route through the helper. Typed the `params` argument on the latter two (was previously `any`). - Real-API tests for `filters` on `list` and `listWorkspace`. - Bump version to 0.2.12. Backend: makeplane/plane-ee#7376 --- README.md | 36 ++++++++++++ package.json | 2 +- src/api/Cycles.ts | 12 ++-- src/api/Modules.ts | 12 ++-- src/api/WorkItems/index.ts | 63 ++++++++++++++++++++- src/models/WorkItem.ts | 21 +++++++ tests/unit/work-items/work-items.test.ts | 70 ++++++++++++++++++++++++ 7 files changed, 205 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3fe460d..9fd1584 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,42 @@ const project = await client.projects.create("workspace-slug", { - **Initiatives**: Initiative management - **Features**: Workspace and project features management +## Filtering work items + +Work item list endpoints accept two filter inputs that map to the same backend +filter engine: + +- **`filters`** — a structured object. Supports nested `and` / `or` / `not` + groups and field operators (`__in`, `__gte`, `__range`, `__icontains`, ...). + The SDK JSON-encodes this into the `filters=` query parameter. +- **`pql`** — a Plane Query Language string. Human-readable alternative + with the same expressive power. + +```ts +// Project-scoped, structured filters +await client.workItems.list("my-workspace", "project-id", { + filters: { + and: [{ priority: "urgent" }, { state_group__in: ["unstarted", "started"] }], + }, + order_by: "-created_at", + per_page: 50, +}); + +// Project-scoped, PQL +await client.workItems.list("my-workspace", "project-id", { + pql: 'priority = "urgent" AND assignee = currentUser()', +}); + +// Workspace-scoped — spans every project the caller can view, with +// per-project authorization honored server-side +await client.workItems.listWorkspace("my-workspace", { + filters: { priority: "urgent" }, +}); +``` + +The same `filters` and `pql` parameters also work on +`cycles.listWorkItemsInCycle` and `modules.listWorkItemsInModule`. + ## Development ```bash diff --git a/package.json b/package.json index 6183213..1920444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makeplane/plane-node-sdk", - "version": "0.2.11", + "version": "0.2.12", "description": "Node SDK for Plane", "author": "Plane ", "repository": { diff --git a/src/api/Cycles.ts b/src/api/Cycles.ts index 4a7d5e6..9bc3b64 100644 --- a/src/api/Cycles.ts +++ b/src/api/Cycles.ts @@ -3,7 +3,8 @@ import { Configuration } from "../Configuration"; import { PaginatedResponse } from "../models/common"; import { Cycle, CreateCycleRequest, UpdateCycleRequest, TransferCycleWorkItemRequest } from "../models/Cycle"; -import { WorkItem } from "../models/WorkItem"; +import { ListWorkItemsParams, WorkItem } from "../models/WorkItem"; +import { prepareWorkItemParams } from "./WorkItems"; /** * Cycles API resource @@ -79,17 +80,20 @@ export class Cycles extends BaseResource { } /** - * List work items in cycle + * List work items in a cycle. + * + * Supports the same `filters` and `pql` query parameters as + * {@link WorkItems.list}. */ async listWorkItemsInCycle( workspaceSlug: string, projectId: string, cycleId: string, - params?: any + params?: ListWorkItemsParams ): Promise> { return this.get>( `/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`, - params + prepareWorkItemParams(params) ); } diff --git a/src/api/Modules.ts b/src/api/Modules.ts index 989843c..acdc32d 100644 --- a/src/api/Modules.ts +++ b/src/api/Modules.ts @@ -2,7 +2,8 @@ import { BaseResource } from "./BaseResource"; import { Configuration } from "../Configuration"; import { PaginatedResponse } from "../models/common"; import { CreateModuleRequest, UpdateModuleRequest, Module, ListModulesParamsRequest } from "../models/Module"; -import { WorkItem } from "../models/WorkItem"; +import { ListWorkItemsParams, WorkItem } from "../models/WorkItem"; +import { prepareWorkItemParams } from "./WorkItems"; /** * Modules API resource @@ -58,17 +59,20 @@ export class Modules extends BaseResource { } /** - * List work items in module + * List work items in a module. + * + * Supports the same `filters` and `pql` query parameters as + * {@link WorkItems.list}. */ async listWorkItemsInModule( workspaceSlug: string, projectId: string, moduleId: string, - params?: any + params?: ListWorkItemsParams ): Promise> { return this.get>( `/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`, - params + prepareWorkItemParams(params) ); } diff --git a/src/api/WorkItems/index.ts b/src/api/WorkItems/index.ts index 896a426..a3a7934 100644 --- a/src/api/WorkItems/index.ts +++ b/src/api/WorkItems/index.ts @@ -19,6 +19,24 @@ import { Comments } from "./Comments"; import { Activities } from "./Activities"; import { WorkLogs } from "./WorkLogs"; +/** + * Prepare query params for work-item list endpoints. + * + * The backend's `filters=` query parameter expects a JSON-encoded string, + * not an exploded object — so we stringify it here before letting axios + * URL-encode the result into a single query value. Everything else passes + * through unchanged. + * + * Exported for reuse from sibling resources (Cycles, Modules) that list + * work items. + */ +export function prepareWorkItemParams(params?: ListWorkItemsParams): Record | undefined { + if (!params) return undefined; + if (params.filters === undefined) return params as Record; + const { filters, ...rest } = params; + return { ...rest, filters: JSON.stringify(filters) }; +} + /** * WorkItems API resource * Handles all work item (issue) related operations @@ -95,7 +113,24 @@ export class WorkItems extends BaseResource { } /** - * List work items with optional filtering + * List work items in a project with optional filtering. + * + * Supports rich filtering via `filters` (structured) and `pql` (Plane + * Query Language). The `filters` object is JSON-encoded into a single + * `filters=` query parameter before sending. + * + * @example + * ```ts + * await client.workItems.list("my-workspace", "project-id", { + * filters: { and: [{ priority: "urgent" }, { state_group__in: ["unstarted", "started"] }] }, + * order_by: "-created_at", + * per_page: 50, + * }); + * + * await client.workItems.list("my-workspace", "project-id", { + * pql: 'priority = "urgent" AND assignee = currentUser()', + * }); + * ``` */ async list( workspaceSlug: string, @@ -104,7 +139,31 @@ export class WorkItems extends BaseResource { ): Promise> { return this.get>( `/workspaces/${workspaceSlug}/projects/${projectId}/work-items/`, - params + prepareWorkItemParams(params) + ); + } + + /** + * List work items across an entire workspace. + * + * Returns a paginated envelope of work items the caller can view, + * spanning every project in the workspace (per-project authorization + * and conditional grants are honored server-side). Supports the same + * `filters` and `pql` query parameters as {@link WorkItems.list}. + * + * @example + * ```ts + * await client.workItems.listWorkspace("my-workspace", { + * filters: { priority: "urgent" }, + * order_by: "-created_at", + * per_page: 50, + * }); + * ``` + */ + async listWorkspace(workspaceSlug: string, params?: ListWorkItemsParams): Promise> { + return this.get>( + `/workspaces/${workspaceSlug}/work-items/`, + prepareWorkItemParams(params) ); } diff --git a/src/models/WorkItem.ts b/src/models/WorkItem.ts index 2c264b6..0c728ce 100644 --- a/src/models/WorkItem.ts +++ b/src/models/WorkItem.ts @@ -87,7 +87,23 @@ export interface ListWorkItemsParams { assignee?: string; limit?: number; offset?: number; + cursor?: string; + per_page?: number; + order_by?: string; + fields?: string; + expand?: string; + /** + * Plane Query Language expression for structured filtering. + * Example: `priority = "urgent" AND assignee = currentUser()`. + */ pql?: string; + /** + * Structured filter expression. Supports nested `and`/`or`/`not` groups + * and field comparisons with operators like `__in`, `__gte`, `__range`, + * `__icontains`, etc. The SDK JSON-encodes this object into the + * `filters=` query parameter before sending the request. + */ + filters?: Record; } export interface WorkItemActivity { @@ -140,6 +156,11 @@ export type AdvancedSearchFilter = { export interface AdvancedSearchWorkItem { query?: string; filters?: AdvancedSearchFilter; + /** + * Plane Query Language expression. Alternative to `filters` with the + * same expressive power. The backend accepts either or both. + */ + pql?: string; limit?: number; } diff --git a/tests/unit/work-items/work-items.test.ts b/tests/unit/work-items/work-items.test.ts index 96de3c3..fdd2cdc 100644 --- a/tests/unit/work-items/work-items.test.ts +++ b/tests/unit/work-items/work-items.test.ts @@ -114,6 +114,76 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It } }); + it("should list work items with structured `filters`", async () => { + let urgent: WorkItem | undefined; + let low: WorkItem | undefined; + + try { + urgent = await client.workItems.create(workspaceSlug, projectId, { + name: randomizeName(), + priority: "urgent", + }); + low = await client.workItems.create(workspaceSlug, projectId, { + name: randomizeName(), + priority: "low", + }); + + const filtered = await client.workItems.list(workspaceSlug, projectId, { + filters: { priority: "urgent" }, + }); + + expect(filtered).toBeDefined(); + expect(Array.isArray(filtered.results)).toBe(true); + const ids = filtered.results.map((wi) => wi.id); + expect(ids).toContain(urgent.id); + expect(ids).not.toContain(low.id); + } finally { + if (urgent?.id) { + try { + await client.workItems.delete(workspaceSlug, projectId, urgent.id); + } catch { + /* best-effort cleanup */ + } + } + if (low?.id) { + try { + await client.workItems.delete(workspaceSlug, projectId, low.id); + } catch { + /* best-effort cleanup */ + } + } + } + }); + + it("should list workspace work items with filters", async () => { + let urgent: WorkItem | undefined; + try { + urgent = await client.workItems.create(workspaceSlug, projectId, { + name: randomizeName(), + priority: "urgent", + }); + + const unfiltered = await client.workItems.listWorkspace(workspaceSlug); + expect(unfiltered).toBeDefined(); + expect(typeof unfiltered.total_results).toBe("number"); + + const filtered = await client.workItems.listWorkspace(workspaceSlug, { + filters: { priority: "urgent" }, + }); + expect(filtered).toBeDefined(); + expect(filtered.total_results).toBeLessThanOrEqual(unfiltered.total_results); + expect(filtered.total_results).toBeGreaterThan(0); + } finally { + if (urgent?.id) { + try { + await client.workItems.delete(workspaceSlug, projectId, urgent.id); + } catch { + /* best-effort cleanup */ + } + } + } + }); + it("should retrieve work item by identifier", async () => { const project = await client.projects.retrieve(workspaceSlug, projectId); const workItemByIdentifier = await client.workItems.retrieveByIdentifier( From 7225a9676af9c272296086520e33785836bfe081 Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Tue, 26 May 2026 14:50:55 +0530 Subject: [PATCH 2/2] fix: tighten work-item filter tests + clarify `pql` docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review: - `list with structured filters` test could be flaky: the just-created urgent item might land on a later page in busy workspaces, breaking `expect(ids).toContain(urgent.id)`. Add `order_by: "-created_at"` and `per_page: 100`, and also assert that every returned item has `priority === "urgent"` — that's the property the test is meant to prove regardless of pagination luck. - `listWorkspace` filters test could pass even when filtering was ignored (it only checked `total_results` bounds). Add the same per-item `priority === "urgent"` assertion plus the same pagination flags. - `pql` docstring in `WorkItem.ts` said "expression for structured filtering", which is misleading — `filters` is structured, `pql` is string-based. Reword. --- src/models/WorkItem.ts | 4 +++- tests/unit/work-items/work-items.test.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/models/WorkItem.ts b/src/models/WorkItem.ts index 0c728ce..a8d8cfd 100644 --- a/src/models/WorkItem.ts +++ b/src/models/WorkItem.ts @@ -93,7 +93,9 @@ export interface ListWorkItemsParams { fields?: string; expand?: string; /** - * Plane Query Language expression for structured filtering. + * Plane Query Language expression — a string-based alternative to the + * structured `filters` object below. The two have equivalent expressive + * power; pick whichever is more convenient for the caller. * Example: `priority = "urgent" AND assignee = currentUser()`. */ pql?: string; diff --git a/tests/unit/work-items/work-items.test.ts b/tests/unit/work-items/work-items.test.ts index fdd2cdc..bcd9eb4 100644 --- a/tests/unit/work-items/work-items.test.ts +++ b/tests/unit/work-items/work-items.test.ts @@ -128,12 +128,21 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It priority: "low", }); + // Order by newest first and request a generous page so the just-created + // item is guaranteed to be on the first page even in busy workspaces. const filtered = await client.workItems.list(workspaceSlug, projectId, { filters: { priority: "urgent" }, + order_by: "-created_at", + per_page: 100, }); expect(filtered).toBeDefined(); expect(Array.isArray(filtered.results)).toBe(true); + // Every returned item must match the filter — directly proves filtering worked. + expect(filtered.results.length).toBeGreaterThan(0); + for (const wi of filtered.results) { + expect(wi.priority).toBe("urgent"); + } const ids = filtered.results.map((wi) => wi.id); expect(ids).toContain(urgent.id); expect(ids).not.toContain(low.id); @@ -169,10 +178,17 @@ describe(!!(config.workspaceSlug && config.projectId && config.userId), "Work It const filtered = await client.workItems.listWorkspace(workspaceSlug, { filters: { priority: "urgent" }, + order_by: "-created_at", + per_page: 100, }); expect(filtered).toBeDefined(); expect(filtered.total_results).toBeLessThanOrEqual(unfiltered.total_results); - expect(filtered.total_results).toBeGreaterThan(0); + // Every returned item must match the filter — this is what actually + // proves the `filters` parameter was applied. + expect(filtered.results.length).toBeGreaterThan(0); + for (const wi of filtered.results) { + expect(wi.priority).toBe("urgent"); + } } finally { if (urgent?.id) { try {