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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ in scope.
**The sweeps.** The v2 rules are enforced by enumeration, not by review.
`tests/unit/v2/tree-walk.ts` derives every resource class from the TypeScript AST under
`src/api/v2/` (triangulated against the modules and the public barrel), and the rule
sweeps run over all 90 of them:
sweeps run over all 92 of them:

| File | Refuses |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,36 @@ await wiki.collections.default("acme"); // the default collection, resolved via
their own, so they are not navigation properties on a fetched workspace row. Reach them
flat.

### Dashboards

`v2.workspaces.dashboards` is workspace dashboards; `dashboards.widgets` is the charts on
one. A fetched dashboard reaches its widgets without the ids repeated. A widget row holds
its settings only: `widgets.chart` answers one widget's data (paged with `page`, plus
`column_page`/`row_ids` on a `TABLE_CHART`; `row_ids` is sent JSON-encoded for you), and
`widgets.charts` answers every widget's first page in one call, each entry carrying its
own `status` and either `chart` or `error`. A chart's shape depends on its `chart_type`, so
`WidgetChart` names only the shared fields. `dashboards.widgetTypes` lists the chart types
a dashboard can hold, the `chart_model` values each accepts and whether the plan includes
it.

```ts
const dashboards = client.v2.workspaces.dashboards;

const overview = await dashboards.create("acme", { name: "Engineering overview", project_ids: [projectId] });
await overview.widgets.create({
name: "By priority",
chart_type: "BAR_CHART",
chart_model: "STACKED",
x_axis_property: "PRIORITY",
y_axis_metric: "WORK_ITEM_COUNT",
group_by: "STATE_GROUPS",
});
const charts = await overview.widgets.charts(); // every widget's data, in layout order
void charts.length;
await dashboards.copy("acme", overview.id, { name: "Engineering overview (Q4)" });
await dashboards.widgetTypes("acme");
```

### Errors

`PlaneApiError` — an RFC 9457 problem detail with `.status`, `.type`, `.code`, `.detail`,
Expand Down Expand Up @@ -334,7 +364,7 @@ are exported so a caller can enumerate valid values rather than guessing.

### How this surface is kept honest

The v2 surface is 90 resource classes, and none of it is spot-checked. Rule sweeps run
The v2 surface is 92 resource classes, and none of it is spot-checked. Rule sweeps run
over **every** class — enumerated from the TypeScript source, not selected by some
property a class might not have yet — and each is proved by introducing the violation and
watching the sweep name it:
Expand Down Expand Up @@ -398,6 +428,7 @@ URL against a mock server — the one class of error no source-level sweep can c
- **WorkItemRelationDefinitions**: Custom work item relation type definitions
- **Releases**: Release management with tags, labels, item labels, changelog, comments, links, and work items
- **Collections**: Folders that group workspace pages, with member and page management
- **Dashboards** (v2): Workspace dashboards with copy, widget types, and widgets with their chart data
- **AgentRuns**: AI agent run orchestration and activity tracking
- **Workflows**: Project workflow management with state attachments, transitions, transition hooks, activities, and work item approvals
- **ProjectTemplates**: Work item and page template management per project
Expand Down
79 changes: 74 additions & 5 deletions scripts/__fixtures__/types-bundle-exports.snapshot.txt

Large diffs are not rendered by default.

202 changes: 202 additions & 0 deletions src/api/v2/Dashboards/Widgets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { Page } from "../../../models/v2/common";
import {
CreateWidget,
StoredWidgetChartType,
UpdateWidget,
Widget,
WidgetChart,
WidgetChartModel,
WidgetChartParams,
WidgetChartResult,
} from "../../../models/v2/Widget";
import { FIELDS, ORDER_BY } from "../generated/constants";
import { AnyOperationId, V2Resource } from "../kernel/resource";

export type WidgetField = (typeof FIELDS)["widgets_list"][number];
export type WidgetOrderBy = (typeof ORDER_BY)["widgets_list"][number];

