Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makeplane/plane-node-sdk",
"version": "0.2.11",
"version": "0.2.12",
"description": "Node SDK for Plane",
"author": "Plane <engineering@plane.so>",
"repository": {
Expand Down
12 changes: 8 additions & 4 deletions src/api/Cycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PaginatedResponse<WorkItem>> {
return this.get<PaginatedResponse<WorkItem>>(
`/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`,
params
prepareWorkItemParams(params)
);
}

Expand Down
12 changes: 8 additions & 4 deletions src/api/Modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<PaginatedResponse<WorkItem>> {
return this.get<PaginatedResponse<WorkItem>>(
`/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/module-issues/`,
params
prepareWorkItemParams(params)
);
}

Expand Down
63 changes: 61 additions & 2 deletions src/api/WorkItems/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined {
if (!params) return undefined;
if (params.filters === undefined) return params as Record<string, unknown>;
const { filters, ...rest } = params;
return { ...rest, filters: JSON.stringify(filters) };
}

/**
* WorkItems API resource
* Handles all work item (issue) related operations
Expand Down Expand Up @@ -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,
Expand All @@ -104,7 +139,31 @@ export class WorkItems extends BaseResource {
): Promise<PaginatedResponse<WorkItem>> {
return this.get<PaginatedResponse<WorkItem>>(
`/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<PaginatedResponse<WorkItem>> {
return this.get<PaginatedResponse<WorkItem>>(
`/workspaces/${workspaceSlug}/work-items/`,
prepareWorkItemParams(params)
);
}

Expand Down
23 changes: 23 additions & 0 deletions src/models/WorkItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,25 @@ 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 — 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;
/**
* 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<string, unknown>;
}

export interface WorkItemActivity {
Expand Down Expand Up @@ -140,6 +158,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;
}

Expand Down
86 changes: 86 additions & 0 deletions tests/unit/work-items/work-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,92 @@ 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",
});

// 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);
Comment on lines +133 to +148
} 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" },
order_by: "-created_at",
per_page: 100,
});
expect(filtered).toBeDefined();
expect(filtered.total_results).toBeLessThanOrEqual(unfiltered.total_results);
// 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 {
Comment on lines +175 to +192
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(
Expand Down
Loading