diff --git a/CLAUDE.md b/CLAUDE.md index ae9866ad..411117b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/README.md b/README.md index c6461abe..e916f621 100644 --- a/README.md +++ b/README.md @@ -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`, @@ -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: @@ -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 diff --git a/scripts/__fixtures__/types-bundle-exports.snapshot.txt b/scripts/__fixtures__/types-bundle-exports.snapshot.txt index 855d9c41..cb675b16 100644 --- a/scripts/__fixtures__/types-bundle-exports.snapshot.txt +++ b/scripts/__fixtures__/types-bundle-exports.snapshot.txt @@ -85,6 +85,7 @@ root CollectionPageSearchResult CollectionPageSearchResult db96ac010e9afa9a root CollectionPagesResource Pages$2 db32805900a83eb9 root Collections Collections$1 7f4d552d43ffae22 root Configuration Configuration 3699a98cbead400c +root CopyDashboard CopyDashboard 84504ff55ac98c03 root CreateAgentRunActivityRequest CreateAgentRunActivityRequest b6ab9ab8d2670c55 root CreateAgentRunRequest CreateAgentRunRequest 4ee23b5478c4a50f root CreateArtifact CreateArtifact a1a0c90fb34f4ef3 @@ -102,6 +103,7 @@ root CreateCustomerPropertyValues CreateCustomerPropertyValues da41ca7f5977895d root CreateCustomerRequest CreateCustomerRequest$1 565f10415fc05cb5 root CreateCycle CreateCycle 23f0ebe512ec0384 root CreateCycleRequest CreateCycleRequest 92245f5608794af0 +root CreateDashboard CreateDashboard bfc4b6753b1a7c63 root CreateDateTimeWorkItemProperty CreateDateTimeWorkItemProperty 126ae159a2d193ba root CreateDecimalWorkItemProperty CreateDecimalWorkItemProperty f119312c419b53cb root CreateEntity CreateEntity 027f8a4bde7bb893 @@ -137,6 +139,7 @@ root CreateTeamspace CreateTeamspace$1 4006be56b72002d5 root CreateTextWorkItemProperty CreateTextWorkItemProperty cd83ddc6cab7cf09 root CreateView CreateView c45c383521d10adb root CreateWebhook CreateWebhook cf78dd312a0d05ba +root CreateWidget CreateWidget 49fc63784e1294bb root CreateWorkflow CreateWorkflow$1 78201216b4754e3a root CreateWorkflowTransition CreateWorkflowTransition$1 45510e0b617f6460 root CreateWorkflowTransitionHook CreateWorkflowTransitionHook 38fff173d57eafa0 @@ -201,6 +204,14 @@ root CycleShapeParams CycleShapeParams 216745d121c9812c root CycleTransferRequest CycleTransferRequest e7f43182d7f1d576 root CycleTransferResult CycleTransferResult d042aace96311191 root CycleWorkItem CycleWorkItem af003efccbfafd8b +root Dashboard Dashboard 5137bc4765c2078a +root DashboardAccess DashboardAccess 9a4882177567be3a +root DashboardField DashboardField a190e0e22a026356 +root DashboardIds DashboardIds dfd479449a7cd57a +root DashboardNavigation DashboardNavigation 182d47ab3fa3d5f2 +root DashboardOrderBy DashboardOrderBy 2ba5d71187618d39 +root DashboardPqlFilters DashboardPqlFilters 1142ca51729a38b2 +root DashboardShapeParams DashboardShapeParams 4687426a01955f0c root DependencyType DependencyType 3ed943095fdce55a root EffectivePermissions EffectivePermissions 5d3fedb6e24b57e8 root EmojiProps EmojiProps 2b5b186e421c9ec0 @@ -283,6 +294,7 @@ root ListCustomerRequestsParams ListCustomerRequestsParams$1 7b1f7d6168d79468 root ListCustomersParams ListCustomersParams$1 8ab3b319d7924e70 root ListCyclesLiteParams ListCyclesLiteParams 77cde90f2cf29adb root ListCyclesParams ListCyclesParams 95683034ab0a9062 +root ListDashboardsParams ListDashboardsParams c5cd54d5b0306462 root ListEstimatePointsParams ListEstimatePointsParams 1362a53a830f6c73 root ListEstimatesParams ListEstimatesParams 6daf133b406ea280 root ListGroupSyncProjectMappingsParams ListGroupSyncProjectMappingsParams 00a094b744dfab95 @@ -321,6 +333,7 @@ root ListTeamspacesParams ListTeamspacesParams$1 c28f2812c77dc449 root ListUserAssetsParams ListUserAssetsParams 7d4cbd36b728669a root ListWebhookLogsParams ListWebhookLogsParams 573e242efd6ba1e0 root ListWebhooksParams ListWebhooksParams 7af80adf7001e64c +root ListWidgetsParams ListWidgetsParams e175c388657fc986 root ListWikiPagesParams ListWikiPagesParams 5290376dc466b3a3 root ListWorkflowActivitiesParams ListWorkflowActivitiesParams 0ff243eda596d2a1 root ListWorkflowsParams ListWorkflowsParams 370088cb48409b55 @@ -358,6 +371,8 @@ root LoadedCustomer LoadedCustomer 0ec83e3fdac137b5 root LoadedCustomerRow LoadedCustomerRow f14ffb011489ddae root LoadedCycle LoadedCycle 85083ab073c1de92 root LoadedCycleRow LoadedCycleRow f8369f4dfd7401e5 +root LoadedDashboard LoadedDashboard 1f0ada408413b70d +root LoadedDashboardRow LoadedDashboardRow cb93823b7f9d1ae5 root LoadedEstimate LoadedEstimate b79f12fd657ab746 root LoadedEstimateRow LoadedEstimateRow d282ee47dfe6cd1c root LoadedInitiative LoadedInitiative b6ca2d1f1ce01700 @@ -555,6 +570,7 @@ root Sticky Sticky$1 254b45a34412a145 root StickyField StickyField 643b8430d75e9720 root StickyFieldsParams StickyFieldsParams 0fdade5bc02755a7 root StickyOrderBy StickyOrderBy bd49f4cf606fd7fa +root StoredWidgetChartType StoredWidgetChartType 1feec4b36ffde2b5 root SubmitWorkItemApproval SubmitWorkItemApproval e05baade0aeb8934 root Teamspace Teamspace$1 897aaec6f2d51519 root TeamspaceExpand TeamspaceExpand e9abd665df475ac7 @@ -585,6 +601,7 @@ root UpdateCustomerPropertyValue UpdateCustomerPropertyValue 60f25edbb6c8a557 root UpdateCustomerRequest UpdateCustomerRequest$1 9492d2887887f4bf root UpdateCycle UpdateCycle d7a6321443f778fc root UpdateCycleRequest UpdateCycleRequest 633c449f7634a34a +root UpdateDashboard UpdateDashboard 7a6293791fc656a0 root UpdateEntity UpdateEntity 42b752b777fbae51 root UpdateEpic UpdateEpic 803b8f8ab713d25e root UpdateEstimate UpdateEstimate 0fae5e80e8f23562 @@ -622,6 +639,7 @@ root UpdateTeamspace UpdateTeamspace$1 8d4d0691624dc911 root UpdateTypeGovernance UpdateTypeGovernance ba2fff182138554f root UpdateView UpdateView 6040aff91b83ecc6 root UpdateWebhook UpdateWebhook 8045e2f55d040ebb +root UpdateWidget UpdateWidget 0469f80037587922 root UpdateWorkflow UpdateWorkflow$1 0929b3752ee0fe44 root UpdateWorkflowState UpdateWorkflowState$1 ecb713c892cf7421 root UpdateWorkflowTransition UpdateWorkflowTransition$1 9190e212b49f3955 @@ -799,6 +817,21 @@ root WebhookLogOrderBy WebhookLogOrderBy ec2dca9ddef07b93 root WebhookNavigation WebhookNavigation 46f44d74dbda490c root WebhookOrderBy WebhookOrderBy 8208ef1cf8b386b0 root WhoAmI WhoAmI 9bda88ee24634842 +root Widget Widget f20ace833052fa1d +root WidgetChart WidgetChart ff3b8751e0493763 +root WidgetChartModel WidgetChartModel 63023f85b7b5a221 +root WidgetChartParams WidgetChartParams e08f42a3efc0e795 +root WidgetChartResult WidgetChartResult cf4ada472fb0989b +root WidgetChartSchemaEntry WidgetChartSchemaEntry 1d893d2793a353a5 +root WidgetChartsParams WidgetChartsParams a6c58190366b284e +root WidgetChartType WidgetChartType 986ca7ab4e136daf +root WidgetDateGrouping WidgetDateGrouping b01881c8ca4e6793 +root WidgetField WidgetField 031f7fd7faa535e1 +root WidgetLastUsedFilter WidgetLastUsedFilter 2a17869a66311d52 +root WidgetOrderBy WidgetOrderBy 84e9b98d55c55510 +root WidgetShapeParams WidgetShapeParams bde434605ccec0a2 +root WidgetType WidgetType be7711d06b85c633 +root WidgetYAxisMetric WidgetYAxisMetric 5838f1f9900b8726 root WikiPageExpand WikiPageExpand b41bd04e55582fc9 root WikiPageField WikiPageField bd8a2930ea927502 root WikiPageOrderBy WikiPageOrderBy 3a466d4c1bcc4994 @@ -982,7 +1015,7 @@ root WorkspaceInviteFieldsParams WorkspaceInviteFieldsParams 0ef3341d4a9644d9 root WorkspaceInviteOrderBy WorkspaceInviteOrderBy d1901ca37563d14d root WorkspaceMember WorkspaceMember$1 b61f7162ec8d5356 root WorkspaceMemberRemoveRequest WorkspaceMemberRemoveRequest 4aa6efab146d5e24 -root WorkspaceNavigation WorkspaceNavigation 1a4d13616ba9f074 +root WorkspaceNavigation WorkspaceNavigation 2cf6f713a92b6f34 root WorkspacePageTemplate WorkspacePageTemplate d209e4164ca13886 root WorkspacePageTemplates Pages$3 2cc15181e9ebcbf7 root WorkspaceProjectLabel WorkspaceProjectLabel a429972341041c5e @@ -1068,6 +1101,13 @@ v2 CycleNavigation CycleNavigation 3460fed225ee677a v2 CycleOrderBy CycleOrderBy 6b31c08b310b702d v2 Cycles Cycles a17a7edc1ef1e569 v2 CycleWorkItems CycleWorkItems 3fcdd031d36e5af3 +v2 DASHBOARD_ID_NAMES DASHBOARD_ID_NAMES bd98a1d9139c2fcb +v2 DashboardField DashboardField a190e0e22a026356 +v2 DashboardIds DashboardIds dfd479449a7cd57a +v2 DashboardNavigation DashboardNavigation 182d47ab3fa3d5f2 +v2 DashboardOrderBy DashboardOrderBy 2ba5d71187618d39 +v2 Dashboards Dashboards 94b1212c6c8eabde +v2 DashboardShapeParams DashboardShapeParams 4687426a01955f0c v2 ESTIMATE_ID_NAMES ESTIMATE_ID_NAMES f7dcd631df5fd14c v2 EstimateExpand EstimateExpand c8abfa612422fc68 v2 EstimateField EstimateField a72670e931819453 @@ -1081,7 +1121,7 @@ v2 EstimatePointShapeParams EstimatePointShapeParams 96705610b691e978 v2 Estimates Estimates 63e06c142c0d270e v2 EstimateShapeParams EstimateShapeParams 811689714b8de6c5 v2 EXPAND EXPAND 142e756475b2b6e3 -v2 FIELDS FIELDS d180ca352a84ec95 +v2 FIELDS FIELDS cc4d7f53e2f5eb1d v2 GroupSync GroupSync c0121fc6a056a668 v2 GroupSyncConfigResource GroupSyncConfigResource 18b8cb8f6179b66b v2 GroupSyncProjectMappingField GroupSyncProjectMappingField cf293d1a76286806 @@ -1121,6 +1161,7 @@ v2 ListCustomerPropertiesParams ListCustomerPropertiesParams 55c9e8dfc2a9f17d v2 ListCustomerRequestsParams ListCustomerRequestsParams 52fe3e2d66e33be8 v2 ListCustomersParams ListCustomersParams 86e0f026102f9edf v2 ListCyclesParams ListCyclesParams 95683034ab0a9062 +v2 ListDashboardsParams ListDashboardsParams c5cd54d5b0306462 v2 ListEstimatePointsParams ListEstimatePointsParams 1362a53a830f6c73 v2 ListEstimatesParams ListEstimatesParams 6daf133b406ea280 v2 ListGroupSyncProjectMappingsParams ListGroupSyncProjectMappingsParams 00a094b744dfab95 @@ -1151,6 +1192,7 @@ v2 ListTeamspacesParams ListTeamspacesParams 0a2ec007446e4b96 v2 ListUserAssetsParams ListUserAssetsParams 7d4cbd36b728669a v2 ListWebhookLogsParams ListWebhookLogsParams 573e242efd6ba1e0 v2 ListWebhooksParams ListWebhooksParams 7af80adf7001e64c +v2 ListWidgetsParams ListWidgetsParams e175c388657fc986 v2 ListWikiPagesParams ListWikiPagesParams 5290376dc466b3a3 v2 ListWorkflowsParams ListWorkflowsParams 370088cb48409b55 v2 ListWorkflowStatesParams ListWorkflowStatesParams a75a30e0c217d42e @@ -1183,6 +1225,8 @@ v2 LoadedCustomer LoadedCustomer 0ec83e3fdac137b5 v2 LoadedCustomerRow LoadedCustomerRow f14ffb011489ddae v2 LoadedCycle LoadedCycle 85083ab073c1de92 v2 LoadedCycleRow LoadedCycleRow f8369f4dfd7401e5 +v2 LoadedDashboard LoadedDashboard 1f0ada408413b70d +v2 LoadedDashboardRow LoadedDashboardRow cb93823b7f9d1ae5 v2 LoadedEstimate LoadedEstimate b79f12fd657ab746 v2 LoadedEstimateRow LoadedEstimateRow d282ee47dfe6cd1c v2 LoadedInitiative LoadedInitiative b6ca2d1f1ce01700 @@ -1236,7 +1280,7 @@ v2 Modules Modules aee10c3a49ca6450 v2 ModuleWorkItems ModuleWorkItems feea697c3da3cf2d v2 NavigationFactories NavigationFactories 5d7432b20941852e v2 OPENAPI_VERSION OPENAPI_VERSION 1a18ca7e9c72b223 -v2 ORDER_BY ORDER_BY 70cab4aa87392d31 +v2 ORDER_BY ORDER_BY 7e6cd8d0a3ff338c v2 owned owned cadd2d5773a9efd5 v2 Owned Owned 456b60e9253eca90 v2 OWNED OWNED cc458929fbdd7953 @@ -1341,6 +1385,11 @@ v2 WebhookLogs WebhookLogs 59a8fa4f91f3559e v2 WebhookNavigation WebhookNavigation 46f44d74dbda490c v2 WebhookOrderBy WebhookOrderBy 8208ef1cf8b386b0 v2 Webhooks Webhooks fea14751bec01d1d +v2 WidgetChartsParams WidgetChartsParams a6c58190366b284e +v2 WidgetField WidgetField 031f7fd7faa535e1 +v2 WidgetOrderBy WidgetOrderBy 84e9b98d55c55510 +v2 Widgets Widgets 12b5d5e3b2a9ce1b +v2 WidgetShapeParams WidgetShapeParams bde434605ccec0a2 v2 Wiki Wiki ba7c9ac08246b4e6 v2 WikiPageExpand WikiPageExpand b41bd04e55582fc9 v2 WikiPageField WikiPageField bd8a2930ea927502 @@ -1448,9 +1497,9 @@ v2 WorkspaceInviteField WorkspaceInviteField 3a2b66d4197c572d v2 WorkspaceInviteFieldsParams WorkspaceInviteFieldsParams 0ef3341d4a9644d9 v2 WorkspaceInviteOrderBy WorkspaceInviteOrderBy d1901ca37563d14d v2 WorkspaceMembers WorkspaceMembers 8629d93386c08f34 -v2 WorkspaceNavigation WorkspaceNavigation 1a4d13616ba9f074 +v2 WorkspaceNavigation WorkspaceNavigation 2cf6f713a92b6f34 v2 WorkspacePermissions WorkspacePermissions 8d6c4bdddf0d9504 -v2 Workspaces Workspaces f86a76b74b94dc1b +v2 Workspaces Workspaces 62bc24dcd561968d v2 WorkspaceViewOrderBy WorkspaceViewOrderBy ea48a52e1e6e629d v2 WorkspaceViews WorkspaceViews 7550deaba489d4d7 v2 WorkspaceWorkItemProperties WorkspaceWorkItemProperties 2794124f1bb68b06 @@ -1500,6 +1549,7 @@ v2models CollectionMemberAccess CollectionMemberAccess 65f60ad10486e9bc v2models CollectionMemberAddItem CollectionMemberAddItem 95e143cfa1f45db6 v2models CollectionMemberSource CollectionMemberSource 277b9eb3f44a2f5b v2models CollectionPageSearch CollectionPageSearch 8b51dfba5d6fd2b3 +v2models CopyDashboard CopyDashboard 84504ff55ac98c03 v2models CreateArtifact CreateArtifact a1a0c90fb34f4ef3 v2models CreateAutomation CreateAutomation 9a9a62c5ec5118bc v2models CreateAutomationEdge CreateAutomationEdge 736ef458b14f945d @@ -1511,6 +1561,7 @@ v2models CreateCustomerPropertyOption CreateCustomerPropertyOption e818442a4ce6c v2models CreateCustomerPropertyValues CreateCustomerPropertyValues da41ca7f5977895d v2models CreateCustomerRequest CreateCustomerRequest 533215d178fc1df4 v2models CreateCycle CreateCycle 23f0ebe512ec0384 +v2models CreateDashboard CreateDashboard bfc4b6753b1a7c63 v2models CreateEstimate CreateEstimate 0413e51a22cb269a v2models CreateEstimatePoint CreateEstimatePoint 1cb096c34f59af7d v2models CreateGroupMapping CreateGroupMapping b2dd47de6138a93d @@ -1533,6 +1584,7 @@ v2models CreateSticky CreateSticky 663e956c171ed192 v2models CreateTeamspace CreateTeamspace 5c83a8d5ff4fb9b3 v2models CreateView CreateView c45c383521d10adb v2models CreateWebhook CreateWebhook cf78dd312a0d05ba +v2models CreateWidget CreateWidget 49fc63784e1294bb v2models CreateWorkflow CreateWorkflow 9f847ecc05a6e64f v2models CreateWorkflowTransition CreateWorkflowTransition 08c3078eb1e4ad81 v2models CreateWorkItem CreateWorkItem eb2dde4ebf77e21e @@ -1557,6 +1609,9 @@ v2models CustomerRequest CustomerRequest 918f9ce3c12e4dab v2models Cycle Cycle 0fbe8469e892070f v2models CycleTransferRequest CycleTransferRequest e7f43182d7f1d576 v2models CycleTransferResult CycleTransferResult d042aace96311191 +v2models Dashboard Dashboard 5137bc4765c2078a +v2models DashboardAccess DashboardAccess 9a4882177567be3a +v2models DashboardPqlFilters DashboardPqlFilters 1142ca51729a38b2 v2models EffectivePermissions EffectivePermissions 5d3fedb6e24b57e8 v2models Estimate Estimate 66f37d2b160c0e65 v2models EstimatePoint EstimatePoint a3601e3fbc67fd61 @@ -1603,6 +1658,7 @@ v2models SetAutomationStatus SetAutomationStatus 3381db7b291bf701 v2models State State 8217bc51d82bec16 v2models StateGroup StateGroup 727c5fd9e0bc44fc v2models Sticky Sticky 44d29dcba60165af +v2models StoredWidgetChartType StoredWidgetChartType 1feec4b36ffde2b5 v2models Teamspace Teamspace bf5b6bfb03068eb1 v2models UpdateArtifactUpdate UpdateArtifactUpdate 08a79442bfe70e67 v2models UpdateAutomation UpdateAutomation 3952af2d12b710c8 @@ -1613,6 +1669,7 @@ v2models UpdateCustomer UpdateCustomer 6b5748efdd6f51f2 v2models UpdateCustomerProperty UpdateCustomerProperty 58cdd9a89580f0e0 v2models UpdateCustomerRequest UpdateCustomerRequest 56dc022c5393d39f v2models UpdateCycle UpdateCycle d7a6321443f778fc +v2models UpdateDashboard UpdateDashboard 7a6293791fc656a0 v2models UpdateEstimate UpdateEstimate 0fae5e80e8f23562 v2models UpdateEstimatePoint UpdateEstimatePoint b364d256f416d2c4 v2models UpdateGroupMapping UpdateGroupMapping a8b0103ea9545567 @@ -1638,6 +1695,7 @@ v2models UpdateSticky UpdateSticky 11183a46f2ad7aaa v2models UpdateTeamspace UpdateTeamspace 5b94d58a57096af9 v2models UpdateView UpdateView 6040aff91b83ecc6 v2models UpdateWebhook UpdateWebhook 8045e2f55d040ebb +v2models UpdateWidget UpdateWidget 0469f80037587922 v2models UpdateWorkflow UpdateWorkflow 2514997086df26d2 v2models UpdateWorkflowState UpdateWorkflowState 1481f93616e5580a v2models UpdateWorkflowTransition UpdateWorkflowTransition fd3e0df1661fd28b @@ -1664,6 +1722,17 @@ v2models WebhookContentType WebhookContentType d3f26efdb3421ac9 v2models WebhookCreateResponse WebhookCreateResponse 00f2ef9f126d731b v2models WebhookLog WebhookLog 86648f9796a85528 v2models WhoAmI WhoAmI 9bda88ee24634842 +v2models Widget Widget f20ace833052fa1d +v2models WidgetChart WidgetChart ff3b8751e0493763 +v2models WidgetChartModel WidgetChartModel 63023f85b7b5a221 +v2models WidgetChartParams WidgetChartParams e08f42a3efc0e795 +v2models WidgetChartResult WidgetChartResult cf4ada472fb0989b +v2models WidgetChartSchemaEntry WidgetChartSchemaEntry 1d893d2793a353a5 +v2models WidgetChartType WidgetChartType 986ca7ab4e136daf +v2models WidgetDateGrouping WidgetDateGrouping b01881c8ca4e6793 +v2models WidgetLastUsedFilter WidgetLastUsedFilter 2a17869a66311d52 +v2models WidgetType WidgetType be7711d06b85c633 +v2models WidgetYAxisMetric WidgetYAxisMetric 5838f1f9900b8726 v2models Workflow Workflow af044ee329fcfaea v2models WorkflowState WorkflowState dcdeb244246c7a20 v2models WorkflowStateAttachRequest WorkflowStateAttachRequest 05957276dd6fdf7c diff --git a/src/api/v2/Dashboards/Widgets.ts b/src/api/v2/Dashboards/Widgets.ts new file mode 100644 index 00000000..03c3321c --- /dev/null +++ b/src/api/v2/Dashboards/Widgets.ts @@ -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 { + 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 = { + 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 { + const params: Record = { 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 & keyof Widget>( + slug: string, + dashboard: string, + params: ListWidgetsParams & { fields: readonly F[] } + ): Promise>>; + /** One page of `Widget` rows. Use `iterate` to follow pages automatically. */ + list(slug: string, dashboard: string, params?: ListWidgetsParams): Promise>; + list(slug: string, dashboard: string, params?: ListWidgetsParams): Promise> { + return this.doList(this._at(slug, dashboard), params as Record); + } + + /** Every widget on the dashboard, following pages automatically. */ + iterate & keyof Widget>( + slug: string, + dashboard: string, + params: Omit & { fields: readonly F[] } + ): AsyncGenerator>; + /** Every widget on the dashboard, following pages automatically. */ + iterate( + slug: string, + dashboard: string, + params?: Omit + ): AsyncGenerator; + iterate( + slug: string, + dashboard: string, + params?: Omit + ): AsyncGenerator { + return this.doIterate(this._at(slug, dashboard), params as Record); + } + + retrieve & keyof Widget>( + slug: string, + dashboard: string, + widget: string, + params: { fields: readonly F[] } + ): Promise>; + retrieve(slug: string, dashboard: string, widget: string, params?: WidgetShapeParams): Promise; + retrieve(slug: string, dashboard: string, widget: string, params?: WidgetShapeParams): Promise { + return this.doRetrieve(this._at(slug, dashboard, widget), params as Record); + } + + /** Add a widget. The API checks it against its chart type first and refuses (400) one it could not draw. */ + create & keyof Widget>( + slug: string, + dashboard: string, + data: CreateWidget, + params: WidgetShapeParams & { fields: readonly F[] } + ): Promise>; + /** 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; + create(slug: string, dashboard: string, data: CreateWidget, params?: WidgetShapeParams): Promise { + return this.doCreate(data, this._at(slug, dashboard), params as Record); + } + + update & keyof Widget>( + slug: string, + dashboard: string, + widget: string, + data: UpdateWidget, + params: WidgetShapeParams & { fields: readonly F[] } + ): Promise>; + update( + slug: string, + dashboard: string, + widget: string, + data: UpdateWidget, + params?: WidgetShapeParams + ): Promise; + update( + slug: string, + dashboard: string, + widget: string, + data: UpdateWidget, + params?: WidgetShapeParams + ): Promise { + return this.doUpdate(data, this._at(slug, dashboard, widget), params as Record); + } + + delete(slug: string, dashboard: string, widget: string): Promise { + 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 { + const { row_ids, ...rest } = params ?? {}; + return this.doCustomAction("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 { + // `?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, + }); + return response.data; + } +} diff --git a/src/api/v2/Dashboards/index.ts b/src/api/v2/Dashboards/index.ts new file mode 100644 index 00000000..67ea503a --- /dev/null +++ b/src/api/v2/Dashboards/index.ts @@ -0,0 +1,209 @@ +import { Page } from "../../../models/v2/common"; +import { + CopyDashboard, + CreateDashboard, + Dashboard, + DashboardAccess, + UpdateDashboard, +} from "../../../models/v2/Dashboard"; +import { WidgetType } from "../../../models/v2/Widget"; +import { FIELDS, ORDER_BY } from "../generated/constants"; +import { LoadedMeta, LoadsNavigableRows, NavigationFactories, owned } from "../kernel/loaded"; +import { AnyOperationId } from "../kernel/resource"; +import { V2Transport } from "../kernel/transport"; +import { DASHBOARD_ID_NAMES, DashboardNavigation, LoadedDashboard, LoadedDashboardRow } from "../loaded/Dashboard"; +import { Widgets } from "./Widgets"; + +export type DashboardField = (typeof FIELDS)["dashboards_list"][number]; +export type DashboardOrderBy = (typeof ORDER_BY)["dashboards_list"][number]; + +export interface ListDashboardsParams { + fields?: readonly DashboardField[]; + access?: DashboardAccess; + owned_by_id?: string; + is_shared?: boolean; + /** Match on name. */ + search?: string; + order_by?: DashboardOrderBy; + 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 DashboardShapeParams { + fields?: readonly DashboardField[]; +} + +/** + * Workspace dashboards — CRUD, `copy`, the `widgetTypes` catalog, and the `widgets` child. + * + * Reached flat — `v2.workspaces.dashboards.list(slug)` — or from a fetched workspace: + * `workspace.dashboards.list()`. Every row-returning method answers a + * {@link LoadedDashboard}, so `(await dashboards.retrieve(slug, id)).widgets.list()` needs + * no id repeated. A dashboard hidden from the caller (another member's private one, or any + * dashboard when the caller isn't in the workspace) answers 404; a visible one the caller's role + * or access level doesn't allow answers 403. + */ +export class Dashboards extends LoadsNavigableRows { + protected path = "/workspaces/{slug}/dashboards/"; + protected extraPaths = { + // The API spells this route with a hyphen while the method is `widgetTypes`. + widgetTypes: "/workspaces/{slug}/dashboards/widget-types/", + }; + protected operations: Record = { + list: "dashboards_list", + retrieve: "dashboards_retrieve", + create: "dashboards_create", + update: "dashboards_partial_update", + delete: "dashboards_destroy", + copy: "dashboards_copy", + widgetTypes: "dashboards_widget_types", + }; + protected loadedIdNames = DASHBOARD_ID_NAMES; + + public widgets: Widgets; + + constructor(transport: V2Transport) { + super(transport); + this.widgets = new Widgets(transport); + } + + protected navigationOf(meta: LoadedMeta): NavigationFactories { + const ids = meta.ids as [string, string]; + return { + widgets: () => owned(this.widgets, ids, meta.idNames), + }; + } + + /** + * One page of `Dashboard` rows, newest first. Use `iterate` to follow pages automatically. + * + * @remarks The row shape returned when `fields` is a literal tuple. `id` is always present. + */ + list & keyof Dashboard>( + slug: string, + params: ListDashboardsParams & { fields: readonly F[] } + ): Promise>>>; + /** One page of `Dashboard` rows, newest first. Use `iterate` to follow pages automatically. */ + list(slug: string, params?: ListDashboardsParams): Promise>; + async list(slug: string, params?: ListDashboardsParams): Promise> { + const page = await this.doList({ slug }, params as Record); + return this.loadPage(page, [slug], params?.fields); + } + + /** Every dashboard the caller can see, following pages automatically. */ + iterate & keyof Dashboard>( + slug: string, + params: Omit & { fields: readonly F[] } + ): AsyncGenerator>>; + /** Every dashboard the caller can see, following pages automatically. */ + iterate(slug: string, params?: Omit): AsyncGenerator; + iterate(slug: string, params?: Omit): AsyncGenerator { + return this.loadIterate(this.doIterate({ slug }, params as Record), [slug], params?.fields); + } + + retrieve & keyof Dashboard>( + slug: string, + dashboard: string, + params: { fields: readonly F[] } + ): Promise>>; + retrieve(slug: string, dashboard: string, params?: DashboardShapeParams): Promise; + async retrieve(slug: string, dashboard: string, params?: DashboardShapeParams): Promise { + const row = await this.doRetrieve({ slug, pk: dashboard }, params as Record); + return this.load(row, [slug], params?.fields); + } + + /** Create an empty dashboard owned by the caller. */ + create & keyof Dashboard>( + slug: string, + data: CreateDashboard, + params: DashboardShapeParams & { fields: readonly F[] } + ): Promise>>; + /** Create an empty dashboard owned by the caller. */ + create(slug: string, data: CreateDashboard, params?: DashboardShapeParams): Promise; + async create(slug: string, data: CreateDashboard, params?: DashboardShapeParams): Promise { + const row = await this.doCreate(data, { slug }, params as Record); + return this.load(row, [slug], params?.fields); + } + + update & keyof Dashboard>( + slug: string, + dashboard: string, + data: UpdateDashboard, + params: DashboardShapeParams & { fields: readonly F[] } + ): Promise>>; + update( + slug: string, + dashboard: string, + data: UpdateDashboard, + params?: DashboardShapeParams + ): Promise; + async update( + slug: string, + dashboard: string, + data: UpdateDashboard, + params?: DashboardShapeParams + ): Promise { + const row = await this.doUpdate(data, { slug, pk: dashboard }, params as Record); + return this.load(row, [slug], params?.fields); + } + + /** Delete the dashboard; its public link stops serving straight away. */ + delete(slug: string, dashboard: string): Promise { + return this.doDelete({ slug, pk: dashboard }); + } + + /** + * Copy a dashboard — settings, projects, filters and every widget — into a new one owned + * by the caller. Shared members and the public link are not copied. Each field in `data` + * overrides the copied value; `name` defaults to `" (Copy)"`. The source's own + * values are copied as stored, so an older dashboard always copies. + */ + copy & keyof Dashboard>( + slug: string, + dashboard: string, + data: CopyDashboard | undefined, + params: DashboardShapeParams & { fields: readonly F[] } + ): Promise>>; + /** + * Copy a dashboard — settings, projects, filters and every widget — into a new one owned + * by the caller. Shared members and the public link are not copied. Each field in `data` + * overrides the copied value; `name` defaults to `" (Copy)"`. + */ + copy(slug: string, dashboard: string, data?: CopyDashboard, params?: DashboardShapeParams): Promise; + async copy( + slug: string, + dashboard: string, + data?: CopyDashboard, + params?: DashboardShapeParams + ): Promise { + const row = await this.doCustomAction("copy", { + method: "POST", + pathParams: { slug }, + pk: dashboard, + data: data ?? {}, + params: params as Record, + }); + return this.load(row, [slug], params?.fields); + } + + /** + * Every chart type a dashboard can hold, the chart models each accepts, and whether the + * workspace's plan includes it. Not paginated; resolves to the response's `data` array. + */ + async widgetTypes(slug: string): Promise { + const response = await this.doCustomAction<{ data: WidgetType[] }>("widgetTypes", { + method: "GET", + pathParams: { slug }, + }); + return response.data; + } +} + +export { Widgets } from "./Widgets"; +export type { ListWidgetsParams, WidgetChartsParams, WidgetField, WidgetOrderBy, WidgetShapeParams } from "./Widgets"; diff --git a/src/api/v2/Workspaces.ts b/src/api/v2/Workspaces.ts index 851cb8ef..7bf1cbe1 100644 --- a/src/api/v2/Workspaces.ts +++ b/src/api/v2/Workspaces.ts @@ -24,6 +24,7 @@ import { WorkspaceMembers } from "./WorkspaceMembers"; import { WorkspaceViews } from "./WorkspaceViews"; import { WorkspaceAutomations } from "./Automations/WorkspaceAutomations"; import { Customers } from "./Customers"; +import { Dashboards } from "./Dashboards"; import { Initiatives } from "./Initiatives"; import { Releases } from "./Releases"; import { ReleaseTags } from "./Releases/Tags"; @@ -95,6 +96,7 @@ export class Workspaces extends LoadsNavigableRows owned(this.releases, ids, meta.idNames), releaseTags: () => owned(this.releaseTags, ids, meta.idNames), webhooks: () => owned(this.webhooks, ids, meta.idNames), + dashboards: () => owned(this.dashboards, ids, meta.idNames), }; } diff --git a/src/api/v2/generated/constants.ts b/src/api/v2/generated/constants.ts index 5ad7b8fa..9f1afff9 100644 --- a/src/api/v2/generated/constants.ts +++ b/src/api/v2/generated/constants.ts @@ -61,6 +61,13 @@ export const OPERATION_IDS = [ "cycles_transfer", "cycles_upsert", "cycles_work_items_manage", + "dashboards_copy", + "dashboards_create", + "dashboards_destroy", + "dashboards_list", + "dashboards_partial_update", + "dashboards_retrieve", + "dashboards_widget_types", "estimate_points_bulk_create", "estimate_points_bulk_delete", "estimate_points_bulk_update", @@ -273,6 +280,13 @@ export const OPERATION_IDS = [ "webhooks_partial_update", "webhooks_regenerate", "webhooks_retrieve", + "widgets_chart", + "widgets_charts", + "widgets_create", + "widgets_destroy", + "widgets_list", + "widgets_partial_update", + "widgets_retrieve", "work_item_comments_bulk_create", "work_item_comments_bulk_delete", "work_item_comments_bulk_update", @@ -1145,6 +1159,114 @@ export const FIELDS = { "start_date", "timezone", ] as const, + dashboards_copy: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, + dashboards_create: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, + dashboards_destroy: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, + dashboards_list: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, + dashboards_partial_update: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, + dashboards_retrieve: [ + "access", + "all", + "archived_at", + "created_at", + "created_by_id", + "filters", + "id", + "is_published", + "is_shared", + "logo_props", + "name", + "owned_by_id", + "pql_filters", + "project_ids", + "updated_at", + "updated_by_id", + ] as const, estimate_points_create: [ "all", "created_at", @@ -3390,6 +3512,131 @@ export const FIELDS = { "url", "version", ] as const, + widgets_create: [ + "all", + "chart_model", + "chart_type", + "config", + "created_at", + "created_by_id", + "dashboard_id", + "filters", + "group_by", + "height", + "id", + "last_used_filter", + "name", + "pql_filters", + "rich_filters", + "updated_at", + "updated_by_id", + "width", + "x_axis_coord", + "x_axis_date_grouping", + "x_axis_property", + "y_axis_coord", + "y_axis_metric", + ] as const, + widgets_destroy: [ + "all", + "chart_model", + "chart_type", + "config", + "created_at", + "created_by_id", + "dashboard_id", + "filters", + "group_by", + "height", + "id", + "last_used_filter", + "name", + "pql_filters", + "rich_filters", + "updated_at", + "updated_by_id", + "width", + "x_axis_coord", + "x_axis_date_grouping", + "x_axis_property", + "y_axis_coord", + "y_axis_metric", + ] as const, + widgets_list: [ + "all", + "chart_model", + "chart_type", + "config", + "created_at", + "created_by_id", + "dashboard_id", + "filters", + "group_by", + "height", + "id", + "last_used_filter", + "name", + "pql_filters", + "rich_filters", + "updated_at", + "updated_by_id", + "width", + "x_axis_coord", + "x_axis_date_grouping", + "x_axis_property", + "y_axis_coord", + "y_axis_metric", + ] as const, + widgets_partial_update: [ + "all", + "chart_model", + "chart_type", + "config", + "created_at", + "created_by_id", + "dashboard_id", + "filters", + "group_by", + "height", + "id", + "last_used_filter", + "name", + "pql_filters", + "rich_filters", + "updated_at", + "updated_by_id", + "width", + "x_axis_coord", + "x_axis_date_grouping", + "x_axis_property", + "y_axis_coord", + "y_axis_metric", + ] as const, + widgets_retrieve: [ + "all", + "chart_model", + "chart_type", + "config", + "created_at", + "created_by_id", + "dashboard_id", + "filters", + "group_by", + "height", + "id", + "last_used_filter", + "name", + "pql_filters", + "rich_filters", + "updated_at", + "updated_by_id", + "width", + "x_axis_coord", + "x_axis_date_grouping", + "x_axis_property", + "y_axis_coord", + "y_axis_metric", + ] as const, work_item_comments_upsert: [ "access", "actor_id", @@ -5051,6 +5298,7 @@ export const ORDER_BY = { customer_requests_list: ["-created_at", "-id", "created_at", "id"] as const, customers_list: ["-created_at", "-id", "-name", "created_at", "id", "name"] as const, cycles_list: ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] as const, + dashboards_list: ["-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at"] as const, estimate_points_list: ["-created_at", "-id", "-key", "created_at", "id", "key"] as const, estimates_list: ["-created_at", "-id", "-name", "created_at", "id", "name"] as const, group_sync_project_mappings_list: [ @@ -5136,6 +5384,7 @@ export const ORDER_BY = { user_assets_list: ["-created_at", "-id", "created_at", "id"] as const, webhook_logs_list: ["-created_at", "-id", "created_at", "id"] as const, webhooks_list: ["-created_at", "-id", "created_at", "id"] as const, + widgets_list: ["-created_at", "-id", "created_at", "id"] as const, work_item_properties_list: ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] as const, work_item_property_contexts_list: ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] as const, work_item_property_options_list: ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] as const, @@ -5359,6 +5608,7 @@ export const FILTERS = { customer_requests_list: ["search"] as const, customers_list: ["contract_status", "domain", "external_id", "external_source", "name", "search", "stage"] as const, cycles_list: ["external_id", "external_source", "name", "owned_by_id", "search"] as const, + dashboards_list: ["access", "is_shared", "owned_by_id", "search"] as const, estimate_points_list: ["external_id", "external_source", "key", "search", "value"] as const, estimates_list: ["external_id", "external_source", "name", "search", "type", "type__in"] as const, group_sync_project_mappings_list: ["search"] as const, @@ -5435,6 +5685,9 @@ export const FILTERS = { stickies_list: ["color", "owner_id", "search"] as const, teamspaces_list: ["lead_id", "name", "search"] as const, webhooks_list: ["is_active", "name", "search", "url"] as const, + widgets_chart: ["column_page", "page", "row_ids"] as const, + widgets_charts: ["widget_ids"] as const, + widgets_list: ["chart_model", "chart_type", "search"] as const, work_item_properties_list: ["display_name", "external_id", "external_source", "name", "search"] as const, work_item_property_contexts_list: ["name", "search"] as const, work_item_property_options_list: ["external_id", "external_source", "name", "search"] as const, @@ -5569,6 +5822,7 @@ export const PAGINATION = { customer_requests_list: ["count", "offset", "paginate", "per_page"] as const, customers_list: ["count", "offset", "paginate", "per_page"] as const, cycles_list: ["count", "offset", "paginate", "per_page"] as const, + dashboards_list: ["count", "offset", "paginate", "per_page"] as const, estimate_points_list: ["count", "offset", "paginate", "per_page"] as const, estimates_list: ["count", "offset", "paginate", "per_page"] as const, group_sync_project_mappings_list: ["count", "offset", "paginate", "per_page"] as const, @@ -5604,6 +5858,7 @@ export const PAGINATION = { user_assets_list: ["count", "offset", "paginate", "per_page"] as const, webhook_logs_list: ["count", "offset", "paginate", "per_page"] as const, webhooks_list: ["count", "offset", "paginate", "per_page"] as const, + widgets_list: ["count", "offset", "paginate", "per_page"] as const, work_item_properties_list: ["count", "offset", "paginate", "per_page"] as const, work_item_property_contexts_list: ["count", "offset", "paginate", "per_page"] as const, work_item_property_options_list: ["count", "offset", "paginate", "per_page"] as const, diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index fa5c4d97..e9d77d8d 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -112,6 +112,8 @@ export { COLLECTION_ID_NAMES } from "./loaded/Collection"; export type { CollectionIds, CollectionNavigation, LoadedCollection, LoadedCollectionRow } from "./loaded/Collection"; export { CUSTOMER_ID_NAMES } from "./loaded/Customer"; export type { CustomerIds, CustomerNavigation, LoadedCustomer, LoadedCustomerRow } from "./loaded/Customer"; +export { DASHBOARD_ID_NAMES } from "./loaded/Dashboard"; +export type { DashboardIds, DashboardNavigation, LoadedDashboard, LoadedDashboardRow } from "./loaded/Dashboard"; export { CYCLE_ID_NAMES } from "./loaded/Cycle"; export type { CycleIds, CycleNavigation, LoadedCycle, LoadedCycleRow } from "./loaded/Cycle"; export { ESTIMATE_ID_NAMES } from "./loaded/Estimate"; @@ -194,6 +196,7 @@ export * from "./Automations"; export * from "./Collections"; export * from "./CustomerProperties"; export * from "./Customers"; +export * from "./Dashboards"; export * from "./Estimates"; export * from "./Features"; export * from "./GroupSync"; diff --git a/src/api/v2/loaded/Dashboard.ts b/src/api/v2/loaded/Dashboard.ts new file mode 100644 index 00000000..942e8e0e --- /dev/null +++ b/src/api/v2/loaded/Dashboard.ts @@ -0,0 +1,26 @@ +import type { Dashboard } from "../../../models/v2/Dashboard"; +import type { Widgets } from "../Dashboards/Widgets"; +import type { Loaded, Owned } from "../kernel/loaded"; + +/** The path ids a child of a dashboard row needs, in URL order. Dashboards are workspace-scoped. */ +export type DashboardIds = [slug: string, dashboard: string]; + +/** The parameter names behind {@link DashboardIds}, in the same order. */ +export const DASHBOARD_ID_NAMES = ["slug", "dashboard"] as const; + +/** Everything a fetched dashboard can reach: its widgets and their chart data. */ +export interface DashboardNavigation { + /** + * Widgets with this row's ids already supplied. + * + * `fields` is accepted here and does **not** narrow the return type: TypeScript erases a + * method's type parameter when it infers through `Owned`'s conditional, so a navigated + * call answers the full row. Call the resource flat for the narrowed one. + */ + readonly widgets: Owned; +} + +/** A fetched dashboard row that is also the place its widgets live. */ +export type LoadedDashboardRow = Loaded; + +export type LoadedDashboard = LoadedDashboardRow; diff --git a/src/api/v2/loaded/Workspace.ts b/src/api/v2/loaded/Workspace.ts index b5579ddf..724d168b 100644 --- a/src/api/v2/loaded/Workspace.ts +++ b/src/api/v2/loaded/Workspace.ts @@ -16,6 +16,7 @@ import type { WorkspaceMembers } from "../WorkspaceMembers"; import type { WorkspaceViews } from "../WorkspaceViews"; import type { WorkspaceAutomations } from "../Automations/WorkspaceAutomations"; import type { Customers } from "../Customers"; +import type { Dashboards } from "../Dashboards"; import type { Initiatives } from "../Initiatives"; import type { Releases } from "../Releases"; import type { ReleaseTags } from "../Releases/Tags"; @@ -262,6 +263,14 @@ export interface WorkspaceNavigation { * call answers the full row. Call the resource flat for the narrowed one. */ readonly webhooks: Owned; + /** + * Dashboards with this row's ids already supplied. + * + * `fields` is accepted here and does **not** narrow the return type: TypeScript erases a + * method's type parameter when it infers through `Owned`'s conditional, so a navigated + * call answers the full row. Call the resource flat for the narrowed one. + */ + readonly dashboards: Owned; } /** diff --git a/src/models/v2/Dashboard.ts b/src/models/v2/Dashboard.ts new file mode 100644 index 00000000..2f4786b9 --- /dev/null +++ b/src/models/v2/Dashboard.ts @@ -0,0 +1,68 @@ +/** `0` = private (owner and shared members), `1` = public (everyone in the workspace). */ +export type DashboardAccess = 0 | 1; + +/** + * A PQL filter as stored on a dashboard or widget. Send `stripped`, the PQL query; it is the only + * key charts and validation read. `json` is the web app's editor document: optional, stored as + * sent. An unset filter reads back as `{ json: {}, stripped: "" }`; send that to clear one. Any + * other key is stored as sent and ignored. + */ +export interface DashboardPqlFilters { + stripped?: string; + json?: Record; + [key: string]: unknown; +} + +/** A workspace dashboard (api_v2). Every field except `id` is optional. */ +export interface Dashboard { + id: string; + name?: string; + /** + * A {@link DashboardAccess} on every dashboard written today. Typed as `number` because older + * dashboards can still carry the retired `2` ("shared"); writes accept only `0` or `1`. + */ + access?: number; + project_ids?: string[]; + pql_filters?: DashboardPqlFilters; + /** Filter object applied to every widget, e.g. `{ priority__in: "urgent" }`. */ + filters?: Record; + logo_props?: unknown; + /** `true` when shared with at least one member. */ + is_shared?: boolean; + /** `true` while a public link is active. */ + is_published?: boolean; + archived_at?: string | null; + owned_by_id?: string | null; + created_at?: string; + updated_at?: string; + created_by_id?: string | null; + updated_by_id?: string | null; +} + +/** POST body. `name` is required by the API. */ +export interface CreateDashboard { + name: string; + /** Defaults to `1` (public: the whole workspace can see it). Send `0` for a private dashboard. */ + access?: DashboardAccess; + /** Projects in this workspace whose work items every widget reads. Duplicates are ignored. */ + project_ids?: string[]; + pql_filters?: DashboardPqlFilters; + filters?: Record; + logo_props?: unknown; +} + +/** + * PATCH body — every field optional. `project_ids` replaces the whole list. v2 has no PUT. + * Changing `access` needs the dashboard's owner, or a workspace admin when the dashboard is + * public; sending the value it already has needs neither. Making a dashboard public removes its + * shared members, so `is_shared` reads `false` afterwards. + */ +export type UpdateDashboard = Partial; + +/** + * Body of `copy`: any create field, each one overriding the copied value and validated like a + * create. The source's own values are copied as stored, so an older dashboard always copies. + * `name` defaults to `" (Copy)"`, shortened to fit 255 characters; a source with the + * retired `access` `2` copies as private. + */ +export type CopyDashboard = Partial; diff --git a/src/models/v2/Widget.ts b/src/models/v2/Widget.ts new file mode 100644 index 00000000..915b093d --- /dev/null +++ b/src/models/v2/Widget.ts @@ -0,0 +1,213 @@ +import { ProblemDetail } from "../../errors/PlaneApiError"; +import { DashboardPqlFilters } from "./Dashboard"; + +/** The chart types a widget can be created or updated with. */ +export type WidgetChartType = + | "BAR_CHART" + | "LINE_CHART" + | "AREA_CHART" + | "PIE_CHART" + | "DONUT_CHART" + | "NUMBER" + | "TABLE_CHART" + | "WORK_ITEMS_TABLE" + | "ASSIGNED_WORK_ITEMS_TABLE" + | "WORK_ITEMS_STATISTICS" + | "WORK_ITEM_TYPE_PROGRESS" + | "WORK_ITEMS_SMART_COUNTER" + | "WORK_ITEMS_SMART_GAUGE" + | "CYCLE_PROGRESS_BAR" + | "ACTIVE_CYCLE_PROGRESS_BAR" + | "INTAKE_VOLUME_BY_SOURCE" + | "INTAKE_ITEMS_AGEING" + | "INTAKE_ACCEPT_VS_DECLINE_AVERAGE" + | "INTAKE_ACCEPT_VS_DECLINE" + | "INTAKE_BREAKDOWN"; + +/** + * The chart types a stored widget can carry: every {@link WidgetChartType}, plus three the + * web app created before the API existed. The API reads and filters on them but refuses + * them on a write. + * + * The list matches the server today, but the server adds chart types over time and the SDK + * doesn't validate responses, so a widget read by an older SDK can carry a value that isn't + * listed here. Handle an unknown `chart_type` (skip or placeholder) rather than assuming the + * union is complete. + */ +export type StoredWidgetChartType = + | WidgetChartType + | "RICH_TEXT" + | "WORK_ITEMS_CREATED_VS_RESOLVED" + | "CYCLE_BURN_DOWN_CHART"; + +/** Allowed values depend on the chart type; a chart type without variants takes `BASIC`. */ +export type WidgetChartModel = "BASIC" | "STACKED" | "GROUPED" | "MULTI_LINE" | "COMPARISON" | "PROGRESS"; + +/** + * Which widget filter a chart applies: `rich_filters` (the default) applies the widget's + * `filters`; `pql_filters` applies `pql_filters.stripped`. The `rich_filters` field itself is + * never read by a chart, whatever this says. + */ +export type WidgetLastUsedFilter = "pql_filters" | "rich_filters"; + +/** Bucket size for a date x axis. */ +export type WidgetDateGrouping = "DAY" | "WEEK" | "MONTH" | "YEAR"; + +/** `y_axis_metric` on axis charts and `NUMBER`. */ +export type WidgetYAxisMetric = + | "WORK_ITEM_COUNT" + | "ESTIMATE_POINT_COUNT" + | "PENDING_WORK_ITEM_COUNT" + | "COMPLETED_WORK_ITEM_COUNT" + | "IN_PROGRESS_WORK_ITEM_COUNT" + | "WORK_ITEM_DUE_THIS_WEEK_COUNT" + | "WORK_ITEM_DUE_TODAY_COUNT" + | "BLOCKED_WORK_ITEM_COUNT"; + +/** + * One chart on a dashboard (api_v2). Every field except `id` is optional. For axis charts + * the four axis fields are mirrored into `config`. + */ +export interface Widget { + id: string; + dashboard_id?: string; + name?: string; + chart_type?: StoredWidgetChartType; + chart_model?: WidgetChartModel; + /** Settings for the chart type; the keys each one reads are in the API's Charts reference. */ + config?: Record; + /** e.g. `PRIORITY`, `STATE_GROUPS`, `TARGET_DATE`, or a custom property as `cp_`. */ + x_axis_property?: string | null; + /** + * A {@link WidgetDateGrouping} on a widget the API wrote. Typed as `string` because the + * column is free text: a widget the web app saved can carry any value, including `""`. + */ + x_axis_date_grouping?: string | null; + /** A {@link WidgetYAxisMetric} on a widget the API wrote; free text (`""` included) otherwise. */ + y_axis_metric?: string | null; + group_by?: string | null; + width?: number; + height?: number; + x_axis_coord?: number; + y_axis_coord?: number; + pql_filters?: DashboardPqlFilters; + /** The web app's filter-builder state. Stored and returned; charts never read it. */ + rich_filters?: Record; + /** + * Which filter the chart applies — see {@link WidgetLastUsedFilter}. Usually one of its two + * values, but the column is free text, so a widget the web app saved can carry another. + */ + last_used_filter?: string | null; + filters?: Record; + created_at?: string; + updated_at?: string; + created_by_id?: string | null; + updated_by_id?: string | null; +} + +/** POST body. `name`, `chart_type` and `chart_model` are required by the API. */ +export interface CreateWidget { + name: string; + chart_type: WidgetChartType; + chart_model: WidgetChartModel; + config?: Record; + x_axis_property?: string | null; + x_axis_date_grouping?: WidgetDateGrouping | null; + y_axis_metric?: WidgetYAxisMetric | null; + group_by?: string | null; + width?: number; + height?: number; + x_axis_coord?: number; + y_axis_coord?: number; + /** Applies (as `pql_filters.stripped`) when `last_used_filter` is `pql_filters`. */ + pql_filters?: DashboardPqlFilters; + /** The web app's filter-builder state. Stored and returned; charts never read it. */ + rich_filters?: Record; + /** + * Defaults to `rich_filters`. On create or update, sending a non-empty `pql_filters` with + * neither `filters` nor `last_used_filter` makes the API store `pql_filters` here, so a + * PQL-only widget charts with its PQL. On an update this switches a rich-filter widget to its + * PQL, so its `filters` stop applying; send `last_used_filter` to choose explicitly. + */ + last_used_filter?: WidgetLastUsedFilter; + /** Applies when `last_used_filter` is `rich_filters`. */ + filters?: Record; +} + +/** + * PATCH body — every field optional. v2 has no PUT. Send `null` to clear `group_by`, + * `x_axis_property`, `x_axis_date_grouping` or `y_axis_metric`; the client sends it as is. + */ +export type UpdateWidget = Partial; + +/** One row of `Dashboards.widgetTypes`: a chart type, the models it accepts, and whether the plan includes it. */ +export interface WidgetType { + chart_type: WidgetChartType; + /** Display name, e.g. `"Bar Chart"`. */ + name: string; + /** The `chart_model` values it accepts; `["BASIC"]` for a chart type without variants. */ + chart_models: WidgetChartModel[]; + /** `false` when the plan doesn't include it: writing or charting a widget of this type answers 402. */ + available: boolean; +} + +/** One entity in a chart's `schema` map — at least `id` and `name`, plus per-entity extras (`color`, `group`, …). */ +export interface WidgetChartSchemaEntry { + id: string; + name: string; + [key: string]: unknown; +} + +/** + * A widget's chart data. The shape depends on the widget's `chart_type` (see the API's + * Charts reference), so only the fields most chart types share are named here; read the + * rest by key. + */ +export interface WidgetChart { + /** + * Entity key (`state`, `priority`, …) → id → display details for the ids in `data`. Named + * `schema` as on the wire; the Python SDK calls it `chart_schema` only because `schema` + * clashes with pydantic. + */ + schema?: Record>; + /** The rows, buckets, slices or groups. `WORK_ITEMS_STATISTICS` puts them under `results.data` instead. */ + data?: unknown; + /** Present on paginated chart types only. */ + total_count?: number; + total_pages?: number; + page?: number; + per_page?: number; + next_page?: number | null; + prev_page?: number | null; + [key: string]: unknown; +} + +/** Query parameters of `Widgets.chart`. A chart type reads only the ones it uses and ignores the rest. */ +export interface WidgetChartParams { + /** Which page of rows (buckets, groups, counters), from 1. The page size is the chart's own config. */ + page?: number; + /** `TABLE_CHART` only: which page of columns, from 1. */ + column_page?: number; + /** + * `TABLE_CHART` only: the `data[].group_by_id` values already on screen, `null` included (the + * "no value" row, e.g. unassigned). Sent JSON-encoded. With `row_ids` the API doesn't rank rows, + * so the row paging fields of the answer (`total_count`, `next_page`, …) only echo the request: + * keep your own row paging and read just the `column_*` fields. + */ + row_ids?: readonly (string | null)[]; +} + +/** + * One widget's entry in `Widgets.charts`. Each widget succeeds or fails on its own: a widget + * that cannot be charted gets a non-200 `status` and an `error` here instead of failing the call. + */ +export interface WidgetChartResult { + widget_id: string; + chart_type: StoredWidgetChartType; + /** What the single `chart` call would answer: `200`, or the error status (`400`, `402`, `500`, …). */ + status: number; + /** With `status: 200`: the same body as `Widgets.chart`. */ + chart?: WidgetChart; + /** Otherwise: the error body the single call would return (no `title` or `status`; use `status` above). */ + error?: Pick; +} diff --git a/src/models/v2/index.ts b/src/models/v2/index.ts index 5b7e0e71..4993e358 100644 --- a/src/models/v2/index.ts +++ b/src/models/v2/index.ts @@ -13,6 +13,7 @@ export * from "./CustomerPropertyValue"; export * from "./CustomerRequest"; export * from "./Cycle"; export * from "./CycleTransfer"; +export * from "./Dashboard"; export * from "./Estimate"; export * from "./EstimatePoint"; export * from "./Feature"; @@ -44,6 +45,7 @@ export * from "./View"; export * from "./Webhook"; export * from "./WebhookLog"; export * from "./WhoAmI"; +export * from "./Widget"; export * from "./Workspace"; // `Page` (wiki page) and `common`'s `Page` (pagination envelope) both export a // member named `Page`; this explicit re-export wins the bare `Page` name for the diff --git a/tests/e2e/v2/dashboards.e2e.test.ts b/tests/e2e/v2/dashboards.e2e.test.ts new file mode 100644 index 00000000..0263a85f --- /dev/null +++ b/tests/e2e/v2/dashboards.e2e.test.ts @@ -0,0 +1,136 @@ +/** + * Dashboards and their widgets against a real server — CRUD, copy, widgetTypes, and the + * chart/charts reads. EE-gated on FeatureFlag.DASHBOARDS, so a 402 sits the file out. + * Widgets are reached off the fetched dashboard row, so the dashboard id is never repeated. + */ +import { LoadedDashboard } from "../../../src/api/v2/loaded/Dashboard"; +import { PlaneApiError } from "../../../src/errors/PlaneApiError"; +import { useCapability } from "./support/capability"; +import { v2Env } from "./support/env"; +import { uniqueName } from "./support/names"; +import { useV2Project } from "./support/suite"; + +const env = v2Env(); +const maybe = env.ready ? describe : describe.skip; + +maybe("v2 dashboards (live)", () => { + const suite = useV2Project("dashboards", env); + const dashboards = () => suite.client.v2.workspaces.dashboards; + const slug = () => suite.workspaceSlug; + const capability = useCapability("FeatureFlag.DASHBOARDS is not enabled for this workspace"); + + // Best-effort, like the project cleanup: an already-gone dashboard is fine, but a failed delete + // leaves it in the test workspace, so say so. + async function deleteDashboard(id: string): Promise { + try { + await dashboards().delete(slug(), id); + } catch (error) { + // A test that deletes its own dashboard leaves nothing to clean up. + if (error instanceof PlaneApiError && error.status === 404) return; + console.warn(`v2 e2e: failed to delete test dashboard ${id}:`, error); + } + } + + async function withDashboard(body: (dashboard: LoadedDashboard) => Promise): Promise { + const dashboard = await dashboards().create(slug(), { + name: uniqueName("dashboard"), + project_ids: [suite.projectId], + }); + try { + await body(dashboard); + } finally { + await deleteDashboard(dashboard.id); + } + } + + capability.it("creates, retrieves, updates, lists, then deletes a dashboard", async () => { + await withDashboard(async (dashboard) => { + expect(dashboard.project_ids).toEqual([suite.projectId]); + + const fetched = await dashboards().retrieve(slug(), dashboard.id); + expect(fetched.name).toBe(dashboard.name); + + const renamed = uniqueName("dashboard"); + const updated = await dashboards().update(slug(), dashboard.id, { name: renamed }); + expect(updated.name).toBe(renamed); + + const page = await dashboards().list(slug(), { search: renamed }); + expect(page.data.map((row) => row.id)).toContain(dashboard.id); + + await expect(dashboards().delete(slug(), dashboard.id)).resolves.toBeUndefined(); + const status = await dashboards() + .retrieve(slug(), dashboard.id) + .then( + () => 200, + (error: unknown) => (error instanceof PlaneApiError ? error.status : -1) + ); + expect(status).toBe(404); + }); + }); + + capability.it("lists widget types with their chart models", async () => { + const types = await dashboards().widgetTypes(slug()); + const bar = types.find((row) => row.chart_type === "BAR_CHART"); + expect(bar?.chart_models).toContain("BASIC"); + }); + + capability.it("creates, updates, charts, then deletes a widget", async () => { + await withDashboard(async (dashboard) => { + const widget = await dashboard.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", + }); + expect(widget.dashboard_id).toBe(dashboard.id); + + const moved = await dashboard.widgets.update(widget.id, { x_axis_coord: 6 }); + expect(moved.x_axis_coord).toBe(6); + + const chart = await dashboard.widgets.chart(widget.id); + expect(Array.isArray(chart.data)).toBe(true); + expect(chart.page).toBe(1); + + const results = await dashboard.widgets.charts({ widget_ids: [widget.id] }); + expect(results.map((row) => [row.widget_id, row.status])).toEqual([[widget.id, 200]]); + + await dashboard.widgets.delete(widget.id); + const page = await dashboard.widgets.list(); + expect(page.data.map((row) => row.id)).not.toContain(widget.id); + }); + }); + + capability.it("refuses a widget missing its required axis", async () => { + await withDashboard(async (dashboard) => { + const status = await dashboard.widgets + .create({ name: "No axis", chart_type: "BAR_CHART", chart_model: "BASIC" }) + .then( + () => 201, + (error: unknown) => (error instanceof PlaneApiError ? error.status : -1) + ); + expect(status).toBe(400); + }); + }); + + capability.it("copies a dashboard with its widgets", async () => { + await withDashboard(async (dashboard) => { + await dashboard.widgets.create({ + name: "Total", + chart_type: "NUMBER", + chart_model: "BASIC", + y_axis_metric: "WORK_ITEM_COUNT", + }); + const copyName = uniqueName("dashboard-copy"); + const copied = await dashboards().copy(slug(), dashboard.id, { name: copyName }); + try { + expect(copied.name).toBe(copyName); + const widgets = await copied.widgets.list(); + expect(widgets.data.map((row) => row.name)).toEqual(["Total"]); + } finally { + await deleteDashboard(copied.id); + } + }); + }); +}); diff --git a/tests/unit/v2/dashboards.test.ts b/tests/unit/v2/dashboards.test.ts new file mode 100644 index 00000000..f89cb793 --- /dev/null +++ b/tests/unit/v2/dashboards.test.ts @@ -0,0 +1,384 @@ +import nock from "nock"; +import { Configuration } from "../../../src/Configuration"; +import { V2Namespace } from "../../../src/api/v2"; +import { Dashboards } from "../../../src/api/v2/Dashboards"; +import { V2Transport } from "../../../src/api/v2/kernel/transport"; +import { PlaneApiError } from "../../../src/errors/PlaneApiError"; + +const BASE = "https://api.example.com"; +const SLUG = "acme"; +const DASHBOARD = "dash-1"; +const WIDGET = "wid-1"; + +const config = () => new Configuration({ baseUrl: BASE, apiKey: "secret" }); +const makeDashboards = () => new Dashboards(new V2Transport(config())); + +afterEach(() => nock.cleanAll()); + +describe("Dashboards (v2)", () => { + const collection = `/api/v2/workspaces/${SLUG}/dashboards/`; + + it("lists, creates, retrieves, updates and deletes a dashboard", async () => { + nock(BASE) + .get(collection) + .query({ access: "1", is_shared: "false", search: "eng", order_by: "-updated_at" }) + .reply(200, { data: [{ id: DASHBOARD, name: "Engineering" }], pagination: { style: "offset" }, total_count: 1 }); + nock(BASE) + .post(collection, { name: "Engineering", access: 1, project_ids: ["p1"] }) + .reply(201, { id: DASHBOARD, name: "Engineering", access: 1, project_ids: ["p1"] }); + nock(BASE).get(`${collection}${DASHBOARD}/`).reply(200, { id: DASHBOARD, name: "Engineering" }); + nock(BASE) + .patch(`${collection}${DASHBOARD}/`, { pql_filters: { json: {}, stripped: 'priority = "high"' } }) + .reply(200, { id: DASHBOARD, pql_filters: { json: {}, stripped: 'priority = "high"' } }); + nock(BASE).delete(`${collection}${DASHBOARD}/`).reply(204); + + const dashboards = makeDashboards(); + const page = await dashboards.list(SLUG, { access: 1, is_shared: false, search: "eng", order_by: "-updated_at" }); + expect(page.data[0].name).toBe("Engineering"); + + const created = await dashboards.create(SLUG, { name: "Engineering", access: 1, project_ids: ["p1"] }); + expect(created.project_ids).toEqual(["p1"]); + + expect((await dashboards.retrieve(SLUG, DASHBOARD)).name).toBe("Engineering"); + + const updated = await dashboards.update(SLUG, DASHBOARD, { + pql_filters: { json: {}, stripped: 'priority = "high"' }, + }); + expect(updated.pql_filters?.stripped).toBe('priority = "high"'); + + await expect(dashboards.delete(SLUG, DASHBOARD)).resolves.toBeUndefined(); + }); + + it("copies a dashboard with overrides, and with an empty body when none are given", async () => { + const withBody = nock(BASE) + .post(`${collection}${DASHBOARD}/copy/`, { name: "Engineering copy", access: 0 }) + .reply(201, { id: "dash-2", name: "Engineering copy", access: 0 }); + const withoutBody = nock(BASE) + .post(`${collection}${DASHBOARD}/copy/`, {}) + .query({ fields: "name" }) + .reply(201, { id: "dash-3", name: "Engineering (Copy)" }); + nock(BASE) + .get(`/api/v2/workspaces/${SLUG}/dashboards/dash-2/widgets/`) + .reply(200, { data: [{ id: WIDGET }], pagination: { style: "offset" } }); + + const dashboards = makeDashboards(); + const copy = await dashboards.copy(SLUG, DASHBOARD, { name: "Engineering copy", access: 0 }); + expect(copy.id).toBe("dash-2"); + // The copy is a loaded row like any other: it reaches its own widgets. + expect((await copy.widgets.list()).data[0].id).toBe(WIDGET); + + const narrowed = await dashboards.copy(SLUG, DASHBOARD, undefined, { fields: ["name"] }); + expect(narrowed.name).toBe("Engineering (Copy)"); + + expect(withBody.isDone()).toBe(true); + expect(withoutBody.isDone()).toBe(true); + }); + + it("lists widget types as a plain array", async () => { + nock(BASE) + .get(`/api/v2/workspaces/${SLUG}/dashboards/widget-types/`) + .reply(200, { + data: [ + { + chart_type: "BAR_CHART", + name: "Bar Chart", + chart_models: ["BASIC", "STACKED", "GROUPED"], + available: true, + }, + { chart_type: "WORK_ITEMS_TABLE", name: "Work Items Table", chart_models: ["BASIC"], available: false }, + ], + }); + + const types = await makeDashboards().widgetTypes(SLUG); + + expect(types.map((type) => type.chart_type)).toEqual(["BAR_CHART", "WORK_ITEMS_TABLE"]); + expect(types[1].available).toBe(false); + }); + + it("answers navigable rows that reach their widgets, from a fetched workspace too", async () => { + nock(BASE).get(`/api/v2/workspaces/${SLUG}/`).reply(200, { id: "ws-1", slug: SLUG }); + nock(BASE).get(`${collection}${DASHBOARD}/`).reply(200, { id: DASHBOARD, name: "Engineering" }); + nock(BASE) + .get(`/api/v2/workspaces/${SLUG}/dashboards/${DASHBOARD}/widgets/`) + .query({ chart_type: "NUMBER" }) + .reply(200, { data: [{ id: WIDGET, chart_type: "NUMBER" }], pagination: { style: "offset" } }); + + const v2 = new V2Namespace(config()); + const workspace = await v2.workspaces.retrieve(SLUG); + const dashboard = await workspace.dashboards.retrieve(DASHBOARD); + + expect(dashboard.$loaded.ids).toEqual([SLUG, DASHBOARD]); + expect(dashboard.$loaded.idNames).toEqual(["slug", "dashboard"]); + // Navigation is non-enumerable: the plain row is what serialises. + expect(Object.keys(dashboard)).toEqual(["id", "name"]); + + const widgets = await dashboard.widgets.list({ chart_type: "NUMBER" }); + expect(widgets.data[0].chart_type).toBe("NUMBER"); + }); + + it("iterates every dashboard across pages, still navigable", async () => { + nock(BASE) + .get(collection) + .reply(200, { data: [{ id: "d1" }], pagination: { style: "offset" }, next: 1 }); + nock(BASE) + .get(collection) + .query({ offset: "1" }) + .reply(200, { data: [{ id: "d2" }], pagination: { style: "offset" }, next: null }); + + const ids: string[] = []; + for await (const dashboard of makeDashboards().iterate(SLUG)) { + ids.push(dashboard.id); + expect(typeof dashboard.widgets.list).toBe("function"); + } + expect(ids).toEqual(["d1", "d2"]); + }); + + it("rejects an unknown field before making the request", async () => { + await expect(makeDashboards().list(SLUG, { fields: ["nonexistent_field" as never] })).rejects.toThrow( + /Unknown field\(s\) for dashboards_list: nonexistent_field/ + ); + }); +}); + +describe("Dashboard widgets (v2)", () => { + const widgetsUrl = `/api/v2/workspaces/${SLUG}/dashboards/${DASHBOARD}/widgets/`; + + it("creates, retrieves, updates and deletes a widget", async () => { + const body = { + name: "Open work items", + chart_type: "NUMBER" as const, + chart_model: "BASIC" as const, + y_axis_metric: "WORK_ITEM_COUNT" as const, + width: 3, + height: 2, + }; + nock(BASE) + .post(widgetsUrl, body) + .reply(201, { id: WIDGET, dashboard_id: DASHBOARD, ...body, config: { y_axis_metric: "WORK_ITEM_COUNT" } }); + nock(BASE) + .get(`${widgetsUrl}${WIDGET}/`) + .query({ fields: "name" }) + .reply(200, { id: WIDGET, name: "Open work items" }); + nock(BASE).patch(`${widgetsUrl}${WIDGET}/`, { x_axis_coord: 6 }).reply(200, { id: WIDGET, x_axis_coord: 6 }); + nock(BASE).delete(`${widgetsUrl}${WIDGET}/`).reply(204); + + const widgets = makeDashboards().widgets; + const created = await widgets.create(SLUG, DASHBOARD, body); + expect(created.dashboard_id).toBe(DASHBOARD); + expect(created.config).toEqual({ y_axis_metric: "WORK_ITEM_COUNT" }); + + const fetched = await widgets.retrieve(SLUG, DASHBOARD, WIDGET, { fields: ["name"] }); + expect(fetched.name).toBe("Open work items"); + + expect((await widgets.update(SLUG, DASHBOARD, WIDGET, { x_axis_coord: 6 })).x_axis_coord).toBe(6); + await expect(widgets.delete(SLUG, DASHBOARD, WIDGET)).resolves.toBeUndefined(); + }); + + it("creates a PQL-filtered widget that charts with its PQL", async () => { + const body = { + name: "Urgent by state", + chart_type: "BAR_CHART" as const, + chart_model: "BASIC" as const, + x_axis_property: "STATE_GROUPS", + y_axis_metric: "WORK_ITEM_COUNT" as const, + pql_filters: { json: {}, stripped: 'priority = "urgent"' }, + last_used_filter: "pql_filters" as const, + }; + const scope = nock(BASE) + .post(widgetsUrl, body) + .reply(201, { id: WIDGET, ...body, pql_filters: { ...body.pql_filters, json: {} }, filters: {} }); + + const created = await makeDashboards().widgets.create(SLUG, DASHBOARD, body); + + expect(scope.isDone()).toBe(true); + expect(created.last_used_filter).toBe("pql_filters"); + expect(created.pql_filters?.stripped).toBe('priority = "urgent"'); + }); + + it("sends null to clear the axis fields on an update", async () => { + const cleared = { group_by: null, x_axis_property: null, x_axis_date_grouping: null, y_axis_metric: null }; + const scope = nock(BASE) + .patch(`${widgetsUrl}${WIDGET}/`, (sent) => JSON.stringify(sent) === JSON.stringify(cleared)) + .reply(200, { id: WIDGET, ...cleared, last_used_filter: "" }); + + const updated = await makeDashboards().widgets.update(SLUG, DASHBOARD, WIDGET, cleared); + + expect(scope.isDone()).toBe(true); + expect(updated.group_by).toBeNull(); + // The read model types these as free text: a widget the web app saved can carry "". + expect(updated.last_used_filter).toBe(""); + }); + + it("lists widgets with the chart filters and sort order", async () => { + nock(BASE) + .get(widgetsUrl) + .query({ chart_type: "BAR_CHART", chart_model: "STACKED", search: "prio", order_by: "-created_at" }) + .reply(200, { data: [{ id: WIDGET, name: "By priority" }], pagination: { style: "offset" } }); + + const page = await makeDashboards().widgets.list(SLUG, DASHBOARD, { + chart_type: "BAR_CHART", + chart_model: "STACKED", + search: "prio", + order_by: "-created_at", + }); + + expect(page.data[0].name).toBe("By priority"); + }); + + it("fetches one widget's chart, JSON-encoding row_ids", async () => { + const scope = nock(BASE) + .get(`${widgetsUrl}${WIDGET}/chart/`) + .query({ page: "2", column_page: "2", row_ids: '["backlog","started"]' }) + .reply(200, { + schema: { state_group: { backlog: { id: "backlog", name: "Backlog" } } }, + page: 2, + total_pages: 2, + data: [{ group_by_id: "backlog", cells: { high: 1 } }], + columns: ["high"], + }); + + const chart = await makeDashboards().widgets.chart(SLUG, DASHBOARD, WIDGET, { + page: 2, + column_page: 2, + row_ids: ["backlog", "started"], + }); + + expect(scope.isDone()).toBe(true); + expect(chart.page).toBe(2); + expect(chart.schema?.state_group.backlog.name).toBe("Backlog"); + expect(chart.columns).toEqual(["high"]); + }); + + it("pages a table chart by rows and columns without sending row_ids", async () => { + const scope = nock(BASE) + .get(`${widgetsUrl}${WIDGET}/chart/`) + .query((query) => JSON.stringify(query) === JSON.stringify({ page: "3", column_page: "2" })) + .reply(200, { + schema: {}, + data: [{ group_by_id: "urgent", cells: { backlog: 4 } }], + columns: ["backlog"], + page: 3, + per_page: 10, + total_pages: 3, + next_page: null, + prev_page: 2, + }); + + const chart = await makeDashboards().widgets.chart(SLUG, DASHBOARD, WIDGET, { page: 3, column_page: 2 }); + + expect(scope.isDone()).toBe(true); + expect(chart.page).toBe(3); + expect(chart.next_page).toBeNull(); + expect(chart.prev_page).toBe(2); + }); + + it("fetches a chart with no query string when no params are given", async () => { + nock(BASE) + .get(`${widgetsUrl}${WIDGET}/chart/`) + .query((query) => Object.keys(query).length === 0) + .reply(200, { schema: {}, value: 10, y_axis_metric: "WORK_ITEM_COUNT" }); + + const chart = await makeDashboards().widgets.chart(SLUG, DASHBOARD, WIDGET); + + expect(chart.value).toBe(10); + }); + + it("fetches every widget's chart in one call, comma-joining widget_ids", async () => { + nock(BASE) + .get(`${widgetsUrl}charts/`) + .query({ widget_ids: "w1,w2" }) + .reply(200, { + data: [ + { widget_id: "w1", chart_type: "NUMBER", status: 200, chart: { schema: {}, value: 10 } }, + { + widget_id: "w2", + chart_type: "WORK_ITEMS_TABLE", + status: 400, + error: { + type: "invalid_request", + code: "invalid_request", + detail: "One or more fields failed validation.", + }, + }, + ], + }); + + const results = await makeDashboards().widgets.charts(SLUG, DASHBOARD, { widget_ids: ["w1", "w2"] }); + + expect(results).toHaveLength(2); + expect(results[0].chart?.value).toBe(10); + expect(results[1].status).toBe(400); + expect(results[1].error?.code).toBe("invalid_request"); + }); + + it("sends the null row of a table chart in row_ids, and an empty list as []", async () => { + const withNull = nock(BASE) + .get(`${widgetsUrl}${WIDGET}/chart/`) + .query({ column_page: "2", row_ids: '["u1",null]' }) + .reply(200, { schema: {}, data: [], columns: [] }); + const empty = nock(BASE) + .get(`${widgetsUrl}${WIDGET}/chart/`) + .query({ row_ids: "[]" }) + .reply(200, { schema: {}, data: [], columns: [] }); + + const widgets = makeDashboards().widgets; + await widgets.chart(SLUG, DASHBOARD, WIDGET, { column_page: 2, row_ids: ["u1", null] }); + await widgets.chart(SLUG, DASHBOARD, WIDGET, { row_ids: [] }); + + expect(withNull.isDone()).toBe(true); + expect(empty.isDone()).toBe(true); + }); + + it("rejects with PlaneApiError when a chart can't be served", async () => { + nock(BASE).get(`${widgetsUrl}${WIDGET}/chart/`).reply( + 402, + { type: "payment_required", code: "payment_required", detail: "Upgrade." }, + { + "Content-Type": "application/problem+json", + } + ); + + const error = await makeDashboards() + .widgets.chart(SLUG, DASHBOARD, WIDGET) + .catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(PlaneApiError); + expect((error as PlaneApiError).status).toBe(402); + }); + + it("fetches every widget's chart with no query string when no widget_ids are given", async () => { + const scope = nock(BASE) + .get(`${widgetsUrl}charts/`) + .query((query) => Object.keys(query).length === 0) + .reply(200, { data: [] }); + + await expect(makeDashboards().widgets.charts(SLUG, DASHBOARD)).resolves.toEqual([]); + expect(scope.isDone()).toBe(true); + }); + + it("iterates every widget across pages", async () => { + nock(BASE) + .get(widgetsUrl) + .reply(200, { data: [{ id: "w1" }], pagination: { style: "offset" }, next: 1 }); + nock(BASE) + .get(widgetsUrl) + .query({ offset: "1" }) + .reply(200, { data: [{ id: "w2" }], pagination: { style: "offset" }, next: null }); + + const ids: string[] = []; + for await (const widget of makeDashboards().widgets.iterate(SLUG, DASHBOARD)) ids.push(widget.id); + expect(ids).toEqual(["w1", "w2"]); + }); + + it("answers an empty widget_ids selection with no request", async () => { + // `?widget_ids=` used to chart every widget, so the call must not reach the server at all. + const scope = nock(BASE) + .get(`${widgetsUrl}charts/`) + .query(true) + .reply(200, { data: [{ widget_id: "w1", chart_type: "NUMBER", status: 200, chart: { value: 1 } }] }); + + await expect(makeDashboards().widgets.charts(SLUG, DASHBOARD, { widget_ids: [] })).resolves.toEqual([]); + + expect(scope.isDone()).toBe(false); + }); +});