From 2c6ca91f2594c5d1354d62d2c9e0ac1f7091fd94 Mon Sep 17 00:00:00 2001 From: gurusinath Date: Fri, 30 Oct 2026 11:40:19 +0530 Subject: [PATCH 1/5] [WEB-7851] feat: dashboards and dashboard widgets on the v2 client Adds client.v2.workspaces.dashboards and its .widgets child for the new /api/v2/workspaces/{slug}/dashboards/ endpoints. - Dashboards: list, iterate, retrieve, create, update, delete, copy and widget_types. Rows come back as LoadedDashboard, which reaches dashboard.widgets with no ids repeated; a loaded workspace reaches workspace.dashboards. - Widgets: CRUD plus chart() (page, column_page, row_ids) and charts() (every widget's first page in one call, per-widget status/error). - Models in plane.models.v2.dashboards, typed from the OpenAPI golden. - Generated constants regenerated from the golden the SDK was built on (plane-ee a8a1a9f43be) plus only the dashboards paths; additions only. - Offline tests, tree/navigation sweep rows, and a live integration suite. --- CLAUDE.md | 23 +- README.md | 45 +- plane/api/v2/__init__.py | 7 +- plane/api/v2/_generated/constants.py | 535 ++++++++++++++++++++++++ plane/api/v2/_loaded/dashboard.py | 41 ++ plane/api/v2/_loaded/workspace.py | 18 + plane/api/v2/dashboards/__init__.py | 186 ++++++++ plane/api/v2/dashboards/widgets.py | 197 +++++++++ plane/api/v2/workspaces.py | 2 + plane/models/v2/__init__.py | 36 ++ plane/models/v2/dashboards.py | 284 +++++++++++++ tests/v2/integration/test_dashboards.py | 135 ++++++ tests/v2/test_dashboards_resource.py | 371 ++++++++++++++++ tests/v2/test_tree.py | 7 + 14 files changed, 1868 insertions(+), 19 deletions(-) create mode 100644 plane/api/v2/_loaded/dashboard.py create mode 100644 plane/api/v2/dashboards/__init__.py create mode 100644 plane/api/v2/dashboards/widgets.py create mode 100644 plane/models/v2/dashboards.py create mode 100644 tests/v2/integration/test_dashboards.py create mode 100644 tests/v2/test_dashboards_resource.py diff --git a/CLAUDE.md b/CLAUDE.md index 1a1caef..47a2f93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ PlaneClient - `plane/client/` — `PlaneClient` (API key / access token auth) and `OAuthClient` (OAuth 2.0 flows). - `plane/errors/` — `PlaneError` → `HttpError`, `ConfigurationError`. - `plane/config.py` — `Configuration` and `RetryConfig` dataclasses. -- `plane/api/v2/` — the v2 surface (`client.v2`). **Migration complete**: all 90 +- `plane/api/v2/` — the v2 surface (`client.v2`). **Migration complete**: all 92 `V2Resource` subclasses in the package (`tests/v2/tree_walk.py`'s `all_resource_classes()`) are on the flat shape and swept by the rule tests. `tests/v2/tree_walk.py`'s `UNMIGRATED_RESOURCES` — the opt-out list the sweeps @@ -87,7 +87,7 @@ PlaneClient never grow, so it cannot silently regain a member. Count resources, not grouping nodes: `wiki` and `group_sync` hold no `V2Resource` base, `path` or `operations` of their own — they only group children (`wiki.pages`, - `wiki.collections`, `group_sync.config`) — so neither is in the 90. + `wiki.collections`, `group_sync.config`) — so neither is in the 92. The whole project band is wired onto `client.v2.workspaces.projects`: `states`, `labels`, `work_items`, `cycles`, `milestones`, `modules`, @@ -95,17 +95,17 @@ PlaneClient `work_item_templates`, `worklogs`, `pages`, `automations`, `work_item_properties`, `work_item_types` and `workflows` — nineteen children, and a fetched project reaches every one of them that is itself - navigable (`project.cycles.list()`, `project.permissions.me()`). Nineteen of - the 90 classes are navigable — `workspaces`, `projects`, `work_items`, + navigable (`project.cycles.list()`, `project.permissions.me()`). Twenty of + the 92 classes are navigable — `workspaces`, `projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, `releases`, `work_item_types`, - `work_item_properties`, `automations` and `workflows` (three of those — + `work_item_properties`, `automations`, `workflows` and `dashboards` (three of those — automations, work item types and work item properties — each have a separate project-scoped and workspace-scoped resource class, each independently - navigable, which is where 16 families become 19 classes) — a fetched row + navigable, which is where 17 families become 20 classes) — a fetched row reaches its child with no ids repeated. `workspaces` is the root of that set and the last to join it: `client.v2.workspaces.retrieve("acme")` answers a - `LoadedWorkspace` reaching all 25 workspace-scoped families + `LoadedWorkspace` reaching all 26 workspace-scoped families (`workspace.projects.list()`), and its children address it by `slug`, never the UUID `id`, which that path segment does not accept. `estimates`' child is `estimate_points`, not `points` — `Estimate.points` is itself an API field, @@ -162,8 +162,8 @@ PlaneClient - **Loaded rows.** A resource with children (`projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, `releases`, `work_item_types`, - `work_item_properties`, `automations`, `workflows`, `workspaces` — 19 of the - 90 classes) returns a `Loaded` row from `retrieve`/`list`/`iterate`, not a bare + `work_item_properties`, `automations`, `workflows`, `dashboards`, `workspaces` — + 20 of the 92 classes) returns a `Loaded` row from `retrieve`/`list`/`iterate`, not a bare pydantic model: it carries its own data and reaches its own children with none of the ids repeated (`project.states.list()`, `work_item.comments.list()`, `cycle.work_items.add(["w1"])`). @@ -245,9 +245,10 @@ PlaneClient per `Loaded` subclass — `project.py`, `work_item.py`, `cycle.py`, `milestone.py`, `module.py`, `estimate.py`, `webhook.py`, `collection.py`, `customer.py`, `initiative.py`, `release.py`, `work_item_type.py`, - `work_item_property.py`, `automation.py`, `workflow.py`, `workspace.py`; copy + `work_item_property.py`, `automation.py`, `workflow.py`, `dashboard.py`, + `workspace.py`; copy whichever is closest in shape (single bridge-only child vs. several plain-CRUD - children). `workspace.py` is the `bind1` exemplar and the widest, at 25 + children). `workspace.py` is the `bind1` exemplar and the widest, at 26 children; the two grouping nodes (`wiki`, `group_sync`) are deliberately not among them — neither holds a `V2Resource` base, so neither is a child a row can bind, and they are reached from the namespace instead. diff --git a/README.md b/README.md index 536a156..bd68c55 100644 --- a/README.md +++ b/README.md @@ -200,16 +200,17 @@ work_items = client.work_items.list( `client.v2` reaches the v2 surface. v1 resources on the client are unchanged. -The v2 surface is complete: every one of the 90 `V2Resource` subclasses in the +The v2 surface is complete: every one of the 92 `V2Resource` subclasses in the package (`tests/v2/tree_walk.py`'s `all_resource_classes()`, the enumeration the test suite itself sweeps) is on the flat shape described below and reachable through `client.v2`. Counting resources means not counting grouping nodes: `wiki` and `group_sync` hold no `V2Resource` base, `path` or `operations` of their own — they only group children (`wiki.pages`, `wiki.collections`, -`group_sync.config`) — and are outside the 90. +`group_sync.config`) — and are outside the 92. Wired directly on `client.v2.workspaces`: `artifacts`, `assets`, `audit_logs`, -`automations`, `customer_properties`, `customers`, `features`, `initiatives`, +`automations`, `customer_properties`, `customers`, `dashboards` (`.widgets`), +`features`, `initiatives`, `invitations`, `members`, `permission_schemes`, `permissions`, `projects`, `releases` (`.labels`, `.tags`, `.comments`, `.links`, `.changelog`, `.work_items`), `roles`, `stickies`, `teamspaces`, `views`, `webhooks` (with @@ -276,8 +277,8 @@ client.v2.workspaces.projects.states.list(slug="acme", project="ENG") A resource with children (`projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, -`releases`, `work_item_types`, `work_item_properties`, `automations` and -`workflows` — 18 of the 90 classes, some families having a separate +`releases`, `work_item_types`, `work_item_properties`, `automations`, +`workflows` and `dashboards` — 19 of the 92 classes, some families having a separate project-scoped and workspace-scoped resource, each independently navigable) doesn't just hand back a bare pydantic model from `retrieve`/`list`/`iterate` — it hands back a row that carries its own data *and* already knows where it @@ -310,7 +311,8 @@ log; `collections` via `.members`/`.pages`; `customers` via `.requests`, `.changelog`, `.work_items`; `work_item_types` via `.properties`; `work_item_properties` via `.property_options` (plus `.contexts` on the workspace-scoped resource only); `automations` via `.edges`, `.nodes`, -`.activities`; and `workflows` via `.states`, `.transitions`. A work item +`.activities`; `workflows` via `.states`, `.transitions`; and `dashboards` via +`.widgets`. A work item itself reaches all seven of its own children this way — `comments`, `attachments`, `links`, `worklogs`, `activities`, `relations`, `dependencies`. @@ -325,6 +327,35 @@ class that declares a `loaded_model` and fails if its row's navigation properties don't match its resource's own children exactly — see [the four rules the tests enforce](#the-four-rules-the-tests-enforce) below. +### Dashboards and widgets + +A dashboard is a workspace-level page of charts over the work items in its projects; each +widget is one chart. A fetched dashboard reaches its widgets directly, and `chart()` returns +the data the widget displays (its shape depends on the chart type): + +```python +from plane.models.v2 import CreateDashboard, CreateWidget + +dashboards = client.v2.workspaces.dashboards +dashboard = dashboards.create("acme", CreateDashboard(name="Engineering", project_ids=[project_id])) + +widget = dashboard.widgets.create( + CreateWidget( + name="By priority", + chart_type="BAR_CHART", + chart_model="STACKED", + x_axis_property="PRIORITY", + y_axis_metric="WORK_ITEM_COUNT", + group_by="STATE_GROUPS", + ) +) +chart = dashboard.widgets.chart(widget.id) # chart.data, chart.chart_schema, paging fields +chart = dashboard.widgets.chart(widget.id, page=2) +everything = dashboard.widgets.charts() # every widget's first page in one call +dashboards.widget_types("acme") # chart types, their models, plan availability +dashboards.copy("acme", dashboard.id) # copies settings, filters and widgets +``` + ### Sparse responses raise, they don't lie Every read field except `id` is optional at the model level, because `?fields=` @@ -369,7 +400,7 @@ scripts to prove it, rather than trusting it by inspection. ### The five rules the tests enforce Five properties of the surface are each enforced by a sweep in `tests/v2/`, over -every one of the 90 resource classes (`tests/v2/tree_walk.py`'s +every one of the 92 resource classes (`tests/v2/tree_walk.py`'s `all_resource_classes()`) rather than a hand-picked subset — so a newly added resource is covered the moment it exists, with nothing to remember to add it to: diff --git a/plane/api/v2/__init__.py b/plane/api/v2/__init__.py index b584529..42da3ba 100644 --- a/plane/api/v2/__init__.py +++ b/plane/api/v2/__init__.py @@ -4,7 +4,7 @@ `users`/`user_assets` have no workspace in their path and sit on `V2Namespace` directly. -The 19 `Loaded*` row types are re-exported here too. They are the declared return +The 20 `Loaded*` row types are re-exported here too. They are the declared return type of every `retrieve`/`list`/`iterate`/`find_by_*`/verb on a navigable resource, so a caller must be able to *name* one -- to annotate a variable, a helper's parameter or a test fixture -- without importing out of the private `_loaded` @@ -26,6 +26,7 @@ from ._loaded.collection import LoadedCollection from ._loaded.customer import LoadedCustomer from ._loaded.cycle import LoadedCycle +from ._loaded.dashboard import LoadedDashboard from ._loaded.estimate import LoadedEstimate from ._loaded.initiative import LoadedInitiative from ._loaded.milestone import LoadedMilestone @@ -46,6 +47,7 @@ from .customer_properties import CustomerProperties from .customers import Customers from .cycles import Cycles +from .dashboards import Dashboards, Widgets from .estimates import Estimates from .features import ProjectFeatures, WorkspaceFeatures from .group_sync import GroupSync @@ -100,6 +102,7 @@ def __init__(self, config: Configuration) -> None: "Customers", "CustomerProperties", "Cycles", + "Dashboards", "Estimates", "FieldError", "FieldNotRequested", @@ -111,6 +114,7 @@ def __init__(self, config: Configuration) -> None: "LoadedCollection", "LoadedCustomer", "LoadedCycle", + "LoadedDashboard", "LoadedEstimate", "LoadedInitiative", "LoadedMilestone", @@ -151,6 +155,7 @@ def __init__(self, config: Configuration) -> None: "Users", "V2Namespace", "Webhooks", + "Widgets", "Wiki", "WikiPages", "WorkItemProperties", diff --git a/plane/api/v2/_generated/constants.py b/plane/api/v2/_generated/constants.py index e2cd942..06f82f3 100644 --- a/plane/api/v2/_generated/constants.py +++ b/plane/api/v2/_generated/constants.py @@ -66,6 +66,13 @@ "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", @@ -278,6 +285,13 @@ "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", @@ -1233,6 +1247,126 @@ "timezone", ] ), + "dashboards_copy": frozenset( + [ + "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", + ] + ), + "dashboards_create": frozenset( + [ + "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", + ] + ), + "dashboards_destroy": frozenset( + [ + "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", + ] + ), + "dashboards_list": frozenset( + [ + "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", + ] + ), + "dashboards_partial_update": frozenset( + [ + "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", + ] + ), + "dashboards_retrieve": frozenset( + [ + "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", + ] + ), "estimate_points_create": frozenset( [ "all", @@ -3689,6 +3823,141 @@ "version", ] ), + "widgets_create": frozenset( + [ + "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", + ] + ), + "widgets_destroy": frozenset( + [ + "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", + ] + ), + "widgets_list": frozenset( + [ + "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", + ] + ), + "widgets_partial_update": frozenset( + [ + "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", + ] + ), + "widgets_retrieve": frozenset( + [ + "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", + ] + ), "work_item_comments_upsert": frozenset( [ "access", @@ -5558,6 +5827,9 @@ "cycles_list": frozenset( ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] ), + "dashboards_list": frozenset( + ["-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at"] + ), "estimate_points_list": frozenset(["-created_at", "-id", "-key", "created_at", "id", "key"]), "estimates_list": frozenset(["-created_at", "-id", "-name", "created_at", "id", "name"]), "group_sync_project_mappings_list": frozenset( @@ -5647,6 +5919,7 @@ "user_assets_list": frozenset(["-created_at", "-id", "created_at", "id"]), "webhook_logs_list": frozenset(["-created_at", "-id", "created_at", "id"]), "webhooks_list": frozenset(["-created_at", "-id", "created_at", "id"]), + "widgets_list": frozenset(["-created_at", "-id", "created_at", "id"]), "work_item_properties_list": frozenset( ["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] ), @@ -5862,6 +6135,7 @@ "customer_requests_list": frozenset(["count", "offset", "paginate", "per_page"]), "customers_list": frozenset(["count", "offset", "paginate", "per_page"]), "cycles_list": frozenset(["count", "offset", "paginate", "per_page"]), + "dashboards_list": frozenset(["count", "offset", "paginate", "per_page"]), "estimate_points_list": frozenset(["count", "offset", "paginate", "per_page"]), "estimates_list": frozenset(["count", "offset", "paginate", "per_page"]), "group_sync_project_mappings_list": frozenset(["count", "offset", "paginate", "per_page"]), @@ -5897,6 +6171,7 @@ "user_assets_list": frozenset(["count", "offset", "paginate", "per_page"]), "webhook_logs_list": frozenset(["count", "offset", "paginate", "per_page"]), "webhooks_list": frozenset(["count", "offset", "paginate", "per_page"]), + "widgets_list": frozenset(["count", "offset", "paginate", "per_page"]), "work_item_properties_list": frozenset(["count", "offset", "paginate", "per_page"]), "work_item_property_contexts_list": frozenset(["count", "offset", "paginate", "per_page"]), "work_item_property_options_list": frozenset(["count", "offset", "paginate", "per_page"]), @@ -6672,6 +6947,114 @@ "start_date", "timezone", ] +DashboardsCopyField = Literal[ + "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", +] +DashboardsCreateField = Literal[ + "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", +] +DashboardsDestroyField = Literal[ + "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", +] +DashboardsListField = Literal[ + "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", +] +DashboardsPartialUpdateField = Literal[ + "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", +] +DashboardsRetrieveField = Literal[ + "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", +] EstimatePointsCreateField = Literal[ "all", "created_at", @@ -8825,6 +9208,131 @@ "url", "version", ] +WidgetsCreateField = Literal[ + "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", +] +WidgetsDestroyField = Literal[ + "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", +] +WidgetsListField = Literal[ + "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", +] +WidgetsPartialUpdateField = Literal[ + "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", +] +WidgetsRetrieveField = Literal[ + "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", +] WorkItemCommentsUpsertField = Literal[ "access", "actor_id", @@ -10485,6 +10993,9 @@ CustomerRequestsListOrderBy = Literal["-created_at", "-id", "created_at", "id"] CustomersListOrderBy = Literal["-created_at", "-id", "-name", "created_at", "id", "name"] CyclesListOrderBy = Literal["-created_at", "-id", "-sort_order", "created_at", "id", "sort_order"] +DashboardsListOrderBy = Literal[ + "-created_at", "-id", "-name", "-updated_at", "created_at", "id", "name", "updated_at" +] EstimatePointsListOrderBy = Literal["-created_at", "-id", "-key", "created_at", "id", "key"] EstimatesListOrderBy = Literal["-created_at", "-id", "-name", "created_at", "id", "name"] GroupSyncProjectMappingsListOrderBy = Literal[ @@ -10558,6 +11069,7 @@ UserAssetsListOrderBy = Literal["-created_at", "-id", "created_at", "id"] WebhookLogsListOrderBy = Literal["-created_at", "-id", "created_at", "id"] WebhooksListOrderBy = Literal["-created_at", "-id", "created_at", "id"] +WidgetsListOrderBy = Literal["-created_at", "-id", "created_at", "id"] WorkItemPropertiesListOrderBy = Literal[ "-created_at", "-id", "-sort_order", "created_at", "id", "sort_order" ] @@ -10704,6 +11216,13 @@ class CyclesListFilters(TypedDict, total=False): search: str +class DashboardsListFilters(TypedDict, total=False): + access: int + is_shared: bool + owned_by_id: str + search: str + + class EstimatePointsListFilters(TypedDict, total=False): external_id: str external_source: str @@ -10955,6 +11474,22 @@ class WebhooksListFilters(TypedDict, total=False): url: str +class WidgetsChartFilters(TypedDict, total=False): + column_page: int + page: int + row_ids: str + + +class WidgetsChartsFilters(TypedDict, total=False): + widget_ids: str + + +class WidgetsListFilters(TypedDict, total=False): + chart_model: str + chart_type: str + search: str + + class WorkItemPropertiesListFilters(TypedDict, total=False): display_name: str external_id: str diff --git a/plane/api/v2/_loaded/dashboard.py b/plane/api/v2/_loaded/dashboard.py new file mode 100644 index 0000000..9b6098a --- /dev/null +++ b/plane/api/v2/_loaded/dashboard.py @@ -0,0 +1,41 @@ +"""A fetched dashboard row that is also the place its widgets live.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ....models.v2.dashboards import Dashboard +from .._kernel.loaded import Loaded, Owned, bind2 + +if TYPE_CHECKING: + from ..dashboards import Dashboards, Widgets + + # Typed view on `Owned`: `Widgets`' own methods with `slug` and `dashboard` already + # supplied -- `bind2`, because two path ids are bound. Evaluated only by a type + # checker -- at runtime the property returns a plain `Owned`. + + class _OwnedWidgets(Owned["Widgets"]): + list = staticmethod(bind2(Widgets.list)) + iterate = staticmethod(bind2(Widgets.iterate)) + retrieve = staticmethod(bind2(Widgets.retrieve)) + create = staticmethod(bind2(Widgets.create)) + update = staticmethod(bind2(Widgets.update)) + delete = staticmethod(bind2(Widgets.delete)) + chart = staticmethod(bind2(Widgets.chart)) + charts = staticmethod(bind2(Widgets.charts)) + + +class LoadedDashboard(Loaded, Dashboard): + """A dashboard row that is also the place its widgets live.""" + + model_config = {**Dashboard.model_config, "arbitrary_types_allowed": True} + + if TYPE_CHECKING: + # Type-checker-only declaration; `Dashboards._load` sets it with + # `object.__setattr__`. A runtime annotation would become a pydantic + # private attribute. + _resources: Dashboards + + @property + def widgets(self) -> _OwnedWidgets: + return cast("_OwnedWidgets", Owned(self._resources.widgets, self._ids, self._id_names)) diff --git a/plane/api/v2/_loaded/workspace.py b/plane/api/v2/_loaded/workspace.py index 430fff8..867636c 100644 --- a/plane/api/v2/_loaded/workspace.py +++ b/plane/api/v2/_loaded/workspace.py @@ -20,6 +20,7 @@ from ..automations import WorkspaceAutomations from ..customer_properties import CustomerProperties from ..customers.customers import Customers + from ..dashboards import Dashboards from ..features import WorkspaceFeatures from ..initiatives.initiatives import Initiatives from ..invitations import Invitations @@ -138,6 +139,16 @@ class _OwnedCustomers(Owned["Customers"]): delete = staticmethod(bind1(Customers.delete)) upsert = staticmethod(bind1(Customers.upsert)) + class _OwnedDashboards(Owned["Dashboards"]): + list = staticmethod(bind1(Dashboards.list)) + iterate = staticmethod(bind1(Dashboards.iterate)) + retrieve = staticmethod(bind1(Dashboards.retrieve)) + create = staticmethod(bind1(Dashboards.create)) + update = staticmethod(bind1(Dashboards.update)) + delete = staticmethod(bind1(Dashboards.delete)) + copy = staticmethod(bind1(Dashboards.copy)) + widget_types = staticmethod(bind1(Dashboards.widget_types)) + class _OwnedInitiatives(Owned["Initiatives"]): list = staticmethod(bind1(Initiatives.list)) iterate = staticmethod(bind1(Initiatives.iterate)) @@ -345,6 +356,13 @@ def customers(self) -> _OwnedCustomers: Owned(self._resources.customers, self._ids, self._id_names), ) + @property + def dashboards(self) -> _OwnedDashboards: + return cast( + "_OwnedDashboards", + Owned(self._resources.dashboards, self._ids, self._id_names), + ) + @property def initiatives(self) -> _OwnedInitiatives: return cast( diff --git a/plane/api/v2/dashboards/__init__.py b/plane/api/v2/dashboards/__init__.py new file mode 100644 index 0000000..4d0bcb7 --- /dev/null +++ b/plane/api/v2/dashboards/__init__.py @@ -0,0 +1,186 @@ +"""Dashboards (api_v2) -- workspace-level pages of charts built from the work items in one or more +projects. A dashboard's charts are its `.widgets`. + +A dashboard is visible to its owner, to everyone in the workspace when public (`access=1`), and to +members it was shared with; one the caller can't see answers 404, never 403. Guests can't use +dashboards. + +A fetched row (`retrieve`/`create`/`update`/`copy`, and every row in a `list` page) comes back as +a `LoadedDashboard`, which reaches `dashboard.widgets.list()` without repeating +`slug`/`dashboard`.""" + +from __future__ import annotations + +import builtins +from collections.abc import Iterator, Sequence + +from typing_extensions import Unpack + +from ....models.v2.dashboards import ( + CopyDashboard, + CreateDashboard, + Dashboard, + UpdateDashboard, + WidgetType, + WidgetTypeList, +) +from .._generated.constants import ( + DashboardsCopyField, + DashboardsCreateField, + DashboardsListField, + DashboardsListFilters, + DashboardsListOrderBy, + DashboardsPartialUpdateField, + DashboardsRetrieveField, +) +from .._kernel.loaded import LoadsNavigableRows +from .._kernel.pagination import Page, PaginateStyle +from .._kernel.resource import V2Resource +from .._kernel.transport import V2Transport +from .._loaded.dashboard import LoadedDashboard +from .widgets import Widgets + +__all__ = ["Dashboards", "Widgets"] + + +class Dashboards( + V2Resource[Dashboard, CreateDashboard, UpdateDashboard], LoadsNavigableRows[LoadedDashboard] +): + path = "/workspaces/{slug}/dashboards/" + model = Dashboard + loaded_model = LoadedDashboard + loaded_names = ("slug", "dashboard") + operations = { + "list": "dashboards_list", + "retrieve": "dashboards_retrieve", + "create": "dashboards_create", + "update": "dashboards_partial_update", + "delete": "dashboards_destroy", + "copy": "dashboards_copy", + "widget_types": "dashboards_widget_types", + } + extra_paths = {"widget_types": "/workspaces/{slug}/dashboards/widget-types/"} + + def __init__(self, transport: V2Transport) -> None: + super().__init__(transport) + self.widgets = Widgets(transport) + + def list( + self, + slug: str, + *, + fields: Sequence[DashboardsListField] | None = None, + order_by: DashboardsListOrderBy | None = None, + per_page: int | None = None, + offset: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + count: bool | None = None, + **filters: Unpack[DashboardsListFilters], + ) -> Page[LoadedDashboard]: + """One page of the dashboards the caller can see, newest first by default.""" + page = self._list( + params={ + "fields": fields, + "order_by": order_by, + "per_page": per_page, + "offset": offset, + "paginate": paginate, + "cursor": cursor, + "count": count, + **filters, + }, + slug=slug, + ) + return self._load_page(page, slug, fields=fields) + + def iterate( + self, + slug: str, + *, + fields: Sequence[DashboardsListField] | None = None, + order_by: DashboardsListOrderBy | None = None, + per_page: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + **filters: Unpack[DashboardsListFilters], + ) -> Iterator[LoadedDashboard]: + """Every dashboard the caller can see, following pages automatically.""" + rows = self._iter( + params={ + "fields": fields, + "order_by": order_by, + "per_page": per_page, + "paginate": paginate, + "cursor": cursor, + **filters, + }, + slug=slug, + ) + return (self._load(row, slug, fields=fields) for row in rows) + + def retrieve( + self, + slug: str, + dashboard: str, + *, + fields: Sequence[DashboardsRetrieveField] | None = None, + ) -> LoadedDashboard: + row = self._retrieve(pk=dashboard, params={"fields": fields}, slug=slug) + return self._load(row, slug, fields=fields) + + def create( + self, + slug: str, + data: CreateDashboard, + *, + fields: Sequence[DashboardsCreateField] | None = None, + ) -> LoadedDashboard: + """Create an empty dashboard owned by the caller.""" + row = self._create(data, params={"fields": fields}, slug=slug) + return self._load(row, slug, fields=fields) + + def update( + self, + slug: str, + dashboard: str, + data: UpdateDashboard, + *, + fields: Sequence[DashboardsPartialUpdateField] | None = None, + ) -> LoadedDashboard: + """Change only the fields sent. Making a dashboard public (`access=1`) removes its shared + members; only the owner, or an admin on a public dashboard, can change `access`.""" + row = self._update(data, pk=dashboard, params={"fields": fields}, slug=slug) + return self._load(row, slug, fields=fields) + + def delete(self, slug: str, dashboard: str) -> None: + """Delete the dashboard and remove it from every user's favorites. Its public link stops + serving straight away.""" + return self._delete(pk=dashboard, slug=slug) + + def copy( + self, + slug: str, + dashboard: str, + data: CopyDashboard | None = None, + *, + fields: Sequence[DashboardsCopyField] | None = None, + ) -> LoadedDashboard: + """A new dashboard owned by the caller with the source's settings, projects, filters and + every widget. Shared members and the public link aren't copied. Each field in `data` + replaces the copied value.""" + row = self._action( + "copy", + pk=dashboard, + data=data or CopyDashboard(), + params={"fields": fields}, + slug=slug, + ) + return self._load(row, slug, fields=fields) + + def widget_types(self, slug: str) -> builtins.list[WidgetType]: + """Every widget type a dashboard can hold, the chart models each accepts, and whether the + workspace's plan includes it (`available`).""" + return self._custom_action( + "widget_types", model=WidgetTypeList, method="GET", slug=slug + ).data diff --git a/plane/api/v2/dashboards/widgets.py b/plane/api/v2/dashboards/widgets.py new file mode 100644 index 0000000..d115a47 --- /dev/null +++ b/plane/api/v2/dashboards/widgets.py @@ -0,0 +1,197 @@ +"""Dashboard widgets (api_v2) -- the charts on a dashboard, always addressed under it. + +Every call answers 404 when the dashboard is missing or not visible to the caller, or when the +widget belongs to a different dashboard. `chart` is the widget's data, computed from the current +work items with the dashboard's and the widget's saved filters; `charts` is every widget's first +page in one call.""" + +from __future__ import annotations + +import builtins +import json +from collections.abc import Iterator, Sequence + +from typing_extensions import Unpack + +from ....models.v2.dashboards import ( + CreateWidget, + UpdateWidget, + Widget, + WidgetChart, + WidgetChartResult, + WidgetChartResultList, +) +from .._generated.constants import ( + WidgetsCreateField, + WidgetsListField, + WidgetsListFilters, + WidgetsListOrderBy, + WidgetsPartialUpdateField, + WidgetsRetrieveField, +) +from .._kernel.pagination import Page, PaginateStyle +from .._kernel.resource import V2Resource + +__all__ = ["Widgets"] + + +class Widgets(V2Resource[Widget, CreateWidget, UpdateWidget]): + path = "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/" + model = Widget + operations = { + "list": "widgets_list", + "retrieve": "widgets_retrieve", + "create": "widgets_create", + "update": "widgets_partial_update", + "delete": "widgets_destroy", + "chart": "widgets_chart", + "charts": "widgets_charts", + } + extra_paths = {"charts": "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/charts/"} + + def list( + self, + slug: str, + dashboard: str, + *, + fields: Sequence[WidgetsListField] | None = None, + order_by: WidgetsListOrderBy | None = None, + per_page: int | None = None, + offset: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + count: bool | None = None, + **filters: Unpack[WidgetsListFilters], + ) -> Page[Widget]: + """One page of the dashboard's widgets, with their settings and grid positions.""" + return self._list( + params={ + "fields": fields, + "order_by": order_by, + "per_page": per_page, + "offset": offset, + "paginate": paginate, + "cursor": cursor, + "count": count, + **filters, + }, + slug=slug, + dashboard_id=dashboard, + ) + + def iterate( + self, + slug: str, + dashboard: str, + *, + fields: Sequence[WidgetsListField] | None = None, + order_by: WidgetsListOrderBy | None = None, + per_page: int | None = None, + paginate: PaginateStyle | None = None, + cursor: str | None = None, + **filters: Unpack[WidgetsListFilters], + ) -> Iterator[Widget]: + """Every widget on the dashboard, following pages automatically.""" + return self._iter( + params={ + "fields": fields, + "order_by": order_by, + "per_page": per_page, + "paginate": paginate, + "cursor": cursor, + **filters, + }, + slug=slug, + dashboard_id=dashboard, + ) + + def retrieve( + self, + slug: str, + dashboard: str, + widget: str, + *, + fields: Sequence[WidgetsRetrieveField] | None = None, + ) -> Widget: + """The widget's settings only; its data is `chart`.""" + return self._retrieve( + pk=widget, params={"fields": fields}, slug=slug, dashboard_id=dashboard + ) + + def create( + self, + slug: str, + dashboard: str, + data: CreateWidget, + *, + fields: Sequence[WidgetsCreateField] | None = None, + ) -> Widget: + return self._create(data, params={"fields": fields}, slug=slug, dashboard_id=dashboard) + + def update( + self, + slug: str, + dashboard: str, + widget: str, + data: UpdateWidget, + *, + fields: Sequence[WidgetsPartialUpdateField] | None = None, + ) -> Widget: + return self._update( + data, pk=widget, params={"fields": fields}, slug=slug, dashboard_id=dashboard + ) + + def delete(self, slug: str, dashboard: str, widget: str) -> None: + return self._delete(pk=widget, slug=slug, dashboard_id=dashboard) + + def chart( + self, + slug: str, + dashboard: str, + widget: str, + *, + page: int | None = None, + column_page: int | None = None, + row_ids: Sequence[str] | None = None, + ) -> WidgetChart: + """The data the widget displays; the shape depends on its chart type. + + `page` pages the rows (buckets, groups, counters) of a paginated chart type; the page size + is the chart's own `config` setting. `column_page` and `row_ids` are `TABLE_CHART` only: + pass the `group_by_id`s already on screen as `row_ids` to load another column page for the + same rows without ranking them again. A parameter the chart type doesn't read is ignored. + """ + return self._custom_action( + "chart", + model=WidgetChart, + method="GET", + pk=widget, + params={ + "page": page, + "column_page": column_page, + # The API reads a JSON array here, not the comma-joined list `_query` builds. + "row_ids": json.dumps(list(row_ids)) if row_ids is not None else None, + }, + slug=slug, + dashboard_id=dashboard, + ) + + def charts( + self, + slug: str, + dashboard: str, + *, + widget_ids: Sequence[str] | None = None, + ) -> builtins.list[WidgetChartResult]: + """The first page of every widget's chart data, in layout order, in one call; or only the + widgets in `widget_ids` (ids not on this dashboard are skipped). A widget whose chart fails + carries its own `error` and `status` instead of `chart`, and the others still return. Page + further with `chart`.""" + return self._custom_action( + "charts", + model=WidgetChartResultList, + method="GET", + params={"widget_ids": widget_ids}, + slug=slug, + dashboard_id=dashboard, + ).data diff --git a/plane/api/v2/workspaces.py b/plane/api/v2/workspaces.py index 5ed0c7b..ccfcce4 100644 --- a/plane/api/v2/workspaces.py +++ b/plane/api/v2/workspaces.py @@ -30,6 +30,7 @@ from .automations import WorkspaceAutomations from .customer_properties import CustomerProperties from .customers import Customers +from .dashboards import Dashboards from .features import WorkspaceFeatures from .group_sync import GroupSync from .initiatives import Initiatives @@ -73,6 +74,7 @@ def __init__(self, transport: V2Transport) -> None: self.automations = WorkspaceAutomations(transport) self.customer_properties = CustomerProperties(transport) self.customers = Customers(transport) + self.dashboards = Dashboards(transport) self.group_sync = GroupSync(transport) self.initiatives = Initiatives(transport) self.invitations = Invitations(transport) diff --git a/plane/models/v2/__init__.py b/plane/models/v2/__init__.py index 0eca328..6cc9d72 100644 --- a/plane/models/v2/__init__.py +++ b/plane/models/v2/__init__.py @@ -86,6 +86,25 @@ Cycle, UpdateCycle, ) +from .dashboards import ( + CopyDashboard, + CreateDashboard, + CreateWidget, + Dashboard, + DashboardAccess, + UpdateDashboard, + UpdateWidget, + Widget, + WidgetChart, + WidgetChartModel, + WidgetChartResult, + WidgetChartType, + WidgetDateGrouping, + WidgetLastUsedFilter, + WidgetMetric, + WidgetType, + WidgetWriteChartType, +) from .estimates import ( CreateEstimate, CreateEstimatePoint, @@ -372,6 +391,23 @@ "CycleTransferResult", "CycleTransfer", "CreateCycle", + "CopyDashboard", + "CreateDashboard", + "CreateWidget", + "Dashboard", + "DashboardAccess", + "UpdateDashboard", + "UpdateWidget", + "Widget", + "WidgetChart", + "WidgetChartModel", + "WidgetChartResult", + "WidgetChartType", + "WidgetDateGrouping", + "WidgetLastUsedFilter", + "WidgetMetric", + "WidgetType", + "WidgetWriteChartType", "DataMode", "DependencyRelationType", "EffectivePermissions", diff --git a/plane/models/v2/dashboards.py b/plane/models/v2/dashboards.py new file mode 100644 index 0000000..92cfb36 --- /dev/null +++ b/plane/models/v2/dashboards.py @@ -0,0 +1,284 @@ +"""Dashboard and widget models for api_v2. A dashboard is a workspace-level page of charts; a +widget is one chart on it. Every read field but `id` is optional.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +DashboardAccess = Literal[0, 1] # 0 = Private, 1 = Public + +# Every chart type a stored widget can carry, including legacy ones the API no longer creates. +WidgetChartType = Literal[ + "BAR_CHART", + "LINE_CHART", + "AREA_CHART", + "PIE_CHART", + "DONUT_CHART", + "NUMBER", + "TABLE_CHART", + "RICH_TEXT", + "WORK_ITEMS_TABLE", + "ASSIGNED_WORK_ITEMS_TABLE", + "WORK_ITEMS_STATISTICS", + "WORK_ITEM_TYPE_PROGRESS", + "WORK_ITEMS_CREATED_VS_RESOLVED", + "WORK_ITEMS_SMART_COUNTER", + "WORK_ITEMS_SMART_GAUGE", + "CYCLE_PROGRESS_BAR", + "CYCLE_BURN_DOWN_CHART", + "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 create or update accepts. +WidgetWriteChartType = Literal[ + "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", +] +WidgetChartModel = Literal["BASIC", "STACKED", "GROUPED", "MULTI_LINE", "COMPARISON", "PROGRESS"] +WidgetLastUsedFilter = Literal["pql_filters", "rich_filters"] +WidgetDateGrouping = Literal["DAY", "WEEK", "MONTH", "YEAR"] +WidgetMetric = Literal[ + "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", +] + + +# -- Dashboards --------------------------------------------------------------------- + + +class Dashboard(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + access: DashboardAccess | None = None + archived_at: datetime | None = None + created_at: datetime | None = None + created_by_id: str | None = None + filters: dict[str, Any] | None = None + is_published: bool | None = None + is_shared: bool | None = None + logo_props: dict[str, Any] | None = None + name: str | None = None + owned_by_id: str | None = None + pql_filters: dict[str, Any] | None = None + project_ids: list[str] | None = None + updated_at: datetime | None = None + updated_by_id: str | None = None + + +class CreateDashboard(BaseModel): + """POST body. `pql_filters` is `{"raw": "", "stripped": ""}`; `filters` is a filter + object such as `{"priority__in": "urgent"}`. Both are validated on save.""" + + model_config = ConfigDict(extra="ignore") + + name: str = Field(min_length=1, max_length=255) + access: DashboardAccess | None = None + project_ids: list[str] | None = None + pql_filters: dict[str, Any] | None = None + filters: dict[str, Any] | None = None + logo_props: dict[str, Any] | None = None + + +class UpdateDashboard(BaseModel): + """PATCH body -- every field optional. v2 has no PUT. `project_ids` replaces the whole list.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = Field(default=None, min_length=1, max_length=255) + access: DashboardAccess | None = None + project_ids: list[str] | None = None + pql_filters: dict[str, Any] | None = None + filters: dict[str, Any] | None = None + logo_props: dict[str, Any] | None = None + + +class CopyDashboard(BaseModel): + """POST body for `copy` -- every field optional; each one sent replaces the copied value. + `name` defaults to `" (Copy)"` server-side.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = Field(default=None, min_length=1, max_length=255) + access: DashboardAccess | None = None + project_ids: list[str] | None = None + pql_filters: dict[str, Any] | None = None + filters: dict[str, Any] | None = None + logo_props: dict[str, Any] | None = None + + +class WidgetType(BaseModel): + """One row of `Dashboards.widget_types`. A type with `available=False` isn't on the + workspace's plan and answers 402 on create and on `chart`.""" + + model_config = ConfigDict(extra="allow") + + chart_type: str + name: str | None = None + chart_models: list[str] | None = None + available: bool | None = None + + +class WidgetTypeList(BaseModel): + """The `widget-types/` envelope, unwrapped by `Dashboards.widget_types`.""" + + model_config = ConfigDict(extra="allow") + + data: list[WidgetType] = [] + + +# -- Widgets ------------------------------------------------------------------------ + + +class Widget(BaseModel): + """For axis charts the axis fields are mirrored into `config`.""" + + model_config = ConfigDict(extra="allow") + + id: str + chart_model: WidgetChartModel | None = None + chart_type: WidgetChartType | None = None + config: dict[str, Any] | None = None + created_at: datetime | None = None + created_by_id: str | None = None + dashboard_id: str | None = None + filters: dict[str, Any] | None = None + group_by: str | None = None + height: int | None = None + last_used_filter: WidgetLastUsedFilter | None = None + name: str | None = None + pql_filters: dict[str, Any] | None = None + rich_filters: dict[str, Any] | None = None + updated_at: datetime | None = None + updated_by_id: str | None = None + width: int | None = None + x_axis_coord: int | None = None + x_axis_date_grouping: WidgetDateGrouping | None = None + x_axis_property: str | None = None + y_axis_coord: int | None = None + y_axis_metric: WidgetMetric | None = None + + +class CreateWidget(BaseModel): + """POST body. Checked against its chart type before it's saved: a missing required setting or + a model the chart type doesn't support is refused with 400. `x_axis_property`/`group_by` take + an axis property (`PRIORITY`, `STATE_GROUPS`, ...) or a custom property as `cp_`. + Set `last_used_filter="pql_filters"` when sending `pql_filters`.""" + + model_config = ConfigDict(extra="ignore") + + name: str = Field(min_length=1, max_length=255) + chart_type: WidgetWriteChartType + chart_model: WidgetChartModel + config: dict[str, Any] | None = None + x_axis_property: str | None = None + x_axis_date_grouping: WidgetDateGrouping | None = None + y_axis_metric: WidgetMetric | None = None + group_by: str | None = None + width: int | None = Field(default=None, ge=0) + height: int | None = Field(default=None, ge=0) + x_axis_coord: int | None = Field(default=None, ge=0) + y_axis_coord: int | None = Field(default=None, ge=0) + pql_filters: dict[str, Any] | None = None + filters: dict[str, Any] | None = None + rich_filters: dict[str, Any] | None = None + last_used_filter: WidgetLastUsedFilter | None = None + + +class UpdateWidget(BaseModel): + """PATCH body -- every field optional. A change that would leave the widget unable to show + data is refused with 400 and nothing is saved.""" + + model_config = ConfigDict(extra="ignore") + + name: str | None = Field(default=None, min_length=1, max_length=255) + chart_type: WidgetWriteChartType | None = None + chart_model: WidgetChartModel | None = None + config: dict[str, Any] | None = None + x_axis_property: str | None = None + x_axis_date_grouping: WidgetDateGrouping | None = None + y_axis_metric: WidgetMetric | None = None + group_by: str | None = None + width: int | None = Field(default=None, ge=0) + height: int | None = Field(default=None, ge=0) + x_axis_coord: int | None = Field(default=None, ge=0) + y_axis_coord: int | None = Field(default=None, ge=0) + pql_filters: dict[str, Any] | None = None + filters: dict[str, Any] | None = None + rich_filters: dict[str, Any] | None = None + last_used_filter: WidgetLastUsedFilter | None = None + + +# -- Chart data --------------------------------------------------------------------- + + +class WidgetChart(BaseModel): + """A widget's chart data. The shape depends on the chart type, so only the keys most charts + share are declared; every other key (`x_axis_schema_key`, `group_keys`, `columns`, `value`, + `results`, ...) is kept and readable as an attribute or via `model_dump()`. + + `schema` maps the ids in `data` to display details, keyed by entity (`state`, `priority`, + ...). The pagination fields are present only on paginated chart types.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + chart_schema: dict[str, Any] | None = Field(default=None, alias="schema") + data: Any = None + total_count: int | None = None + total_pages: int | None = None + page: int | None = None + per_page: int | None = None + next_page: int | None = None + prev_page: int | None = None + + +class WidgetChartResult(BaseModel): + """One widget's entry in `Widgets.charts`: `chart` on success (`status` 200), otherwise + `error` (a problem-details body) with that error's `status`.""" + + model_config = ConfigDict(extra="allow") + + widget_id: str + chart_type: str | None = None + status: int | None = None + chart: WidgetChart | None = None + error: dict[str, Any] | None = None + + +class WidgetChartResultList(BaseModel): + """The `charts/` envelope, unwrapped by `Widgets.charts`.""" + + model_config = ConfigDict(extra="allow") + + data: list[WidgetChartResult] = [] diff --git a/tests/v2/integration/test_dashboards.py b/tests/v2/integration/test_dashboards.py new file mode 100644 index 0000000..23fda42 --- /dev/null +++ b/tests/v2/integration/test_dashboards.py @@ -0,0 +1,135 @@ +"""Live coverage for `client.v2.workspaces.dashboards` and its `widgets`: CRUD, `copy`, +`widget_types`, and the `chart`/`charts` reads. Gated by `FeatureFlag.DASHBOARDS` (402 when +the plan lacks it), which is a declared server-capability skip. + +Widgets are reached off the fetched dashboard row (`dashboard.widgets.create(...)`), so the +dashboard id is never repeated.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from plane.api.v2 import LoadedDashboard, LoadedProject, PlaneAPIError +from plane.api.v2.dashboards import Dashboards +from plane.client import PlaneClient +from plane.models.v2.dashboards import ( + CopyDashboard, + CreateDashboard, + CreateWidget, + UpdateDashboard, + UpdateWidget, +) + +from ._guard import skip_absent_capability +from .helpers import unique_name + + +@pytest.fixture +def dashboards(client: PlaneClient) -> Dashboards: + return client.v2.workspaces.dashboards + + +@pytest.fixture +def dashboard( + dashboards: Dashboards, workspace_slug: str, project: LoadedProject +) -> Iterator[LoadedDashboard]: + try: + created = dashboards.create( + workspace_slug, + CreateDashboard(name=unique_name("dashboard"), project_ids=[project.id]), + ) + except PlaneAPIError as exc: + if exc.status == 402: + skip_absent_capability("DASHBOARDS is not enabled on this workspace's plan") + raise + yield created + try: + dashboards.delete(workspace_slug, created.id) + except PlaneAPIError as exc: + if exc.status != 404: + raise + + +class TestDashboards: + def test_retrieve_update_and_list( + self, dashboards: Dashboards, dashboard: LoadedDashboard, workspace_slug: str + ) -> None: + fetched = dashboards.retrieve(workspace_slug, dashboard.id) + assert fetched.name == dashboard.name + + renamed = unique_name("dashboard") + updated = dashboards.update(workspace_slug, dashboard.id, UpdateDashboard(name=renamed)) + assert updated.name == renamed + + ids = {row.id for row in dashboards.iterate(workspace_slug, search=renamed)} + assert dashboard.id in ids + + def test_widget_types_lists_bar_chart( + self, dashboards: Dashboards, dashboard: LoadedDashboard, workspace_slug: str + ) -> None: + types = {row.chart_type: row for row in dashboards.widget_types(workspace_slug)} + assert "BAR_CHART" in types + assert "BASIC" in (types["BAR_CHART"].chart_models or []) + + def test_copy_carries_the_widgets( + self, dashboards: Dashboards, dashboard: LoadedDashboard, workspace_slug: str + ) -> None: + dashboard.widgets.create( + CreateWidget( + name="Total", + chart_type="NUMBER", + chart_model="BASIC", + y_axis_metric="WORK_ITEM_COUNT", + ) + ) + copy_name = unique_name("dashboard-copy") + copied = dashboards.copy(workspace_slug, dashboard.id, CopyDashboard(name=copy_name)) + try: + assert copied.name == copy_name + assert [row.name for row in copied.widgets.iterate()] == ["Total"] + finally: + dashboards.delete(workspace_slug, copied.id) + + def test_delete_then_retrieve_is_404( + self, dashboards: Dashboards, dashboard: LoadedDashboard, workspace_slug: str + ) -> None: + dashboards.delete(workspace_slug, dashboard.id) + with pytest.raises(PlaneAPIError) as excinfo: + dashboards.retrieve(workspace_slug, dashboard.id) + assert excinfo.value.status == 404 + + +class TestWidgets: + def test_crud_and_chart(self, dashboard: LoadedDashboard) -> None: + widget = dashboard.widgets.create( + CreateWidget( + name="By priority", + chart_type="BAR_CHART", + chart_model="BASIC", + x_axis_property="PRIORITY", + y_axis_metric="WORK_ITEM_COUNT", + ) + ) + assert widget.dashboard_id == dashboard.id + + moved = dashboard.widgets.update(widget.id, UpdateWidget(x_axis_coord=6)) + assert moved.x_axis_coord == 6 + + chart = dashboard.widgets.chart(widget.id) + assert isinstance(chart.data, list) + assert chart.page == 1 + + results = dashboard.widgets.charts(widget_ids=[widget.id]) + assert [(row.widget_id, row.status) for row in results] == [(widget.id, 200)] + + dashboard.widgets.delete(widget.id) + assert widget.id not in {row.id for row in dashboard.widgets.iterate()} + + def test_an_invalid_widget_is_refused(self, dashboard: LoadedDashboard) -> None: + with pytest.raises(PlaneAPIError) as excinfo: + dashboard.widgets.create( + CreateWidget(name="No axis", chart_type="BAR_CHART", chart_model="BASIC") + ) + assert excinfo.value.status == 400 diff --git a/tests/v2/test_dashboards_resource.py b/tests/v2/test_dashboards_resource.py new file mode 100644 index 0000000..828c512 --- /dev/null +++ b/tests/v2/test_dashboards_resource.py @@ -0,0 +1,371 @@ +"""Offline coverage for `Dashboards` and its `widgets` child: CRUD, `copy`, `widget_types`, the +`chart`/`charts` reads, and navigation from a fetched dashboard and workspace.""" + +import json +from urllib.parse import parse_qs, urlparse + +import pytest +import responses + +from plane.api.v2 import V2Namespace +from plane.api.v2._kernel.transport import V2Transport +from plane.api.v2.dashboards import Dashboards +from plane.config import Configuration +from plane.models.v2.dashboards import ( + CopyDashboard, + CreateDashboard, + CreateWidget, + UpdateDashboard, + UpdateWidget, +) + +BASE = "https://api.example.com/api/v2/workspaces/acme/dashboards" +EMPTY_PAGE = {"data": [], "pagination": {"style": "offset"}, "total_count": 0} + + +@pytest.fixture +def dashboards(config: Configuration) -> Dashboards: + return Dashboards(V2Transport(config)) + + +def _query(call_index: int = 0) -> dict[str, list[str]]: + return parse_qs(urlparse(responses.calls[call_index].request.url).query) + + +# -- Dashboards --------------------------------------------------------------------- + + +@responses.activate +def test_list_sends_filters_and_order_by(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/", + json={ + "data": [{"id": "d1", "name": "Engineering overview", "access": 1}], + "pagination": {"style": "offset"}, + "total_count": 1, + }, + ) + + page = dashboards.list("acme", access=1, search="eng", order_by="-updated_at", per_page=10) + + assert page.total_count == 1 + assert page.data[0].name == "Engineering overview" + assert _query() == { + "access": ["1"], + "search": ["eng"], + "order_by": ["-updated_at"], + "per_page": ["10"], + } + + +def test_list_rejects_an_unknown_order_by(dashboards: Dashboards) -> None: + with pytest.raises(ValueError, match="Unknown order_by"): + dashboards.list("acme", order_by="priority") # type: ignore[arg-type] + + +@responses.activate +def test_iterate_follows_pages(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/", + json={"data": [{"id": "d1"}], "next": 1, "pagination": {"style": "offset"}}, + ) + responses.get( + f"{BASE}/", + json={"data": [{"id": "d2"}], "next": None, "pagination": {"style": "offset"}}, + ) + + rows = list(dashboards.iterate("acme", per_page=1)) + + assert [row.id for row in rows] == ["d1", "d2"] + assert rows[1].widgets._ids == ("acme", "d2") + + +@responses.activate +def test_create_sends_the_body(dashboards: Dashboards) -> None: + responses.post(f"{BASE}/", json={"id": "d1", "name": "Eng", "access": 1}, status=201) + + created = dashboards.create( + "acme", + CreateDashboard( + name="Eng", + access=1, + project_ids=["p1"], + pql_filters={"raw": 'priority = "high"', "stripped": 'priority = "high"'}, + ), + ) + + assert created.id == "d1" + assert json.loads(responses.calls[0].request.body) == { + "name": "Eng", + "access": 1, + "project_ids": ["p1"], + "pql_filters": {"raw": 'priority = "high"', "stripped": 'priority = "high"'}, + } + + +def test_create_requires_a_name() -> None: + with pytest.raises(ValueError): + CreateDashboard(name="") + + +@responses.activate +def test_retrieve_passes_fields(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/", json={"id": "d1", "name": "Eng"}) + + row = dashboards.retrieve("acme", "d1", fields=["id", "name"]) + + assert row.name == "Eng" + assert _query() == {"fields": ["id,name"]} + + +@responses.activate +def test_update_uses_patch_and_sends_only_set_fields(dashboards: Dashboards) -> None: + responses.patch(f"{BASE}/d1/", json={"id": "d1", "name": "Eng (Q4)"}) + + updated = dashboards.update("acme", "d1", UpdateDashboard(name="Eng (Q4)")) + + assert updated.name == "Eng (Q4)" + assert json.loads(responses.calls[0].request.body) == {"name": "Eng (Q4)"} + + +@responses.activate +def test_delete_returns_none(dashboards: Dashboards) -> None: + responses.delete(f"{BASE}/d1/", status=204) + + assert dashboards.delete("acme", "d1") is None + + +@responses.activate +def test_copy_without_a_body_posts_an_empty_object(dashboards: Dashboards) -> None: + responses.post(f"{BASE}/d1/copy/", json={"id": "d2", "name": "Eng (Copy)"}, status=201) + + copied = dashboards.copy("acme", "d1") + + assert copied.id == "d2" + assert json.loads(responses.calls[0].request.body) == {} + + +@responses.activate +def test_copy_overrides_the_copied_fields(dashboards: Dashboards) -> None: + responses.post(f"{BASE}/d1/copy/", json={"id": "d2", "name": "Mine"}, status=201) + + copied = dashboards.copy("acme", "d1", CopyDashboard(name="Mine", access=0)) + + assert json.loads(responses.calls[0].request.body) == {"name": "Mine", "access": 0} + assert copied.widgets._ids == ("acme", "d2") + + +@responses.activate +def test_widget_types_unwraps_the_envelope(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/widget-types/", + json={ + "data": [ + { + "chart_type": "BAR_CHART", + "name": "Bar Chart", + "chart_models": ["BASIC", "STACKED", "GROUPED"], + "available": True, + }, + { + "chart_type": "NUMBER", + "name": "Number", + "chart_models": ["BASIC"], + "available": False, + }, + ] + }, + ) + + types = dashboards.widget_types("acme") + + assert [(row.chart_type, row.available) for row in types] == [ + ("BAR_CHART", True), + ("NUMBER", False), + ] + assert types[0].chart_models == ["BASIC", "STACKED", "GROUPED"] + + +# -- Widgets ------------------------------------------------------------------------ + + +@responses.activate +def test_widgets_list_sends_filters(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/", + json={ + "data": [{"id": "w1", "name": "By priority", "chart_type": "BAR_CHART"}], + "pagination": {"style": "offset"}, + }, + ) + + page = dashboards.widgets.list("acme", "d1", chart_type="BAR_CHART", order_by="created_at") + + assert page.data[0].chart_type == "BAR_CHART" + assert _query() == {"chart_type": ["BAR_CHART"], "order_by": ["created_at"]} + + +@responses.activate +def test_widgets_create_sends_the_body(dashboards: Dashboards) -> None: + responses.post( + f"{BASE}/d1/widgets/", + json={ + "id": "w1", + "dashboard_id": "d1", + "chart_type": "BAR_CHART", + "chart_model": "STACKED", + "config": {"x_axis_property": "PRIORITY", "group_by": "STATE_GROUPS"}, + }, + status=201, + ) + + widget = dashboards.widgets.create( + "acme", + "d1", + CreateWidget( + name="By priority", + chart_type="BAR_CHART", + chart_model="STACKED", + x_axis_property="PRIORITY", + y_axis_metric="WORK_ITEM_COUNT", + group_by="STATE_GROUPS", + width=6, + height=4, + ), + ) + + assert widget.dashboard_id == "d1" + assert widget.config == {"x_axis_property": "PRIORITY", "group_by": "STATE_GROUPS"} + assert json.loads(responses.calls[0].request.body) == { + "name": "By priority", + "chart_type": "BAR_CHART", + "chart_model": "STACKED", + "x_axis_property": "PRIORITY", + "y_axis_metric": "WORK_ITEM_COUNT", + "group_by": "STATE_GROUPS", + "width": 6, + "height": 4, + } + + +def test_widgets_create_rejects_a_chart_type_the_api_does_not_create() -> None: + with pytest.raises(ValueError): + CreateWidget(name="Notes", chart_type="RICH_TEXT", chart_model="BASIC") # type: ignore[arg-type] + + +@responses.activate +def test_widgets_retrieve_update_delete(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/widgets/w1/", json={"id": "w1", "name": "By priority"}) + responses.patch(f"{BASE}/d1/widgets/w1/", json={"id": "w1", "x_axis_coord": 6}) + responses.delete(f"{BASE}/d1/widgets/w1/", status=204) + + assert dashboards.widgets.retrieve("acme", "d1", "w1").name == "By priority" + updated = dashboards.widgets.update("acme", "d1", "w1", UpdateWidget(x_axis_coord=6)) + assert updated.x_axis_coord == 6 + assert json.loads(responses.calls[1].request.body) == {"x_axis_coord": 6} + assert dashboards.widgets.delete("acme", "d1", "w1") is None + + +@responses.activate +def test_chart_keeps_the_chart_specific_keys(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/w1/chart/", + json={ + "schema": {"priority": {"high": {"id": "high", "name": "High"}}}, + "total_count": 1, + "total_pages": 1, + "page": 1, + "per_page": 20, + "next_page": None, + "prev_page": None, + "data": [{"key": "high", "count": 2, "groups": {}, "projects": {}}], + "x_axis_schema_key": "priority", + "group_keys": [], + }, + ) + + chart = dashboards.widgets.chart("acme", "d1", "w1") + + assert chart.chart_schema == {"priority": {"high": {"id": "high", "name": "High"}}} + assert chart.data[0]["count"] == 2 + assert chart.total_pages == 1 + assert chart.x_axis_schema_key == "priority" # type: ignore[attr-defined] + assert chart.model_dump(by_alias=True)["schema"]["priority"]["high"]["name"] == "High" + assert urlparse(responses.calls[0].request.url).query == "" + + +@responses.activate +def test_chart_encodes_row_ids_as_a_json_array(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/widgets/w1/chart/", json={"schema": {}, "data": []}) + + dashboards.widgets.chart( + "acme", "d1", "w1", page=2, column_page=2, row_ids=["backlog", "started"] + ) + + assert _query() == { + "page": ["2"], + "column_page": ["2"], + "row_ids": ['["backlog", "started"]'], + } + + +@responses.activate +def test_charts_returns_each_widgets_result(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/charts/", + json={ + "data": [ + { + "widget_id": "w1", + "chart_type": "NUMBER", + "status": 200, + "chart": {"schema": {}, "value": 10, "y_axis_metric": "WORK_ITEM_COUNT"}, + }, + { + "widget_id": "w2", + "chart_type": "TABLE_CHART", + "status": 402, + "error": {"code": "payment_required", "detail": "Upgrade."}, + }, + ] + }, + ) + + results = dashboards.widgets.charts("acme", "d1", widget_ids=["w1", "w2"]) + + assert _query() == {"widget_ids": ["w1,w2"]} + assert results[0].chart is not None + assert results[0].chart.value == 10 # type: ignore[attr-defined] + assert results[1].chart is None + assert results[1].status == 402 + assert results[1].error == {"code": "payment_required", "detail": "Upgrade."} + + +# -- Navigation --------------------------------------------------------------------- + + +@responses.activate +def test_a_fetched_dashboard_reaches_its_widgets(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/", json={"id": "d1", "name": "Eng"}) + responses.get(f"{BASE}/d1/widgets/", json=EMPTY_PAGE) + responses.get(f"{BASE}/d1/widgets/w1/chart/", json={"schema": {}, "value": 3}) + + dashboard = dashboards.retrieve("acme", "d1") + dashboard.widgets.list() + chart = dashboard.widgets.chart("w1") + + assert responses.calls[1].request.url == f"{BASE}/d1/widgets/" + assert chart.value == 3 # type: ignore[attr-defined] + + +@responses.activate +def test_a_fetched_workspace_reaches_its_dashboards(config: Configuration) -> None: + responses.get( + "https://api.example.com/api/v2/workspaces/acme/", json={"id": "ws1", "slug": "acme"} + ) + responses.get(f"{BASE}/", json=EMPTY_PAGE) + + workspace = V2Namespace(config).workspaces.retrieve("acme") + workspace.dashboards.list() + + assert responses.calls[1].request.url == f"{BASE}/" diff --git a/tests/v2/test_tree.py b/tests/v2/test_tree.py index 06d40e1..21a8d71 100644 --- a/tests/v2/test_tree.py +++ b/tests/v2/test_tree.py @@ -26,6 +26,7 @@ CustomerWorkItems, ) from plane.api.v2.cycles import Cycles, CycleWorkItems +from plane.api.v2.dashboards import Dashboards, Widgets from plane.api.v2.estimates import Estimates from plane.api.v2.estimates.points import EstimatePoints from plane.api.v2.features import ProjectFeatures @@ -352,6 +353,8 @@ def test_every_resource_class_is_reachable_from_the_namespace(config: Configurat None, ), ("customers.work_items", lambda ws: ws.customers.work_items, CustomerWorkItems, None), + ("dashboards", lambda ws: ws.dashboards, Dashboards, "/workspaces/acme/dashboards/"), + ("dashboards.widgets", lambda ws: ws.dashboards.widgets, Widgets, None), ("initiatives", lambda ws: ws.initiatives, Initiatives, "/workspaces/acme/initiatives/"), ( "initiatives.labels", @@ -675,6 +678,10 @@ def test_the_newly_attached_workspace_children_reach_their_urls(config: Configur a wrongly-templated child would pass on the `isinstance` half of its row alone.""" ws = V2Namespace(config).workspaces + assert ( + ws.dashboards.widgets._collection_url(slug="acme", dashboard_id="d1") + == "/workspaces/acme/dashboards/d1/widgets/" + ) assert ( ws.customers.requests._collection_url(slug="acme", customer_id="c1") == "/workspaces/acme/customers/c1/requests/" From a2362a633746fe9a5dbdd3871c9fd888115b3268 Mon Sep 17 00:00:00 2001 From: gurusinath Date: Wed, 4 Nov 2026 18:27:58 +0530 Subject: [PATCH 2/5] [WEB-7851] fix: widget read model accepts stored free text; widget PATCH can clear fields - Widget (read) types last_used_filter, x_axis_date_grouping and y_axis_metric as str, so a stored "" no longer fails a whole widgets.list() page; Create/UpdateWidget keep the choices. - widgets.update() sends explicitly-set None (exclude_unset) so group_by and the axis fields can be cleared; unset fields are still omitted. - charts(widget_ids=[]) returns [] without a request. - Docstrings: per-widget errors on charts(), charts never read rich_filters, last_used_filter default and the pql-only auto-switch, access needs the owner only when it changes, chart_schema is the server's schema key. - Tests for each, plus pql_filters create and table chart paging. --- plane/api/v2/dashboards/__init__.py | 3 +- plane/api/v2/dashboards/widgets.py | 31 ++++-- plane/models/v2/dashboards.py | 36 +++++-- tests/v2/test_dashboards_resource.py | 152 +++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 18 deletions(-) diff --git a/plane/api/v2/dashboards/__init__.py b/plane/api/v2/dashboards/__init__.py index 4d0bcb7..cbe0174 100644 --- a/plane/api/v2/dashboards/__init__.py +++ b/plane/api/v2/dashboards/__init__.py @@ -149,7 +149,8 @@ def update( fields: Sequence[DashboardsPartialUpdateField] | None = None, ) -> LoadedDashboard: """Change only the fields sent. Making a dashboard public (`access=1`) removes its shared - members; only the owner, or an admin on a public dashboard, can change `access`.""" + members. Only the owner, or an admin on a public dashboard, can change `access`; sending + the value it already has needs no extra rights.""" row = self._update(data, pk=dashboard, params={"fields": fields}, slug=slug) return self._load(row, slug, fields=fields) diff --git a/plane/api/v2/dashboards/widgets.py b/plane/api/v2/dashboards/widgets.py index d115a47..2af407e 100644 --- a/plane/api/v2/dashboards/widgets.py +++ b/plane/api/v2/dashboards/widgets.py @@ -3,7 +3,10 @@ Every call answers 404 when the dashboard is missing or not visible to the caller, or when the widget belongs to a different dashboard. `chart` is the widget's data, computed from the current work items with the dashboard's and the widget's saved filters; `charts` is every widget's first -page in one call.""" +page in one call. + +Which widget filter a chart applies follows its `last_used_filter`: `filters` for `rich_filters` +(the server default), `pql_filters.stripped` for `pql_filters`. `rich_filters` is never read.""" from __future__ import annotations @@ -137,9 +140,16 @@ def update( *, fields: Sequence[WidgetsPartialUpdateField] | None = None, ) -> Widget: - return self._update( - data, pk=widget, params={"fields": fields}, slug=slug, dashboard_id=dashboard + """Change only the fields set on `data`; a field set to `None` is sent as `null` and + clears it.""" + payload = self.transport.request( + "PATCH", + self._detail_url(widget, "update", slug=slug, dashboard_id=dashboard), + params=self._query({"fields": fields}, action="update"), + # Not `_update`'s `exclude_none`: that would drop the `None` a caller sets to clear. + json=data.model_dump(mode="json", exclude_unset=True), ) + return Widget.model_validate(payload) def delete(self, slug: str, dashboard: str, widget: str) -> None: return self._delete(pk=widget, slug=slug, dashboard_id=dashboard) @@ -154,7 +164,8 @@ def chart( column_page: int | None = None, row_ids: Sequence[str] | None = None, ) -> WidgetChart: - """The data the widget displays; the shape depends on its chart type. + """The data the widget displays; the shape depends on its chart type. It applies the + widget's `filters` or `pql_filters` per `last_used_filter`, never `rich_filters`. `page` pages the rows (buckets, groups, counters) of a paginated chart type; the page size is the chart's own `config` setting. `column_page` and `row_ids` are `TABLE_CHART` only: @@ -184,9 +195,15 @@ def charts( widget_ids: Sequence[str] | None = None, ) -> builtins.list[WidgetChartResult]: """The first page of every widget's chart data, in layout order, in one call; or only the - widgets in `widget_ids` (ids not on this dashboard are skipped). A widget whose chart fails - carries its own `error` and `status` instead of `chart`, and the others still return. Page - further with `chart`.""" + widgets in `widget_ids` (ids not on this dashboard are skipped). An empty `widget_ids` + returns `[]` without a request. + + The call itself succeeds even when some widgets fail: each failing widget's entry has + `chart=None` and the `status` and `error` body its own `chart` call would have answered + with (e.g. 402 for a chart type not on the plan), and the other widgets still carry their + data. Like `chart`, it never reads `rich_filters`. Page further with `chart`.""" + if widget_ids is not None and len(widget_ids) == 0: + return [] return self._custom_action( "charts", model=WidgetChartResultList, diff --git a/plane/models/v2/dashboards.py b/plane/models/v2/dashboards.py index 92cfb36..51a0964 100644 --- a/plane/models/v2/dashboards.py +++ b/plane/models/v2/dashboards.py @@ -162,7 +162,13 @@ class WidgetTypeList(BaseModel): class Widget(BaseModel): - """For axis charts the axis fields are mirrored into `config`.""" + """For axis charts the axis fields are mirrored into `config`. + + `last_used_filter`, `x_axis_date_grouping` and `y_axis_metric` are plain strings here, not the + choices a write accepts: the server stores free text and leaves them `""` on charts that don't + use them. `last_used_filter` picks the filter the chart applies: `filters` when it is + `rich_filters` (the server default), `pql_filters.stripped` when it is `pql_filters`. + `rich_filters` is stored but never read by a chart.""" model_config = ConfigDict(extra="allow") @@ -176,7 +182,7 @@ class Widget(BaseModel): filters: dict[str, Any] | None = None group_by: str | None = None height: int | None = None - last_used_filter: WidgetLastUsedFilter | None = None + last_used_filter: str | None = None name: str | None = None pql_filters: dict[str, Any] | None = None rich_filters: dict[str, Any] | None = None @@ -184,17 +190,21 @@ class Widget(BaseModel): updated_by_id: str | None = None width: int | None = None x_axis_coord: int | None = None - x_axis_date_grouping: WidgetDateGrouping | None = None + x_axis_date_grouping: str | None = None x_axis_property: str | None = None y_axis_coord: int | None = None - y_axis_metric: WidgetMetric | None = None + y_axis_metric: str | None = None class CreateWidget(BaseModel): """POST body. Checked against its chart type before it's saved: a missing required setting or a model the chart type doesn't support is refused with 400. `x_axis_property`/`group_by` take an axis property (`PRIORITY`, `STATE_GROUPS`, ...) or a custom property as `cp_`. - Set `last_used_filter="pql_filters"` when sending `pql_filters`.""" + + `last_used_filter` picks the filter the chart applies: `filters` when it is `rich_filters` + (the default), `pql_filters.stripped` when it is `pql_filters`. Sending non-empty + `pql_filters` with neither `filters` nor `last_used_filter` stores `pql_filters` + automatically. `rich_filters` is stored but never read by a chart.""" model_config = ConfigDict(extra="ignore") @@ -218,7 +228,10 @@ class CreateWidget(BaseModel): class UpdateWidget(BaseModel): """PATCH body -- every field optional. A change that would leave the widget unable to show - data is refused with 400 and nothing is saved.""" + data is refused with 400 and nothing is saved. + + Only the fields you set are sent, and a field set to `None` is sent as `null`, which clears it + (e.g. `UpdateWidget(group_by=None)`). `last_used_filter` works as on `CreateWidget`.""" model_config = ConfigDict(extra="ignore") @@ -248,8 +261,10 @@ class WidgetChart(BaseModel): share are declared; every other key (`x_axis_schema_key`, `group_keys`, `columns`, `value`, `results`, ...) is kept and readable as an attribute or via `model_dump()`. - `schema` maps the ids in `data` to display details, keyed by entity (`state`, `priority`, - ...). The pagination fields are present only on paginated chart types.""" + `chart_schema` is the response's `schema` key, which maps the ids in `data` to display + details keyed by entity (`state`, `priority`, ...). It is renamed because `schema` would + shadow pydantic's `BaseModel.schema`; `model_dump(by_alias=True)` gives `schema` back. The + pagination fields are present only on paginated chart types.""" model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -264,8 +279,9 @@ class WidgetChart(BaseModel): class WidgetChartResult(BaseModel): - """One widget's entry in `Widgets.charts`: `chart` on success (`status` 200), otherwise - `error` (a problem-details body) with that error's `status`.""" + """One widget's entry in `Widgets.charts`. On success `status` is 200 and `chart` is set; + otherwise `chart` is `None` and `error` holds the problem-details body that `chart` alone + would have answered with, under the same `status`.""" model_config = ConfigDict(extra="allow") diff --git a/tests/v2/test_dashboards_resource.py b/tests/v2/test_dashboards_resource.py index 828c512..b662580 100644 --- a/tests/v2/test_dashboards_resource.py +++ b/tests/v2/test_dashboards_resource.py @@ -17,6 +17,7 @@ CreateWidget, UpdateDashboard, UpdateWidget, + Widget, ) BASE = "https://api.example.com/api/v2/workspaces/acme/dashboards" @@ -341,6 +342,157 @@ def test_charts_returns_each_widgets_result(dashboards: Dashboards) -> None: assert results[1].error == {"code": "payment_required", "detail": "Upgrade."} +def test_widget_reads_the_free_text_the_server_stores() -> None: + widget = Widget.model_validate( + { + "id": "w", + "last_used_filter": "", + "x_axis_date_grouping": "", + "y_axis_metric": "SOME_NEW_METRIC", + } + ) + + assert widget.last_used_filter == "" + assert widget.x_axis_date_grouping == "" + assert widget.y_axis_metric == "SOME_NEW_METRIC" + + +@responses.activate +def test_widgets_list_keeps_a_page_with_blank_choice_fields(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/", + json={ + "data": [ + {"id": "w1", "chart_type": "NUMBER", "last_used_filter": ""}, + {"id": "w2", "chart_type": "BAR_CHART", "last_used_filter": "pql_filters"}, + ], + "pagination": {"style": "offset"}, + }, + ) + + page = dashboards.widgets.list("acme", "d1") + + assert [row.last_used_filter for row in page.data] == ["", "pql_filters"] + + +def test_write_models_keep_the_fixed_choices() -> None: + with pytest.raises(ValueError): + UpdateWidget(last_used_filter="") # type: ignore[arg-type] + with pytest.raises(ValueError): + CreateWidget( + name="n", chart_type="NUMBER", chart_model="BASIC", y_axis_metric="NOPE" # type: ignore[arg-type] + ) + + +@responses.activate +def test_widgets_update_sends_explicit_none_to_clear(dashboards: Dashboards) -> None: + responses.patch(f"{BASE}/d1/widgets/w1/", json={"id": "w1", "group_by": None}) + + dashboards.widgets.update( + "acme", + "d1", + "w1", + UpdateWidget( + chart_model="BASIC", + group_by=None, + x_axis_property=None, + x_axis_date_grouping=None, + y_axis_metric=None, + ), + ) + + assert json.loads(responses.calls[0].request.body) == { + "chart_model": "BASIC", + "group_by": None, + "x_axis_property": None, + "x_axis_date_grouping": None, + "y_axis_metric": None, + } + + +@responses.activate +def test_widgets_update_passes_fields(dashboards: Dashboards) -> None: + responses.patch(f"{BASE}/d1/widgets/w1/", json={"id": "w1", "name": "Renamed"}) + + updated = dashboards.widgets.update( + "acme", "d1", "w1", UpdateWidget(name="Renamed"), fields=["id", "name"] + ) + + assert updated.name == "Renamed" + assert _query() == {"fields": ["id,name"]} + assert json.loads(responses.calls[0].request.body) == {"name": "Renamed"} + + +@responses.activate +def test_widgets_create_with_pql_filters(dashboards: Dashboards) -> None: + pql = {"raw": 'priority = "high"', "stripped": 'priority = "high"'} + responses.post( + f"{BASE}/d1/widgets/", + json={ + "id": "w1", + "chart_type": "NUMBER", + "chart_model": "BASIC", + "pql_filters": pql, + "last_used_filter": "pql_filters", + }, + status=201, + ) + + widget = dashboards.widgets.create( + "acme", + "d1", + CreateWidget( + name="High priority", + chart_type="NUMBER", + chart_model="BASIC", + y_axis_metric="WORK_ITEM_COUNT", + pql_filters=pql, + last_used_filter="pql_filters", + ), + ) + + assert widget.last_used_filter == "pql_filters" + assert widget.pql_filters == pql + assert json.loads(responses.calls[0].request.body) == { + "name": "High priority", + "chart_type": "NUMBER", + "chart_model": "BASIC", + "y_axis_metric": "WORK_ITEM_COUNT", + "pql_filters": pql, + "last_used_filter": "pql_filters", + } + + +@responses.activate +def test_chart_pages_a_table_chart(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/w1/chart/", + json={ + "schema": {}, + "data": [{"group_by_id": "backlog"}], + "total_count": 25, + "total_pages": 3, + "page": 2, + "per_page": 10, + "next_page": 3, + "prev_page": 1, + "columns": [{"id": "high"}], + }, + ) + + chart = dashboards.widgets.chart("acme", "d1", "w1", page=2, column_page=3) + + assert _query() == {"page": ["2"], "column_page": ["3"]} + assert (chart.page, chart.next_page, chart.prev_page, chart.total_pages) == (2, 3, 1, 3) + assert chart.columns == [{"id": "high"}] # type: ignore[attr-defined] + + +def test_charts_with_no_widget_ids_makes_no_request(dashboards: Dashboards) -> None: + with responses.RequestsMock() as mocked: + assert dashboards.widgets.charts("acme", "d1", widget_ids=[]) == [] + assert len(mocked.calls) == 0 + + # -- Navigation --------------------------------------------------------------------- From a1a73027228862859cd3ee35009b8f4f11c6396b Mon Sep 17 00:00:00 2001 From: gurusinath Date: Thu, 5 Nov 2026 14:16:34 +0530 Subject: [PATCH 3/5] [WEB-7851] fix: read any stored dashboard access and widget chart type; document clearing dashboard fields - Dashboard.access reads any integer (older rows can carry the retired 2); writes keep 0/1 - Widget.chart_type / chart_model read as str, so a chart type the server adds later doesn't break a page - UpdateDashboard documents clearing with empty values: none of its fields is nullable - README: 20 of the 92 classes, with workspaces in the list --- README.md | 5 ++-- plane/models/v2/dashboards.py | 22 ++++++++++++----- tests/v2/test_dashboards_resource.py | 37 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bd68c55..9306640 100644 --- a/README.md +++ b/README.md @@ -278,8 +278,9 @@ client.v2.workspaces.projects.states.list(slug="acme", project="ENG") A resource with children (`projects`, `work_items`, `cycles`, `milestones`, `modules`, `estimates`, `webhooks`, `collections`, `customers`, `initiatives`, `releases`, `work_item_types`, `work_item_properties`, `automations`, -`workflows` and `dashboards` — 19 of the 92 classes, some families having a separate -project-scoped and workspace-scoped resource, each independently navigable) +`workflows`, `dashboards` and `workspaces` — 20 of the 92 classes, some families +having a separate project-scoped and workspace-scoped resource, each independently +navigable) doesn't just hand back a bare pydantic model from `retrieve`/`list`/`iterate` — it hands back a row that carries its own data *and* already knows where it lives, so the row's own children are reached with none of the ids repeated: diff --git a/plane/models/v2/dashboards.py b/plane/models/v2/dashboards.py index 51a0964..4e6afa4 100644 --- a/plane/models/v2/dashboards.py +++ b/plane/models/v2/dashboards.py @@ -10,7 +10,8 @@ DashboardAccess = Literal[0, 1] # 0 = Private, 1 = Public -# Every chart type a stored widget can carry, including legacy ones the API no longer creates. +# Every chart type a stored widget can carry today, including legacy ones the API no longer +# creates. The server adds chart types over time, so the read model types `chart_type` as `str`. WidgetChartType = Literal[ "BAR_CHART", "LINE_CHART", @@ -78,10 +79,14 @@ class Dashboard(BaseModel): + """`access` is `0` (private) or `1` (public) on every dashboard written today. It is a plain + integer here, not the choice a write accepts, because older dashboards can still carry the + retired `2` ("shared").""" + model_config = ConfigDict(extra="allow") id: str - access: DashboardAccess | None = None + access: int | None = None archived_at: datetime | None = None created_at: datetime | None = None created_by_id: str | None = None @@ -112,7 +117,11 @@ class CreateDashboard(BaseModel): class UpdateDashboard(BaseModel): - """PATCH body -- every field optional. v2 has no PUT. `project_ids` replaces the whole list.""" + """PATCH body -- every field optional. v2 has no PUT. `project_ids` replaces the whole list. + + A field left as `None` is not sent. None of these fields can be null on the server, so clear + them with an empty value instead: `project_ids=[]`, `filters={}`, `logo_props={}`, and + `pql_filters={"raw": "", "stripped": ""}`.""" model_config = ConfigDict(extra="ignore") @@ -168,13 +177,14 @@ class Widget(BaseModel): choices a write accepts: the server stores free text and leaves them `""` on charts that don't use them. `last_used_filter` picks the filter the chart applies: `filters` when it is `rich_filters` (the server default), `pql_filters.stripped` when it is `pql_filters`. - `rich_filters` is stored but never read by a chart.""" + `rich_filters` is stored but never read by a chart. `chart_type` and `chart_model` are plain + strings too, so a chart type the server adds later doesn't break reading the page.""" model_config = ConfigDict(extra="allow") id: str - chart_model: WidgetChartModel | None = None - chart_type: WidgetChartType | None = None + chart_model: str | None = None + chart_type: str | None = None config: dict[str, Any] | None = None created_at: datetime | None = None created_by_id: str | None = None diff --git a/tests/v2/test_dashboards_resource.py b/tests/v2/test_dashboards_resource.py index b662580..1567d7c 100644 --- a/tests/v2/test_dashboards_resource.py +++ b/tests/v2/test_dashboards_resource.py @@ -6,6 +6,7 @@ import pytest import responses +from pydantic import ValidationError from plane.api.v2 import V2Namespace from plane.api.v2._kernel.transport import V2Transport @@ -15,6 +16,7 @@ CopyDashboard, CreateDashboard, CreateWidget, + Dashboard, UpdateDashboard, UpdateWidget, Widget, @@ -375,6 +377,41 @@ def test_widgets_list_keeps_a_page_with_blank_choice_fields(dashboards: Dashboar assert [row.last_used_filter for row in page.data] == ["", "pql_filters"] +def test_dashboard_reads_a_legacy_access_value() -> None: + # `2` ("shared") was retired without a data migration, so old rows can still carry it. + assert Dashboard.model_validate({"id": "d", "access": 2}).access == 2 + + +@responses.activate +def test_dashboards_list_keeps_a_page_with_a_legacy_access_value(dashboards: Dashboards) -> None: + responses.get( + BASE + "/", + json={ + "data": [{"id": "d1", "access": 1}, {"id": "d2", "access": 2}], + "pagination": {"style": "offset"}, + }, + ) + + page = dashboards.list("acme") + + assert [row.access for row in page.data] == [1, 2] + + +def test_widget_reads_a_chart_type_the_sdk_does_not_know_yet() -> None: + widget = Widget.model_validate( + {"id": "w", "chart_type": "SOME_NEW_CHART", "chart_model": "SOME_MODEL"} + ) + + assert widget.chart_type == "SOME_NEW_CHART" + assert widget.chart_model == "SOME_MODEL" + + +def test_dashboard_write_models_keep_the_access_choices() -> None: + for model in (CreateDashboard, UpdateDashboard, CopyDashboard): + with pytest.raises(ValidationError): + model.model_validate({"name": "D", "access": 2}) + + def test_write_models_keep_the_fixed_choices() -> None: with pytest.raises(ValueError): UpdateWidget(last_used_filter="") # type: ignore[arg-type] From 0db27d557ba8b6c43748c1626c49ae5362a8d1b5 Mon Sep 17 00:00:00 2001 From: gurusinath Date: Thu, 5 Nov 2026 15:03:03 +0530 Subject: [PATCH 4/5] [WEB-7851] fix: deep-review findings on dashboards and widgets - Widgets.update sends null only for the four axis fields; None on any other field is left out (the server refuses null there, so one pass-through None used to fail the whole PATCH) - pql_filters documented as {stripped, json?}; clearing uses the server's {json: {}, stripped: ''} - CreateDashboard documents the public default; copy documents copying older dashboards as stored - charts() documents the 50-widget cap; chart(row_ids=...) and charts(widget_ids=...) refuse a bare string instead of splitting it into characters - tests: None on non-nullable fields, bare-string ids, charts without widget_ids, a chart error raised as PlaneAPIError, widgets.iterate paging --- plane/api/v2/dashboards/__init__.py | 2 +- plane/api/v2/dashboards/widgets.py | 29 ++++++++++-- plane/models/v2/dashboards.py | 22 ++++++--- tests/v2/test_dashboards_resource.py | 69 ++++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 17 deletions(-) diff --git a/plane/api/v2/dashboards/__init__.py b/plane/api/v2/dashboards/__init__.py index cbe0174..632b736 100644 --- a/plane/api/v2/dashboards/__init__.py +++ b/plane/api/v2/dashboards/__init__.py @@ -169,7 +169,7 @@ def copy( ) -> LoadedDashboard: """A new dashboard owned by the caller with the source's settings, projects, filters and every widget. Shared members and the public link aren't copied. Each field in `data` - replaces the copied value.""" + replaces the copied value; the source's own values are copied as stored.""" row = self._action( "copy", pk=dashboard, diff --git a/plane/api/v2/dashboards/widgets.py b/plane/api/v2/dashboards/widgets.py index 2af407e..3e01531 100644 --- a/plane/api/v2/dashboards/widgets.py +++ b/plane/api/v2/dashboards/widgets.py @@ -38,6 +38,12 @@ __all__ = ["Widgets"] +#: The only widget write fields the server stores `null` in; `None` elsewhere is not sent. +_NULLABLE_WIDGET_FIELDS = frozenset( + {"x_axis_property", "x_axis_date_grouping", "y_axis_metric", "group_by"} +) + + class Widgets(V2Resource[Widget, CreateWidget, UpdateWidget]): path = "/workspaces/{slug}/dashboards/{dashboard_id}/widgets/" model = Widget @@ -140,14 +146,19 @@ def update( *, fields: Sequence[WidgetsPartialUpdateField] | None = None, ) -> Widget: - """Change only the fields set on `data`; a field set to `None` is sent as `null` and - clears it.""" + """Change only the fields set on `data`. An axis field set to `None` is sent as `null` and + clears it; a `None` on any other field is not sent (the server can't store `null` there).""" + body = { + key: value + for key, value in data.model_dump(mode="json", exclude_unset=True).items() + if value is not None or key in _NULLABLE_WIDGET_FIELDS + } payload = self.transport.request( "PATCH", self._detail_url(widget, "update", slug=slug, dashboard_id=dashboard), params=self._query({"fields": fields}, action="update"), - # Not `_update`'s `exclude_none`: that would drop the `None` a caller sets to clear. - json=data.model_dump(mode="json", exclude_unset=True), + # Not `_update`'s `exclude_none`: it would drop the `None` that clears an axis. + json=body, ) return Widget.model_validate(payload) @@ -172,6 +183,9 @@ def chart( pass the `group_by_id`s already on screen as `row_ids` to load another column page for the same rows without ranking them again. A parameter the chart type doesn't read is ignored. """ + if isinstance(row_ids, str): + # A bare string is a Sequence[str] too, and would be sent one character per id. + raise TypeError("row_ids must be a list of group ids, not a string") return self._custom_action( "chart", model=WidgetChart, @@ -201,7 +215,12 @@ def charts( The call itself succeeds even when some widgets fail: each failing widget's entry has `chart=None` and the `status` and `error` body its own `chart` call would have answered with (e.g. 402 for a chart type not on the plan), and the other widgets still carry their - data. Like `chart`, it never reads `rich_filters`. Page further with `chart`.""" + data. Like `chart`, it never reads `rich_filters`. Page further with `chart`. + + The server charts at most 50 widgets per call and answers 400 past that; pass + `widget_ids` to chart a larger dashboard in batches.""" + if isinstance(widget_ids, str): + raise TypeError("widget_ids must be a list of widget ids, not a string") if widget_ids is not None and len(widget_ids) == 0: return [] return self._custom_action( diff --git a/plane/models/v2/dashboards.py b/plane/models/v2/dashboards.py index 4e6afa4..78be8d3 100644 --- a/plane/models/v2/dashboards.py +++ b/plane/models/v2/dashboards.py @@ -103,8 +103,10 @@ class Dashboard(BaseModel): class CreateDashboard(BaseModel): - """POST body. `pql_filters` is `{"raw": "", "stripped": ""}`; `filters` is a filter - object such as `{"priority__in": "urgent"}`. Both are validated on save.""" + """POST body. `pql_filters` is `{"stripped": ""}` (an optional `json` key holds the web + app's editor state; charts never read it); `filters` is a filter object such as + `{"priority__in": "urgent"}`. Both are validated on save. `access` defaults to `1` (public: + the whole workspace can see it); send `0` for a private dashboard.""" model_config = ConfigDict(extra="ignore") @@ -121,7 +123,7 @@ class UpdateDashboard(BaseModel): A field left as `None` is not sent. None of these fields can be null on the server, so clear them with an empty value instead: `project_ids=[]`, `filters={}`, `logo_props={}`, and - `pql_filters={"raw": "", "stripped": ""}`.""" + `pql_filters={"json": {}, "stripped": ""}` (the server's own empty state).""" model_config = ConfigDict(extra="ignore") @@ -134,8 +136,10 @@ class UpdateDashboard(BaseModel): class CopyDashboard(BaseModel): - """POST body for `copy` -- every field optional; each one sent replaces the copied value. - `name` defaults to `" (Copy)"` server-side.""" + """POST body for `copy` -- every field optional; each one sent replaces the copied value and is + validated like a create. The source's own values are copied as stored, so an older dashboard + always copies. `name` defaults to `" (Copy)"` server-side (shortened to fit 255 + characters), and a source with the retired `access` `2` copies as private.""" model_config = ConfigDict(extra="ignore") @@ -240,8 +244,12 @@ class UpdateWidget(BaseModel): """PATCH body -- every field optional. A change that would leave the widget unable to show data is refused with 400 and nothing is saved. - Only the fields you set are sent, and a field set to `None` is sent as `null`, which clears it - (e.g. `UpdateWidget(group_by=None)`). `last_used_filter` works as on `CreateWidget`.""" + Only the fields you set are sent. Setting one of the four axis fields (`x_axis_property`, + `x_axis_date_grouping`, `y_axis_metric`, `group_by`) to `None` sends `null`, which clears it + (e.g. `UpdateWidget(group_by=None)`). The server can't store `null` in any other field, so a + `None` there is not sent: clear filters with `{}` instead. `last_used_filter` works as on + `CreateWidget`. (The server also takes `""` there, meaning the default; this model doesn't, so + leave it unset for the same effect.)""" model_config = ConfigDict(extra="ignore") diff --git a/tests/v2/test_dashboards_resource.py b/tests/v2/test_dashboards_resource.py index 1567d7c..46c3ed1 100644 --- a/tests/v2/test_dashboards_resource.py +++ b/tests/v2/test_dashboards_resource.py @@ -8,7 +8,7 @@ import responses from pydantic import ValidationError -from plane.api.v2 import V2Namespace +from plane.api.v2 import PlaneAPIError, V2Namespace from plane.api.v2._kernel.transport import V2Transport from plane.api.v2.dashboards import Dashboards from plane.config import Configuration @@ -93,7 +93,7 @@ def test_create_sends_the_body(dashboards: Dashboards) -> None: name="Eng", access=1, project_ids=["p1"], - pql_filters={"raw": 'priority = "high"', "stripped": 'priority = "high"'}, + pql_filters={"json": {}, "stripped": 'priority = "high"'}, ), ) @@ -102,7 +102,7 @@ def test_create_sends_the_body(dashboards: Dashboards) -> None: "name": "Eng", "access": 1, "project_ids": ["p1"], - "pql_filters": {"raw": 'priority = "high"', "stripped": 'priority = "high"'}, + "pql_filters": {"json": {}, "stripped": 'priority = "high"'}, } @@ -462,7 +462,7 @@ def test_widgets_update_passes_fields(dashboards: Dashboards) -> None: @responses.activate def test_widgets_create_with_pql_filters(dashboards: Dashboards) -> None: - pql = {"raw": 'priority = "high"', "stripped": 'priority = "high"'} + pql = {"json": {}, "stripped": 'priority = "high"'} responses.post( f"{BASE}/d1/widgets/", json={ @@ -558,3 +558,64 @@ def test_a_fetched_workspace_reaches_its_dashboards(config: Configuration) -> No workspace.dashboards.list() assert responses.calls[1].request.url == f"{BASE}/" + + +@responses.activate +def test_widgets_update_does_not_send_none_where_the_server_refuses_null( + dashboards: Dashboards, +) -> None: + # A pass-through helper sets every field it takes; only the axis fields may go out as null. + responses.patch(f"{BASE}/d1/widgets/w1/", json={"id": "w1", "width": 4}) + + dashboards.widgets.update( + "acme", + "d1", + "w1", + UpdateWidget(name=None, config=None, filters=None, width=4, group_by=None), + ) + + assert json.loads(responses.calls[0].request.body) == {"width": 4, "group_by": None} + + +def test_a_bare_string_is_refused_for_id_lists(dashboards: Dashboards) -> None: + with pytest.raises(TypeError): + dashboards.widgets.chart("acme", "d1", "w1", row_ids="backlog") + with pytest.raises(TypeError): + dashboards.widgets.charts("acme", "d1", widget_ids="w1") + + +@responses.activate +def test_charts_without_widget_ids_sends_no_filter(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/widgets/charts/", json={"data": []}) + + assert dashboards.widgets.charts("acme", "d1") == [] + assert "widget_ids" not in _query() + + +@responses.activate +def test_a_chart_error_is_raised_as_plane_api_error(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/w1/chart/", + status=402, + json={"type": "payment_required", "code": "payment_required", "detail": "Upgrade."}, + content_type="application/problem+json", + ) + + with pytest.raises(PlaneAPIError) as exc_info: + dashboards.widgets.chart("acme", "d1", "w1") + + assert exc_info.value.status_code == 402 + + +@responses.activate +def test_widgets_iterate_follows_pages(dashboards: Dashboards) -> None: + responses.get( + f"{BASE}/d1/widgets/", + json={"data": [{"id": "w1"}], "next": 1, "pagination": {"style": "offset"}}, + ) + responses.get( + f"{BASE}/d1/widgets/", + json={"data": [{"id": "w2"}], "next": None, "pagination": {"style": "offset"}}, + ) + + assert [row.id for row in dashboards.widgets.iterate("acme", "d1", per_page=1)] == ["w1", "w2"] From 4484a8a09cea178d52de935069a200ff291eb73a Mon Sep 17 00:00:00 2001 From: gurusinath Date: Thu, 5 Nov 2026 15:23:11 +0530 Subject: [PATCH 5/5] [WEB-7851] fix: chart() row_ids takes the table's None row; document row paging with row_ids --- plane/api/v2/dashboards/widgets.py | 9 ++++++--- tests/v2/test_dashboards_resource.py | 9 +++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/plane/api/v2/dashboards/widgets.py b/plane/api/v2/dashboards/widgets.py index 3e01531..08fc7a6 100644 --- a/plane/api/v2/dashboards/widgets.py +++ b/plane/api/v2/dashboards/widgets.py @@ -173,15 +173,18 @@ def chart( *, page: int | None = None, column_page: int | None = None, - row_ids: Sequence[str] | None = None, + row_ids: Sequence[str | None] | None = None, ) -> WidgetChart: """The data the widget displays; the shape depends on its chart type. It applies the widget's `filters` or `pql_filters` per `last_used_filter`, never `rich_filters`. `page` pages the rows (buckets, groups, counters) of a paginated chart type; the page size is the chart's own `config` setting. `column_page` and `row_ids` are `TABLE_CHART` only: - pass the `group_by_id`s already on screen as `row_ids` to load another column page for the - same rows without ranking them again. A parameter the chart type doesn't read is ignored. + pass the `group_by_id`s already on screen as `row_ids` (`None` included, for the "no value" + row) to load another column page for the same rows without ranking them again. With + `row_ids` the row paging fields of the answer only echo the request, so keep your own row + paging and read just the `column_*` fields. A parameter the chart type doesn't read is + ignored. """ if isinstance(row_ids, str): # A bare string is a Sequence[str] too, and would be sent one character per id. diff --git a/tests/v2/test_dashboards_resource.py b/tests/v2/test_dashboards_resource.py index 46c3ed1..675a291 100644 --- a/tests/v2/test_dashboards_resource.py +++ b/tests/v2/test_dashboards_resource.py @@ -619,3 +619,12 @@ def test_widgets_iterate_follows_pages(dashboards: Dashboards) -> None: ) assert [row.id for row in dashboards.widgets.iterate("acme", "d1", per_page=1)] == ["w1", "w2"] + + +@responses.activate +def test_chart_sends_the_null_row_in_row_ids(dashboards: Dashboards) -> None: + responses.get(f"{BASE}/d1/widgets/w1/chart/", json={"schema": {}, "data": [], "columns": []}) + + dashboards.widgets.chart("acme", "d1", "w1", column_page=2, row_ids=["u1", None]) + + assert json.loads(_query()["row_ids"][0]) == ["u1", None]