export interface ListWidgetsParams {
fields?: readonly WidgetField[];
chart_type?: StoredWidgetChartType;
chart_model?: WidgetChartModel;
/** Match on name. */
search?: string;
order_by?: WidgetOrderBy;
offset?: number;
per_page?: number;
/** Set to `"cursor"` to opt into the cursor envelope — see `ListStatesParams.paginate`. */
paginate?: "cursor";
/** Resume a cursor-paginated walk from a `next_cursor` an earlier page answered with. */
cursor?: string;
count?: boolean;
}

/** `?fields=` on a single-row read or write. The golden declares no `?expand=` on this family. */
export interface WidgetShapeParams {
fields?: readonly WidgetField[];
}

/** Query parameters of {@link Widgets.charts}. */
export interface WidgetChartsParams {
/**
* Only these widgets (sent comma-separated). Ids not on the dashboard are skipped. Omit it
* for every widget; an empty array resolves to `[]` without a request.
*/
widget_ids?: readonly string[];
}

/**
* The widgets on a dashboard — CRUD plus their chart data.
*
* Reached flat — `v2.workspaces.dashboards.widgets.list(slug, dashboard)` — or from a
* fetched dashboard: `dashboard.widgets.list()`. A widget row holds its settings only; the
* data it draws comes from {@link chart} (one widget, pageable) or {@link charts} (every
* widget at its first page, in layout order).
*/
export class Widgets extends V2Resource<Widget, CreateWidget, UpdateWidget> {
protected path = "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/";
protected extraPaths = {
chart: "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/{widget_id}/chart/",
charts: "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/charts/",
};
protected operations: Record<string, AnyOperationId> = {
list: "widgets_list",
retrieve: "widgets_retrieve",
create: "widgets_create",
update: "widgets_partial_update",
delete: "widgets_destroy",
chart: "widgets_chart",
charts: "widgets_charts",
};

private _at(slug: string, dashboard: string, widget?: string): Record<string, string> {
const params: Record<string, string> = { slug, dashboard_id: dashboard };
if (widget !== undefined) params.pk = widget;
return params;
}

/**
* One page of `Widget` rows. Use `iterate` to follow pages automatically.
*
* @remarks The row shape returned when `fields` is a literal tuple. `id` is always present.
*/
list<F extends Exclude<WidgetField, "all"> & keyof Widget>(
slug: string,
dashboard: string,
params: ListWidgetsParams & { fields: readonly F[] }
): Promise<Page<Pick<Widget, F | "id">>>;
/** One page of `Widget` rows. Use `iterate` to follow pages automatically. */
list(slug: string, dashboard: string, params?: ListWidgetsParams): Promise<Page<Widget>>;
list(slug: string, dashboard: string, params?: ListWidgetsParams): Promise<Page<Widget>> {
return this.doList(this._at(slug, dashboard), params as Record<string, unknown>);
}

/** Every widget on the dashboard, following pages automatically. */
iterate<F extends Exclude<WidgetField, "all"> & keyof Widget>(
slug: string,
dashboard: string,
params: Omit<ListWidgetsParams, "offset" | "count"> & { fields: readonly F[] }
): AsyncGenerator<Pick<Widget, F | "id">>;
/** Every widget on the dashboard, following pages automatically. */
iterate(
slug: string,
dashboard: string,
params?: Omit<ListWidgetsParams, "offset" | "count">
): AsyncGenerator<Widget>;
iterate(
slug: string,
dashboard: string,
params?: Omit<ListWidgetsParams, "offset" | "count">
): AsyncGenerator<Widget> {
return this.doIterate(this._at(slug, dashboard), params as Record<string, unknown>);
}

retrieve<F extends Exclude<WidgetField, "all"> & keyof Widget>(
slug: string,
dashboard: string,
widget: string,
params: { fields: readonly F[] }
): Promise<Pick<Widget, F | "id">>;
retrieve(slug: string, dashboard: string, widget: string, params?: WidgetShapeParams): Promise<Widget>;
retrieve(slug: string, dashboard: string, widget: string, params?: WidgetShapeParams): Promise<Widget> {
return this.doRetrieve(this._at(slug, dashboard, widget), params as Record<string, unknown>);
}

/** Add a widget. The API checks it against its chart type first and refuses (400) one it could not draw. */
create<F extends Exclude<WidgetField, "all"> & keyof Widget>(
slug: string,
dashboard: string,
data: CreateWidget,
params: WidgetShapeParams & { fields: readonly F[] }
): Promise<Pick<Widget, F | "id">>;
/** Add a widget. The API checks it against its chart type first and refuses (400) one it could not draw. */
create(slug: string, dashboard: string, data: CreateWidget, params?: WidgetShapeParams): Promise<Widget>;
create(slug: string, dashboard: string, data: CreateWidget, params?: WidgetShapeParams): Promise<Widget> {
return this.doCreate(data, this._at(slug, dashboard), params as Record<string, unknown>);
}

update<F extends Exclude<WidgetField, "all"> & keyof Widget>(
slug: string,
dashboard: string,
widget: string,
data: UpdateWidget,
params: WidgetShapeParams & { fields: readonly F[] }
): Promise<Pick<Widget, F | "id">>;
update(
slug: string,
dashboard: string,
widget: string,
data: UpdateWidget,
params?: WidgetShapeParams
): Promise<Widget>;
update(
slug: string,
dashboard: string,
widget: string,
data: UpdateWidget,
params?: WidgetShapeParams
): Promise<Widget> {
return this.doUpdate(data, this._at(slug, dashboard, widget), params as Record<string, unknown>);
}

delete(slug: string, dashboard: string, widget: string): Promise<void> {
return this.doDelete(this._at(slug, dashboard, widget));
}

/**
* The data one widget draws, computed from the dashboard's current work items with both
* the dashboard's and the widget's filters applied. The widget filter is its `filters` when
* `last_used_filter` is `rich_filters` (the default) and `pql_filters.stripped` when it is
* `pql_filters`; `rich_filters` is never read. The shape depends on the widget's
* `chart_type`. `row_ids` is sent as the JSON array the API expects.
*/
chart(slug: string, dashboard: string, widget: string, params?: WidgetChartParams): Promise<WidgetChart> {
const { row_ids, ...rest } = params ?? {};
return this.doCustomAction<WidgetChart>("chart", {
method: "GET",
pathParams: { slug, dashboard_id: dashboard, widget_id: widget },
params: { ...rest, row_ids: row_ids === undefined ? undefined : JSON.stringify(row_ids) },
});
}

/**
* Every widget's chart data in one call, in layout order, each at its first page, with the
* same filters as {@link chart}. Resolves to the response's `data` array: one entry per
* widget, each carrying its own `status` and either `chart` or `error`. A widget that
* cannot be charted (400, 402, 500) fails only its own entry; the call itself still rejects
* for anything about the dashboard (401/403, 404 for a dashboard the caller cannot see,
* 402 when the plan has no dashboards, 400 when more than 50 widgets are selected: pass
* `widget_ids` to chart a larger dashboard in batches). An empty `widget_ids` resolves to `[]`
* without a request.
*/
async charts(slug: string, dashboard: string, params?: WidgetChartsParams): Promise<WidgetChartResult[]> {
// `?widget_ids=` with nothing after it is an empty selection, so skip the round trip.
if (params?.widget_ids !== undefined && params.widget_ids.length === 0) return [];
const response = await this.doCustomAction<{ data: WidgetChartResult[] }>("charts", {
method: "GET",
pathParams: { slug, dashboard_id: dashboard },
params: params as Record<string, unknown>,
});
return response.data;
}
}
Loading
Loading