diff --git a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr index e00c9f1efb95..b357b403fc3c 100644 --- a/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr +++ b/posthog/api/test/dashboards/__snapshots__/test_dashboard.ambr @@ -454,6 +454,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -481,6 +483,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -1288,6 +1292,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -1691,6 +1697,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -1962,6 +1970,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -2366,6 +2376,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -3198,6 +3210,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -3225,6 +3239,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -5147,6 +5163,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -5174,6 +5192,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -6227,6 +6247,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -6254,6 +6276,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -6899,6 +6923,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -7302,6 +7328,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -7550,6 +7578,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -7973,6 +8003,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -8429,6 +8461,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -9664,6 +9698,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -11175,6 +11211,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -11202,6 +11240,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -12339,6 +12379,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -12366,6 +12408,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -12652,6 +12696,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -13201,6 +13247,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -14612,6 +14660,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", @@ -15847,6 +15897,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted", "posthog_dashboarditem"."id", "posthog_dashboarditem"."name", diff --git a/posthog/api/test/dashboards/test_dashboard.py b/posthog/api/test/dashboards/test_dashboard.py index 3d5f0d815ce2..f12572d664f7 100644 --- a/posthog/api/test/dashboards/test_dashboard.py +++ b/posthog/api/test/dashboards/test_dashboard.py @@ -762,6 +762,68 @@ def test_dashboard_tile_spacing_is_saved_and_duplicated(self): Dashboard.objects.get(id=copied_id).customization, {"show_legend": False, "tile_spacing": "wide"} ) + def test_dashboard_group_titles_are_saved_replaced_cleared_and_duplicated(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + self.dashboard_api.update_dashboard(dashboard_id, {"grid_spacing": "relaxed"}) + + _, updated = self.dashboard_api.update_dashboard( + dashboard_id, {"group_titles": {"plans": "Pricing plans", "regions": "Regions"}} + ) + self.assertEqual( + updated["customization"], + {"tile_spacing": "relaxed", "group_titles": {"plans": "Pricing plans", "regions": "Regions"}}, + ) + + _, updated = self.dashboard_api.update_dashboard(dashboard_id, {"group_titles": {"plans": "Plans"}}) + self.assertEqual(updated["customization"], {"tile_spacing": "relaxed", "group_titles": {"plans": "Plans"}}) + + _, copied = self.dashboard_api.create_dashboard({"name": "copy", "use_dashboard": dashboard_id}) + self.assertEqual(copied["customization"], {"tile_spacing": "relaxed", "group_titles": {"plans": "Plans"}}) + + _, updated = self.dashboard_api.update_dashboard(dashboard_id, {"group_titles": None}) + self.assertEqual(updated["customization"], {"tile_spacing": "relaxed"}) + + def test_dashboard_group_title_keys_are_stripped_to_match_tile_group_keys(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + self.dashboard_api.create_insight({"dashboards": [dashboard_id], "name": "insight"}) + tile_id = self.dashboard_api.get_dashboard(dashboard_id)["tiles"][0]["id"] + + self.dashboard_api.update_dashboard(dashboard_id, {"tiles": [{"id": tile_id, "group_key": " plans "}]}) + _, updated = self.dashboard_api.update_dashboard(dashboard_id, {"group_titles": {" plans ": "Pricing plans"}}) + + dashboard = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False}) + self.assertEqual(updated["customization"], {"group_titles": {"plans": "Pricing plans"}}) + self.assertEqual(dashboard["tiles"][0]["group_key"], "plans") + self.assertEqual(dashboard["customization"]["group_titles"], {"plans": "Pricing plans"}) + + def test_dashboard_group_titles_with_padded_keys_saved_earlier_are_read_stripped(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + Dashboard.objects.filter(id=dashboard_id).update( + customization={"group_titles": {" plans ": "Pricing plans", " ": "Blank"}} + ) + + dashboard = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False}) + + self.assertEqual(dashboard["customization"], {"group_titles": {"plans": "Pricing plans"}}) + + @parameterized.expand( + [ + ("blank_key", {" ": "Blank"}), + ("too_long_key", {"k" * 101: "Too long"}), + ("keys_equal_after_strip", {"plans": "Plans", " plans ": "Padded plans"}), + ("null_character_key", {"plans\u0000": "Plans"}), + ("too_many_titles", {f"group-{index}": "Title" for index in range(101)}), + ] + ) + def test_dashboard_group_titles_are_rejected_when_invalid(self, _name: str, group_titles: dict[str, str]) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + + _, response = self.dashboard_api.update_dashboard( + dashboard_id, {"group_titles": group_titles}, expected_status=status.HTTP_400_BAD_REQUEST + ) + + self.assertEqual(response["attr"], "group_titles") + @parameterized.expand([("horizontal",), ("stable",)]) def test_dashboard_layout_compaction_is_saved_and_duplicated(self, layout_compaction: str) -> None: dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) @@ -1886,6 +1948,106 @@ def test_dashboard_tile_show_description_can_be_toggled(self): dashboard_json = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False}) assert dashboard_json["tiles"][0]["show_description"] is False + @parameterized.expand([("shared_insight", False), ("deep_copy", True)]) + @patch("products.dashboards.backend.api.dashboard.report_user_action") + def test_dashboard_tile_group_key_and_badge_are_set_duplicated_and_cleared( + self, _name: str, duplicate_tiles: bool, mock_report_user_action: MagicMock + ) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "test"}) + self.dashboard_api.create_insight({"dashboards": [dashboard_id], "name": "insight"}) + tile_id = self.dashboard_api.get_dashboard(dashboard_id)["tiles"][0]["id"] + mock_report_user_action.reset_mock() + + self.dashboard_api.update_dashboard( + dashboard_id, {"tiles": [{"id": tile_id, "group_key": " plans ", "badge": "winner"}]} + ) + tile = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False})["tiles"][0] + self.assertEqual((tile["group_key"], tile["badge"]), ("plans", "winner")) + + self.dashboard_api.update_dashboard(dashboard_id, {"tiles": [{"id": tile_id, "badge": "winner"}]}) + marking_calls = [ + call for call in mock_report_user_action.call_args_list if call.args[1] == "dashboard tile marking changed" + ] + self.assertEqual(len(marking_calls), 1) + self.assertEqual(marking_calls[0].args[2]["badge"], "winner") + + _, copied = self.dashboard_api.create_dashboard( + {"name": "copy", "use_dashboard": dashboard_id, "duplicate_tiles": duplicate_tiles} + ) + self.assertEqual((copied["tiles"][0]["group_key"], copied["tiles"][0]["badge"]), ("plans", "winner")) + + self.dashboard_api.update_dashboard(dashboard_id, {"tiles": [{"id": tile_id, "group_key": "", "badge": None}]}) + tile = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False})["tiles"][0] + self.assertEqual((tile["group_key"], tile["badge"]), (None, None)) + + _, response = self.dashboard_api.update_dashboard( + dashboard_id, + {"tiles": [{"id": tile_id, "badge": "loser"}]}, + expected_status=status.HTTP_400_BAD_REQUEST, + ) + self.assertEqual(response["attr"], "badge") + + @parameterized.expand( + [ + ("unknown_badge", {"badge": "Winner"}, "badge"), + ("non_string_group_key", {"group_key": 5}, "group_key"), + ("null_character_group_key", {"group_key": "plans\u0000"}, "group_key"), + ] + ) + def test_invalid_tile_marking_is_rejected_before_anything_is_saved( + self, _name: str, invalid_marking: dict, expected_attr: str + ) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "test"}) + self.dashboard_api.create_insight({"dashboards": [dashboard_id], "name": "first"}) + self.dashboard_api.create_insight({"dashboards": [dashboard_id], "name": "second"}) + first_tile_id, second_tile_id = [tile["id"] for tile in self.dashboard_api.get_dashboard(dashboard_id)["tiles"]] + + _, response = self.dashboard_api.update_dashboard( + dashboard_id, + { + "group_titles": {"plans": "Pricing plans"}, + "tiles": [ + {"id": first_tile_id, "badge": "winner"}, + {"id": second_tile_id, **invalid_marking}, + ], + }, + expected_status=status.HTTP_400_BAD_REQUEST, + ) + + self.assertEqual(response["attr"], expected_attr) + dashboard = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False}) + self.assertEqual([tile["badge"] for tile in dashboard["tiles"]], [None, None]) + self.assertEqual(dashboard["customization"], {}) + + @parameterized.expand( + [ + ("unknown_badge", {"badge": "Winner"}, "badge"), + ("non_string_group_key", {"group_key": 5}, "group_key"), + ] + ) + def test_invalid_marking_on_a_content_edit_is_rejected_before_anything_is_saved( + self, _name: str, invalid_marking: dict, expected_attr: str + ) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "test"}) + _, created = self.dashboard_api.create_text_tile(dashboard_id, text="original") + text_tile = created["tiles"][0] + + _, response = self.dashboard_api.update_dashboard( + dashboard_id, + { + "name": "renamed", + "group_titles": {"plans": "Pricing plans"}, + "tiles": [{"id": text_tile["id"], "text": {"body": "edited"}, **invalid_marking}], + }, + expected_status=status.HTTP_400_BAD_REQUEST, + ) + + self.assertEqual(response["attr"], expected_attr) + dashboard = self.dashboard_api.get_dashboard(dashboard_id, query_params={"refresh": False}) + self.assertEqual(dashboard["name"], "test") + self.assertEqual(dashboard["customization"], {}) + self.assertEqual(dashboard["tiles"][0]["text"]["body"], "original") + @patch("products.dashboards.backend.api.dashboard.report_user_action") def test_dashboard_from_template(self, mock_report_user_action): _, response = self.dashboard_api.create_dashboard({"name": "another", "use_template": "DEFAULT_APP"}) @@ -2778,6 +2940,51 @@ def test_can_move_tile_between_dashboards(self) -> None: assert len(dashboard_two["tiles"]) == 1 assert dashboard_two["tiles"][0]["insight"]["id"] == insight_id + @parameterized.expand( + [ + ("copy_insight_tile", "insight", "copy_tile", None), + ("copy_text_tile", "text", "copy_tile", None), + ("move_insight_tile", "insight", "move_tile", None), + ("move_text_tile", "text", "move_tile", None), + ("move_tile_within_same_dashboard", "insight", "move_tile_same_dashboard", "plans"), + ] + ) + def test_copy_or_move_tile_to_another_dashboard_drops_group_key_and_keeps_badge( + self, _name: str, kind: str, action: str, expected_group_key: str | None + ) -> None: + source_id, _ = self.dashboard_api.create_dashboard({"name": "source"}) + destination_id, _ = self.dashboard_api.create_dashboard({"name": "destination"}) + if kind == "insight": + self.dashboard_api.create_insight({"dashboards": [source_id]}) + else: + self.dashboard_api.create_text_tile(source_id, text="hello") + tile_id = self.dashboard_api.get_dashboard(source_id)["tiles"][0]["id"] + self.dashboard_api.update_dashboard( + source_id, {"tiles": [{"id": tile_id, "group_key": "plans", "badge": "winner"}]} + ) + + if action == "copy_tile": + response = self.client.post( + f"/api/projects/{self.team.id}/dashboards/{destination_id}/copy_tile", + {"fromDashboardId": source_id, "tileId": tile_id}, + ) + else: + response = self.client.patch( + f"/api/projects/{self.team.id}/dashboards/{source_id}/move_tile", + { + "tile": {"id": tile_id}, + "to_dashboard": source_id if action == "move_tile_same_dashboard" else destination_id, + }, + ) + assert response.status_code == status.HTTP_200_OK + + landed_on = source_id if action == "move_tile_same_dashboard" else destination_id + landed_tile = self.dashboard_api.get_dashboard(landed_on)["tiles"][0] + assert (landed_tile["group_key"], landed_tile["badge"]) == (expected_group_key, "winner") + if action == "copy_tile": + source_tile = self.dashboard_api.get_dashboard(source_id)["tiles"][0] + assert (source_tile["group_key"], source_tile["badge"]) == ("plans", "winner") + def test_move_text_tile_succeeds_when_destination_has_soft_deleted_shadow_tile(self) -> None: """Soft-deleted rows still hold unique (dashboard, text_id); moving must delete them first.""" dashboard_a_id, _ = self.dashboard_api.create_dashboard({"name": "a"}) @@ -3097,6 +3304,8 @@ def test_create_from_template_json_can_provide_text_tile(self) -> None: "team": self.team.pk, }, "transparent_background": None, + "group_key": None, + "badge": None, }, ] @@ -3275,6 +3484,8 @@ def test_create_from_template_json_can_provide_query_tile(self) -> None: "show_description": None, "text": None, "transparent_background": None, + "group_key": None, + "badge": None, "widget": None, }, ] diff --git a/posthog/api/test/dashboards/test_dashboard_text_tiles.py b/posthog/api/test/dashboards/test_dashboard_text_tiles.py index 542ccf0f608a..5d9579700357 100644 --- a/posthog/api/test/dashboards/test_dashboard_text_tiles.py +++ b/posthog/api/test/dashboards/test_dashboard_text_tiles.py @@ -84,6 +84,8 @@ def _expected_tile_with_text( "layouts": {}, "order": 0, "color": color, + "group_key": None, + "badge": None, "filters_overrides": {}, "text": self._expected_text( body, diff --git a/posthog/models/resource_transfer/test/__snapshots__/test_visitor_field_snapshots.ambr b/posthog/models/resource_transfer/test/__snapshots__/test_visitor_field_snapshots.ambr index b496ca6aa832..25164c64258e 100644 --- a/posthog/models/resource_transfer/test/__snapshots__/test_visitor_field_snapshots.ambr +++ b/posthog/models/resource_transfer/test/__snapshots__/test_visitor_field_snapshots.ambr @@ -188,9 +188,11 @@ # --- # name: TestVisitorFieldSnapshots.test_primitive_fields_DashboardTile list([ + 'badge', 'color', 'deleted', 'filters_overrides', + 'group_key', 'layouts', 'show_description', 'transparent_background', @@ -627,6 +629,7 @@ 'dashboard_id', 'dashboard_queryset', 'filters_hash', + 'get_badge_display', 'id', 'insight_id', 'last_refresh', diff --git a/products/dashboards/backend/admin/dashboard_admin.py b/products/dashboards/backend/admin/dashboard_admin.py index 772b45530e6c..cc524b2880c2 100644 --- a/products/dashboards/backend/admin/dashboard_admin.py +++ b/products/dashboards/backend/admin/dashboard_admin.py @@ -19,6 +19,12 @@ class DashboardTileInline(admin.TabularInline): autocomplete_fields = ("insight", "text", "team") readonly_fields = ("filters_hash",) + def formfield_for_dbfield(self, db_field, request, **kwargs): + formfield = super().formfield_for_dbfield(db_field, request, **kwargs) + if formfield and db_field.name == "badge": + formfield.required = False + return formfield + @admin.register(Dashboard) class DashboardAdmin(admin.ModelAdmin): diff --git a/products/dashboards/backend/api/dashboard.py b/products/dashboards/backend/api/dashboard.py index 5cf04c32645b..46e248d0ad25 100644 --- a/products/dashboards/backend/api/dashboard.py +++ b/products/dashboards/backend/api/dashboard.py @@ -111,6 +111,7 @@ DashboardTemplateCreationJSONSchemaParser, ) from products.dashboards.backend.api.widget_openapi_serializers import ( + GROUP_TITLES_HELP_TEXT, WIDGET_BATCH_ADD_OPENAPI_HELP, AddDashboardWidgetRequestOpenApi, BreakdownColorConfigSerializer, @@ -121,6 +122,9 @@ ) from products.dashboards.backend.constants import ( DASHBOARD_GRID_COLUMN_COUNT, + MAX_GROUP_KEY_LENGTH, + MAX_GROUP_TITLE_LENGTH, + MAX_GROUP_TITLES, MAX_TEXT_TILE_AGENT_CONTEXT_LENGTH, MAX_WIDGETS_BATCH_SIZE, RUN_INSIGHTS_DEFAULT_MAX_RESULT_CHARS, @@ -135,7 +139,7 @@ DASHBOARD_GRID_SPACING_GAPS, Dashboard, ) -from products.dashboards.backend.models.dashboard_tile import ButtonTile, DashboardTile, Text +from products.dashboards.backend.models.dashboard_tile import ButtonTile, DashboardTile, DashboardTileBadge, Text from products.dashboards.backend.models.dashboard_widget import DashboardWidget from products.dashboards.backend.run_insights_output import ( bound_formatted_result, @@ -199,6 +203,37 @@ def _normalize_dashboard_customization(customization: Any) -> dict[str, Any]: return customization.copy() if isinstance(customization, dict) else {} +def _normalized_group_key(group_key: str) -> str: + group_key = group_key.strip() + if "\x00" in group_key: + raise serializers.ValidationError("Group keys can't contain null characters.") + return group_key + + +def _valid_group_titles(customization: Any) -> dict[str, str]: + group_titles = _normalize_dashboard_customization(customization).get("group_titles") + if not isinstance(group_titles, dict): + return {} + valid_titles: dict[str, str] = {} + for key, title in group_titles.items(): + if not isinstance(key, str) or not isinstance(title, str): + continue + try: + group_key = _normalized_group_key(key) + except serializers.ValidationError: + continue + if group_key: + valid_titles[group_key] = title + return valid_titles + + +def _with_group_titles(customization: dict[str, Any], group_titles: dict[str, str] | None) -> dict[str, Any]: + customization = {key: value for key, value in customization.items() if key != "group_titles"} + if group_titles: + customization["group_titles"] = group_titles + return customization + + def _effective_layout_compaction(customization: Any) -> str: layout_compaction = _normalize_dashboard_customization(customization).get("layout_compaction") return layout_compaction if layout_compaction in DASHBOARD_GRID_COMPACTION_MODES else "vertical" @@ -280,6 +315,7 @@ def filed_entry(dashboard: Dashboard) -> FiledEntry | None: "customization", "grid_spacing", "layout_compaction", + "group_titles", ] @@ -1305,6 +1341,11 @@ class DashboardCustomizationSerializer(serializers.Serializer): "to the left, and stable preserves positions while moving colliding tiles." ), ) + group_titles = serializers.DictField( + child=serializers.CharField(), + required=False, + help_text=GROUP_TITLES_HELP_TEXT, + ) class BreakdownColorsField(serializers.ListField): @@ -1378,6 +1419,13 @@ class DashboardMetadataSerializer(DashboardBasicSerializer): "to the left, and stable preserves positions while moving colliding tiles." ), ) + group_titles = serializers.DictField( + child=serializers.CharField(max_length=MAX_GROUP_TITLE_LENGTH), + required=False, + allow_null=True, + write_only=True, + help_text=GROUP_TITLES_HELP_TEXT, + ) persisted_filters = serializers.SerializerMethodField() persisted_variables = serializers.SerializerMethodField() @@ -1399,21 +1447,41 @@ def validate_restriction_level(self, value: int) -> int: ) return value + def validate_group_titles(self, value: dict[str, str] | None) -> dict[str, str] | None: + if value is None: + return None + if len(value) > MAX_GROUP_TITLES: + raise serializers.ValidationError(f"A dashboard can have at most {MAX_GROUP_TITLES} group titles.") + normalized: dict[str, str] = {} + for key, title in value.items(): + group_key = _normalized_group_key(key) + if not group_key: + raise serializers.ValidationError("Group keys can't be empty.") + if len(group_key) > MAX_GROUP_KEY_LENGTH: + raise serializers.ValidationError(f"Group keys can have at most {MAX_GROUP_KEY_LENGTH} characters.") + if group_key in normalized: + raise serializers.ValidationError(f'Group key "{group_key}" is used more than once.') + normalized[group_key] = title + return normalized + def get_filters(self, dashboard: Dashboard) -> dict: request = self.context.get("request") is_shared = self.context.get("is_shared", False) return filters_override_requested_by_client(request, dashboard, is_shared=is_shared) @extend_schema_field(DashboardCustomizationSerializer) - def get_customization(self, dashboard: Dashboard) -> dict[str, str]: + def get_customization(self, dashboard: Dashboard) -> dict[str, Any]: customization = _normalize_dashboard_customization(dashboard.customization) tile_spacing = customization.get("tile_spacing") layout_compaction = customization.get("layout_compaction") - result = {} + result: dict[str, Any] = {} if isinstance(tile_spacing, str) and tile_spacing in DASHBOARD_GRID_SPACING_GAPS: result["tile_spacing"] = tile_spacing if isinstance(layout_compaction, str) and layout_compaction in DASHBOARD_GRID_COMPACTION_MODES: result["layout_compaction"] = layout_compaction + group_titles = _valid_group_titles(customization) + if group_titles: + result["group_titles"] = group_titles return result def get_variables(self, dashboard: Dashboard) -> dict | None: @@ -1539,6 +1607,29 @@ def _report_dashboard_tile_added( ) +def _report_dashboard_tile_marking_changed( + *, + user: User, + dashboard: Dashboard, + tile: DashboardTile, + request: Request | None = None, +) -> None: + tile_type, widget_type = _tile_type_and_widget_type(tile) + report_user_action( + user, + "dashboard tile marking changed", + { + "dashboard_id": dashboard.id, + "tile_type": tile_type, + "widget_type": widget_type, + "badge": tile.badge, + "has_group_key": tile.group_key is not None, + }, + team=dashboard.team, + request=request, + ) + + def _report_dashboard_tile_removed( *, user: User, @@ -1691,6 +1782,8 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Dashboard: team = self.context["get_team"]() grid_spacing = validated_data.pop("grid_spacing", None) layout_compaction = validated_data.pop("layout_compaction", None) + has_group_titles = "group_titles" in validated_data + group_titles = validated_data.pop("group_titles", None) current_count = Dashboard.objects.filter(team_id=team_id, deleted=False).count() check_count_limit( team=team, @@ -1753,6 +1846,8 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Dashboard: **validated_data.get("customization", {}), "layout_compaction": layout_compaction, } + if has_group_titles: + validated_data["customization"] = _with_group_titles(validated_data.get("customization", {}), group_titles) with transaction.atomic(): dashboard = Dashboard.objects.create(team_id=team_id, filters=filters, **validated_data) @@ -1855,6 +1950,8 @@ def _deep_duplicate_tiles( filters_overrides=existing_tile.filters_overrides, show_description=existing_tile.show_description, transparent_background=existing_tile.transparent_background, + group_key=existing_tile.group_key, + badge=existing_tile.badge, ) elif existing_tile.text: new_data = { @@ -1875,6 +1972,8 @@ def _deep_duplicate_tiles( filters_overrides=existing_tile.filters_overrides, show_description=existing_tile.show_description, transparent_background=existing_tile.transparent_background, + group_key=existing_tile.group_key, + badge=existing_tile.badge, ) elif existing_tile.button_tile: new_data = { @@ -1895,6 +1994,8 @@ def _deep_duplicate_tiles( filters_overrides=existing_tile.filters_overrides, show_description=existing_tile.show_description, transparent_background=existing_tile.transparent_background, + group_key=existing_tile.group_key, + badge=existing_tile.badge, ) elif existing_tile.widget: request = self.context["request"] @@ -1911,6 +2012,7 @@ def _clone_widget_tile_to_dashboard( user: User, *, append_copy_suffix: bool = True, + keep_group_key: bool = True, ) -> DashboardTile: if source_tile.widget is None: raise serializers.ValidationError("Tile is not a widget tile.") @@ -1942,6 +2044,8 @@ def _clone_widget_tile_to_dashboard( filters_overrides=source_tile.filters_overrides, show_description=source_tile.show_description, transparent_background=source_tile.transparent_background, + group_key=source_tile.group_key if keep_group_key else None, + badge=source_tile.badge, ) @staticmethod @@ -1969,6 +2073,11 @@ def update(self, instance: Dashboard, validated_data: dict, *args: Any, **kwargs **({"tile_spacing": grid_spacing} if grid_spacing is not None else {}), **({"layout_compaction": layout_compaction} if layout_compaction is not None else {}), } + if "group_titles" in validated_data: + validated_data["customization"] = _with_group_titles( + validated_data.get("customization", _normalize_dashboard_customization(instance.customization)), + validated_data.pop("group_titles"), + ) being_undeleted = instance.deleted and "deleted" in validated_data and not validated_data["deleted"] if being_undeleted: @@ -2013,7 +2122,7 @@ def update(self, instance: Dashboard, validated_data: dict, *args: Any, **kwargs raise serializers.ValidationError("Variables must be a dictionary") instance.variables = request_variables - self._validate_display_only_tile_ids(instance, initial_data.get("tiles", [])) + self._validate_tiles_before_saving(instance, initial_data.get("tiles", [])) instance = super().update(instance, validated_data) @@ -2095,6 +2204,8 @@ def update(self, instance: Dashboard, validated_data: dict, *args: Any, **kwargs "filters_overrides", "show_description", "transparent_background", + "group_key", + "badge", "deleted", } @@ -2115,19 +2226,53 @@ def _extract_display_defaults(tile_data: dict, existing_layouts: dict | None = N tile_filters = defaults["filters_overrides"] if tile_filters is not None: defaults["filters_overrides"] = DashboardSerializer._validated_filters(tile_filters) + defaults.update(DashboardSerializer._validated_tile_marking(defaults)) return defaults @staticmethod - def _validate_display_only_tile_ids(instance: Dashboard, tiles: list[dict]) -> None: - tile_ids = { - tile["id"] + def _validated_tile_marking(tile_fields: dict) -> dict: + marking: dict = {} + if "group_key" in tile_fields: + marking["group_key"] = DashboardSerializer._validated_group_key(tile_fields["group_key"]) + if "badge" in tile_fields: + if tile_fields["badge"] not in (None, *DashboardTileBadge.values): + raise serializers.ValidationError( + {"badge": f"Badge must be one of {', '.join(DashboardTileBadge.values)}, or null to remove it."} + ) + marking["badge"] = tile_fields["badge"] + return marking + + @staticmethod + def _validated_group_key(group_key: Any) -> str | None: + if group_key is None: + return None + if not isinstance(group_key, str): + raise serializers.ValidationError({"group_key": "Group key must be a string or null."}) + try: + group_key = _normalized_group_key(group_key) + except serializers.ValidationError as error: + raise serializers.ValidationError({"group_key": error.detail}) from error + if len(group_key) > MAX_GROUP_KEY_LENGTH: + raise serializers.ValidationError( + {"group_key": f"Group key can have at most {MAX_GROUP_KEY_LENGTH} characters."} + ) + return group_key or None + + @staticmethod + def _validate_tiles_before_saving(instance: Dashboard, tiles: list[dict]) -> None: + for tile in tiles: + DashboardSerializer._validated_tile_marking(tile) + + display_only_tiles = [ + tile for tile in tiles if tile.get("id") is not None and not tile.get("text") and not tile.get("button_tile") and not tile.get("widget") and any(field in tile for field in DashboardSerializer.TILE_DISPLAY_FIELDS) - } + ] + tile_ids = {tile["id"] for tile in display_only_tiles} if not tile_ids: return @@ -2218,7 +2363,7 @@ def _upsert_tile(instance: Dashboard, tile_data: dict, **extra_defaults: Any) -> @staticmethod def _update_existing_tile_display_fields( - instance: Dashboard, tile_data: dict, user: User + instance: Dashboard, tile_data: dict, user: User, request: Request | None = None ) -> tuple[DashboardTile | None, bool]: """Update display fields on an existing tile. @@ -2232,9 +2377,13 @@ def _update_existing_tile_display_fields( if tile_id is None: return None, False - existing = DashboardTile.objects_including_soft_deleted.filter( - id=tile_id, dashboard=instance, dashboard__team_id=instance.team_id - ).first() + existing = ( + DashboardTile.objects_including_soft_deleted.filter( + id=tile_id, dashboard=instance, dashboard__team_id=instance.team_id + ) + .select_related("widget") + .first() + ) if existing is None: raise serializers.ValidationError({"tiles": f"Tile ID {tile_id} is not on this dashboard."}) @@ -2255,12 +2404,20 @@ def _update_existing_tile_display_fields( if became_live and insight is not None: check_can_add_insight_to_shared_dashboard(user, instance, insight.query) + marking_changed = any( + getattr(existing, field) != tile_defaults[field] + for field in ("group_key", "badge") + if field in tile_defaults + ) + for attr, val in tile_defaults.items(): setattr(existing, attr, val) # update_fields scopes the UPDATE to only the columns we changed, so concurrent writes # to other columns aren't clobbered by our stale read. save() (vs queryset.update()) # keeps DashboardTile.save() side effects like filters_hash upkeep. existing.save(update_fields=list(tile_defaults.keys())) + if marking_changed: + _report_dashboard_tile_marking_changed(user=user, dashboard=instance, tile=existing, request=request) return existing, became_deleted @staticmethod @@ -2414,10 +2571,12 @@ def _update_tiles( or "filters_overrides" in tile_data or "show_description" in tile_data or "transparent_background" in tile_data + or "group_key" in tile_data + or "badge" in tile_data ): tile_data.pop("insight", None) # don't ever update insight tiles here updated_tile, became_deleted = DashboardSerializer._update_existing_tile_display_fields( - instance, tile_data, user + instance, tile_data, user, request ) # The dashboard UI soft-deletes tiles through this PATCH path rather than the # delete_tile endpoint, so removal analytics must fire here too. @@ -3083,7 +3242,9 @@ def move_tile(self, request: Request, *args: Any, **kwargs: Any) -> Response: tile.dashboard_id = to_dashboard # Destination is scoped to the current project; align team_id when moving within it. tile.team_id = to_dashboard_obj.team_id - tile.save(update_fields=["dashboard_id", "team_id"]) + if to_dashboard_obj.pk != from_dashboard.pk: + tile.group_key = None + tile.save(update_fields=["dashboard_id", "team_id", "group_key"]) except DjangoValidationError: logger.exception("validation_error_while_moving_dashboard_tile") raise exceptions.ValidationError("Invalid request data for moving tile.") @@ -3130,7 +3291,9 @@ def copy_tile(self, request: Request, *args: Any, **kwargs: Any) -> Response: DashboardSerializer._check_widget_tile_product_access(tile.widget, user_access_control) try: with transaction.atomic(): - DashboardSerializer._clone_widget_tile_to_dashboard(tile, destination, cast(User, request.user)) + DashboardSerializer._clone_widget_tile_to_dashboard( + tile, destination, cast(User, request.user), keep_group_key=False + ) except DjangoValidationError: logger.warning("validation_error_while_copying_dashboard_tile", exc_info=True) raise exceptions.ValidationError("Unable to copy tile due to invalid data.") @@ -3161,7 +3324,7 @@ def copy_tile(self, request: Request, *args: Any, **kwargs: Any) -> Response: try: with transaction.atomic(): - tile.copy_to_dashboard(destination) + tile.copy_to_dashboard(destination, keep_group_key=False) except DjangoValidationError: logger.warning("validation_error_while_copying_dashboard_tile", exc_info=True) raise exceptions.ValidationError("Unable to copy tile due to invalid data.") diff --git a/products/dashboards/backend/api/test/test_dashboard_widgets.py b/products/dashboards/backend/api/test/test_dashboard_widgets.py index 404ed634b2dd..63a962d77671 100644 --- a/products/dashboards/backend/api/test/test_dashboard_widgets.py +++ b/products/dashboards/backend/api/test/test_dashboard_widgets.py @@ -4,7 +4,9 @@ from posthog.test.base import APIBaseTest from unittest.mock import ANY, MagicMock, PropertyMock, patch +from django.db import connection from django.test import override_settings +from django.test.utils import CaptureQueriesContext from drf_spectacular.generators import SchemaGenerator from parameterized import parameterized @@ -145,6 +147,21 @@ def test_dashboard_patch_widget_update_without_config_preserves_config(self) -> assert updated_tile["widget"]["config"]["limit"] == 5 assert updated_tile["widget"]["config"]["orderBy"] == "last_seen" + @override_settings(IN_UNIT_TESTING=True) + def test_marking_widget_tiles_does_not_load_each_widget_separately(self) -> None: + dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "dashboard"}) + for _ in range(3): + _, dashboard_json = self.dashboard_api.create_widget_tile(dashboard_id) + tile_ids = [tile["id"] for tile in dashboard_json["tiles"]] + + with CaptureQueriesContext(connection) as queries: + self.dashboard_api.update_dashboard( + dashboard_id, {"tiles": [{"id": tile_id, "badge": "winner"} for tile_id in tile_ids]} + ) + + widget_lookups = [query["sql"] for query in queries if 'FROM "posthog_dashboardwidget" WHERE' in query["sql"]] + assert widget_lookups == [] + @override_settings(IN_UNIT_TESTING=True) def test_duplicate_dashboard_copies_widget_name_with_suffix(self) -> None: dashboard_id, _ = self.dashboard_api.create_dashboard({"name": "source"}) @@ -518,6 +535,8 @@ def test_copy_tile_copies_widget_tile(self) -> None: tile = source_json["tiles"][0] tile["widget"]["name"] = "Top errors" tile["widget"]["description"] = "Weekly summary" + tile["group_key"] = "plans" + tile["badge"] = "winner" self.client.patch( f"/api/projects/{self.team.id}/dashboards/{source_id}", {"tiles": [tile]}, @@ -541,6 +560,8 @@ def test_copy_tile_copies_widget_tile(self) -> None: assert dest_widget["description"] == "Weekly summary" assert dest_widget["config"]["limit"] == 7 assert dest_dashboard["tiles"][0]["layouts"] == source_dashboard["tiles"][0]["layouts"] + assert source_dashboard["tiles"][0]["group_key"] == "plans" + assert (dest_dashboard["tiles"][0]["group_key"], dest_dashboard["tiles"][0]["badge"]) == (None, "winner") @override_settings(IN_UNIT_TESTING=True) def test_move_tile_moves_widget_tile(self) -> None: @@ -549,6 +570,9 @@ def test_move_tile_moves_widget_tile(self) -> None: _, source_json = self.dashboard_api.create_widget_tile(source_id, config={"limit": 9}) tile = source_json["tiles"][0] widget_id = tile["widget"]["id"] + self.dashboard_api.update_dashboard( + source_id, {"tiles": [{"id": tile["id"], "group_key": "plans", "badge": "winner"}]} + ) response = self.client.patch( f"/api/projects/{self.team.id}/dashboards/{source_id}/move_tile", @@ -563,6 +587,7 @@ def test_move_tile_moves_widget_tile(self) -> None: assert len(dest_dashboard["tiles"]) == 1 assert dest_dashboard["tiles"][0]["widget"]["id"] == widget_id assert dest_dashboard["tiles"][0]["widget"]["config"]["limit"] == 9 + assert (dest_dashboard["tiles"][0]["group_key"], dest_dashboard["tiles"][0]["badge"]) == (None, "winner") @override_settings(IN_UNIT_TESTING=True) def test_widget_catalog_lists_registered_types(self) -> None: diff --git a/products/dashboards/backend/api/widget_openapi_serializers.py b/products/dashboards/backend/api/widget_openapi_serializers.py index d5ab3b752331..f1cd9a5f331b 100644 --- a/products/dashboards/backend/api/widget_openapi_serializers.py +++ b/products/dashboards/backend/api/widget_openapi_serializers.py @@ -7,6 +7,7 @@ """ from products.dashboards.backend.widget_specs.openapi import ( + GROUP_TITLES_HELP_TEXT, WIDGET_BATCH_ADD_OPENAPI_HELP, WIDGET_CONFIG_SERIALIZERS, AddDashboardWidgetRequestOpenApi, @@ -32,6 +33,7 @@ "UpdateDashboardWidgetRequestOpenApi", "WidgetCatalogEntryOpenApi", "WidgetCatalogResponseSerializer", + "GROUP_TITLES_HELP_TEXT", "WIDGET_BATCH_ADD_OPENAPI_HELP", "WIDGET_CONFIG_SERIALIZERS", *[serializer.__name__ for serializer in WIDGET_CONFIG_SERIALIZERS.values()], diff --git a/products/dashboards/backend/constants.py b/products/dashboards/backend/constants.py index d54f2c29a874..f72ec1c6da2c 100644 --- a/products/dashboards/backend/constants.py +++ b/products/dashboards/backend/constants.py @@ -24,6 +24,9 @@ MAX_WIDGETS_BATCH_SIZE = 10 MAX_TEXT_TILE_AGENT_CONTEXT_LENGTH = 10_000 +MAX_GROUP_TITLES = 100 +MAX_GROUP_KEY_LENGTH = 100 +MAX_GROUP_TITLE_LENGTH = 200 # Upper bound on pinned recordings pulled from a collection to scope a session replay widget. Bounds the # session_ids IN clause sent to ClickHouse and the Python list we materialize, matching the saved-filter diff --git a/products/dashboards/backend/migrations/0026_dashboardtile_group_key_badge.py b/products/dashboards/backend/migrations/0026_dashboardtile_group_key_badge.py new file mode 100644 index 000000000000..6a0a89b7577b --- /dev/null +++ b/products/dashboards/backend/migrations/0026_dashboardtile_group_key_badge.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.18 on 2026-10-09 19:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dashboards", "0025_teamhometabdashboardconfig"), + ] + + operations = [ + migrations.AddField( + model_name="dashboardtile", + name="badge", + field=models.CharField( + choices=[("winner", "Winner"), ("cheeky-hog", "Cheeky hog")], max_length=20, null=True + ), + ), + migrations.AddField( + model_name="dashboardtile", + name="group_key", + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/products/dashboards/backend/migrations/max_migration.txt b/products/dashboards/backend/migrations/max_migration.txt index 63f49b21ba19..11d00959425f 100644 --- a/products/dashboards/backend/migrations/max_migration.txt +++ b/products/dashboards/backend/migrations/max_migration.txt @@ -1 +1 @@ -0025_teamhometabdashboardconfig +0026_dashboardtile_group_key_badge diff --git a/products/dashboards/backend/models/dashboard_tile.py b/products/dashboards/backend/models/dashboard_tile.py index abd2debd9068..95e79cc205eb 100644 --- a/products/dashboards/backend/models/dashboard_tile.py +++ b/products/dashboards/backend/models/dashboard_tile.py @@ -5,6 +5,7 @@ from posthog.models.utils import UUIDModel, build_unique_relationship_check +from products.dashboards.backend.constants import MAX_GROUP_KEY_LENGTH from products.dashboards.backend.models.dashboard import Dashboard @@ -52,6 +53,11 @@ class Meta: db_table = "posthog_buttontile" +class DashboardTileBadge(models.TextChoices): + WINNER = "winner", "Winner" + CHEEKY_HOG = "cheeky-hog", "Cheeky hog" + + class DashboardTileManager(models.Manager): def get_queryset(self): return super().get_queryset().exclude(deleted=True).exclude(dashboard__deleted=True) @@ -107,6 +113,10 @@ class DashboardTile(models.Model): transparent_background = models.BooleanField(null=True, blank=True) + # Tiles that share a group key belong together. The dashboard's `customization.group_titles` names each group. + group_key = models.CharField(max_length=MAX_GROUP_KEY_LENGTH, null=True, blank=True) + badge = models.CharField(max_length=20, choices=DashboardTileBadge.choices, null=True) + deleted = models.BooleanField(null=True, blank=True) objects = DashboardTileManager() @@ -217,7 +227,7 @@ def prepare_move_to_dashboard(self, to_dashboard_id: int) -> None: raise ValidationError("This content is already on the destination dashboard.") stale.delete() - def copy_to_dashboard(self, dashboard: Dashboard) -> None: + def copy_to_dashboard(self, dashboard: Dashboard, *, keep_group_key: bool = True) -> None: """ Place this tile's content on another dashboard: create a new row, or undelete a soft-deleted row for the same insight, text, or button (unique constraint would block a second insert otherwise). @@ -249,6 +259,8 @@ def copy_to_dashboard(self, dashboard: Dashboard) -> None: existing.color = self.color existing.show_description = self.show_description existing.transparent_background = self.transparent_background + existing.group_key = self.group_key if keep_group_key else None + existing.badge = self.badge existing.filters_overrides = self.filters_overrides existing.save() return @@ -263,6 +275,8 @@ def copy_to_dashboard(self, dashboard: Dashboard) -> None: layouts=self.layouts, show_description=self.show_description, transparent_background=self.transparent_background, + group_key=self.group_key if keep_group_key else None, + badge=self.badge, filters_overrides=self.filters_overrides, ) diff --git a/products/dashboards/backend/test/test_dashboard_admin.py b/products/dashboards/backend/test/test_dashboard_admin.py index f7d6c734e638..7d084f009629 100644 --- a/products/dashboards/backend/test/test_dashboard_admin.py +++ b/products/dashboards/backend/test/test_dashboard_admin.py @@ -4,9 +4,9 @@ from django.contrib.admin.sites import AdminSite from django.contrib.messages.storage.fallback import FallbackStorage from django.http import HttpRequest -from django.test import RequestFactory +from django.test import RequestFactory, SimpleTestCase -from products.dashboards.backend.admin.dashboard_admin import DashboardAdmin +from products.dashboards.backend.admin.dashboard_admin import DashboardAdmin, DashboardTileInline from products.dashboards.backend.models.dashboard import Dashboard from products.dashboards.backend.models.dashboard_tile import DashboardTile from products.product_analytics.backend.facade.models import Insight @@ -72,3 +72,14 @@ def test_bulk_hard_delete_action_is_not_offered(self): actions = self.admin.get_actions(self._post_request()) assert "delete_selected" not in actions assert "restore_selected" in actions + + +class TestDashboardTileInline(SimpleTestCase): + def test_an_unmarked_tile_can_be_saved_without_a_badge(self): + inline = DashboardTileInline(DashboardTile, AdminSite()) + + badge_field = inline.formfield_for_dbfield(DashboardTile._meta.get_field("badge"), request=None) + + assert badge_field is not None + assert badge_field.required is False + assert badge_field.clean("") is None diff --git a/products/dashboards/backend/widget_specs/openapi.py b/products/dashboards/backend/widget_specs/openapi.py index 9598696dbf0c..3b6b31a8d74c 100644 --- a/products/dashboards/backend/widget_specs/openapi.py +++ b/products/dashboards/backend/widget_specs/openapi.py @@ -6,12 +6,31 @@ from pydantic import BaseModel from rest_framework import serializers -from products.dashboards.backend.constants import DASHBOARD_GRID_COLUMN_COUNT +from products.dashboards.backend.constants import ( + DASHBOARD_GRID_COLUMN_COUNT, + MAX_GROUP_KEY_LENGTH, + MAX_GROUP_TITLE_LENGTH, +) from products.dashboards.backend.facade.enums import RestrictionLevel from products.dashboards.backend.models.dashboard import DASHBOARD_GRID_COMPACTION_MODES, DASHBOARD_GRID_SPACING_GAPS +from products.dashboards.backend.models.dashboard_tile import DashboardTileBadge from products.dashboards.backend.widget_specs.pydantic_openapi import pydantic_config_field, pydantic_stub_serializer from products.dashboards.backend.widget_specs.registry import EXPECTED_WIDGET_TYPES, WIDGET_SPECS +GROUP_TITLES_HELP_TEXT = ( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. " + "A write replaces the whole map. Send an empty object or null to remove all group titles." +) +GROUP_KEY_HELP_TEXT = ( + "Key that puts this tile in a group with the other tiles that have the same key on this dashboard. " + "Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group." +) +BADGE_HELP_TEXT = ( + "Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. " + "cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. " + "More than one tile in a group can have the winner badge." +) + WIDGET_BATCH_ADD_OPENAPI_HELP = ( "Widget tiles to add atomically. Supported widget_type values: " + ", ".join(sorted(EXPECTED_WIDGET_TYPES)) @@ -299,6 +318,19 @@ class DashboardPatchTileOpenApiSerializer(serializers.Serializer): "or widget content — this only toggles whether it renders on the dashboard." ), ) + group_key = serializers.CharField( + max_length=MAX_GROUP_KEY_LENGTH, + required=False, + allow_null=True, + allow_blank=True, + help_text=GROUP_KEY_HELP_TEXT, + ) + badge = serializers.ChoiceField( + choices=DashboardTileBadge.choices, + required=False, + allow_null=True, + help_text=BADGE_HELP_TEXT, + ) widget = DashboardPatchWidgetOpenApiSerializer(required=False, help_text="Nested widget row updates.") @@ -431,6 +463,12 @@ class PatchedDashboardOpenApiSerializer(serializers.Serializer): "to the left, and stable preserves positions while moving colliding tiles." ), ) + group_titles = serializers.DictField( + child=serializers.CharField(max_length=MAX_GROUP_TITLE_LENGTH), + required=False, + allow_null=True, + help_text=GROUP_TITLES_HELP_TEXT, + ) tiles = DashboardPatchTileOpenApiSerializer( many=True, required=False, diff --git a/products/dashboards/frontend/generated/api.schemas.ts b/products/dashboards/frontend/generated/api.schemas.ts index 092b02873bca..87fbc8035bf8 100644 --- a/products/dashboards/frontend/generated/api.schemas.ts +++ b/products/dashboards/frontend/generated/api.schemas.ts @@ -321,6 +321,12 @@ export type DashboardWriteOpenApiApiPersistedFilters = { [key: string]: unknown */ export type DashboardWriteOpenApiApiPersistedVariables = { [key: string]: unknown } | null +/** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ +export type DashboardWriteOpenApiApiGroupTitles = { [key: string]: string } | null + export type DashboardWriteOpenApiApiTilesItem = { [key: string]: unknown } /** @@ -391,6 +397,11 @@ export const LayoutCompactionEnumApi = { Stable: 'stable', } as const +/** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + */ +export type DashboardCustomizationApiGroupTitles = { [key: string]: string } + export interface DashboardCustomizationApi { /** Named tile density preset. * @@ -406,6 +417,8 @@ export interface DashboardCustomizationApi { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnumApi + /** Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. */ + group_titles?: DashboardCustomizationApiGroupTitles } /** @@ -500,6 +513,11 @@ export interface DashboardWriteOpenApiApi { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnumApi + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: DashboardWriteOpenApiApiGroupTitles /** @nullable */ readonly tiles: readonly DashboardWriteOpenApiApiTilesItem[] | null /** Template key to create the dashboard from a predefined template. */ @@ -531,6 +549,12 @@ export type DashboardApiPersistedFilters = { [key: string]: unknown } | null */ export type DashboardApiPersistedVariables = { [key: string]: unknown } | null +/** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ +export type DashboardApiGroupTitles = { [key: string]: string } | null + export type DashboardApiTilesItem = { [key: string]: unknown } /** @@ -624,6 +648,11 @@ export interface DashboardApi { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnumApi + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: DashboardApiGroupTitles /** @nullable */ readonly tiles: readonly DashboardApiTilesItem[] | null /** Template key to create the dashboard from a predefined template. */ @@ -638,6 +667,12 @@ export interface DashboardApi { _create_in_folder?: string } +/** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ +export type PatchedPatchedDashboardOpenApiApiGroupTitles = { [key: string]: string } | null + /** * OpenAPI-only shape for a dashboard's filters object (agents/MCP). * @@ -692,6 +727,17 @@ export interface _DashboardPatchTileLayoutsOpenApiApi { xs?: _DashboardPatchTileLayoutBoxOpenApiApi } +/** + * * `winner` - Winner + * * `cheeky-hog` - Cheeky hog + */ +export type DashboardTileBadgeEnumApi = (typeof DashboardTileBadgeEnumApi)[keyof typeof DashboardTileBadgeEnumApi] + +export const DashboardTileBadgeEnumApi = { + Winner: 'winner', + CheekyHog: 'cheeky-hog', +} as const + /** * * `activity_events_list` - activity_events_list * * `canvas_app` - canvas_app @@ -1209,6 +1255,17 @@ export interface DashboardPatchTileOpenApiApi { layouts?: _DashboardPatchTileLayoutsOpenApiApi /** Whether to show the tile's description underneath its title on the dashboard. Works for every tile type. The description itself is unaffected and still comes from the tile's insight, text, or widget content — this only toggles whether it renders on the dashboard. */ show_description?: boolean + /** + * Key that puts this tile in a group with the other tiles that have the same key on this dashboard. Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group. + * @maxLength 100 + * @nullable + */ + group_key?: string | null + /** Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. More than one tile in a group can have the winner badge. + * + * * `winner` - Winner + * * `cheeky-hog` - Cheeky hog */ + badge?: DashboardTileBadgeEnumApi | null /** Nested widget row updates. */ widget?: DashboardPatchWidgetOpenApiApi } @@ -1265,6 +1322,11 @@ export interface PatchedPatchedDashboardOpenApiApi { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnumApi + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: PatchedPatchedDashboardOpenApiApiGroupTitles /** Dashboard tiles to update, each identified by its tile id. Any tile type accepts `layouts` to set its grid position and size. Widget tiles also accept nested widget.config patches. */ tiles?: DashboardPatchTileOpenApiApi[] /** Template key to create the dashboard from a predefined template. */ @@ -10012,6 +10074,12 @@ export interface DashboardTileApi { show_description?: boolean | null /** @nullable */ transparent_background?: boolean | null + /** + * @maxLength 100 + * @nullable + */ + group_key?: string | null + badge?: DashboardTileBadgeEnumApi | null } export interface DeleteTileRequestApi { diff --git a/products/dashboards/frontend/generated/api.zod.ts b/products/dashboards/frontend/generated/api.zod.ts index f5f8a4f71e39..6fdbcb833000 100644 --- a/products/dashboards/frontend/generated/api.zod.ts +++ b/products/dashboards/frontend/generated/api.zod.ts @@ -135,6 +135,8 @@ export const dashboardsCreateBodyBreakdownColorsItemColorTokenRegExp = new RegEx export const dashboardsCreateBodyRestrictionLevelMin = 21 export const dashboardsCreateBodyRestrictionLevelMax = 21 +export const dashboardsCreateBodyGroupTitlesMaxOne = 200 + export const dashboardsCreateBodyDeleteInsightsDefault = false export const DashboardsCreateBody = /* @__PURE__ */ zod @@ -214,6 +216,12 @@ export const DashboardsCreateBody = /* @__PURE__ */ zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsCreateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), use_template: zod .string() .optional() @@ -233,6 +241,8 @@ export const dashboardsUpdateBodyBreakdownColorsItemColorTokenRegExp = new RegEx export const dashboardsUpdateBodyRestrictionLevelMin = 21 export const dashboardsUpdateBodyRestrictionLevelMax = 21 +export const dashboardsUpdateBodyGroupTitlesMaxOne = 200 + export const dashboardsUpdateBodyDeleteInsightsDefault = false export const DashboardsUpdateBody = /* @__PURE__ */ zod @@ -312,6 +322,12 @@ export const DashboardsUpdateBody = /* @__PURE__ */ zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsUpdateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), use_template: zod .string() .optional() @@ -331,6 +347,8 @@ export const dashboardsPartialUpdateBodyBreakdownColorsItemColorTokenRegExp = ne export const dashboardsPartialUpdateBodyRestrictionLevelMin = 21 export const dashboardsPartialUpdateBodyRestrictionLevelMax = 21 +export const dashboardsPartialUpdateBodyGroupTitlesMaxOne = 200 + export const dashboardsPartialUpdateBodyTilesItemLayoutsOneSmOneXMin = 0 export const dashboardsPartialUpdateBodyTilesItemLayoutsOneSmOneXMax = 11 @@ -345,6 +363,8 @@ export const dashboardsPartialUpdateBodyTilesItemLayoutsOneXsOneYMin = 0 export const dashboardsPartialUpdateBodyTilesItemLayoutsOneXsOneWMax = 12 +export const dashboardsPartialUpdateBodyTilesItemGroupKeyMax = 100 + export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneNotebookShortIdOneMax = 128 export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault = 25 @@ -508,6 +528,12 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsPartialUpdateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), tiles: zod .array( zod.object({ @@ -568,6 +594,24 @@ export const DashboardsPartialUpdateBody = /* @__PURE__ */ zod .describe( "Whether to show the tile's description underneath its title on the dashboard. Works for every tile type. The description itself is unaffected and still comes from the tile's insight, text, or widget content — this only toggles whether it renders on the dashboard." ), + group_key: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemGroupKeyMax) + .nullish() + .describe( + "Key that puts this tile in a group with the other tiles that have the same key on this dashboard. Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group." + ), + badge: zod + .union([ + zod + .enum(['winner', 'cheeky-hog']) + .describe('\* `winner` - Winner\n\* `cheeky-hog` - Cheeky hog'), + zod.null(), + ]) + .optional() + .describe( + 'Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. More than one tile in a group can have the winner badge.\n\n\* `winner` - Winner\n\* `cheeky-hog` - Cheeky hog' + ), widget: zod .object({ id: zod @@ -2483,6 +2527,8 @@ export const dashboardsCreateFromTemplateJsonCreateBodyNameMax = 400 export const dashboardsCreateFromTemplateJsonCreateBodyBreakdownColorsItemColorTokenRegExp = new RegExp( '^preset-[1-9][0-9]\*$' ) +export const dashboardsCreateFromTemplateJsonCreateBodyGroupTitlesMaxOne = 200 + export const dashboardsCreateFromTemplateJsonCreateBodyDeleteInsightsDefault = false export const DashboardsCreateFromTemplateJsonCreateBody = /* @__PURE__ */ zod @@ -2563,6 +2609,12 @@ export const DashboardsCreateFromTemplateJsonCreateBody = /* @__PURE__ */ zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsCreateFromTemplateJsonCreateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), use_template: zod .string() .optional() @@ -2586,6 +2638,8 @@ export const dashboardsCreateUnlistedDashboardCreateBodyNameMax = 400 export const dashboardsCreateUnlistedDashboardCreateBodyBreakdownColorsItemColorTokenRegExp = new RegExp( '^preset-[1-9][0-9]\*$' ) +export const dashboardsCreateUnlistedDashboardCreateBodyGroupTitlesMaxOne = 200 + export const dashboardsCreateUnlistedDashboardCreateBodyDeleteInsightsDefault = false export const DashboardsCreateUnlistedDashboardCreateBody = /* @__PURE__ */ zod @@ -2666,6 +2720,12 @@ export const DashboardsCreateUnlistedDashboardCreateBody = /* @__PURE__ */ zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsCreateUnlistedDashboardCreateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), use_template: zod .string() .optional() diff --git a/products/dashboards/mcp/tools.yaml b/products/dashboards/mcp/tools.yaml index 4a27677999c2..d3cf87c116f9 100644 --- a/products/dashboards/mcp/tools.yaml +++ b/products/dashboards/mcp/tools.yaml @@ -459,9 +459,13 @@ tools: dashboard-reorder-tiles only to repack the whole dashboard with preserved or uniform tile sizes. The same `tiles` array also updates a widget tile's own settings: add a nested `widget.id` from dashboard-get plus the fields to change (`config`, `name`, `description`). That is the same PATCH path the UI uses, but prefer - dashboard-widgets-batch-update, which takes just a tile_id per widget. To add new widget tiles to this - dashboard, use dashboard-widgets-batch-add instead. The returned tiles omit insight results to save context - — use dashboard-insights-run to fetch the actual data for each insight. + dashboard-widgets-batch-update, which takes just a tile_id per widget. To group tiles, give each tile in the + group the same `group_key`, and set the group's title in `group_titles` (a map from group key to title, + which replaces the stored map). To mark a tile, set its `badge`: `winner` shows a crown, for example on the + best tile in a group, and `cheeky-hog` shows a hedgehog on hover. Set `badge` or `group_key` to null to + remove it. To add new widget tiles to this dashboard, use dashboard-widgets-batch-add instead. The returned + tiles omit insight results to save context — use dashboard-insights-run to fetch the actual data for each + insight. exclude_params: - creation_mode - effective_restriction_level diff --git a/products/product_analytics/backend/tests/api/__snapshots__/test_insight.ambr b/products/product_analytics/backend/tests/api/__snapshots__/test_insight.ambr index 76c5f2796d8e..ba930f82a628 100644 --- a/products/product_analytics/backend/tests/api/__snapshots__/test_insight.ambr +++ b/products/product_analytics/backend/tests/api/__snapshots__/test_insight.ambr @@ -396,6 +396,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") @@ -423,6 +425,8 @@ "posthog_dashboardtile"."filters_overrides", "posthog_dashboardtile"."show_description", "posthog_dashboardtile"."transparent_background", + "posthog_dashboardtile"."group_key", + "posthog_dashboardtile"."badge", "posthog_dashboardtile"."deleted" FROM "posthog_dashboardtile" INNER JOIN "posthog_dashboard" ON ("posthog_dashboardtile"."dashboard_id" = "posthog_dashboard"."id") diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index 66f0ca1e5e69..4e020fc44a23 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -3488,7 +3488,7 @@ } }, "dashboard-update": { - "description": "Resize, reposition, or update dashboard tiles. It can also update the dashboard name, description, pinned status, tags, filters, and restriction level 21. Legacy value 37 is deprecated and rejected by the API. After you create or add insight tiles, call dashboard-get to get their tile IDs, then pass `tiles` with each tile's `id` and a `layouts` object holding x, y, w, and h per breakpoint. This works for every tile type, including insight tiles. The desktop grid (`layouts.sm`) is 12 columns wide. Set each tile's width and height for its content and importance. Width can be any whole number from 1 to 12, subject to the tile's minimum size. Mixed rows such as 8-wide and 4-wide tiles can show hierarchy. A write replaces the tile's whole layout, so send a complete box, not just the value you want to change. Boxes are stored as sent and overlaps are not resolved, so plan the whole grid and send every tile you move in one request. Only set `layouts.sm`: the dashboard derives the mobile layout from the sm order and heights. Use dashboard-reorder-tiles only to repack the whole dashboard with preserved or uniform tile sizes. The same `tiles` array also updates a widget tile's own settings: add a nested `widget.id` from dashboard-get plus the fields to change (`config`, `name`, `description`). That is the same PATCH path the UI uses, but prefer dashboard-widgets-batch-update, which takes just a tile_id per widget. To add new widget tiles to this dashboard, use dashboard-widgets-batch-add instead. The returned tiles omit insight results to save context — use dashboard-insights-run to fetch the actual data for each insight.", + "description": "Resize, reposition, or update dashboard tiles. It can also update the dashboard name, description, pinned status, tags, filters, and restriction level 21. Legacy value 37 is deprecated and rejected by the API. After you create or add insight tiles, call dashboard-get to get their tile IDs, then pass `tiles` with each tile's `id` and a `layouts` object holding x, y, w, and h per breakpoint. This works for every tile type, including insight tiles. The desktop grid (`layouts.sm`) is 12 columns wide. Set each tile's width and height for its content and importance. Width can be any whole number from 1 to 12, subject to the tile's minimum size. Mixed rows such as 8-wide and 4-wide tiles can show hierarchy. A write replaces the tile's whole layout, so send a complete box, not just the value you want to change. Boxes are stored as sent and overlaps are not resolved, so plan the whole grid and send every tile you move in one request. Only set `layouts.sm`: the dashboard derives the mobile layout from the sm order and heights. Use dashboard-reorder-tiles only to repack the whole dashboard with preserved or uniform tile sizes. The same `tiles` array also updates a widget tile's own settings: add a nested `widget.id` from dashboard-get plus the fields to change (`config`, `name`, `description`). That is the same PATCH path the UI uses, but prefer dashboard-widgets-batch-update, which takes just a tile_id per widget. To group tiles, give each tile in the group the same `group_key`, and set the group's title in `group_titles` (a map from group key to title, which replaces the stored map). To mark a tile, set its `badge`: `winner` shows a crown, for example on the best tile in a group, and `cheeky-hog` shows a hedgehog on hover. Set `badge` or `group_key` to null to remove it. To add new widget tiles to this dashboard, use dashboard-widgets-batch-add instead. The returned tiles omit insight results to save context — use dashboard-insights-run to fetch the actual data for each insight.", "category": "Dashboards", "feature": "dashboards", "summary": "Update dashboard", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index c5e0f49af3ba..fa72bbe82153 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -3503,7 +3503,7 @@ } }, "dashboard-update": { - "description": "Resize, reposition, or update dashboard tiles. It can also update the dashboard name, description, pinned status, tags, filters, and restriction level 21. Legacy value 37 is deprecated and rejected by the API. After you create or add insight tiles, call dashboard-get to get their tile IDs, then pass `tiles` with each tile's `id` and a `layouts` object holding x, y, w, and h per breakpoint. This works for every tile type, including insight tiles. The desktop grid (`layouts.sm`) is 12 columns wide. Set each tile's width and height for its content and importance. Width can be any whole number from 1 to 12, subject to the tile's minimum size. Mixed rows such as 8-wide and 4-wide tiles can show hierarchy. A write replaces the tile's whole layout, so send a complete box, not just the value you want to change. Boxes are stored as sent and overlaps are not resolved, so plan the whole grid and send every tile you move in one request. Only set `layouts.sm`: the dashboard derives the mobile layout from the sm order and heights. Use dashboard-reorder-tiles only to repack the whole dashboard with preserved or uniform tile sizes. The same `tiles` array also updates a widget tile's own settings: add a nested `widget.id` from dashboard-get plus the fields to change (`config`, `name`, `description`). That is the same PATCH path the UI uses, but prefer dashboard-widgets-batch-update, which takes just a tile_id per widget. To add new widget tiles to this dashboard, use dashboard-widgets-batch-add instead. The returned tiles omit insight results to save context — use dashboard-insights-run to fetch the actual data for each insight.", + "description": "Resize, reposition, or update dashboard tiles. It can also update the dashboard name, description, pinned status, tags, filters, and restriction level 21. Legacy value 37 is deprecated and rejected by the API. After you create or add insight tiles, call dashboard-get to get their tile IDs, then pass `tiles` with each tile's `id` and a `layouts` object holding x, y, w, and h per breakpoint. This works for every tile type, including insight tiles. The desktop grid (`layouts.sm`) is 12 columns wide. Set each tile's width and height for its content and importance. Width can be any whole number from 1 to 12, subject to the tile's minimum size. Mixed rows such as 8-wide and 4-wide tiles can show hierarchy. A write replaces the tile's whole layout, so send a complete box, not just the value you want to change. Boxes are stored as sent and overlaps are not resolved, so plan the whole grid and send every tile you move in one request. Only set `layouts.sm`: the dashboard derives the mobile layout from the sm order and heights. Use dashboard-reorder-tiles only to repack the whole dashboard with preserved or uniform tile sizes. The same `tiles` array also updates a widget tile's own settings: add a nested `widget.id` from dashboard-get plus the fields to change (`config`, `name`, `description`). That is the same PATCH path the UI uses, but prefer dashboard-widgets-batch-update, which takes just a tile_id per widget. To group tiles, give each tile in the group the same `group_key`, and set the group's title in `group_titles` (a map from group key to title, which replaces the stored map). To mark a tile, set its `badge`: `winner` shows a crown, for example on the best tile in a group, and `cheeky-hog` shows a hedgehog on hover. Set `badge` or `group_key` to null to remove it. To add new widget tiles to this dashboard, use dashboard-widgets-batch-add instead. The returned tiles omit insight results to save context — use dashboard-insights-run to fetch the actual data for each insight.", "category": "Dashboards", "feature": "dashboards", "summary": "Update dashboard", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index c7bc94850b64..727f238f1743 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -11308,6 +11308,18 @@ export namespace Schemas { team: number; } + /** + * * `winner` - Winner + * * `cheeky-hog` - Cheeky hog + */ + export type DashboardTileBadgeEnum = typeof DashboardTileBadgeEnum[keyof typeof DashboardTileBadgeEnum]; + + + export const DashboardTileBadgeEnum = { + Winner: 'winner', + CheekyHog: 'cheeky-hog', + } as const; + export interface DashboardTile { id?: number; insight: Insight; @@ -11325,6 +11337,12 @@ export namespace Schemas { show_description?: boolean | null; /** @nullable */ transparent_background?: boolean | null; + /** + * @maxLength 100 + * @nullable + */ + group_key?: string | null; + badge?: DashboardTileBadgeEnum | null; } export interface AddDashboardWidgetsBatchResponse { @@ -27462,6 +27480,12 @@ export namespace Schemas { */ export type DashboardPersistedVariables = { [key: string]: unknown } | null; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + export type DashboardGroupTitles = {[key: string]: string} | null; + export type DashboardTilesItem = { [key: string]: unknown }; /** @@ -27512,6 +27536,11 @@ export namespace Schemas { Stable: 'stable', } as const; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + */ + export type DashboardCustomizationGroupTitles = {[key: string]: string}; + export interface DashboardCustomization { /** Named tile density preset. * @@ -27527,6 +27556,8 @@ export namespace Schemas { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnum; + /** Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. */ + group_titles?: DashboardCustomizationGroupTitles; } /** @@ -27620,6 +27651,11 @@ export namespace Schemas { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnum; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: DashboardGroupTitles; /** @nullable */ readonly tiles: readonly DashboardTilesItem[] | null; /** Template key to create the dashboard from a predefined template. */ @@ -27810,6 +27846,17 @@ export namespace Schemas { layouts?: _DashboardPatchTileLayoutsOpenApi; /** Whether to show the tile's description underneath its title on the dashboard. Works for every tile type. The description itself is unaffected and still comes from the tile's insight, text, or widget content — this only toggles whether it renders on the dashboard. */ show_description?: boolean; + /** + * Key that puts this tile in a group with the other tiles that have the same key on this dashboard. Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group. + * @maxLength 100 + * @nullable + */ + group_key?: string | null; + /** Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. More than one tile in a group can have the winner badge. + * + * * `winner` - Winner + * * `cheeky-hog` - Cheeky hog */ + badge?: DashboardTileBadgeEnum | null; /** Nested widget row updates. */ widget?: DashboardPatchWidgetOpenApi; } @@ -27945,6 +27992,12 @@ export namespace Schemas { */ export type DashboardWriteOpenApiPersistedVariables = { [key: string]: unknown } | null; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + export type DashboardWriteOpenApiGroupTitles = {[key: string]: string} | null; + export type DashboardWriteOpenApiTilesItem = { [key: string]: unknown }; /** @@ -28039,6 +28092,11 @@ export namespace Schemas { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnum; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: DashboardWriteOpenApiGroupTitles; /** @nullable */ readonly tiles: readonly DashboardWriteOpenApiTilesItem[] | null; /** Template key to create the dashboard from a predefined template. */ @@ -81410,6 +81468,12 @@ export namespace Schemas { readonly updated_at?: string | null; } + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + export type PatchedPatchedDashboardOpenApiGroupTitles = {[key: string]: string} | null; + /** * OpenAPI-only PATCH body for dashboards (agents/MCP). * @@ -81462,6 +81526,11 @@ export namespace Schemas { * * `horizontal` - horizontal * * `stable` - stable */ layout_compaction?: LayoutCompactionEnum; + /** + * Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles. + * @nullable + */ + group_titles?: PatchedPatchedDashboardOpenApiGroupTitles; /** Dashboard tiles to update, each identified by its tile id. Any tile type accepts `layouts` to set its grid position and size. Widget tiles also accept nested widget.config patches. */ tiles?: DashboardPatchTileOpenApi[]; /** Template key to create the dashboard from a predefined template. */ diff --git a/services/mcp/src/generated/dashboards/api.ts b/services/mcp/src/generated/dashboards/api.ts index 60e04272e259..ffb861099b23 100644 --- a/services/mcp/src/generated/dashboards/api.ts +++ b/services/mcp/src/generated/dashboards/api.ts @@ -112,6 +112,8 @@ export const dashboardsCreateBodyBreakdownColorsItemColorTokenRegExp = new RegEx export const dashboardsCreateBodyRestrictionLevelMin = 21 export const dashboardsCreateBodyRestrictionLevelMax = 21 +export const dashboardsCreateBodyGroupTitlesMaxOne = 200 + export const dashboardsCreateBodyDeleteInsightsDefault = false export const DashboardsCreateBody = () => zod @@ -189,6 +191,12 @@ export const DashboardsCreateBody = () => zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsCreateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), use_template: zod .string() .optional() @@ -257,6 +265,8 @@ export const dashboardsPartialUpdateBodyBreakdownColorsItemColorTokenRegExp = ne export const dashboardsPartialUpdateBodyRestrictionLevelMin = 21 export const dashboardsPartialUpdateBodyRestrictionLevelMax = 21 +export const dashboardsPartialUpdateBodyGroupTitlesMaxOne = 200 + export const dashboardsPartialUpdateBodyTilesItemLayoutsOneSmOneXMin = 0 export const dashboardsPartialUpdateBodyTilesItemLayoutsOneSmOneXMax = 11 @@ -271,6 +281,8 @@ export const dashboardsPartialUpdateBodyTilesItemLayoutsOneXsOneYMin = 0 export const dashboardsPartialUpdateBodyTilesItemLayoutsOneXsOneWMax = 12 +export const dashboardsPartialUpdateBodyTilesItemGroupKeyMax = 100 + export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneOneNotebookShortIdOneMax = 128 export const dashboardsPartialUpdateBodyTilesItemWidgetOneConfigOneTwoLimitDefault = 25 @@ -432,6 +444,12 @@ export const DashboardsPartialUpdateBody = () => zod .describe( 'How tiles rearrange after a move or resize. vertical stacks tiles upward, horizontal stacks tiles to the left, and stable preserves positions while moving colliding tiles.\n\n\* `vertical` - vertical\n\* `horizontal` - horizontal\n\* `stable` - stable' ), + group_titles: zod + .record(zod.string(), zod.string().max(dashboardsPartialUpdateBodyGroupTitlesMaxOne)) + .nullish() + .describe( + "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + ), tiles: zod .array( zod.object({ @@ -492,6 +510,24 @@ export const DashboardsPartialUpdateBody = () => zod .describe( "Whether to show the tile's description underneath its title on the dashboard. Works for every tile type. The description itself is unaffected and still comes from the tile's insight, text, or widget content — this only toggles whether it renders on the dashboard." ), + group_key: zod + .string() + .max(dashboardsPartialUpdateBodyTilesItemGroupKeyMax) + .nullish() + .describe( + "Key that puts this tile in a group with the other tiles that have the same key on this dashboard. Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group." + ), + badge: zod + .union([ + zod + .enum(['winner', 'cheeky-hog']) + .describe('\* `winner` - Winner\n\* `cheeky-hog` - Cheeky hog'), + zod.null(), + ]) + .optional() + .describe( + 'Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. More than one tile in a group can have the winner badge.\n\n\* `winner` - Winner\n\* `cheeky-hog` - Cheeky hog' + ), widget: zod .object({ id: zod diff --git a/services/mcp/src/tools/generated/dashboards.ts b/services/mcp/src/tools/generated/dashboards.ts index 4b052016fab1..85d57f76226f 100644 --- a/services/mcp/src/tools/generated/dashboards.ts +++ b/services/mcp/src/tools/generated/dashboards.ts @@ -68,6 +68,9 @@ const dashboardCreate = (): ToolBase< if (params.layout_compaction !== undefined) { body['layout_compaction'] = params.layout_compaction } + if (params.group_titles !== undefined) { + body['group_titles'] = params.group_titles + } if (params.use_template !== undefined) { body['use_template'] = params.use_template } @@ -621,6 +624,9 @@ const dashboardUpdate = (): ToolBase< if (params.layout_compaction !== undefined) { body['layout_compaction'] = params.layout_compaction } + if (params.group_titles !== undefined) { + body['group_titles'] = params.group_titles + } if (params.tiles !== undefined) { body['tiles'] = params.tiles } diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-create.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-create.json index 5ed33321da53..4c6b334f6896 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-create.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-create.json @@ -93,6 +93,24 @@ "enum": ["tight", "condensed", "standard", "relaxed", "wide"], "type": "string" }, + "group_titles": { + "anyOf": [ + { + "additionalProperties": { + "maxLength": 200, + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + }, "include_dashboards": { "description": "Opt in to receiving the deprecated `dashboards` field in insight payloads. Once opt-in enforcement is enabled, API-token callers stop receiving it by default; use `dashboard_tiles` instead.", "type": "boolean" diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json index 47aba33d6e49..cfdb9bbda5ba 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/dashboard-update.json @@ -123,6 +123,24 @@ "enum": ["tight", "condensed", "standard", "relaxed", "wide"], "type": "string" }, + "group_titles": { + "anyOf": [ + { + "additionalProperties": { + "maxLength": 200, + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Titles for tile groups, keyed by the tiles' group_key. The dashboard shows a title above its group. A write replaces the whole map. Send an empty object or null to remove all group titles." + }, "id": { "description": "A unique integer value identifying this dashboard.", "type": "number" @@ -180,6 +198,31 @@ "description": "Dashboard tiles to update, each identified by its tile id. Any tile type accepts `layouts` to set its grid position and size. Widget tiles also accept nested widget.config patches.", "items": { "properties": { + "badge": { + "anyOf": [ + { + "description": "* `winner` - Winner\n* `cheeky-hog` - Cheeky hog", + "enum": ["winner", "cheeky-hog"], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Badge on the tile. winner shows a crown in the top left corner, for example to mark the best tile in a group. cheeky-hog shows a hedgehog when a person hovers over the tile. Null removes the badge. More than one tile in a group can have the winner badge.\n\n* `winner` - Winner\n* `cheeky-hog` - Cheeky hog" + }, + "group_key": { + "anyOf": [ + { + "maxLength": 100, + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Key that puts this tile in a group with the other tiles that have the same key on this dashboard. Set the group's title in the dashboard's group_titles. Null or an empty string removes the tile from its group." + }, "id": { "description": "Dashboard tile ID to update.", "type": "number"