From cabe7d676fcc36f6f1c8c1500e4ef4991097aa94 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 10:50:44 +0530 Subject: [PATCH 01/16] Add config-driven dashboard widget pilot (agents_pilot, 3 widgets) Static widget registry (My Patches, My Reminders, Open Incident (Team)) resolved through the existing /cases/search filter shape via a new GET /dashboards/{dashboardId}/widgets endpoint, so the case-table dashboards currently hosted on the backing data source can be replaced incrementally without a new filter DSL or entity-service change. --- apps/csm-portal/backend/cmd/server/main.go | 2 + .../backend/internal/dashboard/widgets.go | 136 +++++++++++++++ .../backend/internal/handler/dashboards.go | 107 ++++++++++++ .../internal/handler/dashboards_test.go | 162 ++++++++++++++++++ apps/csm-portal/backend/openapi.yaml | 69 ++++++++ 5 files changed, 476 insertions(+) create mode 100644 apps/csm-portal/backend/internal/dashboard/widgets.go create mode 100644 apps/csm-portal/backend/internal/handler/dashboards.go create mode 100644 apps/csm-portal/backend/internal/handler/dashboards_test.go diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 84924687bb..1f6f58defd 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -61,6 +61,7 @@ func main() { customerEntityClient := entity.NewCustomerEntityClient(customerEntityCfg) caseHandler := handler.NewCaseHandler(customerEntityClient) + dashboardHandler := handler.NewDashboardHandler(customerEntityClient) accountHandler := handler.NewAccountHandler(customerEntityClient) projectHandler := handler.NewProjectHandler(customerEntityClient) productHandler := handler.NewProductHandler(customerEntityClient) @@ -139,6 +140,7 @@ func main() { mux.HandleFunc("DELETE /cases/{id}/tags/{tagId}", caseHandler.RemoveCaseTag) mux.HandleFunc("GET /tags/search", caseHandler.SearchTags) mux.HandleFunc("POST /cases/search", caseHandler.SearchCases) + mux.HandleFunc("GET /dashboards/{dashboardId}/widgets", dashboardHandler.GetDashboardWidgets) mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels) mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels) mux.HandleFunc("GET /users/me", usersHandler.GetMe) diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go new file mode 100644 index 0000000000..01765f1470 --- /dev/null +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package dashboard holds the pilot's static, config-driven dashboard widget +// templates. Each widget resolves to a case search against the existing +// /cases/search filter shape (see CaseSearchFilters in openapi.yaml) — there +// is no generic filter DSL and no database backing this; new widgets are +// added by extending the Dashboards registry below. +package dashboard + +// CurrentUserPlaceholder marks an assignedUserIds entry that must be resolved +// to the requesting user's id before the filters are sent upstream. It never +// reaches the entity service: ResolveFilters always substitutes it. +const CurrentUserPlaceholder = "__current_user__" + +// DisplayType is how a widget's resolved data should be rendered. +type DisplayType string + +// DisplayTypeSingleScore is the only display type this pilot supports: a +// single resolved count. +const DisplayTypeSingleScore DisplayType = "single_score" + +// CaseSearchFilters mirrors the subset of the entity service's +// CaseSearchFilters schema (openapi.yaml, component CaseSearchFilters) that +// the pilot widgets need. Field names and JSON tags match that schema exactly +// so the marshaled payload is forwarded to /cases/search unchanged. +type CaseSearchFilters struct { + States []string `json:"states,omitempty"` + Tags []string `json:"tags,omitempty"` + AssignedUserIDs []string `json:"assignedUserIds,omitempty"` +} + +// caseSearchPagination mirrors the Pagination fields /cases/search accepts. +type caseSearchPagination struct { + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +// CaseSearchPayload is the body ResolveFilters produces for /cases/search. +type CaseSearchPayload struct { + Filters CaseSearchFilters `json:"filters"` + Pagination caseSearchPagination `json:"pagination"` +} + +// WidgetTemplate is a static, config-driven widget definition: which case +// filters it runs and how its resolved data should be displayed. +type WidgetTemplate struct { + ID string + DisplayName string + DisplayType DisplayType + Filters CaseSearchFilters +} + +// Dashboards is the static registry of widget templates, keyed by dashboard id. +var Dashboards = map[string][]WidgetTemplate{ + "agents_pilot": { + { + ID: "my_patches", + DisplayName: "My Patches", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + AssignedUserIDs: []string{CurrentUserPlaceholder}, + Tags: []string{"patch"}, + States: []string{ + "open", + "work_in_progress", + "waiting_on_wso2", + "reopened", + "awaiting_info", + }, + }, + }, + { + ID: "my_reminders", + DisplayName: "My Reminders", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + AssignedUserIDs: []string{CurrentUserPlaceholder}, + States: []string{ + "awaiting_info", + "solution_proposed", + }, + }, + }, + { + ID: "open_incident_team", + DisplayName: "Open Incident (Team)", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + Tags: []string{"s_dip"}, + States: []string{ + "work_in_progress", + "open", + "waiting_on_wso2", + "reopened", + }, + }, + }, + }, +} + +// ResolveFilters builds the /cases/search payload for tpl, substituting +// CurrentUserPlaceholder in AssignedUserIDs with currentUserID. Pagination is +// fixed at limit 1: callers only need the response's total count, matching +// the existing count-only /cases/search usage pattern (see +// useCaseCountsMatrix.ts on the frontend). +func ResolveFilters(tpl WidgetTemplate, currentUserID string) CaseSearchPayload { + filters := tpl.Filters + if len(filters.AssignedUserIDs) > 0 { + resolved := make([]string, len(filters.AssignedUserIDs)) + for i, id := range filters.AssignedUserIDs { + if id == CurrentUserPlaceholder { + id = currentUserID + } + resolved[i] = id + } + filters.AssignedUserIDs = resolved + } + return CaseSearchPayload{ + Filters: filters, + Pagination: caseSearchPagination{Limit: 1, Offset: 0}, + } +} diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go new file mode 100644 index 0000000000..85e1314bf4 --- /dev/null +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package handler + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" + "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware" +) + +// dashboardEntityClient is the subset of entityCaseClient DashboardHandler +// needs: it resolves each widget's filters through the same /cases/search +// path CaseHandler.SearchCases uses. +type dashboardEntityClient interface { + SearchCases(ctx context.Context, body []byte) ([]byte, error) +} + +// widgetResult is a single resolved widget's data, returned by +// GET /dashboards/{dashboardId}/widgets. +type widgetResult struct { + WidgetID string `json:"widgetId"` + DisplayName string `json:"displayName"` + DisplayType dashboard.DisplayType `json:"displayType"` + Count int `json:"count"` +} + +// DashboardHandler handles HTTP requests for the config-driven dashboard +// widget pilot, delegating each widget's data resolution to the entity +// service's case search. +type DashboardHandler struct { + entity dashboardEntityClient +} + +// NewDashboardHandler creates a DashboardHandler backed by the given entity client. +func NewDashboardHandler(entity dashboardEntityClient) *DashboardHandler { + return &DashboardHandler{entity: entity} +} + +// GetDashboardWidgets handles GET /dashboards/{dashboardId}/widgets. +func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Request) { + user := middleware.UserInfoFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + return + } + + dashboardID := r.PathValue("dashboardId") + templates, ok := dashboard.Dashboards[dashboardID] + if !ok { + writeError(w, http.StatusNotFound, ErrMsgNotFound) + return + } + + results := make([]widgetResult, 0, len(templates)) + for _, tpl := range templates { + payload := dashboard.ResolveFilters(tpl, user.UserID) + body, err := json.Marshal(payload) + if err != nil { + slog.ErrorContext(r.Context(), "failed to marshal widget search payload", "userID", user.UserID, "widgetID", tpl.ID, "err", err) + writeError(w, http.StatusInternalServerError, ErrMsgInternal) + return + } + + searchResult, err := h.entity.SearchCases(r.Context(), body) + if err != nil { + slog.ErrorContext(r.Context(), "entity SearchCases failed for widget", "userID", user.UserID, "widgetID", tpl.ID, "err", err) + mapUpstreamError(w, err, "Failed to resolve dashboard widgets.") + return + } + + var parsed struct { + Total int `json:"total"` + } + if err := json.Unmarshal(searchResult, &parsed); err != nil { + slog.ErrorContext(r.Context(), "failed to parse widget search response", "userID", user.UserID, "widgetID", tpl.ID, "err", err) + writeError(w, http.StatusInternalServerError, ErrMsgInternal) + return + } + + results = append(results, widgetResult{ + WidgetID: tpl.ID, + DisplayName: tpl.DisplayName, + DisplayType: tpl.DisplayType, + Count: parsed.Total, + }) + } + + writeJSONValue(w, http.StatusOK, results) +} diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go new file mode 100644 index 0000000000..f8dc1c7dd5 --- /dev/null +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/apierror" +) + +type mockDashboardEntityClient struct { + searchCasesFn func(ctx context.Context, body []byte) ([]byte, error) +} + +func (m *mockDashboardEntityClient) SearchCases(ctx context.Context, body []byte) ([]byte, error) { + if m.searchCasesFn != nil { + return m.searchCasesFn(ctx, body) + } + return []byte(`{"cases":[],"total":0}`), nil +} + +func withDashboardID(r *http.Request, dashboardID string) *http.Request { + r.SetPathValue("dashboardId", dashboardID) + return r +} + +func TestGetDashboardWidgets(t *testing.T) { + t.Run("requires authenticated user", func(t *testing.T) { + h := NewDashboardHandler(&mockDashboardEntityClient{}) + r := withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot") + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + assertStatus(t, w, http.StatusUnauthorized) + assertErrorMessage(t, w, ErrMsgUnauthorized) + assertContentType(t, w, "application/json") + }) + + t.Run("unknown dashboard id returns 404", func(t *testing.T) { + h := NewDashboardHandler(&mockDashboardEntityClient{}) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/bogus/widgets", nil), "bogus")) + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + assertStatus(t, w, http.StatusNotFound) + assertErrorMessage(t, w, ErrMsgNotFound) + }) + + t.Run("resolves all three pilot widgets and substitutes the current user id", func(t *testing.T) { + var capturedBodies [][]byte + client := &mockDashboardEntityClient{ + searchCasesFn: func(_ context.Context, body []byte) ([]byte, error) { + capturedBodies = append(capturedBodies, body) + return []byte(`{"cases":[],"total":7}`), nil + }, + } + h := NewDashboardHandler(client) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + + var results []struct { + WidgetID string `json:"widgetId"` + DisplayName string `json:"displayName"` + DisplayType string `json:"displayType"` + Count int `json:"count"` + } + if err := json.NewDecoder(w.Body).Decode(&results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) + } + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + for _, res := range results { + if res.DisplayType != "single_score" { + t.Errorf("widget %s displayType = %q, want single_score", res.WidgetID, res.DisplayType) + } + if res.Count != 7 { + t.Errorf("widget %s count = %d, want 7", res.WidgetID, res.Count) + } + if res.DisplayName == "" { + t.Errorf("widget %s has empty displayName", res.WidgetID) + } + } + + if len(capturedBodies) != 3 { + t.Fatalf("len(capturedBodies) = %d, want 3", len(capturedBodies)) + } + // The two user-scoped widgets ("my_patches", "my_reminders") must carry the + // resolved user id, never the raw placeholder. + for _, body := range capturedBodies { + var sent struct { + Filters struct { + AssignedUserIDs []string `json:"assignedUserIds"` + } `json:"filters"` + Pagination struct { + Limit int `json:"limit"` + } `json:"pagination"` + } + if err := json.Unmarshal(body, &sent); err != nil { + t.Fatalf("upstream body invalid JSON: %v", err) + } + if sent.Pagination.Limit != 1 { + t.Errorf("pagination.limit = %d, want 1", sent.Pagination.Limit) + } + for _, id := range sent.Filters.AssignedUserIDs { + if id == "__current_user__" { + t.Errorf("assignedUserIds leaked the unresolved placeholder: %v", sent.Filters.AssignedUserIDs) + } + if id != testUser.UserID { + t.Errorf("assignedUserIds = %v, want [%q]", sent.Filters.AssignedUserIDs, testUser.UserID) + } + } + } + }) + + t.Run("upstream error maps through mapUpstreamError", func(t *testing.T) { + client := &mockDashboardEntityClient{ + searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { + return nil, &apierror.Error{StatusCode: http.StatusServiceUnavailable} + }, + } + h := NewDashboardHandler(client) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + assertStatus(t, w, http.StatusServiceUnavailable) + }) + + t.Run("non-apierror upstream failure maps to 500", func(t *testing.T) { + client := &mockDashboardEntityClient{ + searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { + return nil, errors.New("connection refused") + }, + } + h := NewDashboardHandler(client) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + assertStatus(t, w, http.StatusInternalServerError) + }) +} diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index da6c0c610f..560884db6d 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -1624,6 +1624,56 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' + /dashboards/{dashboardId}/widgets: + get: + summary: Get the resolved widgets for a dashboard. + description: > + Resolves every widget template registered for the given dashboard id + against the current case data, using the same filters accepted by + POST /cases/search. This is a config-driven pilot: widget templates + are a small static registry, not user-configurable. + operationId: getDashboardWidgets + parameters: + - name: dashboardId + in: path + description: ID of the dashboard (e.g. "agents_pilot") + required: true + schema: + type: string + responses: + "200": + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DashboardWidget' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "404": + description: NotFound + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + /deployments: post: summary: Create a new deployment. @@ -5185,6 +5235,25 @@ components: hasMore: type: boolean + DashboardWidget: + type: object + properties: + widgetId: + type: string + description: Static id of the widget template within its dashboard + displayName: + type: string + displayType: + type: string + enum: + - single_score + description: How the widget's resolved data should be rendered + count: + type: integer + description: > + Resolved count for a single_score widget (the total from the + underlying case search, with pagination limited to 1) + Case: type: object properties: From f4f3c56eef75c661853160812a2b205402aae516 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 10:55:40 +0530 Subject: [PATCH 02/16] Add frontend for the config-driven dashboard widget pilot Wires the new GET /dashboards/agents_pilot/widgets endpoint into one shared query hook, renders its 3 single_score widgets as tiles with per-tile skeleton/error states, and adds the pilot as a clearly delimited add-on section below the existing CSM dashboard. --- .../webapp/src/api/backend/types.ts | 13 ++++ .../webapp/src/constants/apiConstants.ts | 1 + .../api/useDashboardWidgets.test.tsx | 70 +++++++++++++++++ .../csm-dashboard/api/useDashboardWidgets.ts | 50 ++++++++++++ .../AgentsLandingPagePilot.test.tsx | 77 +++++++++++++++++++ .../components/AgentsLandingPagePilot.tsx | 72 +++++++++++++++++ .../components/DashboardWidgetTile.tsx | 53 +++++++++++++ .../csm-dashboard/pages/CsmDashboardPage.tsx | 9 ++- 8 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index 4ce71e3474..20f0f4799d 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2587,3 +2587,16 @@ export interface BeTimeCardMutationResponse { export interface BeUserSearchByEmailResponse { users?: Array<{ id: string; email: string }>; } + +// --------------------------------------------------------------------------- +// Dashboards +// --------------------------------------------------------------------------- + +/** A single resolved widget from `GET /dashboards/{dashboardId}/widgets`. */ +export interface BeDashboardWidget { + widgetId: string; + displayName: string; + /** Only "single_score" exists today. */ + displayType: "single_score"; + count: number; +} diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index 654f349ae9..fdc53493a0 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -107,6 +107,7 @@ export const ApiQueryKeys = { CSM_CASES: "csm-cases", CSM_ANNOUNCEMENTS: "csm-announcements", CSM_CASE_COUNTS: "csm-case-counts", + CSM_DASHBOARD_WIDGETS: "csm-dashboard-widgets", CSM_CASE_DETAIL: "csm-case-detail", CSM_CASE_COMMENTS: "csm-case-comments", CSM_CASE_ATTACHMENTS: "csm-case-attachments", diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx new file mode 100644 index 0000000000..ee624e39f2 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx @@ -0,0 +1,70 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; + +const getMock = vi.fn(); + +// The real client reads runtime config at module load, which isn't present +// under vitest (same approach as useSearchGroups.test.tsx). +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: getMock }), +})); + +import { useDashboardWidgets } from "@features/csm-dashboard/api/useDashboardWidgets"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe("useDashboardWidgets", () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it("fetches the agents_pilot widget set from a single call", async () => { + getMock.mockResolvedValue([ + { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, + ]); + + const { result } = renderHook(() => useDashboardWidgets(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith("/dashboards/agents_pilot/widgets"); + expect(result.current.data).toEqual([ + { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, + ]); + }); + + it("surfaces a query error when the call fails", async () => { + getMock.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useDashboardWidgets(), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe("boom"); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts new file mode 100644 index 0000000000..d402d759d8 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { BeDashboardWidget } from "@api/backend/types"; + +/** Dashboard id for the config-driven widget pilot (3 widgets, all `single_score`). */ +export const AGENTS_PILOT_DASHBOARD_ID = "agents_pilot"; + +/** + * Resolved widgets for the "agents_pilot" dashboard. + * + * All widgets on a dashboard resolve in one backend call (not one request per + * widget) so the pattern scales to dozens of widgets without request + * fan-out — see `GET /dashboards/{dashboardId}/widgets`. Callers render one + * tile per entry in the returned array; every tile shares this single query's + * loading/error state. + */ +export function useDashboardWidgets(): UseQueryResult< + BeDashboardWidget[], + Error +> { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_DASHBOARD_WIDGETS, AGENTS_PILOT_DASHBOARD_ID], + queryFn: async (): Promise => { + const res = await api.get( + `/dashboards/${AGENTS_PILOT_DASHBOARD_ID}/widgets`, + ); + return res ?? []; + }, + staleTime: 30_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx new file mode 100644 index 0000000000..d6ba4be603 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import type { ReactNode } from "react"; + +const getMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: getMock }), +})); + +import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; + +function renderWithClient(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +describe("AgentsLandingPagePilot", () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it("renders skeleton tiles while the shared query is in flight", () => { + getMock.mockReturnValue(new Promise(() => {})); + const { container } = renderWithClient(); + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(3); + }); + + it("renders one tile per resolved widget once the query succeeds", async () => { + getMock.mockResolvedValue([ + { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, + { widgetId: "my_reminders", displayName: "My Reminders", displayType: "single_score", count: 5 }, + { widgetId: "open_incident_team", displayName: "Open Incidents (Team)", displayType: "single_score", count: 12 }, + ]); + + renderWithClient(); + + await waitFor(() => expect(screen.getByText("My Patches")).toBeInTheDocument()); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("My Reminders")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("Open Incidents (Team)")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + }); + + it("renders an inline error state on each tile when the shared query fails", async () => { + getMock.mockRejectedValue(new Error("boom")); + + renderWithClient(); + + await waitFor(() => + expect(screen.getAllByText("Could not load this widget.").length).toBe(3), + ); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx new file mode 100644 index 0000000000..b42446413e --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Box } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; +import { useDashboardWidgets } from "@features/csm-dashboard/api/useDashboardWidgets"; +import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; +import SectionCard from "@features/csm-dashboard/components/SectionCard"; +import RefreshButton from "@features/csm-dashboard/components/RefreshButton"; + +/** Placeholder count while the pilot's single shared query is in flight. */ +const PILOT_TILE_COUNT = 3; + +/** + * Pilot section for the config-driven dashboard widget system (the + * "agents_pilot" dashboard: 3 `single_score` widgets resolved by one backend + * call — see {@link useDashboardWidgets}). Kept as a clearly separate, + * labeled add-on below the existing dashboard sections, not a redesign. + */ +export default function AgentsLandingPagePilot(): JSX.Element { + const { data, isLoading, isError, isFetching, refetch } = + useDashboardWidgets(); + + const tiles = data ?? new Array(PILOT_TILE_COUNT).fill(undefined); + + return ( + void refetch()} + isFetching={isFetching} + label="Refresh widget pilot" + /> + } + > + + {tiles.map((widget, i) => ( + + ))} + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx new file mode 100644 index 0000000000..a758ec87fc --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -0,0 +1,53 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Card, Skeleton, Typography } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; +import type { BeDashboardWidget } from "@api/backend/types"; + +interface DashboardWidgetTileProps { + widget?: BeDashboardWidget; + isLoading: boolean; + isError: boolean; +} + +/** Single "single_score" dashboard widget tile: a display name and its count. */ +export default function DashboardWidgetTile({ + widget, + isLoading, + isError, +}: DashboardWidgetTileProps): JSX.Element { + return ( + + {isLoading ? ( + + ) : isError || !widget ? ( + + Could not load this widget. + + ) : ( + <> + + {widget.displayName} + + + {widget.count} + + + )} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index ddde2b0837..b02e9b7e5b 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -14,9 +14,10 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Chip, Typography } from "@wso2/oxygen-ui"; +import { Box, Card, Chip, Divider, Typography } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; +import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; import CaseCompositionCharts from "@features/csm-dashboard/components/CaseCompositionCharts"; import CaseCountsMatrix from "@features/csm-dashboard/components/CaseCountsMatrix"; import MyAssignedCases from "@features/csm-dashboard/components/MyAssignedCases"; @@ -57,6 +58,12 @@ export default function CsmDashboardPage(): JSX.Element { + {/* Pilot add-on: config-driven dashboard widgets, kept clearly + separate from the sections above rather than blended in. */} + + + + ) : ( From cdd0e7924ed7e723622c9ee7b6be0382838f28c8 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 15:23:10 +0530 Subject: [PATCH 03/16] Isolate dashboard widget failures to the widget that failed GetDashboardWidgets aborted the whole response on the first widget's upstream search error, taking down unrelated widgets (e.g. a team-wide widget with no dependency on the failing one). Each widget now resolves independently and reports its own error without affecting siblings. --- .../backend/internal/handler/dashboards.go | 78 +++++++------ .../internal/handler/dashboards_test.go | 103 ++++++++++++++++-- .../backend/internal/handler/response.go | 19 ++-- apps/csm-portal/backend/openapi.yaml | 9 +- .../webapp/src/api/backend/types.ts | 10 +- .../AgentsLandingPagePilot.test.tsx | 19 ++++ .../components/DashboardWidgetTile.tsx | 11 +- 7 files changed, 196 insertions(+), 53 deletions(-) diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 85e1314bf4..9e4fef7c6d 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -34,12 +34,16 @@ type dashboardEntityClient interface { } // widgetResult is a single resolved widget's data, returned by -// GET /dashboards/{dashboardId}/widgets. +// GET /dashboards/{dashboardId}/widgets. Count is set on success; Error is +// set when this widget's own data resolution failed. A failure is scoped to +// its own widget and never prevents the other widgets in the same response +// from carrying a resolved Count. type widgetResult struct { WidgetID string `json:"widgetId"` DisplayName string `json:"displayName"` DisplayType dashboard.DisplayType `json:"displayType"` - Count int `json:"count"` + Count *int `json:"count,omitempty"` + Error string `json:"error,omitempty"` } // DashboardHandler handles HTTP requests for the config-driven dashboard @@ -71,37 +75,47 @@ func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Re results := make([]widgetResult, 0, len(templates)) for _, tpl := range templates { - payload := dashboard.ResolveFilters(tpl, user.UserID) - body, err := json.Marshal(payload) - if err != nil { - slog.ErrorContext(r.Context(), "failed to marshal widget search payload", "userID", user.UserID, "widgetID", tpl.ID, "err", err) - writeError(w, http.StatusInternalServerError, ErrMsgInternal) - return - } - - searchResult, err := h.entity.SearchCases(r.Context(), body) - if err != nil { - slog.ErrorContext(r.Context(), "entity SearchCases failed for widget", "userID", user.UserID, "widgetID", tpl.ID, "err", err) - mapUpstreamError(w, err, "Failed to resolve dashboard widgets.") - return - } - - var parsed struct { - Total int `json:"total"` - } - if err := json.Unmarshal(searchResult, &parsed); err != nil { - slog.ErrorContext(r.Context(), "failed to parse widget search response", "userID", user.UserID, "widgetID", tpl.ID, "err", err) - writeError(w, http.StatusInternalServerError, ErrMsgInternal) - return - } - - results = append(results, widgetResult{ - WidgetID: tpl.ID, - DisplayName: tpl.DisplayName, - DisplayType: tpl.DisplayType, - Count: parsed.Total, - }) + results = append(results, h.resolveWidget(r.Context(), tpl, user.UserID)) } writeJSONValue(w, http.StatusOK, results) } + +// resolveWidget resolves a single widget's data. A failure here (marshal, +// upstream search, or parse) is scoped to this widget: it is reported via +// the returned result's Error field rather than aborting the whole handler, +// so one widget's upstream failure never takes down its siblings. +func (h *DashboardHandler) resolveWidget(ctx context.Context, tpl dashboard.WidgetTemplate, currentUserID string) widgetResult { + base := widgetResult{ + WidgetID: tpl.ID, + DisplayName: tpl.DisplayName, + DisplayType: tpl.DisplayType, + } + + payload := dashboard.ResolveFilters(tpl, currentUserID) + body, err := json.Marshal(payload) + if err != nil { + slog.ErrorContext(ctx, "failed to marshal widget search payload", "userID", currentUserID, "widgetID", tpl.ID, "err", err) + base.Error = ErrMsgWidgetResolutionFailed + return base + } + + searchResult, err := h.entity.SearchCases(ctx, body) + if err != nil { + slog.ErrorContext(ctx, "entity SearchCases failed for widget", "userID", currentUserID, "widgetID", tpl.ID, "err", summarizeErr(err)) + base.Error = ErrMsgWidgetResolutionFailed + return base + } + + var parsed struct { + Total int `json:"total"` + } + if err := json.Unmarshal(searchResult, &parsed); err != nil { + slog.ErrorContext(ctx, "failed to parse widget search response", "userID", currentUserID, "widgetID", tpl.ID, "err", err) + base.Error = ErrMsgWidgetResolutionFailed + return base + } + + base.Count = &parsed.Total + return base +} diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index f8dc1c7dd5..66951d0b3c 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -83,7 +83,8 @@ func TestGetDashboardWidgets(t *testing.T) { WidgetID string `json:"widgetId"` DisplayName string `json:"displayName"` DisplayType string `json:"displayType"` - Count int `json:"count"` + Count *int `json:"count"` + Error string `json:"error"` } if err := json.NewDecoder(w.Body).Decode(&results); err != nil { t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) @@ -95,8 +96,11 @@ func TestGetDashboardWidgets(t *testing.T) { if res.DisplayType != "single_score" { t.Errorf("widget %s displayType = %q, want single_score", res.WidgetID, res.DisplayType) } - if res.Count != 7 { - t.Errorf("widget %s count = %d, want 7", res.WidgetID, res.Count) + if res.Error != "" { + t.Errorf("widget %s error = %q, want none", res.WidgetID, res.Error) + } + if res.Count == nil || *res.Count != 7 { + t.Errorf("widget %s count = %v, want 7", res.WidgetID, res.Count) } if res.DisplayName == "" { t.Errorf("widget %s has empty displayName", res.WidgetID) @@ -134,7 +138,7 @@ func TestGetDashboardWidgets(t *testing.T) { } }) - t.Run("upstream error maps through mapUpstreamError", func(t *testing.T) { + t.Run("upstream error on every widget still returns 200 with each widget carrying its own error", func(t *testing.T) { client := &mockDashboardEntityClient{ searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { return nil, &apierror.Error{StatusCode: http.StatusServiceUnavailable} @@ -144,10 +148,30 @@ func TestGetDashboardWidgets(t *testing.T) { r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) w := httptest.NewRecorder() h.GetDashboardWidgets(w, r) - assertStatus(t, w, http.StatusServiceUnavailable) + assertStatus(t, w, http.StatusOK) + + var results []struct { + WidgetID string `json:"widgetId"` + Count *int `json:"count"` + Error string `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) + } + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + for _, res := range results { + if res.Error == "" { + t.Errorf("widget %s error = %q, want non-empty", res.WidgetID, res.Error) + } + if res.Count != nil { + t.Errorf("widget %s count = %v, want omitted", res.WidgetID, *res.Count) + } + } }) - t.Run("non-apierror upstream failure maps to 500", func(t *testing.T) { + t.Run("non-apierror upstream failure is reported per widget, not as a handler-level 500", func(t *testing.T) { client := &mockDashboardEntityClient{ searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { return nil, errors.New("connection refused") @@ -157,6 +181,71 @@ func TestGetDashboardWidgets(t *testing.T) { r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) w := httptest.NewRecorder() h.GetDashboardWidgets(w, r) - assertStatus(t, w, http.StatusInternalServerError) + assertStatus(t, w, http.StatusOK) + + var results []struct { + Error string `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) + } + for _, res := range results { + if res.Error == "" { + t.Error("expected every widget to carry an error, got none") + } + } + }) + + t.Run("one widget's upstream failure does not take down its siblings", func(t *testing.T) { + var calls int + client := &mockDashboardEntityClient{ + searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { + calls++ + if calls == 1 { + // Only the first-resolved widget ("my_patches") fails, mirroring the + // live SN DEV finding: an assignedUserIds-based search 400s for one + // widget while the others resolve normally in the same response. + return nil, &apierror.Error{StatusCode: http.StatusBadRequest, Body: `{"message":"no active user found for sys_id ..."}`} + } + return []byte(`{"cases":[],"total":7}`), nil + }, + } + h := NewDashboardHandler(client) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardWidgets(w, r) + assertStatus(t, w, http.StatusOK) + + var results []struct { + WidgetID string `json:"widgetId"` + Count *int `json:"count"` + Error string `json:"error"` + } + if err := json.NewDecoder(w.Body).Decode(&results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) + } + if len(results) != 3 { + t.Fatalf("len(results) = %d, want 3", len(results)) + } + + failed := results[0] + if failed.WidgetID != "my_patches" { + t.Fatalf("results[0].WidgetID = %q, want my_patches", failed.WidgetID) + } + if failed.Error == "" { + t.Errorf("widget %s: expected an error, got none", failed.WidgetID) + } + if failed.Count != nil { + t.Errorf("widget %s: count = %v, want omitted", failed.WidgetID, *failed.Count) + } + + for _, res := range results[1:] { + if res.Error != "" { + t.Errorf("widget %s: unexpected error %q, want it unaffected by my_patches' failure", res.WidgetID, res.Error) + } + if res.Count == nil || *res.Count != 7 { + t.Errorf("widget %s: count = %v, want 7", res.WidgetID, res.Count) + } + } }) } diff --git a/apps/csm-portal/backend/internal/handler/response.go b/apps/csm-portal/backend/internal/handler/response.go index 0e26da54b3..0fea1a0f89 100644 --- a/apps/csm-portal/backend/internal/handler/response.go +++ b/apps/csm-portal/backend/internal/handler/response.go @@ -27,19 +27,20 @@ import ( // Error message constants matching the customer-portal error vocabulary. const ( - ErrMsgUnauthorized = "You are not authorized to perform this action. Please try again." - ErrMsgForbidden = "Access to the requested resource is forbidden!" - ErrMsgNotFound = "The requested resource was not found!" - ErrMsgBadRequest = "Invalid request payload." - ErrMsgTooLarge = "Request body too large." - ErrMsgInternal = "An internal server error occurred. Please try again later." - ErrMsgInvalidTransition = "Invalid state transition." - ErrMsgWorkStateNotAllowed = "Work state can only be updated when the case is in progress." + ErrMsgUnauthorized = "You are not authorized to perform this action. Please try again." + ErrMsgForbidden = "Access to the requested resource is forbidden!" + ErrMsgNotFound = "The requested resource was not found!" + ErrMsgBadRequest = "Invalid request payload." + ErrMsgTooLarge = "Request body too large." + ErrMsgInternal = "An internal server error occurred. Please try again later." + ErrMsgInvalidTransition = "Invalid state transition." + ErrMsgWorkStateNotAllowed = "Work state can only be updated when the case is in progress." ErrMsgCommentNotAllowed = "Comments can only be added when the case is in progress and the work state is ongoing." ErrMsgWorkNoteOnClosedCase = "Work notes cannot be added to a closed case." ErrMsgAttachmentOnClosedCase = "Attachments cannot be added to a closed case." ErrMsgInvalidUUID = "Invalid UUID format." - errMsgReadBody = "Failed to read request body." + ErrMsgWidgetResolutionFailed = "Failed to resolve this widget's data." + errMsgReadBody = "Failed to read request body." ) // errorBody is the JSON error payload format matching the customer-portal pattern. diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 560884db6d..c5804e42d3 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -5252,7 +5252,14 @@ components: type: integer description: > Resolved count for a single_score widget (the total from the - underlying case search, with pagination limited to 1) + underlying case search, with pagination limited to 1). Omitted + when error is set. + error: + type: string + description: > + Present only when this widget's own data resolution failed; the + other widgets in the same response are unaffected and still + carry a resolved count. Case: type: object diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index 20f0f4799d..ef0271b30d 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2592,11 +2592,17 @@ export interface BeUserSearchByEmailResponse { // Dashboards // --------------------------------------------------------------------------- -/** A single resolved widget from `GET /dashboards/{dashboardId}/widgets`. */ +/** + * A single resolved widget from `GET /dashboards/{dashboardId}/widgets`. + * `count` is present on success; `error` is present only when this widget's + * own data resolution failed, independent of the other widgets in the + * response. + */ export interface BeDashboardWidget { widgetId: string; displayName: string; /** Only "single_score" exists today. */ displayType: "single_score"; - count: number; + count?: number; + error?: string; } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx index d6ba4be603..a8815878ac 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -74,4 +74,23 @@ describe("AgentsLandingPagePilot", () => { expect(screen.getAllByText("Could not load this widget.").length).toBe(3), ); }); + + it("isolates one widget's error to its own tile while siblings render their real counts", async () => { + getMock.mockResolvedValue([ + { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", error: "Failed to resolve this widget's data." }, + { widgetId: "my_reminders", displayName: "My Reminders", displayType: "single_score", count: 5 }, + { widgetId: "open_incident_team", displayName: "Open Incidents (Team)", displayType: "single_score", count: 12 }, + ]); + + renderWithClient(); + + await waitFor(() => + expect(screen.getAllByText("Could not load this widget.").length).toBe(1), + ); + expect(screen.getByText("My Reminders")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("Open Incidents (Team)")).toBeInTheDocument(); + expect(screen.getByText("12")).toBeInTheDocument(); + expect(screen.queryByText("3")).not.toBeInTheDocument(); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index a758ec87fc..ada7812dec 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -24,7 +24,14 @@ interface DashboardWidgetTileProps { isError: boolean; } -/** Single "single_score" dashboard widget tile: a display name and its count. */ +/** + * Single "single_score" dashboard widget tile: a display name and its count. + * + * `isError` reflects the shared query's own failure (e.g. the whole request + * never came back); `widget.error` reflects this widget's own upstream + * resolution failing while its siblings in the same response still resolved + * — the two are independent and both render the same error state. + */ export default function DashboardWidgetTile({ widget, isLoading, @@ -34,7 +41,7 @@ export default function DashboardWidgetTile({ {isLoading ? ( - ) : isError || !widget ? ( + ) : isError || !widget || widget.error ? ( Could not load this widget. From 6b452f09094e266abd151328fc96924d1073c7b8 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 15:29:26 +0530 Subject: [PATCH 04/16] feat(csm-dashboard): make pilot widgets the default engineer dashboard Replace MyAssignedCases/CaseCountsMatrix/CaseCompositionCharts with the config-driven pilot widget section as the engineer dashboard's sole content, instead of keeping it as an add-on below the old sections. --- .../csm-dashboard/pages/CsmDashboardPage.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index b02e9b7e5b..593bfc7100 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -14,13 +14,10 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Chip, Divider, Typography } from "@wso2/oxygen-ui"; +import { Box, Card, Chip, Typography } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; -import CaseCompositionCharts from "@features/csm-dashboard/components/CaseCompositionCharts"; -import CaseCountsMatrix from "@features/csm-dashboard/components/CaseCountsMatrix"; -import MyAssignedCases from "@features/csm-dashboard/components/MyAssignedCases"; import { DASHBOARD_OPTIONS, type DashboardKey, @@ -54,17 +51,7 @@ export default function CsmDashboardPage(): JSX.Element { onDashboardChange={setDashboardKey} /> {dashboardKey === "engineer" ? ( - <> - - - - {/* Pilot add-on: config-driven dashboard widgets, kept clearly - separate from the sections above rather than blended in. */} - - - - - + ) : ( )} From 40e6763cbd3f4ffcc0a3f40b8e5bc21e960baf28 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 15:50:39 +0530 Subject: [PATCH 05/16] fix(csm-dashboard): return widget filter criteria instead of resolved data GET /dashboards/{id}/widgets no longer calls the entity-service or computes per-widget counts server-side. It returns each widget's display metadata plus its resolved CaseSearchFilters (current-user placeholder substituted), so the frontend runs each widget's own /cases/search independently. --- apps/csm-portal/backend/cmd/server/main.go | 2 +- .../backend/internal/handler/dashboards.go | 94 +++------ .../internal/handler/dashboards_test.go | 195 +++--------------- .../backend/internal/handler/response.go | 1 - apps/csm-portal/backend/openapi.yaml | 31 ++- 5 files changed, 66 insertions(+), 257 deletions(-) diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 1f6f58defd..f70d8176ed 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -61,7 +61,7 @@ func main() { customerEntityClient := entity.NewCustomerEntityClient(customerEntityCfg) caseHandler := handler.NewCaseHandler(customerEntityClient) - dashboardHandler := handler.NewDashboardHandler(customerEntityClient) + dashboardHandler := handler.NewDashboardHandler() accountHandler := handler.NewAccountHandler(customerEntityClient) projectHandler := handler.NewProjectHandler(customerEntityClient) productHandler := handler.NewProductHandler(customerEntityClient) diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 9e4fef7c6d..2438b1e5b6 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -17,45 +17,30 @@ package handler import ( - "context" - "encoding/json" - "log/slog" "net/http" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware" ) -// dashboardEntityClient is the subset of entityCaseClient DashboardHandler -// needs: it resolves each widget's filters through the same /cases/search -// path CaseHandler.SearchCases uses. -type dashboardEntityClient interface { - SearchCases(ctx context.Context, body []byte) ([]byte, error) -} - -// widgetResult is a single resolved widget's data, returned by -// GET /dashboards/{dashboardId}/widgets. Count is set on success; Error is -// set when this widget's own data resolution failed. A failure is scoped to -// its own widget and never prevents the other widgets in the same response -// from carrying a resolved Count. -type widgetResult struct { - WidgetID string `json:"widgetId"` - DisplayName string `json:"displayName"` - DisplayType dashboard.DisplayType `json:"displayType"` - Count *int `json:"count,omitempty"` - Error string `json:"error,omitempty"` +// dashboardWidgetView is a single widget's filter criteria and display +// metadata, returned by GET /dashboards/{dashboardId}/widgets. The caller +// resolves each widget's own data by issuing its own POST /cases/search +// request with Filters. +type dashboardWidgetView struct { + WidgetID string `json:"widgetId"` + DisplayName string `json:"displayName"` + DisplayType dashboard.DisplayType `json:"displayType"` + Filters dashboard.CaseSearchFilters `json:"filters"` } // DashboardHandler handles HTTP requests for the config-driven dashboard -// widget pilot, delegating each widget's data resolution to the entity -// service's case search. -type DashboardHandler struct { - entity dashboardEntityClient -} +// widget pilot. +type DashboardHandler struct{} -// NewDashboardHandler creates a DashboardHandler backed by the given entity client. -func NewDashboardHandler(entity dashboardEntityClient) *DashboardHandler { - return &DashboardHandler{entity: entity} +// NewDashboardHandler creates a DashboardHandler. +func NewDashboardHandler() *DashboardHandler { + return &DashboardHandler{} } // GetDashboardWidgets handles GET /dashboards/{dashboardId}/widgets. @@ -73,49 +58,16 @@ func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Re return } - results := make([]widgetResult, 0, len(templates)) + views := make([]dashboardWidgetView, 0, len(templates)) for _, tpl := range templates { - results = append(results, h.resolveWidget(r.Context(), tpl, user.UserID)) - } - - writeJSONValue(w, http.StatusOK, results) -} - -// resolveWidget resolves a single widget's data. A failure here (marshal, -// upstream search, or parse) is scoped to this widget: it is reported via -// the returned result's Error field rather than aborting the whole handler, -// so one widget's upstream failure never takes down its siblings. -func (h *DashboardHandler) resolveWidget(ctx context.Context, tpl dashboard.WidgetTemplate, currentUserID string) widgetResult { - base := widgetResult{ - WidgetID: tpl.ID, - DisplayName: tpl.DisplayName, - DisplayType: tpl.DisplayType, - } - - payload := dashboard.ResolveFilters(tpl, currentUserID) - body, err := json.Marshal(payload) - if err != nil { - slog.ErrorContext(ctx, "failed to marshal widget search payload", "userID", currentUserID, "widgetID", tpl.ID, "err", err) - base.Error = ErrMsgWidgetResolutionFailed - return base - } - - searchResult, err := h.entity.SearchCases(ctx, body) - if err != nil { - slog.ErrorContext(ctx, "entity SearchCases failed for widget", "userID", currentUserID, "widgetID", tpl.ID, "err", summarizeErr(err)) - base.Error = ErrMsgWidgetResolutionFailed - return base - } - - var parsed struct { - Total int `json:"total"` - } - if err := json.Unmarshal(searchResult, &parsed); err != nil { - slog.ErrorContext(ctx, "failed to parse widget search response", "userID", currentUserID, "widgetID", tpl.ID, "err", err) - base.Error = ErrMsgWidgetResolutionFailed - return base + resolved := dashboard.ResolveFilters(tpl, user.UserID) + views = append(views, dashboardWidgetView{ + WidgetID: tpl.ID, + DisplayName: tpl.DisplayName, + DisplayType: tpl.DisplayType, + Filters: resolved.Filters, + }) } - base.Count = &parsed.Total - return base + writeJSONValue(w, http.StatusOK, views) } diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index 66951d0b3c..1b643e7c9f 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -17,27 +17,12 @@ package handler import ( - "context" "encoding/json" - "errors" "net/http" "net/http/httptest" "testing" - - "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/apierror" ) -type mockDashboardEntityClient struct { - searchCasesFn func(ctx context.Context, body []byte) ([]byte, error) -} - -func (m *mockDashboardEntityClient) SearchCases(ctx context.Context, body []byte) ([]byte, error) { - if m.searchCasesFn != nil { - return m.searchCasesFn(ctx, body) - } - return []byte(`{"cases":[],"total":0}`), nil -} - func withDashboardID(r *http.Request, dashboardID string) *http.Request { r.SetPathValue("dashboardId", dashboardID) return r @@ -45,7 +30,7 @@ func withDashboardID(r *http.Request, dashboardID string) *http.Request { func TestGetDashboardWidgets(t *testing.T) { t.Run("requires authenticated user", func(t *testing.T) { - h := NewDashboardHandler(&mockDashboardEntityClient{}) + h := NewDashboardHandler() r := withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot") w := httptest.NewRecorder() h.GetDashboardWidgets(w, r) @@ -55,7 +40,7 @@ func TestGetDashboardWidgets(t *testing.T) { }) t.Run("unknown dashboard id returns 404", func(t *testing.T) { - h := NewDashboardHandler(&mockDashboardEntityClient{}) + h := NewDashboardHandler() r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/bogus/widgets", nil), "bogus")) w := httptest.NewRecorder() h.GetDashboardWidgets(w, r) @@ -63,15 +48,8 @@ func TestGetDashboardWidgets(t *testing.T) { assertErrorMessage(t, w, ErrMsgNotFound) }) - t.Run("resolves all three pilot widgets and substitutes the current user id", func(t *testing.T) { - var capturedBodies [][]byte - client := &mockDashboardEntityClient{ - searchCasesFn: func(_ context.Context, body []byte) ([]byte, error) { - capturedBodies = append(capturedBodies, body) - return []byte(`{"cases":[],"total":7}`), nil - }, - } - h := NewDashboardHandler(client) + t.Run("returns filter criteria and display metadata for all three pilot widgets", func(t *testing.T) { + h := NewDashboardHandler() r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) w := httptest.NewRecorder() h.GetDashboardWidgets(w, r) @@ -83,8 +61,11 @@ func TestGetDashboardWidgets(t *testing.T) { WidgetID string `json:"widgetId"` DisplayName string `json:"displayName"` DisplayType string `json:"displayType"` - Count *int `json:"count"` - Error string `json:"error"` + Filters struct { + AssignedUserIDs []string `json:"assignedUserIds"` + Tags []string `json:"tags"` + States []string `json:"states"` + } `json:"filters"` } if err := json.NewDecoder(w.Body).Decode(&results); err != nil { t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) @@ -92,160 +73,40 @@ func TestGetDashboardWidgets(t *testing.T) { if len(results) != 3 { t.Fatalf("len(results) = %d, want 3", len(results)) } - for _, res := range results { + + byID := make(map[string]int) + for i, res := range results { + byID[res.WidgetID] = i if res.DisplayType != "single_score" { t.Errorf("widget %s displayType = %q, want single_score", res.WidgetID, res.DisplayType) } - if res.Error != "" { - t.Errorf("widget %s error = %q, want none", res.WidgetID, res.Error) - } - if res.Count == nil || *res.Count != 7 { - t.Errorf("widget %s count = %v, want 7", res.WidgetID, res.Count) - } if res.DisplayName == "" { t.Errorf("widget %s has empty displayName", res.WidgetID) } } - if len(capturedBodies) != 3 { - t.Fatalf("len(capturedBodies) = %d, want 3", len(capturedBodies)) - } - // The two user-scoped widgets ("my_patches", "my_reminders") must carry the - // resolved user id, never the raw placeholder. - for _, body := range capturedBodies { - var sent struct { - Filters struct { - AssignedUserIDs []string `json:"assignedUserIds"` - } `json:"filters"` - Pagination struct { - Limit int `json:"limit"` - } `json:"pagination"` - } - if err := json.Unmarshal(body, &sent); err != nil { - t.Fatalf("upstream body invalid JSON: %v", err) + for _, id := range []string{"my_patches", "my_reminders"} { + idx, ok := byID[id] + if !ok { + t.Fatalf("missing widget %q in response", id) } - if sent.Pagination.Limit != 1 { - t.Errorf("pagination.limit = %d, want 1", sent.Pagination.Limit) + filters := results[idx].Filters + if len(filters.AssignedUserIDs) != 1 || filters.AssignedUserIDs[0] != testUser.UserID { + t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, filters.AssignedUserIDs, testUser.UserID) } - for _, id := range sent.Filters.AssignedUserIDs { - if id == "__current_user__" { - t.Errorf("assignedUserIds leaked the unresolved placeholder: %v", sent.Filters.AssignedUserIDs) + for _, uid := range filters.AssignedUserIDs { + if uid == "__current_user__" { + t.Errorf("widget %s assignedUserIds leaked the unresolved placeholder", id) } - if id != testUser.UserID { - t.Errorf("assignedUserIds = %v, want [%q]", sent.Filters.AssignedUserIDs, testUser.UserID) - } - } - } - }) - - t.Run("upstream error on every widget still returns 200 with each widget carrying its own error", func(t *testing.T) { - client := &mockDashboardEntityClient{ - searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { - return nil, &apierror.Error{StatusCode: http.StatusServiceUnavailable} - }, - } - h := NewDashboardHandler(client) - r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) - w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) - assertStatus(t, w, http.StatusOK) - - var results []struct { - WidgetID string `json:"widgetId"` - Count *int `json:"count"` - Error string `json:"error"` - } - if err := json.NewDecoder(w.Body).Decode(&results); err != nil { - t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) - } - if len(results) != 3 { - t.Fatalf("len(results) = %d, want 3", len(results)) - } - for _, res := range results { - if res.Error == "" { - t.Errorf("widget %s error = %q, want non-empty", res.WidgetID, res.Error) - } - if res.Count != nil { - t.Errorf("widget %s count = %v, want omitted", res.WidgetID, *res.Count) - } - } - }) - - t.Run("non-apierror upstream failure is reported per widget, not as a handler-level 500", func(t *testing.T) { - client := &mockDashboardEntityClient{ - searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { - return nil, errors.New("connection refused") - }, - } - h := NewDashboardHandler(client) - r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) - w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) - assertStatus(t, w, http.StatusOK) - - var results []struct { - Error string `json:"error"` - } - if err := json.NewDecoder(w.Body).Decode(&results); err != nil { - t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) - } - for _, res := range results { - if res.Error == "" { - t.Error("expected every widget to carry an error, got none") } } - }) - - t.Run("one widget's upstream failure does not take down its siblings", func(t *testing.T) { - var calls int - client := &mockDashboardEntityClient{ - searchCasesFn: func(_ context.Context, _ []byte) ([]byte, error) { - calls++ - if calls == 1 { - // Only the first-resolved widget ("my_patches") fails, mirroring the - // live SN DEV finding: an assignedUserIds-based search 400s for one - // widget while the others resolve normally in the same response. - return nil, &apierror.Error{StatusCode: http.StatusBadRequest, Body: `{"message":"no active user found for sys_id ..."}`} - } - return []byte(`{"cases":[],"total":7}`), nil - }, - } - h := NewDashboardHandler(client) - r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) - w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) - assertStatus(t, w, http.StatusOK) - - var results []struct { - WidgetID string `json:"widgetId"` - Count *int `json:"count"` - Error string `json:"error"` - } - if err := json.NewDecoder(w.Body).Decode(&results); err != nil { - t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) - } - if len(results) != 3 { - t.Fatalf("len(results) = %d, want 3", len(results)) - } - failed := results[0] - if failed.WidgetID != "my_patches" { - t.Fatalf("results[0].WidgetID = %q, want my_patches", failed.WidgetID) - } - if failed.Error == "" { - t.Errorf("widget %s: expected an error, got none", failed.WidgetID) + teamIdx, ok := byID["open_incident_team"] + if !ok { + t.Fatalf("missing widget %q in response", "open_incident_team") } - if failed.Count != nil { - t.Errorf("widget %s: count = %v, want omitted", failed.WidgetID, *failed.Count) - } - - for _, res := range results[1:] { - if res.Error != "" { - t.Errorf("widget %s: unexpected error %q, want it unaffected by my_patches' failure", res.WidgetID, res.Error) - } - if res.Count == nil || *res.Count != 7 { - t.Errorf("widget %s: count = %v, want 7", res.WidgetID, res.Count) - } + if len(results[teamIdx].Filters.AssignedUserIDs) != 0 { + t.Errorf("widget open_incident_team assignedUserIds = %v, want none", results[teamIdx].Filters.AssignedUserIDs) } }) } diff --git a/apps/csm-portal/backend/internal/handler/response.go b/apps/csm-portal/backend/internal/handler/response.go index 0fea1a0f89..fae7502fbc 100644 --- a/apps/csm-portal/backend/internal/handler/response.go +++ b/apps/csm-portal/backend/internal/handler/response.go @@ -39,7 +39,6 @@ const ( ErrMsgWorkNoteOnClosedCase = "Work notes cannot be added to a closed case." ErrMsgAttachmentOnClosedCase = "Attachments cannot be added to a closed case." ErrMsgInvalidUUID = "Invalid UUID format." - ErrMsgWidgetResolutionFailed = "Failed to resolve this widget's data." errMsgReadBody = "Failed to read request body." ) diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index c5804e42d3..0d75330835 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -1626,12 +1626,14 @@ paths: /dashboards/{dashboardId}/widgets: get: - summary: Get the resolved widgets for a dashboard. + summary: Get the widget templates for a dashboard. description: > - Resolves every widget template registered for the given dashboard id - against the current case data, using the same filters accepted by - POST /cases/search. This is a config-driven pilot: widget templates - are a small static registry, not user-configurable. + Returns every widget template registered for the given dashboard id: + its display metadata and the filter criteria to run against + POST /cases/search. The caller resolves each widget's own data by + issuing that search itself; this endpoint does not touch case data. + This is a config-driven pilot: widget templates are a small static + registry, not user-configurable. operationId: getDashboardWidgets parameters: - name: dashboardId @@ -5247,19 +5249,14 @@ components: type: string enum: - single_score - description: How the widget's resolved data should be rendered - count: - type: integer - description: > - Resolved count for a single_score widget (the total from the - underlying case search, with pagination limited to 1). Omitted - when error is set. - error: - type: string + description: How the widget's data should be rendered once resolved + filters: + $ref: '#/components/schemas/CaseSearchFilters' description: > - Present only when this widget's own data resolution failed; the - other widgets in the same response are unaffected and still - carry a resolved count. + Filter criteria for this widget, with any current-user + placeholder already substituted. Pass this directly as the + filters of a POST /cases/search request to resolve the widget's + data. Case: type: object From 7cd7958befaca2428eb87ee57de222fbbd68316e Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 15:54:52 +0530 Subject: [PATCH 06/16] fix(csm-dashboard): resolve each widget's count from its own /cases/search call The widgets endpoint now returns only display metadata and filter criteria (commit 40e6763cb), so each tile independently fetches its own count via POST /cases/search instead of reading a pre-resolved value off the shared list response. One tile's fetch failure no longer requires special-casing against a shared query's success. --- .../webapp/src/api/backend/types.ts | 11 ++- .../api/useDashboardWidgets.test.tsx | 16 +++- .../csm-dashboard/api/useDashboardWidgets.ts | 13 ++- .../api/useWidgetCaseCount.test.tsx | 77 ++++++++++++++++++ .../csm-dashboard/api/useWidgetCaseCount.ts | 52 ++++++++++++ .../AgentsLandingPagePilot.test.tsx | 81 ++++++++++++++----- .../components/AgentsLandingPagePilot.tsx | 64 +++++++++------ .../components/DashboardWidgetTile.test.tsx | 79 ++++++++++++++++++ .../components/DashboardWidgetTile.tsx | 36 +++++---- 9 files changed, 349 insertions(+), 80 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index ef0271b30d..dee5e584e1 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2593,16 +2593,15 @@ export interface BeUserSearchByEmailResponse { // --------------------------------------------------------------------------- /** - * A single resolved widget from `GET /dashboards/{dashboardId}/widgets`. - * `count` is present on success; `error` is present only when this widget's - * own data resolution failed, independent of the other widgets in the - * response. + * A single widget template from `GET /dashboards/{dashboardId}/widgets`: + * display metadata plus its already-resolved filter criteria. The caller + * resolves the widget's own data by issuing its own `POST /cases/search` + * with `filters` and reading `total` off the response. */ export interface BeDashboardWidget { widgetId: string; displayName: string; /** Only "single_score" exists today. */ displayType: "single_score"; - count?: number; - error?: string; + filters: BeCaseSearchFilters; } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx index ee624e39f2..efae012af2 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx @@ -43,9 +43,14 @@ describe("useDashboardWidgets", () => { getMock.mockReset(); }); - it("fetches the agents_pilot widget set from a single call", async () => { + it("fetches the agents_pilot widget template set from a single call", async () => { getMock.mockResolvedValue([ - { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, ]); const { result } = renderHook(() => useDashboardWidgets(), { wrapper }); @@ -55,7 +60,12 @@ describe("useDashboardWidgets", () => { expect(getMock).toHaveBeenCalledTimes(1); expect(getMock).toHaveBeenCalledWith("/dashboards/agents_pilot/widgets"); expect(result.current.data).toEqual([ - { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, ]); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts index d402d759d8..7378300122 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts @@ -23,13 +23,12 @@ import type { BeDashboardWidget } from "@api/backend/types"; export const AGENTS_PILOT_DASHBOARD_ID = "agents_pilot"; /** - * Resolved widgets for the "agents_pilot" dashboard. - * - * All widgets on a dashboard resolve in one backend call (not one request per - * widget) so the pattern scales to dozens of widgets without request - * fan-out — see `GET /dashboards/{dashboardId}/widgets`. Callers render one - * tile per entry in the returned array; every tile shares this single query's - * loading/error state. + * Widget templates for the "agents_pilot" dashboard: display metadata plus + * each widget's resolved filter criteria, from a single + * `GET /dashboards/{dashboardId}/widgets` call. This does not resolve any + * widget's data — callers render one tile per entry and each tile resolves + * its own data independently via its own `POST /cases/search` call (see + * `useWidgetCaseCount`). */ export function useDashboardWidgets(): UseQueryResult< BeDashboardWidget[], diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx new file mode 100644 index 0000000000..7215c36ee6 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); + +import { useWidgetCaseCount } from "@features/csm-dashboard/api/useWidgetCaseCount"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe("useWidgetCaseCount", () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it("resolves the widget's count from its own /cases/search call", async () => { + postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); + + const { result } = renderHook( + () => + useWidgetCaseCount("my_patches", { + assignedUserIds: ["user-1"], + tags: ["patch"], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(postMock).toHaveBeenCalledWith("/cases/search", { + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + pagination: { offset: 0, limit: 1 }, + }); + expect(result.current.data).toBe(3); + }); + + it("surfaces a query error when the call fails", async () => { + postMock.mockRejectedValue(new Error("boom")); + + const { result } = renderHook( + () => useWidgetCaseCount("my_reminders", { states: ["awaiting_info"] }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe("boom"); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts new file mode 100644 index 0000000000..c014d7f6c9 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { + BeCaseSearchFilters, + BeCaseSearchPayload, + BeCaseSearchResponse, +} from "@api/backend/types"; + +/** + * One dashboard widget's resolved count, fetched independently of any other + * widget on the same dashboard: a single `POST /cases/search` with the + * widget's own filters, `limit: 1`, reading `total` off the response (same + * count-only pattern as `useCaseCountsMatrix`). + */ +export function useWidgetCaseCount( + widgetId: string, + filters: BeCaseSearchFilters, +): UseQueryResult { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_DASHBOARD_WIDGETS, widgetId, filters], + queryFn: async (): Promise => { + const res = await api.post( + "/cases/search", + { + filters, + pagination: { offset: 0, limit: 1 }, + }, + ); + return res.total ?? 0; + }, + staleTime: 60_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx index a8815878ac..8f1c11c0f0 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -21,9 +21,10 @@ import "@testing-library/jest-dom/vitest"; import type { ReactNode } from "react"; const getMock = vi.fn(); +const postMock = vi.fn(); vi.mock("@api/backend/client", () => ({ - useBackendApi: () => ({ get: getMock }), + useBackendApi: () => ({ get: getMock, post: postMock }), })); import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; @@ -37,55 +38,91 @@ function renderWithClient(ui: ReactNode) { ); } +const TEMPLATES = [ + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, + { + widgetId: "my_reminders", + displayName: "My Reminders", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], states: ["awaiting_info"] }, + }, + { + widgetId: "open_incident_team", + displayName: "Open Incidents (Team)", + displayType: "single_score", + filters: { tags: ["s_dip"] }, + }, +]; + +function searchResponseFor(total: number) { + return { total, cases: [], limit: 1, offset: 0, hasMore: false }; +} + describe("AgentsLandingPagePilot", () => { beforeEach(() => { getMock.mockReset(); + postMock.mockReset(); }); - it("renders skeleton tiles while the shared query is in flight", () => { + it("renders skeleton tiles while the template list is in flight", () => { getMock.mockReturnValue(new Promise(() => {})); const { container } = renderWithClient(); expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(3); }); - it("renders one tile per resolved widget once the query succeeds", async () => { - getMock.mockResolvedValue([ - { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", count: 3 }, - { widgetId: "my_reminders", displayName: "My Reminders", displayType: "single_score", count: 5 }, - { widgetId: "open_incident_team", displayName: "Open Incidents (Team)", displayType: "single_score", count: 12 }, - ]); + it("renders one tile per widget, each resolving its own count independently", async () => { + getMock.mockResolvedValue(TEMPLATES); + postMock.mockImplementation((_path: string, body: { filters: Record }) => { + if (body.filters.tags && (body.filters.tags as string[]).includes("patch")) { + return Promise.resolve(searchResponseFor(3)); + } + if (body.filters.states) { + return Promise.resolve(searchResponseFor(5)); + } + return Promise.resolve(searchResponseFor(12)); + }); renderWithClient(); await waitFor(() => expect(screen.getByText("My Patches")).toBeInTheDocument()); - expect(screen.getByText("3")).toBeInTheDocument(); - expect(screen.getByText("My Reminders")).toBeInTheDocument(); - expect(screen.getByText("5")).toBeInTheDocument(); - expect(screen.getByText("Open Incidents (Team)")).toBeInTheDocument(); - expect(screen.getByText("12")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("5")).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("12")).toBeInTheDocument()); + expect(postMock).toHaveBeenCalledTimes(3); }); - it("renders an inline error state on each tile when the shared query fails", async () => { + it("shows an error state when the template list itself fails to load", async () => { getMock.mockRejectedValue(new Error("boom")); renderWithClient(); await waitFor(() => - expect(screen.getAllByText("Could not load this widget.").length).toBe(3), + expect(screen.getByText("Could not load the widget pilot.")).toBeInTheDocument(), ); + expect(postMock).not.toHaveBeenCalled(); }); - it("isolates one widget's error to its own tile while siblings render their real counts", async () => { - getMock.mockResolvedValue([ - { widgetId: "my_patches", displayName: "My Patches", displayType: "single_score", error: "Failed to resolve this widget's data." }, - { widgetId: "my_reminders", displayName: "My Reminders", displayType: "single_score", count: 5 }, - { widgetId: "open_incident_team", displayName: "Open Incidents (Team)", displayType: "single_score", count: 12 }, - ]); + it("isolates one widget's failed count fetch to its own tile while siblings render their real counts", async () => { + getMock.mockResolvedValue(TEMPLATES); + postMock.mockImplementation((_path: string, body: { filters: Record }) => { + if (body.filters.tags && (body.filters.tags as string[]).includes("patch")) { + return Promise.reject(new Error("boom")); + } + if (body.filters.states) { + return Promise.resolve(searchResponseFor(5)); + } + return Promise.resolve(searchResponseFor(12)); + }); renderWithClient(); await waitFor(() => - expect(screen.getAllByText("Could not load this widget.").length).toBe(1), + expect(screen.getByText("Could not load this widget.")).toBeInTheDocument(), ); expect(screen.getByText("My Reminders")).toBeInTheDocument(); expect(screen.getByText("5")).toBeInTheDocument(); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx index b42446413e..c7aa7e807e 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -14,28 +14,28 @@ // specific language governing permissions and limitations // under the License. -import { Box } from "@wso2/oxygen-ui"; +import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; import type { JSX } from "react"; import { useDashboardWidgets } from "@features/csm-dashboard/api/useDashboardWidgets"; import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; import SectionCard from "@features/csm-dashboard/components/SectionCard"; import RefreshButton from "@features/csm-dashboard/components/RefreshButton"; -/** Placeholder count while the pilot's single shared query is in flight. */ +/** Placeholder tile count while the widget template list is in flight. */ const PILOT_TILE_COUNT = 3; /** * Pilot section for the config-driven dashboard widget system (the - * "agents_pilot" dashboard: 3 `single_score` widgets resolved by one backend - * call — see {@link useDashboardWidgets}). Kept as a clearly separate, - * labeled add-on below the existing dashboard sections, not a redesign. + * "agents_pilot" dashboard: 3 `single_score` widgets). The widget template + * list — display metadata plus each widget's filter criteria — is fetched + * once via {@link useDashboardWidgets}; each rendered tile then resolves its + * own data independently. Kept as a clearly separate, labeled add-on below + * the existing dashboard sections, not a redesign. */ export default function AgentsLandingPagePilot(): JSX.Element { const { data, isLoading, isError, isFetching, refetch } = useDashboardWidgets(); - const tiles = data ?? new Array(PILOT_TILE_COUNT).fill(undefined); - return ( } > - - {tiles.map((widget, i) => ( - - ))} - + {isError ? ( + + Could not load the widget pilot. + + ) : ( + + {isLoading + ? Array.from({ length: PILOT_TILE_COUNT }, (_, i) => ( + + + + )) + : (data ?? []).map((widget) => ( + + ))} + + )} ); } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx new file mode 100644 index 0000000000..75926974ea --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import type { ReactNode } from "react"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); + +import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; + +function renderWithClient(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +describe("DashboardWidgetTile", () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it("renders a skeleton while its own count is in flight", () => { + postMock.mockReturnValue(new Promise(() => {})); + const { container } = renderWithClient( + , + ); + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(1); + }); + + it("renders the resolved count once its own /cases/search call succeeds", async () => { + postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); + + renderWithClient( + , + ); + + await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); + expect(screen.getByText("My Patches")).toBeInTheDocument(); + expect(postMock).toHaveBeenCalledWith("/cases/search", { + filters: {}, + pagination: { offset: 0, limit: 1 }, + }); + }); + + it("renders its own error state when its /cases/search call fails", async () => { + postMock.mockRejectedValue(new Error("boom")); + + renderWithClient( + , + ); + + await waitFor(() => + expect(screen.getByText("Could not load this widget.")).toBeInTheDocument(), + ); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index ada7812dec..cac2c89d7b 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -16,42 +16,46 @@ import { Card, Skeleton, Typography } from "@wso2/oxygen-ui"; import type { JSX } from "react"; -import type { BeDashboardWidget } from "@api/backend/types"; +import type { BeCaseSearchFilters } from "@api/backend/types"; +import { useWidgetCaseCount } from "@features/csm-dashboard/api/useWidgetCaseCount"; interface DashboardWidgetTileProps { - widget?: BeDashboardWidget; - isLoading: boolean; - isError: boolean; + widgetId: string; + displayName: string; + filters: BeCaseSearchFilters; } /** - * Single "single_score" dashboard widget tile: a display name and its count. - * - * `isError` reflects the shared query's own failure (e.g. the whole request - * never came back); `widget.error` reflects this widget's own upstream - * resolution failing while its siblings in the same response still resolved - * — the two are independent and both render the same error state. + * Single "single_score" dashboard widget tile: fetches and renders its own + * count independently of any sibling tile, so one widget's loading/error + * state never affects another's. */ export default function DashboardWidgetTile({ - widget, - isLoading, - isError, + widgetId, + displayName, + filters, }: DashboardWidgetTileProps): JSX.Element { + const { + data: count, + isLoading, + isError, + } = useWidgetCaseCount(widgetId, filters); + return ( {isLoading ? ( - ) : isError || !widget || widget.error ? ( + ) : isError ? ( Could not load this widget. ) : ( <> - {widget.displayName} + {displayName} - {widget.count} + {count} )} From 8a424dc61898605019b4f5456ca473d1cbf1cc41 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 17:57:29 +0530 Subject: [PATCH 07/16] fix(csm-dashboard): address review findings on filter resolution and docs - Drop the dead Pagination struct ResolveFilters computed and the handler discarded; it now returns CaseSearchFilters directly, matching what actually ships (pagination is enforced client-side). - Fix two stale doc comments left over from the dashboard-replacement commit (AgentsLandingPagePilot.tsx, CsmDashboardPage.tsx) that still described the pilot as an add-on beside sections it now replaces. - Decode the widget-list test against the real dashboardWidgetView type instead of a duplicated ad hoc struct, and assert the response's JSON keys match openapi.yaml's DashboardWidget schema, so a field rename or add/remove can't drift past the test suite silently. --- .../backend/internal/dashboard/widgets.go | 28 ++------- .../backend/internal/handler/dashboards.go | 3 +- .../internal/handler/dashboards_test.go | 59 ++++++++++++++----- .../components/AgentsLandingPagePilot.tsx | 4 +- .../csm-dashboard/pages/CsmDashboardPage.tsx | 14 ++--- 5 files changed, 61 insertions(+), 47 deletions(-) diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index 01765f1470..de8f7ced25 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -43,18 +43,6 @@ type CaseSearchFilters struct { AssignedUserIDs []string `json:"assignedUserIds,omitempty"` } -// caseSearchPagination mirrors the Pagination fields /cases/search accepts. -type caseSearchPagination struct { - Limit int `json:"limit"` - Offset int `json:"offset"` -} - -// CaseSearchPayload is the body ResolveFilters produces for /cases/search. -type CaseSearchPayload struct { - Filters CaseSearchFilters `json:"filters"` - Pagination caseSearchPagination `json:"pagination"` -} - // WidgetTemplate is a static, config-driven widget definition: which case // filters it runs and how its resolved data should be displayed. type WidgetTemplate struct { @@ -112,12 +100,11 @@ var Dashboards = map[string][]WidgetTemplate{ }, } -// ResolveFilters builds the /cases/search payload for tpl, substituting -// CurrentUserPlaceholder in AssignedUserIDs with currentUserID. Pagination is -// fixed at limit 1: callers only need the response's total count, matching -// the existing count-only /cases/search usage pattern (see -// useCaseCountsMatrix.ts on the frontend). -func ResolveFilters(tpl WidgetTemplate, currentUserID string) CaseSearchPayload { +// ResolveFilters substitutes CurrentUserPlaceholder in tpl's AssignedUserIDs +// with currentUserID, returning the filters to send to /cases/search. The +// caller adds its own pagination (the frontend hardcodes limit 1, since it +// only needs the response's total count). +func ResolveFilters(tpl WidgetTemplate, currentUserID string) CaseSearchFilters { filters := tpl.Filters if len(filters.AssignedUserIDs) > 0 { resolved := make([]string, len(filters.AssignedUserIDs)) @@ -129,8 +116,5 @@ func ResolveFilters(tpl WidgetTemplate, currentUserID string) CaseSearchPayload } filters.AssignedUserIDs = resolved } - return CaseSearchPayload{ - Filters: filters, - Pagination: caseSearchPagination{Limit: 1, Offset: 0}, - } + return filters } diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 2438b1e5b6..23821ca734 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -60,12 +60,11 @@ func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Re views := make([]dashboardWidgetView, 0, len(templates)) for _, tpl := range templates { - resolved := dashboard.ResolveFilters(tpl, user.UserID) views = append(views, dashboardWidgetView{ WidgetID: tpl.ID, DisplayName: tpl.DisplayName, DisplayType: tpl.DisplayType, - Filters: resolved.Filters, + Filters: dashboard.ResolveFilters(tpl, user.UserID), }) } diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index 1b643e7c9f..c11546f4fc 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -20,9 +20,21 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "reflect" + "sort" "testing" + + "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" ) +// dashboardWidgetJSONKeys are the top-level JSON keys openapi.yaml's +// DashboardWidget schema declares. Kept in sync with that schema by hand; +// TestGetDashboardWidgets fails if the handler's actual response keys ever +// diverge from this set, catching an unannounced field rename/add/remove +// that a struct-only decode (which silently ignores unknown keys and +// zero-values missing ones) would miss. +var dashboardWidgetJSONKeys = []string{"widgetId", "displayName", "displayType", "filters"} + func withDashboardID(r *http.Request, dashboardID string) *http.Request { r.SetPathValue("dashboardId", dashboardID) return r @@ -57,28 +69,47 @@ func TestGetDashboardWidgets(t *testing.T) { assertStatus(t, w, http.StatusOK) assertContentType(t, w, "application/json") - var results []struct { - WidgetID string `json:"widgetId"` - DisplayName string `json:"displayName"` - DisplayType string `json:"displayType"` - Filters struct { - AssignedUserIDs []string `json:"assignedUserIds"` - Tags []string `json:"tags"` - States []string `json:"states"` - } `json:"filters"` - } - if err := json.NewDecoder(w.Body).Decode(&results); err != nil { - t.Fatalf("decode response body: %v; raw: %s", err, w.Body.String()) + body := w.Body.Bytes() + + // Decode into the real production type (dashboardWidgetView, defined + // in dashboards.go), not a duplicate ad hoc struct — a JSON tag + // rename on the real type breaks this decode/assertions directly, + // instead of silently zero-valuing a field in a copy that has + // already drifted from what's actually returned. + var results []dashboardWidgetView + if err := json.Unmarshal(body, &results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, body) } if len(results) != 3 { t.Fatalf("len(results) = %d, want 3", len(results)) } + // Confirm the actual JSON keys match openapi.yaml's declared + // DashboardWidget schema exactly — catches an added/removed field + // that the struct decode above wouldn't (json.Unmarshal ignores + // unknown keys and zero-values missing ones). + var raw []map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + } + wantKeys := append([]string(nil), dashboardWidgetJSONKeys...) + sort.Strings(wantKeys) + for i, obj := range raw { + gotKeys := make([]string, 0, len(obj)) + for k := range obj { + gotKeys = append(gotKeys, k) + } + sort.Strings(gotKeys) + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Errorf("result[%d] JSON keys = %v, want %v (keep dashboardWidgetJSONKeys in sync with openapi.yaml's DashboardWidget schema)", i, gotKeys, wantKeys) + } + } + byID := make(map[string]int) for i, res := range results { byID[res.WidgetID] = i - if res.DisplayType != "single_score" { - t.Errorf("widget %s displayType = %q, want single_score", res.WidgetID, res.DisplayType) + if res.DisplayType != dashboard.DisplayTypeSingleScore { + t.Errorf("widget %s displayType = %q, want %q", res.WidgetID, res.DisplayType, dashboard.DisplayTypeSingleScore) } if res.DisplayName == "" { t.Errorf("widget %s has empty displayName", res.WidgetID) diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx index c7aa7e807e..8257f08a22 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -29,8 +29,8 @@ const PILOT_TILE_COUNT = 3; * "agents_pilot" dashboard: 3 `single_score` widgets). The widget template * list — display metadata plus each widget's filter criteria — is fetched * once via {@link useDashboardWidgets}; each rendered tile then resolves its - * own data independently. Kept as a clearly separate, labeled add-on below - * the existing dashboard sections, not a redesign. + * own data independently. Renders as the engineer dashboard's sole content + * for this pilot rollout (see CsmDashboardPage.tsx). */ export default function AgentsLandingPagePilot(): JSX.Element { const { data, isLoading, isError, isFetching, refetch } = diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index 593bfc7100..5540d0d554 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -26,13 +26,13 @@ import { /** * Top-level CSM dashboard. Currently locked to the Engineer dashboard and - * showing only the "Cases by severity and state" matrix: the queue / SLA / - * customers / activity widgets are hidden, and the dashboard switcher dropdown - * (Operations, IAM CS, Security, Team performance) is disabled in the header - * because those are mock placeholders. Re-enable via DASHBOARD_SWITCHER_ENABLED - * in AbtDashboardHeader and restore the hidden sections here once the real - * tab+widget model (DashboardsAndReportsProposal.md, entity-service reports - * DSL) lands. The placeholder dashboards below are kept for that restore. + * showing the config-driven widget pilot (AgentsLandingPagePilot), and the + * dashboard switcher dropdown (Operations, IAM CS, Security, Team + * performance) is disabled in the header because those are mock + * placeholders. Re-enable via DASHBOARD_SWITCHER_ENABLED in + * AbtDashboardHeader once the real tab+widget model + * (DashboardsAndReportsProposal.md, entity-service reports DSL) lands. The + * placeholder dashboards below are kept for that restore. */ export default function CsmDashboardPage(): JSX.Element { // ABT scoping is not implemented yet, so default to (and stay on) From aed6cb0c2ba9e8ca9ef10ee0d1d87b1d0873e5cb Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 19:32:41 +0530 Subject: [PATCH 08/16] feat(csm-dashboard): add dashboard list endpoint, combine widgets into per-dashboard detail --- apps/csm-portal/backend/cmd/server/main.go | 3 +- .../backend/internal/dashboard/widgets.go | 126 +++++++---- .../backend/internal/handler/dashboards.go | 58 ++++- .../internal/handler/dashboards_test.go | 213 +++++++++++++++--- apps/csm-portal/backend/openapi.yaml | 86 ++++++- 5 files changed, 391 insertions(+), 95 deletions(-) diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index f70d8176ed..7287a761c0 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -140,7 +140,8 @@ func main() { mux.HandleFunc("DELETE /cases/{id}/tags/{tagId}", caseHandler.RemoveCaseTag) mux.HandleFunc("GET /tags/search", caseHandler.SearchTags) mux.HandleFunc("POST /cases/search", caseHandler.SearchCases) - mux.HandleFunc("GET /dashboards/{dashboardId}/widgets", dashboardHandler.GetDashboardWidgets) + mux.HandleFunc("GET /dashboards", dashboardHandler.GetDashboards) + mux.HandleFunc("GET /dashboards/{dashboardId}", dashboardHandler.GetDashboardDetail) mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels) mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels) mux.HandleFunc("GET /users/me", usersHandler.GetMe) diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index de8f7ced25..55c217db1e 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -52,52 +52,102 @@ type WidgetTemplate struct { Filters CaseSearchFilters } -// Dashboards is the static registry of widget templates, keyed by dashboard id. -var Dashboards = map[string][]WidgetTemplate{ - "agents_pilot": { - { - ID: "my_patches", - DisplayName: "My Patches", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - AssignedUserIDs: []string{CurrentUserPlaceholder}, - Tags: []string{"patch"}, - States: []string{ - "open", - "work_in_progress", - "waiting_on_wso2", - "reopened", - "awaiting_info", +// Dashboard is a single dashboard's metadata plus its static widget +// templates. +type Dashboard struct { + ID string + DisplayName string + IsDefault bool + Widgets []WidgetTemplate +} + +// Dashboards is the ordered, static registry of dashboards. Order is +// deterministic and is what the frontend's dashboard picker displays. +var Dashboards = []Dashboard{ + { + ID: "agents_pilot", + DisplayName: "Engineer overview", + IsDefault: true, + Widgets: []WidgetTemplate{ + { + ID: "my_patches", + DisplayName: "My Patches", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + AssignedUserIDs: []string{CurrentUserPlaceholder}, + Tags: []string{"patch"}, + States: []string{ + "open", + "work_in_progress", + "waiting_on_wso2", + "reopened", + "awaiting_info", + }, }, }, - }, - { - ID: "my_reminders", - DisplayName: "My Reminders", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - AssignedUserIDs: []string{CurrentUserPlaceholder}, - States: []string{ - "awaiting_info", - "solution_proposed", + { + ID: "my_reminders", + DisplayName: "My Reminders", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + AssignedUserIDs: []string{CurrentUserPlaceholder}, + States: []string{ + "awaiting_info", + "solution_proposed", + }, }, }, - }, - { - ID: "open_incident_team", - DisplayName: "Open Incident (Team)", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - Tags: []string{"s_dip"}, - States: []string{ - "work_in_progress", - "open", - "waiting_on_wso2", - "reopened", + { + ID: "open_incident_team", + DisplayName: "Open Incident (Team)", + DisplayType: DisplayTypeSingleScore, + Filters: CaseSearchFilters{ + Tags: []string{"s_dip"}, + States: []string{ + "work_in_progress", + "open", + "waiting_on_wso2", + "reopened", + }, }, }, }, }, + { + ID: "operations", + DisplayName: "Operations", + IsDefault: false, + Widgets: nil, + }, + { + ID: "iam", + DisplayName: "IAM CS", + IsDefault: false, + Widgets: nil, + }, + { + ID: "security", + DisplayName: "Security center", + IsDefault: false, + Widgets: nil, + }, + { + ID: "team_performance", + DisplayName: "Team performance", + IsDefault: false, + Widgets: nil, + }, +} + +// DashboardByID looks up a dashboard by id, returning ok=false if the id +// isn't in the registry. +func DashboardByID(id string) (Dashboard, bool) { + for _, d := range Dashboards { + if d.ID == id { + return d, true + } + } + return Dashboard{}, false } // ResolveFilters substitutes CurrentUserPlaceholder in tpl's AssignedUserIDs diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 23821ca734..2ac1fa843b 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -24,7 +24,7 @@ import ( ) // dashboardWidgetView is a single widget's filter criteria and display -// metadata, returned by GET /dashboards/{dashboardId}/widgets. The caller +// metadata, returned as part of GET /dashboards/{dashboardId}. The caller // resolves each widget's own data by issuing its own POST /cases/search // request with Filters. type dashboardWidgetView struct { @@ -34,6 +34,23 @@ type dashboardWidgetView struct { Filters dashboard.CaseSearchFilters `json:"filters"` } +// dashboardListItemView is a dashboard's list-level metadata, returned by +// GET /dashboards. +type dashboardListItemView struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` +} + +// dashboardDetailView is a dashboard's full metadata plus its resolved +// widgets, returned by GET /dashboards/{dashboardId}. +type dashboardDetailView struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` + Widgets []dashboardWidgetView `json:"widgets"` +} + // DashboardHandler handles HTTP requests for the config-driven dashboard // widget pilot. type DashboardHandler struct{} @@ -43,8 +60,28 @@ func NewDashboardHandler() *DashboardHandler { return &DashboardHandler{} } -// GetDashboardWidgets handles GET /dashboards/{dashboardId}/widgets. -func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Request) { +// GetDashboards handles GET /dashboards. +func (h *DashboardHandler) GetDashboards(w http.ResponseWriter, r *http.Request) { + user := middleware.UserInfoFromContext(r.Context()) + if user == nil { + writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) + return + } + + views := make([]dashboardListItemView, 0, len(dashboard.Dashboards)) + for _, d := range dashboard.Dashboards { + views = append(views, dashboardListItemView{ + ID: d.ID, + DisplayName: d.DisplayName, + IsDefault: d.IsDefault, + }) + } + + writeJSONValue(w, http.StatusOK, views) +} + +// GetDashboardDetail handles GET /dashboards/{dashboardId}. +func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Request) { user := middleware.UserInfoFromContext(r.Context()) if user == nil { writeError(w, http.StatusUnauthorized, ErrMsgUnauthorized) @@ -52,15 +89,15 @@ func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Re } dashboardID := r.PathValue("dashboardId") - templates, ok := dashboard.Dashboards[dashboardID] + d, ok := dashboard.DashboardByID(dashboardID) if !ok { writeError(w, http.StatusNotFound, ErrMsgNotFound) return } - views := make([]dashboardWidgetView, 0, len(templates)) - for _, tpl := range templates { - views = append(views, dashboardWidgetView{ + widgets := make([]dashboardWidgetView, 0, len(d.Widgets)) + for _, tpl := range d.Widgets { + widgets = append(widgets, dashboardWidgetView{ WidgetID: tpl.ID, DisplayName: tpl.DisplayName, DisplayType: tpl.DisplayType, @@ -68,5 +105,10 @@ func (h *DashboardHandler) GetDashboardWidgets(w http.ResponseWriter, r *http.Re }) } - writeJSONValue(w, http.StatusOK, views) + writeJSONValue(w, http.StatusOK, dashboardDetailView{ + ID: d.ID, + DisplayName: d.DisplayName, + IsDefault: d.IsDefault, + Widgets: widgets, + }) } diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index c11546f4fc..6b87a1effa 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -18,6 +18,7 @@ package handler import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "reflect" @@ -29,23 +30,111 @@ import ( // dashboardWidgetJSONKeys are the top-level JSON keys openapi.yaml's // DashboardWidget schema declares. Kept in sync with that schema by hand; -// TestGetDashboardWidgets fails if the handler's actual response keys ever -// diverge from this set, catching an unannounced field rename/add/remove -// that a struct-only decode (which silently ignores unknown keys and -// zero-values missing ones) would miss. +// the tests below fail if the handler's actual response keys ever diverge +// from this set, catching an unannounced field rename/add/remove that a +// struct-only decode (which silently ignores unknown keys and zero-values +// missing ones) would miss. var dashboardWidgetJSONKeys = []string{"widgetId", "displayName", "displayType", "filters"} +// dashboardListItemJSONKeys are the top-level JSON keys openapi.yaml's +// DashboardListItem schema declares. +var dashboardListItemJSONKeys = []string{"id", "displayName", "isDefault"} + +// dashboardDetailJSONKeys are the top-level JSON keys openapi.yaml's +// Dashboard schema declares. +var dashboardDetailJSONKeys = []string{"id", "displayName", "isDefault", "widgets"} + +func assertJSONKeys(t *testing.T, obj map[string]json.RawMessage, want []string, context string) { + t.Helper() + wantKeys := append([]string(nil), want...) + sort.Strings(wantKeys) + gotKeys := make([]string, 0, len(obj)) + for k := range obj { + gotKeys = append(gotKeys, k) + } + sort.Strings(gotKeys) + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Errorf("%s JSON keys = %v, want %v", context, gotKeys, wantKeys) + } +} + func withDashboardID(r *http.Request, dashboardID string) *http.Request { r.SetPathValue("dashboardId", dashboardID) return r } -func TestGetDashboardWidgets(t *testing.T) { +func TestGetDashboards(t *testing.T) { + t.Run("requires authenticated user", func(t *testing.T) { + h := NewDashboardHandler() + r := httptest.NewRequest(http.MethodGet, "/dashboards", nil) + w := httptest.NewRecorder() + h.GetDashboards(w, r) + assertStatus(t, w, http.StatusUnauthorized) + assertErrorMessage(t, w, ErrMsgUnauthorized) + assertContentType(t, w, "application/json") + }) + + t.Run("returns all dashboards in registry order with correct isDefault", func(t *testing.T) { + h := NewDashboardHandler() + r := withUser(httptest.NewRequest(http.MethodGet, "/dashboards", nil)) + w := httptest.NewRecorder() + h.GetDashboards(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + + body := w.Body.Bytes() + + var results []dashboardListItemView + if err := json.Unmarshal(body, &results); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, body) + } + if len(results) != len(dashboard.Dashboards) { + t.Fatalf("len(results) = %d, want %d", len(results), len(dashboard.Dashboards)) + } + + var raw []map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + } + for i, obj := range raw { + assertJSONKeys(t, obj, dashboardListItemJSONKeys, fmt.Sprintf("result[%d]", i)) + } + + for i, want := range dashboard.Dashboards { + got := results[i] + if got.ID != want.ID { + t.Errorf("result[%d].ID = %q, want %q (registry order must be preserved)", i, got.ID, want.ID) + } + if got.DisplayName != want.DisplayName { + t.Errorf("result[%d].DisplayName = %q, want %q", i, got.DisplayName, want.DisplayName) + } + if got.IsDefault != want.IsDefault { + t.Errorf("result[%d].IsDefault = %v, want %v", i, got.IsDefault, want.IsDefault) + } + } + + defaultCount := 0 + for _, res := range results { + if res.IsDefault { + defaultCount++ + if res.ID != "agents_pilot" { + t.Errorf("unexpected default dashboard %q, want agents_pilot", res.ID) + } + } + } + if defaultCount != 1 { + t.Errorf("default dashboard count = %d, want 1", defaultCount) + } + }) +} + +func TestGetDashboardDetail(t *testing.T) { t.Run("requires authenticated user", func(t *testing.T) { h := NewDashboardHandler() - r := withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot") + r := withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot") w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) + h.GetDashboardDetail(w, r) assertStatus(t, w, http.StatusUnauthorized) assertErrorMessage(t, w, ErrMsgUnauthorized) assertContentType(t, w, "application/json") @@ -53,60 +142,69 @@ func TestGetDashboardWidgets(t *testing.T) { t.Run("unknown dashboard id returns 404", func(t *testing.T) { h := NewDashboardHandler() - r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/bogus/widgets", nil), "bogus")) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/bogus", nil), "bogus")) w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) + h.GetDashboardDetail(w, r) assertStatus(t, w, http.StatusNotFound) assertErrorMessage(t, w, ErrMsgNotFound) }) - t.Run("returns filter criteria and display metadata for all three pilot widgets", func(t *testing.T) { + t.Run("agents_pilot returns metadata and its three widgets", func(t *testing.T) { h := NewDashboardHandler() - r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot/widgets", nil), "agents_pilot")) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot")) w := httptest.NewRecorder() - h.GetDashboardWidgets(w, r) + h.GetDashboardDetail(w, r) assertStatus(t, w, http.StatusOK) assertContentType(t, w, "application/json") body := w.Body.Bytes() - // Decode into the real production type (dashboardWidgetView, defined + // Decode into the real production type (dashboardDetailView, defined // in dashboards.go), not a duplicate ad hoc struct — a JSON tag // rename on the real type breaks this decode/assertions directly, // instead of silently zero-valuing a field in a copy that has // already drifted from what's actually returned. - var results []dashboardWidgetView - if err := json.Unmarshal(body, &results); err != nil { + var result dashboardDetailView + if err := json.Unmarshal(body, &result); err != nil { t.Fatalf("decode response body: %v; raw: %s", err, body) } - if len(results) != 3 { - t.Fatalf("len(results) = %d, want 3", len(results)) + + if result.ID != "agents_pilot" { + t.Errorf("ID = %q, want %q", result.ID, "agents_pilot") + } + if result.DisplayName != "Engineer overview" { + t.Errorf("DisplayName = %q, want %q", result.DisplayName, "Engineer overview") + } + if !result.IsDefault { + t.Errorf("IsDefault = %v, want true", result.IsDefault) + } + if len(result.Widgets) != 3 { + t.Fatalf("len(result.Widgets) = %d, want 3", len(result.Widgets)) } - // Confirm the actual JSON keys match openapi.yaml's declared + // Confirm the actual top-level JSON keys match openapi.yaml's + // declared Dashboard schema exactly. + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + } + assertJSONKeys(t, raw, dashboardDetailJSONKeys, "response") + + // Confirm each widget's JSON keys match openapi.yaml's declared // DashboardWidget schema exactly — catches an added/removed field // that the struct decode above wouldn't (json.Unmarshal ignores // unknown keys and zero-values missing ones). - var raw []map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + var rawWidgets []map[string]json.RawMessage + if err := json.Unmarshal(raw["widgets"], &rawWidgets); err != nil { + t.Fatalf("decode widgets as raw keys: %v; raw: %s", err, raw["widgets"]) } - wantKeys := append([]string(nil), dashboardWidgetJSONKeys...) - sort.Strings(wantKeys) - for i, obj := range raw { - gotKeys := make([]string, 0, len(obj)) - for k := range obj { - gotKeys = append(gotKeys, k) - } - sort.Strings(gotKeys) - if !reflect.DeepEqual(gotKeys, wantKeys) { - t.Errorf("result[%d] JSON keys = %v, want %v (keep dashboardWidgetJSONKeys in sync with openapi.yaml's DashboardWidget schema)", i, gotKeys, wantKeys) - } + for i, obj := range rawWidgets { + assertJSONKeys(t, obj, dashboardWidgetJSONKeys, fmt.Sprintf("widgets[%d]", i)) } byID := make(map[string]int) - for i, res := range results { + for i, res := range result.Widgets { byID[res.WidgetID] = i if res.DisplayType != dashboard.DisplayTypeSingleScore { t.Errorf("widget %s displayType = %q, want %q", res.WidgetID, res.DisplayType, dashboard.DisplayTypeSingleScore) @@ -121,7 +219,7 @@ func TestGetDashboardWidgets(t *testing.T) { if !ok { t.Fatalf("missing widget %q in response", id) } - filters := results[idx].Filters + filters := result.Widgets[idx].Filters if len(filters.AssignedUserIDs) != 1 || filters.AssignedUserIDs[0] != testUser.UserID { t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, filters.AssignedUserIDs, testUser.UserID) } @@ -136,8 +234,51 @@ func TestGetDashboardWidgets(t *testing.T) { if !ok { t.Fatalf("missing widget %q in response", "open_incident_team") } - if len(results[teamIdx].Filters.AssignedUserIDs) != 0 { - t.Errorf("widget open_incident_team assignedUserIds = %v, want none", results[teamIdx].Filters.AssignedUserIDs) + if len(result.Widgets[teamIdx].Filters.AssignedUserIDs) != 0 { + t.Errorf("widget open_incident_team assignedUserIds = %v, want none", result.Widgets[teamIdx].Filters.AssignedUserIDs) + } + }) + + t.Run("mock dashboard with no widgets returns an empty widgets array, not null", func(t *testing.T) { + h := NewDashboardHandler() + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/operations", nil), "operations")) + w := httptest.NewRecorder() + h.GetDashboardDetail(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + + body := w.Body.Bytes() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + } + assertJSONKeys(t, raw, dashboardDetailJSONKeys, "response") + + widgetsRaw, present := raw["widgets"] + if !present { + t.Fatalf("response has no \"widgets\" key at all: %s", body) + } + if string(widgetsRaw) != "[]" { + t.Errorf("widgets raw JSON = %s, want literal \"[]\" (never null)", widgetsRaw) + } + + var result dashboardDetailView + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, body) + } + if result.ID != "operations" { + t.Errorf("ID = %q, want %q", result.ID, "operations") + } + if result.DisplayName != "Operations" { + t.Errorf("DisplayName = %q, want %q", result.DisplayName, "Operations") + } + if result.IsDefault { + t.Errorf("IsDefault = true, want false") + } + if len(result.Widgets) != 0 { + t.Errorf("len(result.Widgets) = %d, want 0", len(result.Widgets)) } }) } diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index 0d75330835..ade3b62a8f 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -1624,17 +1624,53 @@ paths: schema: $ref: '#/components/schemas/ErrorPayload' - /dashboards/{dashboardId}/widgets: + /dashboards: get: - summary: Get the widget templates for a dashboard. + summary: List available dashboards. description: > - Returns every widget template registered for the given dashboard id: - its display metadata and the filter criteria to run against - POST /cases/search. The caller resolves each widget's own data by - issuing that search itself; this endpoint does not touch case data. - This is a config-driven pilot: widget templates are a small static - registry, not user-configurable. - operationId: getDashboardWidgets + Returns every dashboard registered in the config-driven pilot: its + id, display name, and whether it is the default dashboard. This is a + small static registry, not user-configurable. + operationId: getDashboards + responses: + "200": + description: Ok + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DashboardListItem' + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "403": + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + "500": + description: InternalServerError + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorPayload' + + /dashboards/{dashboardId}: + get: + summary: Get a dashboard's metadata and widget templates. + description: > + Returns the given dashboard id's display metadata plus every widget + template registered for it: its display metadata and the filter + criteria to run against POST /cases/search. The caller resolves each + widget's own data by issuing that search itself; this endpoint does + not touch case data. This is a config-driven pilot: widget templates + are a small static registry, not user-configurable. + operationId: getDashboardDetail parameters: - name: dashboardId in: path @@ -1648,9 +1684,7 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/DashboardWidget' + $ref: '#/components/schemas/Dashboard' "401": description: Unauthorized content: @@ -5258,6 +5292,34 @@ components: filters of a POST /cases/search request to resolve the widget's data. + DashboardListItem: + type: object + properties: + id: + type: string + description: Static id of the dashboard + displayName: + type: string + isDefault: + type: boolean + description: Whether this is the default dashboard to show first + + Dashboard: + type: object + properties: + id: + type: string + description: Static id of the dashboard + displayName: + type: string + isDefault: + type: boolean + description: Whether this is the default dashboard to show first + widgets: + type: array + items: + $ref: '#/components/schemas/DashboardWidget' + Case: type: object properties: From bf96224096fdb60aa6dee32ca489144bff4f763a Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 19:41:24 +0530 Subject: [PATCH 09/16] feat(csm-dashboard): source the dashboard switcher and default selection from the backend --- .../webapp/src/api/backend/types.ts | 33 +++- .../webapp/src/constants/apiConstants.ts | 2 + .../csm-dashboard/api/useDashboard.test.tsx | 97 +++++++++++ .../csm-dashboard/api/useDashboard.ts | 46 ++++++ ...ets.test.tsx => useDashboardList.test.tsx} | 28 ++-- .../csm-dashboard/api/useDashboardList.ts | 43 +++++ .../csm-dashboard/api/useDashboardWidgets.ts | 49 ------ .../components/AbtDashboardHeader.tsx | 68 ++++---- .../AgentsLandingPagePilot.test.tsx | 57 ++++--- .../components/AgentsLandingPagePilot.tsx | 31 ++-- .../pages/CsmDashboardPage.test.tsx | 151 ++++++++++++++++++ .../csm-dashboard/pages/CsmDashboardPage.tsx | 85 +++++++--- .../csm-dashboard/types/abtDashboard.ts | 59 +++---- 13 files changed, 547 insertions(+), 202 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.ts rename apps/csm-portal/webapp/src/features/csm-dashboard/api/{useDashboardWidgets.test.tsx => useDashboardList.test.tsx} (69%) create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts delete mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index dee5e584e1..dd78ebf851 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2593,10 +2593,10 @@ export interface BeUserSearchByEmailResponse { // --------------------------------------------------------------------------- /** - * A single widget template from `GET /dashboards/{dashboardId}/widgets`: - * display metadata plus its already-resolved filter criteria. The caller - * resolves the widget's own data by issuing its own `POST /cases/search` - * with `filters` and reading `total` off the response. + * A single widget template, embedded in {@link BeDashboard}: display metadata + * plus its already-resolved filter criteria. The caller resolves the + * widget's own data by issuing its own `POST /cases/search` with `filters` + * and reading `total` off the response. */ export interface BeDashboardWidget { widgetId: string; @@ -2605,3 +2605,28 @@ export interface BeDashboardWidget { displayType: "single_score"; filters: BeCaseSearchFilters; } + +/** + * One entry from `GET /dashboards`: every dashboard registered in the + * config-driven pilot, without its widgets. A small static registry, not + * user-configurable — drives the dashboard switcher and the initial + * dashboard selection (the `isDefault` entry). + */ +export interface BeDashboardListItem { + id: string; + displayName: string; + isDefault: boolean; +} + +/** + * Response of `GET /dashboards/{dashboardId}`: a dashboard's display + * metadata plus every widget template registered for it. `widgets` is + * always an array, `[]` when the dashboard has none (mock placeholder + * dashboards today). + */ +export interface BeDashboard { + id: string; + displayName: string; + isDefault: boolean; + widgets: BeDashboardWidget[]; +} diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index fdc53493a0..9a5d92a08c 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -108,6 +108,8 @@ export const ApiQueryKeys = { CSM_ANNOUNCEMENTS: "csm-announcements", CSM_CASE_COUNTS: "csm-case-counts", CSM_DASHBOARD_WIDGETS: "csm-dashboard-widgets", + CSM_DASHBOARD_LIST: "csm-dashboard-list", + CSM_DASHBOARD_DETAIL: "csm-dashboard-detail", CSM_CASE_DETAIL: "csm-case-detail", CSM_CASE_COMMENTS: "csm-case-comments", CSM_CASE_ATTACHMENTS: "csm-case-attachments", diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx new file mode 100644 index 0000000000..b13b6e978b --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; + +const getMock = vi.fn(); + +// The real client reads runtime config at module load, which isn't present +// under vitest (same approach as useSearchGroups.test.tsx). +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: getMock }), +})); + +import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe("useDashboard", () => { + beforeEach(() => { + getMock.mockReset(); + }); + + it("fetches a dashboard's metadata and widget templates from a single call", async () => { + getMock.mockResolvedValue({ + id: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + widgets: [ + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, + ], + }); + + const { result } = renderHook(() => useDashboard("agents_pilot"), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(getMock).toHaveBeenCalledTimes(1); + expect(getMock).toHaveBeenCalledWith("/dashboards/agents_pilot"); + expect(result.current.data?.widgets).toEqual([ + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, + ]); + }); + + it("does not fetch while dashboardId is undefined", () => { + const { result } = renderHook(() => useDashboard(undefined), { wrapper }); + + expect(getMock).not.toHaveBeenCalled(); + expect(result.current.isPending).toBe(true); + expect(result.current.fetchStatus).toBe("idle"); + }); + + it("surfaces a query error when the call fails", async () => { + getMock.mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useDashboard("agents_pilot"), { + wrapper, + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.error?.message).toBe("boom"); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.ts new file mode 100644 index 0000000000..71ae599c5a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { BeDashboard } from "@api/backend/types"; + +/** + * A single dashboard's display metadata plus every widget template + * registered for it: display metadata and each widget's own filter criteria. + * This does not resolve any widget's data — callers render one tile per + * entry and each tile resolves its own data independently via its own + * `POST /cases/search` call (see `useWidgetCaseCount`). + * + * Disabled while `dashboardId` is `undefined`, e.g. before the dashboard + * list has loaded and an initial selection has been made. + */ +export function useDashboard( + dashboardId: string | undefined, +): UseQueryResult { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_DASHBOARD_DETAIL, dashboardId ?? ""], + queryFn: async (): Promise => { + if (!dashboardId) return null; + return api.get(`/dashboards/${dashboardId}`); + }, + enabled: dashboardId !== undefined, + staleTime: 30_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx similarity index 69% rename from apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx rename to apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx index efae012af2..a048b45604 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx @@ -27,7 +27,7 @@ vi.mock("@api/backend/client", () => ({ useBackendApi: () => ({ get: getMock }), })); -import { useDashboardWidgets } from "@features/csm-dashboard/api/useDashboardWidgets"; +import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; function wrapper({ children }: { children: ReactNode }) { const queryClient = new QueryClient({ @@ -38,41 +38,33 @@ function wrapper({ children }: { children: ReactNode }) { ); } -describe("useDashboardWidgets", () => { +describe("useDashboardList", () => { beforeEach(() => { getMock.mockReset(); }); - it("fetches the agents_pilot widget template set from a single call", async () => { + it("fetches the dashboard registry from a single call", async () => { getMock.mockResolvedValue([ - { - widgetId: "my_patches", - displayName: "My Patches", - displayType: "single_score", - filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, - }, + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true }, + { id: "operations", displayName: "Operations", isDefault: false }, ]); - const { result } = renderHook(() => useDashboardWidgets(), { wrapper }); + const { result } = renderHook(() => useDashboardList(), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(getMock).toHaveBeenCalledTimes(1); - expect(getMock).toHaveBeenCalledWith("/dashboards/agents_pilot/widgets"); + expect(getMock).toHaveBeenCalledWith("/dashboards"); expect(result.current.data).toEqual([ - { - widgetId: "my_patches", - displayName: "My Patches", - displayType: "single_score", - filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, - }, + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true }, + { id: "operations", displayName: "Operations", isDefault: false }, ]); }); it("surfaces a query error when the call fails", async () => { getMock.mockRejectedValue(new Error("boom")); - const { result } = renderHook(() => useDashboardWidgets(), { wrapper }); + const { result } = renderHook(() => useDashboardList(), { wrapper }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error?.message).toBe("boom"); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts new file mode 100644 index 0000000000..cc2c1df5cc --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { BeDashboardListItem } from "@api/backend/types"; + +/** + * Every dashboard registered in the config-driven pilot: id, display name, + * and whether it is the default. A small static registry, not + * user-configurable — drives the dashboard switcher dropdown + * (AbtDashboardHeader) and the initial dashboard selection (the `isDefault` + * entry, see CsmDashboardPage). + */ +export function useDashboardList(): UseQueryResult< + BeDashboardListItem[], + Error +> { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_DASHBOARD_LIST], + queryFn: async (): Promise => { + const res = await api.get("/dashboards"); + return res ?? []; + }, + staleTime: 30_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts deleted file mode 100644 index 7378300122..0000000000 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardWidgets.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; -import { ApiQueryKeys } from "@constants/apiConstants"; -import { useBackendApi } from "@api/backend/client"; -import type { BeDashboardWidget } from "@api/backend/types"; - -/** Dashboard id for the config-driven widget pilot (3 widgets, all `single_score`). */ -export const AGENTS_PILOT_DASHBOARD_ID = "agents_pilot"; - -/** - * Widget templates for the "agents_pilot" dashboard: display metadata plus - * each widget's resolved filter criteria, from a single - * `GET /dashboards/{dashboardId}/widgets` call. This does not resolve any - * widget's data — callers render one tile per entry and each tile resolves - * its own data independently via its own `POST /cases/search` call (see - * `useWidgetCaseCount`). - */ -export function useDashboardWidgets(): UseQueryResult< - BeDashboardWidget[], - Error -> { - const api = useBackendApi(); - - return useQuery({ - queryKey: [ApiQueryKeys.CSM_DASHBOARD_WIDGETS, AGENTS_PILOT_DASHBOARD_ID], - queryFn: async (): Promise => { - const res = await api.get( - `/dashboards/${AGENTS_PILOT_DASHBOARD_ID}/widgets`, - ); - return res ?? []; - }, - staleTime: 30_000, - }); -} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx index 2b2477ac48..f46b8cd5bf 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx @@ -24,10 +24,10 @@ import { Typography, } from "@wso2/oxygen-ui"; import type { JSX } from "react"; -import { - DASHBOARD_OPTIONS, - type DashboardKey, - type DashboardScope, +import type { BeDashboardListItem } from "@api/backend/types"; +import type { + DashboardKey, + DashboardScope, } from "@features/csm-dashboard/types/abtDashboard"; // ABT (Account-Based Team) scoping is not implemented yet, so the My ABT / All @@ -35,16 +35,18 @@ import { // Flip this to re-enable the toggle once ABT membership data is available. const ABT_SCOPING_ENABLED = false; -// Only the Engineer dashboard is live; the others are mock placeholders, so the -// switcher is disabled and locked to Engineer. Flip this to re-enable the -// dropdown once the other dashboards are real. -const DASHBOARD_SWITCHER_ENABLED = false; - interface AbtDashboardHeaderProps { scope: DashboardScope; onScopeChange: (scope: DashboardScope) => void; dashboardKey: DashboardKey; onDashboardChange: (key: DashboardKey) => void; + /** Every dashboard in the BE registry (GET /dashboards), for the switcher. */ + dashboardList: BeDashboardListItem[]; + /** Whether the selected dashboard is scope-relevant (shows the My ABT / All + * customers toggle). Computed by the caller — see CsmDashboardPage — since + * that depends on whether the dashboard has real widgets or is a mock + * placeholder, which the header itself doesn't know about. */ + scopeBased: boolean; } export default function AbtDashboardHeader({ @@ -52,9 +54,10 @@ export default function AbtDashboardHeader({ onScopeChange, dashboardKey, onDashboardChange, + dashboardList, + scopeBased, }: AbtDashboardHeaderProps): JSX.Element { - const currentOption = DASHBOARD_OPTIONS.find((o) => o.key === dashboardKey); - const showScopeButtons = currentOption?.scopeBased ?? false; + const currentOption = dashboardList.find((o) => o.id === dashboardKey); return ( Dashboard - Engineer overview + {currentOption?.displayName ?? ""} - {showScopeButtons && ( + {scopeBased && ( )} - - - - - + + + ); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx index 8f1c11c0f0..82fe77cf6f 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -38,26 +38,31 @@ function renderWithClient(ui: ReactNode) { ); } -const TEMPLATES = [ - { - widgetId: "my_patches", - displayName: "My Patches", - displayType: "single_score", - filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, - }, - { - widgetId: "my_reminders", - displayName: "My Reminders", - displayType: "single_score", - filters: { assignedUserIds: ["user-1"], states: ["awaiting_info"] }, - }, - { - widgetId: "open_incident_team", - displayName: "Open Incidents (Team)", - displayType: "single_score", - filters: { tags: ["s_dip"] }, - }, -]; +const DASHBOARD_DETAIL = { + id: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + widgets: [ + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, + }, + { + widgetId: "my_reminders", + displayName: "My Reminders", + displayType: "single_score", + filters: { assignedUserIds: ["user-1"], states: ["awaiting_info"] }, + }, + { + widgetId: "open_incident_team", + displayName: "Open Incidents (Team)", + displayType: "single_score", + filters: { tags: ["s_dip"] }, + }, + ], +}; function searchResponseFor(total: number) { return { total, cases: [], limit: 1, offset: 0, hasMore: false }; @@ -71,12 +76,12 @@ describe("AgentsLandingPagePilot", () => { it("renders skeleton tiles while the template list is in flight", () => { getMock.mockReturnValue(new Promise(() => {})); - const { container } = renderWithClient(); + const { container } = renderWithClient(); expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(3); }); it("renders one tile per widget, each resolving its own count independently", async () => { - getMock.mockResolvedValue(TEMPLATES); + getMock.mockResolvedValue(DASHBOARD_DETAIL); postMock.mockImplementation((_path: string, body: { filters: Record }) => { if (body.filters.tags && (body.filters.tags as string[]).includes("patch")) { return Promise.resolve(searchResponseFor(3)); @@ -87,7 +92,7 @@ describe("AgentsLandingPagePilot", () => { return Promise.resolve(searchResponseFor(12)); }); - renderWithClient(); + renderWithClient(); await waitFor(() => expect(screen.getByText("My Patches")).toBeInTheDocument()); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); @@ -99,7 +104,7 @@ describe("AgentsLandingPagePilot", () => { it("shows an error state when the template list itself fails to load", async () => { getMock.mockRejectedValue(new Error("boom")); - renderWithClient(); + renderWithClient(); await waitFor(() => expect(screen.getByText("Could not load the widget pilot.")).toBeInTheDocument(), @@ -108,7 +113,7 @@ describe("AgentsLandingPagePilot", () => { }); it("isolates one widget's failed count fetch to its own tile while siblings render their real counts", async () => { - getMock.mockResolvedValue(TEMPLATES); + getMock.mockResolvedValue(DASHBOARD_DETAIL); postMock.mockImplementation((_path: string, body: { filters: Record }) => { if (body.filters.tags && (body.filters.tags as string[]).includes("patch")) { return Promise.reject(new Error("boom")); @@ -119,7 +124,7 @@ describe("AgentsLandingPagePilot", () => { return Promise.resolve(searchResponseFor(12)); }); - renderWithClient(); + renderWithClient(); await waitFor(() => expect(screen.getByText("Could not load this widget.")).toBeInTheDocument(), diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx index 8257f08a22..72486ef22f 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -16,25 +16,34 @@ import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; import type { JSX } from "react"; -import { useDashboardWidgets } from "@features/csm-dashboard/api/useDashboardWidgets"; +import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; import SectionCard from "@features/csm-dashboard/components/SectionCard"; import RefreshButton from "@features/csm-dashboard/components/RefreshButton"; -/** Placeholder tile count while the widget template list is in flight. */ +/** Placeholder tile count while the dashboard detail is in flight. */ const PILOT_TILE_COUNT = 3; +interface AgentsLandingPagePilotProps { + /** Id of the dashboard to render (e.g. "agents_pilot"). */ + dashboardId: string; +} + /** - * Pilot section for the config-driven dashboard widget system (the - * "agents_pilot" dashboard: 3 `single_score` widgets). The widget template - * list — display metadata plus each widget's filter criteria — is fetched - * once via {@link useDashboardWidgets}; each rendered tile then resolves its - * own data independently. Renders as the engineer dashboard's sole content - * for this pilot rollout (see CsmDashboardPage.tsx). + * Pilot section for the config-driven dashboard widget system: renders + * whichever dashboard's real `single_score` widgets are passed in via + * `dashboardId`. The dashboard's metadata plus its widget templates — + * display metadata and each widget's filter criteria — are fetched once via + * {@link useDashboard}; each rendered tile then resolves its own data + * independently. Today only the "agents_pilot" dashboard has real widgets + * (see CsmDashboardPage.tsx), but this component is generic over any + * dashboard id with widgets. */ -export default function AgentsLandingPagePilot(): JSX.Element { +export default function AgentsLandingPagePilot({ + dashboardId, +}: AgentsLandingPagePilotProps): JSX.Element { const { data, isLoading, isError, isFetching, refetch } = - useDashboardWidgets(); + useDashboard(dashboardId); return ( )) - : (data ?? []).map((widget) => ( + : (data?.widgets ?? []).map((widget) => ( ({ + useDashboardList: vi.fn(), +})); + +vi.mock("@features/csm-dashboard/api/useDashboard", () => ({ + useDashboard: vi.fn(), +})); + +// Keeps this test focused on dashboard selection + the header; the pilot +// widget grid has its own tests (AgentsLandingPagePilot.test.tsx). +vi.mock("@features/csm-dashboard/components/AgentsLandingPagePilot", () => ({ + default: ({ dashboardId }: { dashboardId: string }) => ( +
{dashboardId}
+ ), +})); + +const mockedUseDashboardList = vi.mocked(useDashboardList); +const mockedUseDashboard = vi.mocked(useDashboard); + +const DASHBOARD_LIST = [ + { id: "operations", displayName: "Operations", isDefault: false }, + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true }, + { id: "iam", displayName: "IAM CS", isDefault: false }, +]; + +function mockListResult( + overrides: Partial>, +): void { + mockedUseDashboardList.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + ...overrides, + } as unknown as ReturnType); +} + +function mockDashboardResult( + overrides: Partial>, +): void { + mockedUseDashboard.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + ...overrides, + } as unknown as ReturnType); +} + +beforeEach(() => { + mockedUseDashboardList.mockReset(); + mockedUseDashboard.mockReset(); +}); + +describe("CsmDashboardPage", () => { + it("shows a loading skeleton before the dashboard list resolves", () => { + mockListResult({ data: undefined, isLoading: true }); + mockDashboardResult({ data: undefined, isLoading: true }); + + const { container } = render(); + + expect(container.querySelectorAll(".MuiSkeleton-root").length).toBeGreaterThan(0); + expect(screen.queryByTestId("agents-landing-pilot")).not.toBeInTheDocument(); + }); + + it("selects the isDefault dashboard once the list loads and renders the enabled, populated switcher", () => { + mockListResult({ data: DASHBOARD_LIST, isLoading: false }); + mockDashboardResult({ + data: { + id: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + widgets: [ + { + widgetId: "my_patches", + displayName: "My Patches", + displayType: "single_score", + filters: {}, + }, + ], + }, + isLoading: false, + }); + + render(); + + // The isDefault entry ("agents_pilot") is selected on load. + expect(screen.getByTestId("agents-landing-pilot")).toHaveTextContent( + "agents_pilot", + ); + + // The switcher is populated from the BE list and enabled (no + // disabled-state tooltip gate any more): open it and check every + // dashboard from the list appears as an option. + const select = screen.getByRole("combobox"); + expect(select).not.toHaveAttribute("aria-disabled", "true"); + + fireEvent.mouseDown(select); + const listbox = screen.getByRole("listbox"); + expect(within(listbox).getByText("Operations")).toBeInTheDocument(); + expect(within(listbox).getByText("IAM CS")).toBeInTheDocument(); + expect(within(listbox).getByText("Engineer overview")).toBeInTheDocument(); + }); + + it("renders the mock placeholder for a dashboard with no real widgets", () => { + // "operations" is the default entry here (a mock placeholder dashboard, + // unlike "agents_pilot" which always has real widgets). + mockListResult({ + data: [ + { id: "agents_pilot", displayName: "Engineer overview", isDefault: false }, + { id: "operations", displayName: "Operations", isDefault: true }, + ], + isLoading: false, + }); + mockDashboardResult({ + data: { + id: "operations", + displayName: "Operations", + isDefault: true, + widgets: [], + }, + isLoading: false, + }); + + render(); + + expect(screen.queryByTestId("agents-landing-pilot")).not.toBeInTheDocument(); + expect(screen.getByText("Mock")).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index 5540d0d554..fdc0c2d0a3 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -14,33 +14,61 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Chip, Typography } from "@wso2/oxygen-ui"; +import { Box, Card, Chip, Skeleton, Typography } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; +import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; +import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; import { - DASHBOARD_OPTIONS, + MOCK_DASHBOARD_META, type DashboardKey, type DashboardScope, } from "@features/csm-dashboard/types/abtDashboard"; /** - * Top-level CSM dashboard. Currently locked to the Engineer dashboard and - * showing the config-driven widget pilot (AgentsLandingPagePilot), and the - * dashboard switcher dropdown (Operations, IAM CS, Security, Team - * performance) is disabled in the header because those are mock - * placeholders. Re-enable via DASHBOARD_SWITCHER_ENABLED in - * AbtDashboardHeader once the real tab+widget model - * (DashboardsAndReportsProposal.md, entity-service reports DSL) lands. The - * placeholder dashboards below are kept for that restore. + * Top-level CSM dashboard. The dashboard list and the default selection are + * BE-driven: `GET /dashboards` populates the switcher in the header (now + * always enabled, see AbtDashboardHeader), and the `isDefault` entry is + * selected on load. Only the "agents_pilot" dashboard has real + * (config-driven) widgets today; every other dashboard in the registry + * (Operations, IAM CS, Security, Team performance) renders the mock + * `DashboardPlaceholder` below until the real tab+widget model + * (DashboardsAndReportsProposal.md, entity-service reports DSL) lands. */ export default function CsmDashboardPage(): JSX.Element { // ABT scoping is not implemented yet, so default to (and stay on) // all-customers; the My ABT / All customers toggle is disabled in the header. const [scope, setScope] = useState("all_customers"); - // Locked to the Engineer dashboard: the switcher is disabled in the header - // (the other dashboards are mock placeholders), so this never changes today. - const [dashboardKey, setDashboardKey] = useState("engineer"); + // Undefined until the switcher is used; until then the selection derives + // from the loaded list's isDefault entry (see `dashboardKey` below), so + // there is nothing to synchronize via an effect. + const [manualDashboardKey, setManualDashboardKey] = useState< + DashboardKey | undefined + >(undefined); + + const dashboardList = useDashboardList(); + const list = dashboardList.data; + const defaultEntry = + list && list.length > 0 ? (list.find((d) => d.isDefault) ?? list[0]) : undefined; + const dashboardKey = manualDashboardKey ?? defaultEntry?.id; + + const dashboard = useDashboard(dashboardKey); + const hasRealWidgets = (dashboard.data?.widgets.length ?? 0) > 0; + const mockMeta = dashboardKey ? MOCK_DASHBOARD_META[dashboardKey] : undefined; + // Real (widget-bearing) dashboards are personal-queue-shaped and always + // scope-relevant; mock placeholders use their own FE-local metadata. + const scopeBased = hasRealWidgets ? true : (mockMeta?.scopeBased ?? false); + const currentEntry = dashboardList.data?.find((d) => d.id === dashboardKey); + + if (dashboardKey === undefined) { + return ( + + + + + ); + } return ( @@ -48,12 +76,17 @@ export default function CsmDashboardPage(): JSX.Element { scope={scope} onScopeChange={setScope} dashboardKey={dashboardKey} - onDashboardChange={setDashboardKey} + onDashboardChange={setManualDashboardKey} + dashboardList={dashboardList.data ?? []} + scopeBased={scopeBased} /> - {dashboardKey === "engineer" ? ( - + {hasRealWidgets ? ( + ) : ( - + )} ); @@ -61,11 +94,15 @@ export default function CsmDashboardPage(): JSX.Element { interface DashboardPlaceholderProps { dashboardKey: DashboardKey; + displayName: string; } -function DashboardPlaceholder({ dashboardKey }: DashboardPlaceholderProps): JSX.Element { - const option = DASHBOARD_OPTIONS.find((o) => o.key === dashboardKey); - if (!option) return <>; +function DashboardPlaceholder({ + dashboardKey, + displayName, +}: DashboardPlaceholderProps): JSX.Element { + const meta = MOCK_DASHBOARD_META[dashboardKey]; + if (!meta) return <>; // Mock KPI tiles per dashboard. Numbers are pinned (no real query); the // shape matches the v1 widget set in DashboardsAndReportsProposal.md. @@ -74,9 +111,9 @@ function DashboardPlaceholder({ dashboardKey }: DashboardPlaceholderProps): JSX. return ( - {option.name} + {displayName} - {option.description} + {meta.description} @@ -136,8 +173,8 @@ interface Tile { color: TileColor; } -const TILE_SETS: Record = { - engineer: [], +const TILE_SETS: Record = { + agents_pilot: [], operations: [ { label: "Open cases", value: "287", color: "neutral" }, { label: "Created today", value: "34", sub: "+12% vs 7d avg", color: "info" }, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index e8ce01d212..99c0275132 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -105,49 +105,42 @@ export interface CsmRecentActivity { // Multi-dashboard switcher — mirrors ServiceNow Performance Analytics where // engineers pivot between several dashboards (Engineer / Operations / IAM / // Security / Team Performance). See DashboardsAndReportsProposal.md. -export type DashboardKey = - | "engineer" - | "operations" - | "iam" - | "security" - | "team_performance"; +// +// Dashboard ids now come from the backend registry (GET /dashboards, see +// useDashboardList), not a fixed compile-time set, so this is just `string`. +export type DashboardKey = string; -export interface DashboardOption { - key: DashboardKey; - name: string; +export interface MockDashboardMeta { description: string; scopeBased: boolean; } -export const DASHBOARD_OPTIONS: DashboardOption[] = [ - { - key: "engineer", - name: "Engineer overview", - description: "Personal queue, SLA at risk, customers in scope, recent activity.", - scopeBased: true, - }, - { - key: "operations", - name: "Operations", - description: "Cross-team case throughput, state distribution, escalations, SLA breach trends.", +/** + * Description + scope-relevance for the mock placeholder dashboards (every + * dashboard in the BE registry other than "agents_pilot", which has real + * widgets and doesn't need an entry here). Keyed by dashboard id. Drives + * `DashboardPlaceholder`'s card copy and the header's My ABT / All customers + * toggle visibility (see CsmDashboardPage, AbtDashboardHeader). + */ +export const MOCK_DASHBOARD_META: Record = { + operations: { + description: + "Cross-team case throughput, state distribution, escalations, SLA breach trends.", scopeBased: false, }, - { - key: "iam", - name: "IAM CS", - description: "Identity Server / Asgardeo case posture, top accounts, vulnerability links.", + iam: { + description: + "Identity Server / Asgardeo case posture, top accounts, vulnerability links.", scopeBased: false, }, - { - key: "security", - name: "Security center", - description: "Vulnerability posture, security report cases, response time.", + security: { + description: + "Vulnerability posture, security report cases, response time.", scopeBased: false, }, - { - key: "team_performance", - name: "Team performance", - description: "Per-team throughput, time-card distribution, on-call coverage gaps.", + team_performance: { + description: + "Per-team throughput, time-card distribution, on-call coverage gaps.", scopeBased: false, }, -]; +}; From 8de309b2e02cdb4273fbfdd002142fb84e635032 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 20:25:01 +0530 Subject: [PATCH 10/16] feat(csm-dashboard): generalize widget schema to any resource type, real widgets for every dashboard --- .../backend/internal/dashboard/widgets.go | 265 ++++++++++++------ .../backend/internal/handler/dashboards.go | 30 +- .../internal/handler/dashboards_test.go | 250 ++++++++++++++--- apps/csm-portal/backend/openapi.yaml | 49 +++- 4 files changed, 447 insertions(+), 147 deletions(-) diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index 55c217db1e..9b31b091cb 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -15,41 +15,57 @@ // under the License. // Package dashboard holds the pilot's static, config-driven dashboard widget -// templates. Each widget resolves to a case search against the existing -// /cases/search filter shape (see CaseSearchFilters in openapi.yaml) — there -// is no generic filter DSL and no database backing this; new widgets are -// added by extending the Dashboards registry below. +// templates. Each widget resolves to a search against that ResourceType's own +// /search endpoint (every resource's search payload shape is +// {filters: {...}, pagination: {...}}) — there is no generic filter DSL and +// no database backing this; new widgets are added by extending the +// Dashboards registry below. package dashboard -// CurrentUserPlaceholder marks an assignedUserIds entry that must be resolved -// to the requesting user's id before the filters are sent upstream. It never +// CurrentUserPlaceholder marks a filter value that must be resolved to the +// requesting user's id before the filters are sent upstream. It never // reaches the entity service: ResolveFilters always substitutes it. const CurrentUserPlaceholder = "__current_user__" -// DisplayType is how a widget's resolved data should be rendered. -type DisplayType string +// ResourceType identifies which resource a widget's filters search against. +type ResourceType string -// DisplayTypeSingleScore is the only display type this pilot supports: a -// single resolved count. -const DisplayTypeSingleScore DisplayType = "single_score" +const ( + ResourceCase ResourceType = "case" + ResourceIncident ResourceType = "incident" + ResourceChangeRequest ResourceType = "change_request" + ResourceAccount ResourceType = "account" + ResourceProject ResourceType = "project" + ResourceUser ResourceType = "user" + ResourceTimeCard ResourceType = "time_card" + ResourceProblem ResourceType = "problem" + ResourceProductVulnerability ResourceType = "product_vulnerability" +) -// CaseSearchFilters mirrors the subset of the entity service's -// CaseSearchFilters schema (openapi.yaml, component CaseSearchFilters) that -// the pilot widgets need. Field names and JSON tags match that schema exactly -// so the marshaled payload is forwarded to /cases/search unchanged. -type CaseSearchFilters struct { - States []string `json:"states,omitempty"` - Tags []string `json:"tags,omitempty"` - AssignedUserIDs []string `json:"assignedUserIds,omitempty"` -} +// Shape is how a widget's resolved data should be rendered. +type Shape string + +const ( + ShapeCount Shape = "count" // single resolved number + ShapeList Shape = "list" // top-N matching records + ShapePie Shape = "pie" // grouped counts — NOT resolvable by any /search endpoint today (no aggregate endpoint exists anywhere in the stack); keep the const so a future dashboard doesn't need a schema migration, but do not wire any rendering logic for it beyond accepting the value + ShapeBar Shape = "bar" // same caveat as ShapePie +) -// WidgetTemplate is a static, config-driven widget definition: which case -// filters it runs and how its resolved data should be displayed. +// WidgetTemplate is resource-agnostic: Filters is opaque JSON, forwarded +// verbatim (after __current_user__ substitution) as the filters object of +// that ResourceType's own /search payload (every resource's search payload +// shape is {filters: {...}, pagination: {...}}). The BE never interprets +// filter contents beyond substituting the current-user placeholder. type WidgetTemplate struct { - ID string - DisplayName string - DisplayType DisplayType - Filters CaseSearchFilters + ID string + DisplayName string + ResourceType ResourceType + Shape Shape + GridWidth int // 1-12, CSS grid columns out of 12 + Filters map[string]any + GroupBy string `json:",omitempty"` // only meaningful for Shape pie/bar — see the caveat on those consts; unused by every widget below + ListLimit int `json:",omitempty"` // only meaningful for Shape list; how many records to show } // Dashboard is a single dashboard's metadata plus its static widget @@ -58,84 +74,125 @@ type Dashboard struct { ID string DisplayName string IsDefault bool - Widgets []WidgetTemplate + // TargetTeam is purely descriptive metadata (e.g. for a future FE team + // picker); it is not enforced anywhere. GET /dashboards still returns + // every dashboard to every caller regardless of team membership. + TargetTeam string + Widgets []WidgetTemplate } // Dashboards is the ordered, static registry of dashboards. Order is // deterministic and is what the frontend's dashboard picker displays. var Dashboards = []Dashboard{ { - ID: "agents_pilot", - DisplayName: "Engineer overview", - IsDefault: true, + ID: "agents_pilot", DisplayName: "Engineer overview", IsDefault: true, TargetTeam: "cs_engineers", Widgets: []WidgetTemplate{ { - ID: "my_patches", - DisplayName: "My Patches", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - AssignedUserIDs: []string{CurrentUserPlaceholder}, - Tags: []string{"patch"}, - States: []string{ - "open", - "work_in_progress", - "waiting_on_wso2", - "reopened", - "awaiting_info", - }, + ID: "my_patches", DisplayName: "My Patches", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, + Filters: map[string]any{ + "assignedUserIds": []string{CurrentUserPlaceholder}, + "tags": []string{"patch"}, + "states": []string{"open", "work_in_progress", "waiting_on_wso2", "reopened", "awaiting_info"}, + }, + }, + { + ID: "my_reminders", DisplayName: "My Reminders", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, + Filters: map[string]any{ + "assignedUserIds": []string{CurrentUserPlaceholder}, + "states": []string{"awaiting_info", "solution_proposed"}, }, }, { - ID: "my_reminders", - DisplayName: "My Reminders", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - AssignedUserIDs: []string{CurrentUserPlaceholder}, - States: []string{ - "awaiting_info", - "solution_proposed", - }, + ID: "open_incident_team", DisplayName: "Open Incident (Team)", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, + Filters: map[string]any{ + "tags": []string{"s_dip"}, + "states": []string{"work_in_progress", "open", "waiting_on_wso2", "reopened"}, }, }, { - ID: "open_incident_team", - DisplayName: "Open Incident (Team)", - DisplayType: DisplayTypeSingleScore, - Filters: CaseSearchFilters{ - Tags: []string{"s_dip"}, - States: []string{ - "work_in_progress", - "open", - "waiting_on_wso2", - "reopened", - }, + ID: "my_critical_open", DisplayName: "My Critical & High Cases", ResourceType: ResourceCase, Shape: ShapeList, GridWidth: 3, ListLimit: 5, + Filters: map[string]any{ + "assignedUserIds": []string{CurrentUserPlaceholder}, + "severities": []string{"catastrophic", "critical"}, + "states": []string{"open", "work_in_progress"}, }, }, }, }, { - ID: "operations", - DisplayName: "Operations", - IsDefault: false, - Widgets: nil, + ID: "operations", DisplayName: "Operations", TargetTeam: "cs_operations", + Widgets: []WidgetTemplate{ + { + ID: "p0_p1_open", DisplayName: "P0/P1 Open", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{ + "severities": []string{"catastrophic", "critical"}, + "states": []string{"open", "work_in_progress"}, + }, + }, + { + ID: "open_critical_incidents", DisplayName: "Open Critical Incidents", ResourceType: ResourceIncident, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{"priorities": []string{"CRITICAL", "HIGH"}}, + }, + { + ID: "crs_awaiting_approval", DisplayName: "CRs Awaiting Approval", ResourceType: ResourceChangeRequest, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{"states": []string{"customer_approval"}}, + }, + }, }, { - ID: "iam", - DisplayName: "IAM CS", - IsDefault: false, - Widgets: nil, + ID: "iam", DisplayName: "IAM CS", TargetTeam: "iam_cs", + Widgets: []WidgetTemplate{ + { + ID: "iam_open_cases", DisplayName: "IAM Open Cases", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, + Filters: map[string]any{ + "tags": []string{"iam"}, + "states": []string{"open", "work_in_progress", "awaiting_info"}, + }, + }, + { + ID: "asgardeo_open_cases", DisplayName: "Asgardeo Open Cases", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, + Filters: map[string]any{ + "tags": []string{"asgardeo"}, + "states": []string{"open", "work_in_progress", "awaiting_info"}, + }, + }, + }, }, { - ID: "security", - DisplayName: "Security center", - IsDefault: false, - Widgets: nil, + ID: "security", DisplayName: "Security center", TargetTeam: "security", + Widgets: []WidgetTemplate{ + { + ID: "critical_vulns", DisplayName: "Critical Vulnerabilities", ResourceType: ResourceProductVulnerability, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{"priority": "critical"}, + }, + { + ID: "high_vulns", DisplayName: "High Vulnerabilities", ResourceType: ResourceProductVulnerability, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{"priority": "high"}, + }, + { + ID: "sra_cases_open", DisplayName: "Open SRAs", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 4, + Filters: map[string]any{ + "types": []string{"security_report_analysis"}, + "states": []string{"open", "work_in_progress", "awaiting_info"}, + }, + }, + }, }, { - ID: "team_performance", - DisplayName: "Team performance", - IsDefault: false, - Widgets: nil, + ID: "team_performance", DisplayName: "Team performance", TargetTeam: "cs_team_leads", + Widgets: []WidgetTemplate{ + { + ID: "time_cards_pending_approval", DisplayName: "Time Cards Pending Approval", ResourceType: ResourceTimeCard, Shape: ShapeCount, GridWidth: 6, + Filters: map[string]any{"states": []string{"pending"}}, + }, + { + ID: "team_open_cases", DisplayName: "Team Open P0/P1", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, + Filters: map[string]any{ + "severities": []string{"catastrophic", "critical"}, + "states": []string{"open", "work_in_progress"}, + }, + }, + }, }, } @@ -150,21 +207,43 @@ func DashboardByID(id string) (Dashboard, bool) { return Dashboard{}, false } -// ResolveFilters substitutes CurrentUserPlaceholder in tpl's AssignedUserIDs -// with currentUserID, returning the filters to send to /cases/search. The -// caller adds its own pagination (the frontend hardcodes limit 1, since it -// only needs the response's total count). -func ResolveFilters(tpl WidgetTemplate, currentUserID string) CaseSearchFilters { - filters := tpl.Filters - if len(filters.AssignedUserIDs) > 0 { - resolved := make([]string, len(filters.AssignedUserIDs)) - for i, id := range filters.AssignedUserIDs { - if id == CurrentUserPlaceholder { - id = currentUserID +// ResolveFilters returns tpl's filters with CurrentUserPlaceholder substituted +// by currentUserID wherever it appears as a string inside a []any (the only +// place a per-user value belongs in a filters object — e.g. assignedUserIds, +// userIds). It does not mutate tpl.Filters. +func ResolveFilters(tpl WidgetTemplate, currentUserID string) map[string]any { + return substituteCurrentUser(tpl.Filters, currentUserID).(map[string]any) +} + +func substituteCurrentUser(v any, currentUserID string) any { + switch val := v.(type) { + case map[string]any: + out := make(map[string]any, len(val)) + for k, sub := range val { + out[k] = substituteCurrentUser(sub, currentUserID) + } + return out + case []string: + out := make([]string, len(val)) + for i, s := range val { + if s == CurrentUserPlaceholder { + s = currentUserID } - resolved[i] = id + out[i] = s + } + return out + case []any: + out := make([]any, len(val)) + for i, sub := range val { + out[i] = substituteCurrentUser(sub, currentUserID) + } + return out + case string: + if val == CurrentUserPlaceholder { + return currentUserID } - filters.AssignedUserIDs = resolved + return val + default: + return val } - return filters } diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 2ac1fa843b..906c9e8748 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -25,13 +25,17 @@ import ( // dashboardWidgetView is a single widget's filter criteria and display // metadata, returned as part of GET /dashboards/{dashboardId}. The caller -// resolves each widget's own data by issuing its own POST /cases/search -// request with Filters. +// resolves each widget's own data by issuing its own POST /{resourceType}s/search +// request (see ResourceType) with Filters. type dashboardWidgetView struct { - WidgetID string `json:"widgetId"` - DisplayName string `json:"displayName"` - DisplayType dashboard.DisplayType `json:"displayType"` - Filters dashboard.CaseSearchFilters `json:"filters"` + WidgetID string `json:"widgetId"` + DisplayName string `json:"displayName"` + ResourceType dashboard.ResourceType `json:"resourceType"` + Shape dashboard.Shape `json:"shape"` + GridWidth int `json:"gridWidth"` + Filters map[string]any `json:"filters"` + GroupBy string `json:"groupBy,omitempty"` + ListLimit int `json:"listLimit,omitempty"` } // dashboardListItemView is a dashboard's list-level metadata, returned by @@ -48,6 +52,7 @@ type dashboardDetailView struct { ID string `json:"id"` DisplayName string `json:"displayName"` IsDefault bool `json:"isDefault"` + TargetTeam string `json:"targetTeam"` Widgets []dashboardWidgetView `json:"widgets"` } @@ -98,10 +103,14 @@ func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Req widgets := make([]dashboardWidgetView, 0, len(d.Widgets)) for _, tpl := range d.Widgets { widgets = append(widgets, dashboardWidgetView{ - WidgetID: tpl.ID, - DisplayName: tpl.DisplayName, - DisplayType: tpl.DisplayType, - Filters: dashboard.ResolveFilters(tpl, user.UserID), + WidgetID: tpl.ID, + DisplayName: tpl.DisplayName, + ResourceType: tpl.ResourceType, + Shape: tpl.Shape, + GridWidth: tpl.GridWidth, + Filters: dashboard.ResolveFilters(tpl, user.UserID), + GroupBy: tpl.GroupBy, + ListLimit: tpl.ListLimit, }) } @@ -109,6 +118,7 @@ func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Req ID: d.ID, DisplayName: d.DisplayName, IsDefault: d.IsDefault, + TargetTeam: d.TargetTeam, Widgets: widgets, }) } diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index 6b87a1effa..c382ba88b6 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -34,7 +34,10 @@ import ( // from this set, catching an unannounced field rename/add/remove that a // struct-only decode (which silently ignores unknown keys and zero-values // missing ones) would miss. -var dashboardWidgetJSONKeys = []string{"widgetId", "displayName", "displayType", "filters"} +// +// groupBy and listLimit are omitempty on the wire and are not included here; +// widgets that set them are checked individually where relevant. +var dashboardWidgetJSONKeys = []string{"widgetId", "displayName", "resourceType", "shape", "gridWidth", "filters"} // dashboardListItemJSONKeys are the top-level JSON keys openapi.yaml's // DashboardListItem schema declares. @@ -42,7 +45,7 @@ var dashboardListItemJSONKeys = []string{"id", "displayName", "isDefault"} // dashboardDetailJSONKeys are the top-level JSON keys openapi.yaml's // Dashboard schema declares. -var dashboardDetailJSONKeys = []string{"id", "displayName", "isDefault", "widgets"} +var dashboardDetailJSONKeys = []string{"id", "displayName", "isDefault", "targetTeam", "widgets"} func assertJSONKeys(t *testing.T, obj map[string]json.RawMessage, want []string, context string) { t.Helper() @@ -58,6 +61,27 @@ func assertJSONKeys(t *testing.T, obj map[string]json.RawMessage, want []string, } } +// assertJSONKeysSubset is like assertJSONKeys but only requires want to be +// present; used for widgets that additionally carry an omitempty field +// (groupBy/listLimit) beyond the base set. +func assertJSONKeysSuperset(t *testing.T, obj map[string]json.RawMessage, want []string, context string) { + t.Helper() + for _, k := range want { + if _, ok := obj[k]; !ok { + t.Errorf("%s missing expected key %q; got keys %v", context, k, keysOf(obj)) + } + } +} + +func keysOf(obj map[string]json.RawMessage) []string { + out := make([]string, 0, len(obj)) + for k := range obj { + out = append(out, k) + } + sort.Strings(out) + return out +} + func withDashboardID(r *http.Request, dashboardID string) *http.Request { r.SetPathValue("dashboardId", dashboardID) return r @@ -129,6 +153,19 @@ func TestGetDashboards(t *testing.T) { }) } +// TestAllDashboardsHaveWidgets is the "no more mock/empty placeholders" +// guarantee: every dashboard in the registry now has real widgets. +func TestAllDashboardsHaveWidgets(t *testing.T) { + if len(dashboard.Dashboards) != 5 { + t.Fatalf("len(dashboard.Dashboards) = %d, want 5", len(dashboard.Dashboards)) + } + for _, d := range dashboard.Dashboards { + if len(d.Widgets) == 0 { + t.Errorf("dashboard %q has no widgets, want at least 1", d.ID) + } + } +} + func TestGetDashboardDetail(t *testing.T) { t.Run("requires authenticated user", func(t *testing.T) { h := NewDashboardHandler() @@ -149,7 +186,7 @@ func TestGetDashboardDetail(t *testing.T) { assertErrorMessage(t, w, ErrMsgNotFound) }) - t.Run("agents_pilot returns metadata and its three widgets", func(t *testing.T) { + t.Run("agents_pilot returns metadata and its four widgets", func(t *testing.T) { h := NewDashboardHandler() r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot")) w := httptest.NewRecorder() @@ -159,6 +196,7 @@ func TestGetDashboardDetail(t *testing.T) { assertContentType(t, w, "application/json") body := w.Body.Bytes() + t.Logf("GET /dashboards/agents_pilot response: %s", body) // Decode into the real production type (dashboardDetailView, defined // in dashboards.go), not a duplicate ad hoc struct — a JSON tag @@ -179,8 +217,11 @@ func TestGetDashboardDetail(t *testing.T) { if !result.IsDefault { t.Errorf("IsDefault = %v, want true", result.IsDefault) } - if len(result.Widgets) != 3 { - t.Fatalf("len(result.Widgets) = %d, want 3", len(result.Widgets)) + if result.TargetTeam != "cs_engineers" { + t.Errorf("TargetTeam = %q, want %q", result.TargetTeam, "cs_engineers") + } + if len(result.Widgets) != 4 { + t.Fatalf("len(result.Widgets) = %d, want 4", len(result.Widgets)) } // Confirm the actual top-level JSON keys match openapi.yaml's @@ -192,54 +233,112 @@ func TestGetDashboardDetail(t *testing.T) { assertJSONKeys(t, raw, dashboardDetailJSONKeys, "response") // Confirm each widget's JSON keys match openapi.yaml's declared - // DashboardWidget schema exactly — catches an added/removed field - // that the struct decode above wouldn't (json.Unmarshal ignores - // unknown keys and zero-values missing ones). + // DashboardWidget schema exactly (allowing the omitempty + // groupBy/listLimit extras) — catches an added/removed field that + // the struct decode above wouldn't (json.Unmarshal ignores unknown + // keys and zero-values missing ones). var rawWidgets []map[string]json.RawMessage if err := json.Unmarshal(raw["widgets"], &rawWidgets); err != nil { t.Fatalf("decode widgets as raw keys: %v; raw: %s", err, raw["widgets"]) } for i, obj := range rawWidgets { - assertJSONKeys(t, obj, dashboardWidgetJSONKeys, fmt.Sprintf("widgets[%d]", i)) + assertJSONKeysSuperset(t, obj, dashboardWidgetJSONKeys, fmt.Sprintf("widgets[%d]", i)) } byID := make(map[string]int) for i, res := range result.Widgets { byID[res.WidgetID] = i - if res.DisplayType != dashboard.DisplayTypeSingleScore { - t.Errorf("widget %s displayType = %q, want %q", res.WidgetID, res.DisplayType, dashboard.DisplayTypeSingleScore) - } if res.DisplayName == "" { t.Errorf("widget %s has empty displayName", res.WidgetID) } } + wantResourceShape := map[string]struct { + resourceType dashboard.ResourceType + shape dashboard.Shape + gridWidth int + }{ + "my_patches": {dashboard.ResourceCase, dashboard.ShapeCount, 3}, + "my_reminders": {dashboard.ResourceCase, dashboard.ShapeCount, 3}, + "open_incident_team": {dashboard.ResourceCase, dashboard.ShapeCount, 3}, + "my_critical_open": {dashboard.ResourceCase, dashboard.ShapeList, 3}, + } + for id, want := range wantResourceShape { + idx, ok := byID[id] + if !ok { + t.Fatalf("missing widget %q in response", id) + } + got := result.Widgets[idx] + if got.ResourceType != want.resourceType { + t.Errorf("widget %s resourceType = %q, want %q", id, got.ResourceType, want.resourceType) + } + if got.Shape != want.shape { + t.Errorf("widget %s shape = %q, want %q", id, got.Shape, want.shape) + } + if got.GridWidth != want.gridWidth { + t.Errorf("widget %s gridWidth = %d, want %d", id, got.GridWidth, want.gridWidth) + } + } + + if idx := byID["my_critical_open"]; result.Widgets[idx].ListLimit != 5 { + t.Errorf("widget my_critical_open listLimit = %d, want 5", result.Widgets[idx].ListLimit) + } + for _, id := range []string{"my_patches", "my_reminders"} { idx, ok := byID[id] if !ok { t.Fatalf("missing widget %q in response", id) } filters := result.Widgets[idx].Filters - if len(filters.AssignedUserIDs) != 1 || filters.AssignedUserIDs[0] != testUser.UserID { - t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, filters.AssignedUserIDs, testUser.UserID) + assignedRaw, present := filters["assignedUserIds"] + if !present { + t.Fatalf("widget %s filters has no assignedUserIds key", id) + } + assigned, ok := assignedRaw.([]any) + if !ok { + t.Fatalf("widget %s assignedUserIds is %T, want []any", id, assignedRaw) } - for _, uid := range filters.AssignedUserIDs { + if len(assigned) != 1 || assigned[0] != testUser.UserID { + t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, assigned, testUser.UserID) + } + for _, uid := range assigned { if uid == "__current_user__" { t.Errorf("widget %s assignedUserIds leaked the unresolved placeholder", id) } } } - teamIdx, ok := byID["open_incident_team"] - if !ok { - t.Fatalf("missing widget %q in response", "open_incident_team") - } - if len(result.Widgets[teamIdx].Filters.AssignedUserIDs) != 0 { - t.Errorf("widget open_incident_team assignedUserIds = %v, want none", result.Widgets[teamIdx].Filters.AssignedUserIDs) + // Widgets with no assignedUserIds field in their template must not + // gain one during substitution: substituteCurrentUser only rewrites + // values already present, it never adds keys. + for _, id := range []string{"open_incident_team", "my_critical_open"} { + idx, ok := byID[id] + if !ok { + t.Fatalf("missing widget %q in response", id) + } + if id == "my_critical_open" { + // my_critical_open DOES carry assignedUserIds (the current + // user's critical/high cases) — verify it resolved cleanly + // instead of asserting absence. + filters := result.Widgets[idx].Filters + assignedRaw, present := filters["assignedUserIds"] + if !present { + t.Fatalf("widget %s filters has no assignedUserIds key", id) + } + assigned, ok := assignedRaw.([]any) + if !ok || len(assigned) != 1 || assigned[0] != testUser.UserID { + t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, assignedRaw, testUser.UserID) + } + continue + } + filters := result.Widgets[idx].Filters + if _, present := filters["assignedUserIds"]; present { + t.Errorf("widget %s filters unexpectedly has an assignedUserIds key: %v", id, filters["assignedUserIds"]) + } } }) - t.Run("mock dashboard with no widgets returns an empty widgets array, not null", func(t *testing.T) { + t.Run("operations dashboard has three resource-type-diverse widgets", func(t *testing.T) { h := NewDashboardHandler() r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/operations", nil), "operations")) w := httptest.NewRecorder() @@ -250,35 +349,110 @@ func TestGetDashboardDetail(t *testing.T) { body := w.Body.Bytes() - var raw map[string]json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - t.Fatalf("decode response body as raw keys: %v; raw: %s", err, body) + var result dashboardDetailView + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, body) + } + if result.ID != "operations" { + t.Errorf("ID = %q, want %q", result.ID, "operations") + } + if result.TargetTeam != "cs_operations" { + t.Errorf("TargetTeam = %q, want %q", result.TargetTeam, "cs_operations") + } + if len(result.Widgets) != 3 { + t.Fatalf("len(result.Widgets) = %d, want 3", len(result.Widgets)) + } + + byID := make(map[string]dashboardWidgetView) + for _, w := range result.Widgets { + byID[w.WidgetID] = w + } + + wantTypes := map[string]dashboard.ResourceType{ + "p0_p1_open": dashboard.ResourceCase, + "open_critical_incidents": dashboard.ResourceIncident, + "crs_awaiting_approval": dashboard.ResourceChangeRequest, + } + for id, wantType := range wantTypes { + got, ok := byID[id] + if !ok { + t.Fatalf("missing widget %q in response", id) + } + if got.ResourceType != wantType { + t.Errorf("widget %s resourceType = %q, want %q", id, got.ResourceType, wantType) + } } - assertJSONKeys(t, raw, dashboardDetailJSONKeys, "response") - widgetsRaw, present := raw["widgets"] + incident, ok := byID["open_critical_incidents"] + if !ok { + t.Fatalf("missing widget %q in response", "open_critical_incidents") + } + prioritiesRaw, present := incident.Filters["priorities"] if !present { - t.Fatalf("response has no \"widgets\" key at all: %s", body) + t.Fatalf("open_critical_incidents filters has no priorities key: %v", incident.Filters) } - if string(widgetsRaw) != "[]" { - t.Errorf("widgets raw JSON = %s, want literal \"[]\" (never null)", widgetsRaw) + priorities, ok := prioritiesRaw.([]any) + if !ok || len(priorities) != 2 || priorities[0] != "CRITICAL" || priorities[1] != "HIGH" { + t.Errorf("open_critical_incidents filters.priorities = %v, want [CRITICAL HIGH] unmodified", prioritiesRaw) } + }) + + t.Run("security dashboard's product_vulnerability widget has a scalar string filter", func(t *testing.T) { + h := NewDashboardHandler() + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/security", nil), "security")) + w := httptest.NewRecorder() + h.GetDashboardDetail(w, r) + + assertStatus(t, w, http.StatusOK) + assertContentType(t, w, "application/json") + + body := w.Body.Bytes() + t.Logf("GET /dashboards/security response: %s", body) var result dashboardDetailView if err := json.Unmarshal(body, &result); err != nil { t.Fatalf("decode response body: %v; raw: %s", err, body) } - if result.ID != "operations" { - t.Errorf("ID = %q, want %q", result.ID, "operations") + if len(result.Widgets) != 3 { + t.Fatalf("len(result.Widgets) = %d, want 3", len(result.Widgets)) } - if result.DisplayName != "Operations" { - t.Errorf("DisplayName = %q, want %q", result.DisplayName, "Operations") + + byID := make(map[string]dashboardWidgetView) + for _, w := range result.Widgets { + byID[w.WidgetID] = w + } + + critical, ok := byID["critical_vulns"] + if !ok { + t.Fatalf("missing widget %q in response", "critical_vulns") + } + if critical.ResourceType != dashboard.ResourceProductVulnerability { + t.Errorf("critical_vulns resourceType = %q, want %q", critical.ResourceType, dashboard.ResourceProductVulnerability) + } + priority, present := critical.Filters["priority"] + if !present { + t.Fatalf("critical_vulns filters has no priority key: %v", critical.Filters) } - if result.IsDefault { - t.Errorf("IsDefault = true, want false") + if s, ok := priority.(string); !ok || s != "critical" { + t.Errorf("critical_vulns filters.priority = %v (%T), want string %q", priority, priority, "critical") } - if len(result.Widgets) != 0 { - t.Errorf("len(result.Widgets) = %d, want 0", len(result.Widgets)) + }) + + t.Run("every dashboard in the registry now has at least one widget", func(t *testing.T) { + h := NewDashboardHandler() + for _, d := range dashboard.Dashboards { + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/"+d.ID, nil), d.ID)) + w := httptest.NewRecorder() + h.GetDashboardDetail(w, r) + assertStatus(t, w, http.StatusOK) + + var result dashboardDetailView + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("dashboard %s: decode response body: %v; raw: %s", d.ID, err, w.Body.Bytes()) + } + if len(result.Widgets) == 0 { + t.Errorf("dashboard %s has 0 widgets in the response, want > 0", d.ID) + } } }) } diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index ade3b62a8f..d477ba66db 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -5279,18 +5279,50 @@ components: description: Static id of the widget template within its dashboard displayName: type: string - displayType: + resourceType: type: string enum: - - single_score - description: How the widget's data should be rendered once resolved + - case + - incident + - change_request + - account + - project + - user + - time_card + - problem + - product_vulnerability + description: Which resource's /search endpoint this widget's filters target + shape: + type: string + enum: + - count + - list + - pie + - bar + description: > + How the widget's data should be rendered once resolved. pie/bar + are not resolvable by any /search endpoint today (no aggregate + endpoint exists anywhere in the stack); reserved for a future + dashboard. + gridWidth: + type: integer + minimum: 1 + maximum: 12 + description: CSS grid columns out of 12 this widget should occupy filters: - $ref: '#/components/schemas/CaseSearchFilters' + type: object + additionalProperties: true description: > Filter criteria for this widget, with any current-user placeholder already substituted. Pass this directly as the - filters of a POST /cases/search request to resolve the widget's - data. + filters of that resourceType's own POST /{resourceType}s/search + request to resolve the widget's data. + groupBy: + type: string + description: Only meaningful for shape pie/bar; the field to group counts by + listLimit: + type: integer + description: Only meaningful for shape list; how many records to show DashboardListItem: type: object @@ -5315,6 +5347,11 @@ components: isDefault: type: boolean description: Whether this is the default dashboard to show first + targetTeam: + type: string + description: > + Descriptive metadata for which team this dashboard targets; not + enforced — every dashboard is returned to every caller. widgets: type: array items: From e3bccb4e4b33e40e0af50c0923b0df472a297209 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 20:42:12 +0530 Subject: [PATCH 11/16] feat(csm-dashboard): generic resource-driven widget rendering, click-through navigation, remove mock dashboards --- .../webapp/src/api/backend/types.ts | 51 +++- .../webapp/src/constants/apiConstants.ts | 2 +- .../api/useWidgetCaseCount.test.tsx | 77 ----- .../csm-dashboard/api/useWidgetCaseCount.ts | 52 ---- .../csm-dashboard/api/useWidgetData.ts | 79 +++++ .../AgentsLandingPagePilot.test.tsx | 17 +- .../components/AgentsLandingPagePilot.tsx | 31 +- .../components/DashboardWidgetTile.test.tsx | 85 +++++- .../components/DashboardWidgetTile.tsx | 121 +++++++- .../config/widgetResourceConfig.ts | 281 ++++++++++++++++++ .../pages/CsmDashboardPage.test.tsx | 59 +--- .../csm-dashboard/pages/CsmDashboardPage.tsx | 177 +---------- .../csm-dashboard/types/abtDashboard.ts | 35 --- 13 files changed, 651 insertions(+), 416 deletions(-) delete mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx delete mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index dd78ebf851..9927963110 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2592,18 +2592,53 @@ export interface BeUserSearchByEmailResponse { // Dashboards // --------------------------------------------------------------------------- +/** + * Which resource a widget's filters search against — the widget resolves its + * own data by issuing a `POST /{resourceType}s/search`-shaped request (see + * `widgetResourceConfig.ts` for the real endpoint per type) with `filters` + * forwarded verbatim. + */ +export type BeWidgetResourceType = + | "case" + | "incident" + | "change_request" + | "account" + | "project" + | "user" + | "time_card" + | "problem" + | "product_vulnerability"; + +/** + * How a widget's resolved data should be rendered. `pie`/`bar` are not + * resolvable by any `/search` endpoint today (no aggregate endpoint exists + * anywhere in the stack) — reserved for a future dashboard. + */ +export type BeWidgetShape = "count" | "list" | "pie" | "bar"; + /** * A single widget template, embedded in {@link BeDashboard}: display metadata * plus its already-resolved filter criteria. The caller resolves the - * widget's own data by issuing its own `POST /cases/search` with `filters` - * and reading `total` off the response. + * widget's own data by issuing its own `POST /{resourceType}s/search` with + * `filters` and reading `total` (or the item list) off the response. */ export interface BeDashboardWidget { widgetId: string; displayName: string; - /** Only "single_score" exists today. */ - displayType: "single_score"; - filters: BeCaseSearchFilters; + resourceType: BeWidgetResourceType; + shape: BeWidgetShape; + /** CSS grid columns out of 12 this widget should occupy. */ + gridWidth: number; + /** + * Opaque filter criteria, with any current-user placeholder already + * substituted. Pass this directly as the `filters` of that + * `resourceType`'s own `POST /{resourceType}s/search` request. + */ + filters: Record; + /** Only meaningful for shape pie/bar; the field to group counts by. */ + groupBy?: string; + /** Only meaningful for shape list; how many records to show. */ + listLimit?: number; } /** @@ -2621,12 +2656,14 @@ export interface BeDashboardListItem { /** * Response of `GET /dashboards/{dashboardId}`: a dashboard's display * metadata plus every widget template registered for it. `widgets` is - * always an array, `[]` when the dashboard has none (mock placeholder - * dashboards today). + * always an array; every dashboard in the registry has at least one. */ export interface BeDashboard { id: string; displayName: string; isDefault: boolean; + /** Descriptive metadata for which team this dashboard targets; not + * enforced — every dashboard is returned to every caller. */ + targetTeam?: string; widgets: BeDashboardWidget[]; } diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index 9a5d92a08c..45eb0ca35c 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -107,7 +107,7 @@ export const ApiQueryKeys = { CSM_CASES: "csm-cases", CSM_ANNOUNCEMENTS: "csm-announcements", CSM_CASE_COUNTS: "csm-case-counts", - CSM_DASHBOARD_WIDGETS: "csm-dashboard-widgets", + CSM_DASHBOARD_WIDGET_DATA: "csm-dashboard-widget-data", CSM_DASHBOARD_LIST: "csm-dashboard-list", CSM_DASHBOARD_DETAIL: "csm-dashboard-detail", CSM_CASE_DETAIL: "csm-case-detail", diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx deleted file mode 100644 index 7215c36ee6..0000000000 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { renderHook, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi, beforeEach } from "vitest"; -import type { ReactNode } from "react"; - -const postMock = vi.fn(); - -vi.mock("@api/backend/client", () => ({ - useBackendApi: () => ({ post: postMock }), -})); - -import { useWidgetCaseCount } from "@features/csm-dashboard/api/useWidgetCaseCount"; - -function wrapper({ children }: { children: ReactNode }) { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - return ( - {children} - ); -} - -describe("useWidgetCaseCount", () => { - beforeEach(() => { - postMock.mockReset(); - }); - - it("resolves the widget's count from its own /cases/search call", async () => { - postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); - - const { result } = renderHook( - () => - useWidgetCaseCount("my_patches", { - assignedUserIds: ["user-1"], - tags: ["patch"], - }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.isSuccess).toBe(true)); - - expect(postMock).toHaveBeenCalledTimes(1); - expect(postMock).toHaveBeenCalledWith("/cases/search", { - filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, - pagination: { offset: 0, limit: 1 }, - }); - expect(result.current.data).toBe(3); - }); - - it("surfaces a query error when the call fails", async () => { - postMock.mockRejectedValue(new Error("boom")); - - const { result } = renderHook( - () => useWidgetCaseCount("my_reminders", { states: ["awaiting_info"] }), - { wrapper }, - ); - - await waitFor(() => expect(result.current.isError).toBe(true)); - expect(result.current.error?.message).toBe("boom"); - }); -}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts deleted file mode 100644 index c014d7f6c9..0000000000 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetCaseCount.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). -// -// WSO2 LLC. licenses this file to you under the Apache License, -// Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -import { useQuery, type UseQueryResult } from "@tanstack/react-query"; -import { ApiQueryKeys } from "@constants/apiConstants"; -import { useBackendApi } from "@api/backend/client"; -import type { - BeCaseSearchFilters, - BeCaseSearchPayload, - BeCaseSearchResponse, -} from "@api/backend/types"; - -/** - * One dashboard widget's resolved count, fetched independently of any other - * widget on the same dashboard: a single `POST /cases/search` with the - * widget's own filters, `limit: 1`, reading `total` off the response (same - * count-only pattern as `useCaseCountsMatrix`). - */ -export function useWidgetCaseCount( - widgetId: string, - filters: BeCaseSearchFilters, -): UseQueryResult { - const api = useBackendApi(); - - return useQuery({ - queryKey: [ApiQueryKeys.CSM_DASHBOARD_WIDGETS, widgetId, filters], - queryFn: async (): Promise => { - const res = await api.post( - "/cases/search", - { - filters, - pagination: { offset: 0, limit: 1 }, - }, - ); - return res.total ?? 0; - }, - staleTime: 60_000, - }); -} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts new file mode 100644 index 0000000000..4c4ff0a70c --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { BeWidgetResourceType, BeWidgetShape } from "@api/backend/types"; +import { WIDGET_RESOURCE_CONFIG } from "@features/csm-dashboard/config/widgetResourceConfig"; + +/** Default number of rows fetched for a `shape: "list"` widget when the + * template doesn't set its own `listLimit`. */ +const DEFAULT_LIST_LIMIT = 5; + +export interface WidgetData { + /** Total matching records — what a `shape: "count"` tile renders. */ + total: number; + /** The resolved page of records — what a `shape: "list"` tile renders. */ + items: Record[]; +} + +/** + * Resolves one dashboard widget's own data, independently of any sibling + * widget on the same dashboard: a single `POST` to that `resourceType`'s own + * search endpoint (see `WIDGET_RESOURCE_CONFIG`) with the widget's own + * filters. Always reads both `total` and the item page off the response — + * a `count`-shape widget only needs `total`, a `list`-shape widget only + * needs `items`, but fetching both from the one call keeps this a single + * code path instead of two near-identical ones. + */ +export function useWidgetData( + widgetId: string, + resourceType: BeWidgetResourceType, + filters: Record, + shape: BeWidgetShape, + listLimit?: number, +): UseQueryResult { + const api = useBackendApi(); + const config = WIDGET_RESOURCE_CONFIG[resourceType]; + const limit = shape === "list" ? (listLimit ?? DEFAULT_LIST_LIMIT) : 1; + + return useQuery({ + queryKey: [ + ApiQueryKeys.CSM_DASHBOARD_WIDGET_DATA, + widgetId, + resourceType, + filters, + limit, + ], + queryFn: async (): Promise => { + const res = await api.post< + { filters: Record; pagination: { offset: number; limit: number } }, + Record + >(config.searchEndpoint, { + filters, + pagination: { offset: 0, limit }, + }); + const total = typeof res.total === "number" ? res.total : 0; + const rawItems = res[config.itemsKey]; + const items = Array.isArray(rawItems) + ? (rawItems as Record[]) + : []; + return { total, items }; + }, + staleTime: 60_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx index 82fe77cf6f..c52f81927a 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.test.tsx @@ -19,6 +19,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom/vitest"; import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; const getMock = vi.fn(); const postMock = vi.fn(); @@ -34,7 +35,9 @@ function renderWithClient(ui: ReactNode) { defaultOptions: { queries: { retry: false } }, }); return render( - {ui}, + + {ui} + , ); } @@ -46,19 +49,25 @@ const DASHBOARD_DETAIL = { { widgetId: "my_patches", displayName: "My Patches", - displayType: "single_score", + resourceType: "case", + shape: "count", + gridWidth: 3, filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, }, { widgetId: "my_reminders", displayName: "My Reminders", - displayType: "single_score", + resourceType: "case", + shape: "count", + gridWidth: 3, filters: { assignedUserIds: ["user-1"], states: ["awaiting_info"] }, }, { widgetId: "open_incident_team", displayName: "Open Incidents (Team)", - displayType: "single_score", + resourceType: "case", + shape: "count", + gridWidth: 3, filters: { tags: ["s_dip"] }, }, ], diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx index 72486ef22f..84dc413dd0 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AgentsLandingPagePilot.tsx @@ -66,25 +66,40 @@ export default function AgentsLandingPagePilot({ sx={{ display: "grid", gap: 1.5, + // 12-column grid, matching each widget's own `gridWidth`; on very + // small screens there's only room for 4 columns, so a wide widget + // there wraps to (at most) one extra row rather than overflowing. gridTemplateColumns: { - xs: "repeat(1, minmax(0, 1fr))", - sm: "repeat(3, minmax(0, 1fr))", + xs: "repeat(4, minmax(0, 1fr))", + sm: "repeat(12, minmax(0, 1fr))", }, }} > {isLoading ? Array.from({ length: PILOT_TILE_COUNT }, (_, i) => ( - + )) : (data?.widgets ?? []).map((widget) => ( - + sx={{ + gridColumn: { + xs: `span ${Math.min(widget.gridWidth, 4)}`, + sm: `span ${widget.gridWidth}`, + }, + }} + > + + ))} )} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx index 75926974ea..c88319bbcf 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx @@ -19,6 +19,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom/vitest"; import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router"; const postMock = vi.fn(); @@ -33,7 +34,9 @@ function renderWithClient(ui: ReactNode) { defaultOptions: { queries: { retry: false } }, }); return render( - {ui}, + + {ui} + , ); } @@ -45,7 +48,13 @@ describe("DashboardWidgetTile", () => { it("renders a skeleton while its own count is in flight", () => { postMock.mockReturnValue(new Promise(() => {})); const { container } = renderWithClient( - , + , ); expect(container.querySelectorAll(".MuiSkeleton-root").length).toBe(1); }); @@ -54,7 +63,13 @@ describe("DashboardWidgetTile", () => { postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); renderWithClient( - , + , ); await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); @@ -69,11 +84,73 @@ describe("DashboardWidgetTile", () => { postMock.mockRejectedValue(new Error("boom")); renderWithClient( - , + , ); await waitFor(() => expect(screen.getByText("Could not load this widget.")).toBeInTheDocument(), ); }); + + it("renders a compact row list for shape: list, capped at listLimit", async () => { + postMock.mockResolvedValue({ + total: 2, + cases: [ + { number: "CS-1", subject: "Disk full", state: "open" }, + { number: "CS-2", subject: "Auth failing", state: "work_in_progress" }, + ], + limit: 5, + offset: 0, + hasMore: false, + }); + + renderWithClient( + , + ); + + await waitFor(() => + expect(screen.getByText("CS-1 — Disk full")).toBeInTheDocument(), + ); + expect(screen.getByText("CS-2 — Auth failing")).toBeInTheDocument(); + expect(postMock).toHaveBeenCalledWith("/cases/search", { + filters: {}, + pagination: { offset: 0, limit: 5 }, + }); + }); + + it("navigates to /cases with translated filters when a case-resource tile is clicked", async () => { + postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); + + renderWithClient( + , + ); + + await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument()); + + const link = screen.getByRole("link"); + const href = link.getAttribute("href") ?? ""; + expect(href.startsWith("/cases?")).toBe(true); + const params = new URLSearchParams(href.split("?")[1]); + expect(params.get("severities")).toBe("S1"); + expect(params.get("states")).toBe("open"); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index cac2c89d7b..9eb9f52cd3 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -14,35 +14,66 @@ // specific language governing permissions and limitations // under the License. -import { Card, Skeleton, Typography } from "@wso2/oxygen-ui"; +import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; import type { JSX } from "react"; -import type { BeCaseSearchFilters } from "@api/backend/types"; -import { useWidgetCaseCount } from "@features/csm-dashboard/api/useWidgetCaseCount"; +import { Link as RouterLink } from "react-router"; +import type { BeWidgetResourceType, BeWidgetShape } from "@api/backend/types"; +import { useWidgetData } from "@features/csm-dashboard/api/useWidgetData"; +import { WIDGET_RESOURCE_CONFIG } from "@features/csm-dashboard/config/widgetResourceConfig"; interface DashboardWidgetTileProps { widgetId: string; displayName: string; - filters: BeCaseSearchFilters; + resourceType: BeWidgetResourceType; + shape: BeWidgetShape; + filters: Record; + /** Only meaningful for shape "list"; how many rows to render. */ + listLimit?: number; } /** - * Single "single_score" dashboard widget tile: fetches and renders its own - * count independently of any sibling tile, so one widget's loading/error - * state never affects another's. + * Single dashboard widget tile: fetches and renders its own data + * independently of any sibling tile, so one widget's loading/error state + * never affects another's. Renders a big number for `shape: "count"`, a + * compact row list for `shape: "list"`, and is clickable through to the + * resource's own list page with the widget's filters translated into that + * page's own URL filter scheme (see `widgetResourceConfig.ts`). */ export default function DashboardWidgetTile({ widgetId, displayName, + resourceType, + shape, filters, + listLimit, }: DashboardWidgetTileProps): JSX.Element { - const { - data: count, - isLoading, - isError, - } = useWidgetCaseCount(widgetId, filters); + const { data, isLoading, isError } = useWidgetData( + widgetId, + resourceType, + filters, + shape, + listLimit, + ); + const config = WIDGET_RESOURCE_CONFIG[resourceType]; + const href = config.buildHref(filters); return ( - + {isLoading ? ( ) : isError ? ( @@ -54,11 +85,69 @@ export default function DashboardWidgetTile({ {displayName} - - {count} - + {shape === "list" ? ( + + ) : shape === "count" ? ( + + {data?.total ?? 0} + + ) : ( + // pie/bar: no aggregate endpoint exists anywhere in the stack + // today, so there is nothing to resolve or render yet — see + // `BeWidgetShape`. + + Not yet supported. + + )} )} ); } + +interface WidgetListBodyProps { + items: Record[]; + limit: number; + resourceType: BeWidgetResourceType; +} + +function WidgetListBody({ items, limit, resourceType }: WidgetListBodyProps): JSX.Element { + const config = WIDGET_RESOURCE_CONFIG[resourceType]; + const rows = items.slice(0, limit); + + if (rows.length === 0) { + return ( + + No records. + + ); + } + + return ( + + {rows.map((item, i) => { + const secondary = config.secondaryLabel?.(item); + // Rows have no stable id in this loosely-typed shape; the primary + // label (usually a record number) is unique enough in practice for a + // short, non-reorderable list, with the index as a tiebreaker. + const key = `${config.primaryLabel(item)}-${i}`; + return ( + + + {config.primaryLabel(item)} + + {secondary && ( + + {secondary} + + )} + + ); + })} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts new file mode 100644 index 0000000000..e47d5de65c --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import type { BeWidgetResourceType } from "@api/backend/types"; +import { humanizeState } from "@features/csm-dashboard/utils/abtDashboard"; +import { casesHref } from "@features/csm-cases/utils/casesFiltersUrl"; +import type { CasesFilters } from "@features/csm-cases/components/CasesFilterBar"; +import type { Severity } from "@features/csm-dashboard/types/abtDashboard"; +import { + DEFAULT_INCIDENT_FILTERS, + type IncidentFilters, +} from "@features/csm-operations/utils/incidents"; +import { writeIncidentFiltersToUrl } from "@features/csm-operations/utils/incidentsFiltersUrl"; +import { + DEFAULT_CR_FILTERS, + type ChangeRequestFilters, +} from "@features/csm-operations/utils/changeRequests"; +import { writeChangeRequestFiltersToUrl } from "@features/csm-operations/utils/changeRequestsFiltersUrl"; + +/** A resolved search-result row, typed loosely since its real shape depends + * on `resourceType` — the label extractors below narrow what they read. */ +type WidgetItem = Record; + +/** + * Per-resource-type wiring for a dashboard widget: where to fetch its data, + * how to read a list-shape row for display, and where a click on the tile + * navigates. + */ +export interface WidgetResourceConfig { + /** `POST` endpoint this resource's own search lives at. */ + searchEndpoint: string; + /** Key the response's item array is nested under. */ + itemsKey: string; + /** Primary (bold) line for one list-shape row. */ + primaryLabel: (item: WidgetItem) => string; + /** Optional secondary (muted) line for one list-shape row. */ + secondaryLabel?: (item: WidgetItem) => string | undefined; + /** Where a click on this widget's tile navigates, given its (opaque, + * already current-user-resolved) filters. */ + buildHref: (filters: Record) => string; +} + +function asString(v: unknown): string | undefined { + return typeof v === "string" && v.length > 0 ? v : undefined; +} + +function asStringArray(v: unknown): string[] | undefined { + return Array.isArray(v) && v.every((x) => typeof x === "string") + ? (v as string[]) + : undefined; +} + +// --------------------------------------------------------------------------- +// case — /cases, translating the opaque dashboard filters into CasesFilters. +// --------------------------------------------------------------------------- + +/** + * The dashboard/entity-service case severity values are the lowercase + * `catastrophic|critical|high|medium|low` enum; the cases list's own filter + * bar (and its URL encoding) uses the `S0`..`S4` codes instead. No existing + * mapping between the two lives anywhere else in the app (the app-wide + * `SEVERITY_LABEL` maps `S0` -> "Catastrophic", a display label, not this + * enum) — scoped here to dashboard click-through only. + */ +const DASHBOARD_SEVERITY_TO_S_CODE: Record = { + catastrophic: "S0", + critical: "S1", + high: "S2", + medium: "S3", + low: "S4", +}; + +/** + * Translate a dashboard widget's opaque case filters into the cases list's + * own `CasesFilters` shape. `tags` has no equivalent in `CasesFilters` today + * (the case-list tag filter was pulled out of the filter bar/URL — see the + * note on `CasesFilters.tags` in `casesFiltersUrl.ts`) and is dropped rather + * than invented. `assignedUserIds` carries the current user's own UUID + * (every widget that sets it does so via the current-user placeholder), and + * `CasesFilters.assignees` is email/`@me`-based with no UUID lookup + * available here — since these widgets only ever filter "assigned to me", + * any non-empty `assignedUserIds` maps to the `@me` sentinel rather than an + * (unresolvable) literal UUID. + */ +function translateCaseDashboardFilters( + filters: Record, +): Partial { + const out: Partial = {}; + const states = asStringArray(filters.states); + if (states) out.states = states as CasesFilters["states"]; + const severities = asStringArray(filters.severities); + if (severities) { + out.severities = severities + .map((s) => DASHBOARD_SEVERITY_TO_S_CODE[s]) + .filter((s): s is Severity => Boolean(s)); + } + const types = asStringArray(filters.types); + if (types) out.caseTypes = types as CasesFilters["caseTypes"]; + const productNames = asStringArray(filters.productNames); + if (productNames) out.productNames = productNames; + const assignedUserIds = asStringArray(filters.assignedUserIds); + if (assignedUserIds && assignedUserIds.length > 0) out.assignees = ["@me"]; + return out; +} + +// --------------------------------------------------------------------------- +// incident / change_request / problem — all live under /operations, switched +// by `?tab=`. +// --------------------------------------------------------------------------- + +function operationsHref(tab: string, params?: URLSearchParams): string { + const out = new URLSearchParams(); + out.set("tab", tab); + params?.forEach((value, key) => out.set(key, value)); + return `/operations?${out.toString()}`; +} + +/** Dashboard incident filters already use the real `BeIncidentPriority` + * wire values (`CRITICAL`/`HIGH`/...), same as `IncidentFilters.priorities` — + * no translation table needed, only a type narrowing. */ +function translateIncidentDashboardFilters( + filters: Record, +): Partial { + const out: Partial = {}; + const priorities = asStringArray(filters.priorities); + if (priorities) out.priorities = priorities as IncidentFilters["priorities"]; + return out; +} + +/** Dashboard CR filters already use the real `BeChangeRequestState`/`Impact` + * wire values, same as `ChangeRequestFilters` — no translation needed. */ +function translateChangeRequestDashboardFilters( + filters: Record, +): Partial { + const out: Partial = {}; + const states = asStringArray(filters.states); + if (states) out.states = states as ChangeRequestFilters["states"]; + const impacts = asStringArray(filters.impacts); + if (impacts) out.impacts = impacts as ChangeRequestFilters["impacts"]; + return out; +} + +export const WIDGET_RESOURCE_CONFIG: Record< + BeWidgetResourceType, + WidgetResourceConfig +> = { + case: { + searchEndpoint: "/cases/search", + itemsKey: "cases", + primaryLabel: (item) => + [asString(item.number), asString(item.subject)] + .filter(Boolean) + .join(" — ") || "—", + secondaryLabel: (item) => { + const state = asString(item.state); + return state ? humanizeState(state) : undefined; + }, + buildHref: (filters) => casesHref(translateCaseDashboardFilters(filters)), + }, + incident: { + searchEndpoint: "/incidents/search", + itemsKey: "incidents", + primaryLabel: (item) => + [asString(item.number), asString(item.subject)] + .filter(Boolean) + .join(" — ") || "—", + secondaryLabel: (item) => asString(item.priority), + buildHref: (filters) => + operationsHref( + "incidents", + writeIncidentFiltersToUrl({ + ...DEFAULT_INCIDENT_FILTERS, + ...translateIncidentDashboardFilters(filters), + }), + ), + }, + change_request: { + searchEndpoint: "/change-requests/search", + itemsKey: "changeRequests", + primaryLabel: (item) => + [asString(item.number), asString(item.subject)] + .filter(Boolean) + .join(" — ") || "—", + secondaryLabel: (item) => { + const state = asString(item.state); + return state ? humanizeState(state) : undefined; + }, + buildHref: (filters) => + operationsHref( + "change_requests", + writeChangeRequestFiltersToUrl({ + ...DEFAULT_CR_FILTERS, + ...translateChangeRequestDashboardFilters(filters), + }), + ), + }, + problem: { + searchEndpoint: "/problems/search", + itemsKey: "problems", + primaryLabel: (item) => + [asString(item.number), asString(item.subject)] + .filter(Boolean) + .join(" — ") || "—", + secondaryLabel: (item) => { + const state = asString(item.state); + return state ? humanizeState(state) : undefined; + }, + // No dashboard widget filters problems today; the tab has no URL filter + // scheme of its own yet either, so this is unfiltered. + buildHref: () => operationsHref("problems"), + }, + account: { + searchEndpoint: "/accounts/search", + itemsKey: "accounts", + primaryLabel: (item) => asString(item.name) ?? "—", + secondaryLabel: (item) => asString(item.tier), + buildHref: () => "/customers/accounts", + }, + project: { + searchEndpoint: "/projects/search", + itemsKey: "projects", + primaryLabel: (item) => asString(item.name) ?? asString(item.projectKey) ?? "—", + secondaryLabel: (item) => asString(item.subscriptionType), + buildHref: () => "/customers/projects", + }, + user: { + searchEndpoint: "/users/search", + itemsKey: "users", + primaryLabel: (item) => { + const first = asString(item.firstName); + const last = asString(item.lastName); + const full = [first, last].filter(Boolean).join(" "); + return full || asString(item.userName) || asString(item.email) || "—"; + }, + secondaryLabel: (item) => asString(item.email), + buildHref: () => "/admin/users", + }, + time_card: { + searchEndpoint: "/time-cards/search", + itemsKey: "timeCards", + primaryLabel: (item) => { + const caseNumber = nestedNumber(item.case); + const workDate = asString(item.workDate); + return [caseNumber, workDate].filter(Boolean).join(" — ") || "—"; + }, + secondaryLabel: (item) => { + const state = asString(item.state); + return state ? humanizeState(state) : undefined; + }, + buildHref: () => "/time-cards", + }, + product_vulnerability: { + searchEndpoint: "/products/vulnerabilities/search", + itemsKey: "productVulnerabilities", + primaryLabel: (item) => + asString(item.cveId) ?? asString(item.vulnerabilityId) ?? "—", + secondaryLabel: (item) => + asString(item.priority) ?? asString(item.productName), + buildHref: () => "/security-center", + }, +}; + +function nestedNumber(v: unknown): string | undefined { + if (v && typeof v === "object" && "number" in v) { + return asString((v as { number?: unknown }).number); + } + return undefined; +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx index fa043d7ac2..b1dce870c7 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx @@ -19,18 +19,13 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom/vitest"; import CsmDashboardPage from "@features/csm-dashboard/pages/CsmDashboardPage"; import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; -import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; vi.mock("@features/csm-dashboard/api/useDashboardList", () => ({ useDashboardList: vi.fn(), })); -vi.mock("@features/csm-dashboard/api/useDashboard", () => ({ - useDashboard: vi.fn(), -})); - -// Keeps this test focused on dashboard selection + the header; the pilot -// widget grid has its own tests (AgentsLandingPagePilot.test.tsx). +// Keeps this test focused on dashboard selection + the header; the widget +// grid itself has its own tests (AgentsLandingPagePilot.test.tsx). vi.mock("@features/csm-dashboard/components/AgentsLandingPagePilot", () => ({ default: ({ dashboardId }: { dashboardId: string }) => (
{dashboardId}
@@ -38,7 +33,6 @@ vi.mock("@features/csm-dashboard/components/AgentsLandingPagePilot", () => ({ })); const mockedUseDashboardList = vi.mocked(useDashboardList); -const mockedUseDashboard = vi.mocked(useDashboard); const DASHBOARD_LIST = [ { id: "operations", displayName: "Operations", isDefault: false }, @@ -57,26 +51,13 @@ function mockListResult( } as unknown as ReturnType); } -function mockDashboardResult( - overrides: Partial>, -): void { - mockedUseDashboard.mockReturnValue({ - data: undefined, - isLoading: true, - isError: false, - ...overrides, - } as unknown as ReturnType); -} - beforeEach(() => { mockedUseDashboardList.mockReset(); - mockedUseDashboard.mockReset(); }); describe("CsmDashboardPage", () => { it("shows a loading skeleton before the dashboard list resolves", () => { mockListResult({ data: undefined, isLoading: true }); - mockDashboardResult({ data: undefined, isLoading: true }); const { container } = render(); @@ -86,22 +67,6 @@ describe("CsmDashboardPage", () => { it("selects the isDefault dashboard once the list loads and renders the enabled, populated switcher", () => { mockListResult({ data: DASHBOARD_LIST, isLoading: false }); - mockDashboardResult({ - data: { - id: "agents_pilot", - displayName: "Engineer overview", - isDefault: true, - widgets: [ - { - widgetId: "my_patches", - displayName: "My Patches", - displayType: "single_score", - filters: {}, - }, - ], - }, - isLoading: false, - }); render(); @@ -123,9 +88,9 @@ describe("CsmDashboardPage", () => { expect(within(listbox).getByText("Engineer overview")).toBeInTheDocument(); }); - it("renders the mock placeholder for a dashboard with no real widgets", () => { - // "operations" is the default entry here (a mock placeholder dashboard, - // unlike "agents_pilot" which always has real widgets). + it("renders the real widget grid for every dashboard, not only agents_pilot", () => { + // "operations" is the default entry here — every dashboard now has real + // widgets, so the grid renders regardless of which one is selected. mockListResult({ data: [ { id: "agents_pilot", displayName: "Engineer overview", isDefault: false }, @@ -133,19 +98,11 @@ describe("CsmDashboardPage", () => { ], isLoading: false, }); - mockDashboardResult({ - data: { - id: "operations", - displayName: "Operations", - isDefault: true, - widgets: [], - }, - isLoading: false, - }); render(); - expect(screen.queryByTestId("agents-landing-pilot")).not.toBeInTheDocument(); - expect(screen.getByText("Mock")).toBeInTheDocument(); + expect(screen.getByTestId("agents-landing-pilot")).toHaveTextContent( + "operations", + ); }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index fdc0c2d0a3..91a85d0245 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -14,27 +14,24 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Chip, Skeleton, Typography } from "@wso2/oxygen-ui"; +import { Box, Skeleton } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; -import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; -import { - MOCK_DASHBOARD_META, - type DashboardKey, - type DashboardScope, +import type { + DashboardKey, + DashboardScope, } from "@features/csm-dashboard/types/abtDashboard"; /** * Top-level CSM dashboard. The dashboard list and the default selection are - * BE-driven: `GET /dashboards` populates the switcher in the header (now - * always enabled, see AbtDashboardHeader), and the `isDefault` entry is - * selected on load. Only the "agents_pilot" dashboard has real - * (config-driven) widgets today; every other dashboard in the registry - * (Operations, IAM CS, Security, Team performance) renders the mock - * `DashboardPlaceholder` below until the real tab+widget model - * (DashboardsAndReportsProposal.md, entity-service reports DSL) lands. + * BE-driven: `GET /dashboards` populates the switcher in the header (always + * enabled, see AbtDashboardHeader), and the `isDefault` entry is selected on + * load. Every dashboard in the registry now has at least one real + * (config-driven) widget, so this always renders the real widget grid — the + * earlier mock `DashboardPlaceholder` (pinned KPI numbers per dashboard) is + * gone. */ export default function CsmDashboardPage(): JSX.Element { // ABT scoping is not implemented yet, so default to (and stay on) @@ -53,13 +50,11 @@ export default function CsmDashboardPage(): JSX.Element { list && list.length > 0 ? (list.find((d) => d.isDefault) ?? list[0]) : undefined; const dashboardKey = manualDashboardKey ?? defaultEntry?.id; - const dashboard = useDashboard(dashboardKey); - const hasRealWidgets = (dashboard.data?.widgets.length ?? 0) > 0; - const mockMeta = dashboardKey ? MOCK_DASHBOARD_META[dashboardKey] : undefined; - // Real (widget-bearing) dashboards are personal-queue-shaped and always - // scope-relevant; mock placeholders use their own FE-local metadata. - const scopeBased = hasRealWidgets ? true : (mockMeta?.scopeBased ?? false); - const currentEntry = dashboardList.data?.find((d) => d.id === dashboardKey); + // Only the engineer-overview dashboard is a personal queue (my patches, my + // reminders, ...); every other dashboard is team/org-wide and has no + // scope-relevant My ABT / All customers toggle. Not worth a BE field for + // this single-dashboard UI nuance. + const scopeBased = dashboardKey === "agents_pilot"; if (dashboardKey === undefined) { return ( @@ -80,147 +75,7 @@ export default function CsmDashboardPage(): JSX.Element { dashboardList={dashboardList.data ?? []} scopeBased={scopeBased} /> - {hasRealWidgets ? ( - - ) : ( - - )} + ); } - -interface DashboardPlaceholderProps { - dashboardKey: DashboardKey; - displayName: string; -} - -function DashboardPlaceholder({ - dashboardKey, - displayName, -}: DashboardPlaceholderProps): JSX.Element { - const meta = MOCK_DASHBOARD_META[dashboardKey]; - if (!meta) return <>; - - // Mock KPI tiles per dashboard. Numbers are pinned (no real query); the - // shape matches the v1 widget set in DashboardsAndReportsProposal.md. - const tiles = TILE_SETS[dashboardKey] ?? []; - - return ( - - - {displayName} - - {meta.description} - - - - - - - - {tiles.map((t) => ( - - - {t.label} - - - {t.value} - - {t.sub && ( - - {t.sub} - - )} - - ))} - - - ); -} - -type TileColor = "neutral" | "info" | "success" | "warning" | "danger"; -interface Tile { - label: string; - value: string; - sub?: string; - color: TileColor; -} - -const TILE_SETS: Record = { - agents_pilot: [], - operations: [ - { label: "Open cases", value: "287", color: "neutral" }, - { label: "Created today", value: "34", sub: "+12% vs 7d avg", color: "info" }, - { label: "Resolved today", value: "29", sub: "+3% vs 7d avg", color: "success" }, - { label: "Solution proposed", value: "41", color: "neutral" }, - { label: "Awaiting info", value: "62", color: "neutral" }, - { label: "P0/P1 open", value: "9", color: "danger" }, - { label: "P0/P1 breached", value: "2", color: "danger" }, - { label: "Escalations open", value: "11", color: "warning" }, - { label: "SLA breach 24h", value: "4", color: "warning" }, - { label: "Time-card pending approval", value: "18", color: "neutral" }, - ], - iam: [ - { label: "IS cases open", value: "53", color: "neutral" }, - { label: "Asgardeo cases open", value: "41", color: "neutral" }, - { label: "IS P0/P1 open", value: "3", color: "danger" }, - { label: "Top product: IS 7.1.0", value: "22", sub: "Open cases", color: "info" }, - { label: "Auth-failure clusters", value: "5", color: "warning" }, - { label: "Top account: Bank of Georgia", value: "9", sub: "Open cases", color: "info" }, - { label: "Avg ack time", value: "22 m", sub: "Target 30 m", color: "success" }, - { label: "Avg resolution (P2)", value: "8.4 h", sub: "Target 24 h", color: "success" }, - { label: "Customer satisfaction", value: "4.4 / 5", sub: "Last 30d", color: "success" }, - { label: "Vuln links to active cases", value: "7", color: "warning" }, - ], - security: [ - { label: "Critical vulns", value: "4", color: "danger" }, - { label: "High vulns", value: "18", color: "warning" }, - { label: "Medium vulns", value: "62", color: "neutral" }, - { label: "Patches released 30d", value: "11", color: "success" }, - { label: "SRA cases open", value: "6", color: "warning" }, - { label: "Avg disclosure SLA", value: "12 d", sub: "Target 14 d", color: "success" }, - { label: "Customers with critical exposure", value: "9", color: "danger" }, - { label: "Affected products", value: "5", color: "neutral" }, - { label: "Pending CVE assignments", value: "3", color: "warning" }, - { label: "Open advisories", value: "27", color: "neutral" }, - ], - team_performance: [ - { label: "Cases per engineer (7d avg)", value: "5.2", color: "neutral" }, - { label: "First-response within SLA", value: "94%", sub: "Last 30d", color: "success" }, - { label: "Resolution within SLA", value: "89%", sub: "Last 30d", color: "success" }, - { label: "On-call coverage gaps", value: "1", sub: "Bijira SRE — Sun 03:00", color: "warning" }, - { label: "Top performer (cases closed)", value: "Priya N.", sub: "42 last 30d", color: "info" }, - { label: "Most reassigned engineer", value: "Asanka R.", sub: "8 outbound", color: "warning" }, - { label: "Time-card submission rate", value: "97%", color: "success" }, - { label: "Time-card approval lag", value: "1.3 d", color: "neutral" }, - { label: "Median ack time", value: "18 m", sub: "Across all P0–P3", color: "success" }, - { label: "Median resolution (P2)", value: "9.6 h", color: "success" }, - ], -}; diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index 99c0275132..e2320ea8e2 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -109,38 +109,3 @@ export interface CsmRecentActivity { // Dashboard ids now come from the backend registry (GET /dashboards, see // useDashboardList), not a fixed compile-time set, so this is just `string`. export type DashboardKey = string; - -export interface MockDashboardMeta { - description: string; - scopeBased: boolean; -} - -/** - * Description + scope-relevance for the mock placeholder dashboards (every - * dashboard in the BE registry other than "agents_pilot", which has real - * widgets and doesn't need an entry here). Keyed by dashboard id. Drives - * `DashboardPlaceholder`'s card copy and the header's My ABT / All customers - * toggle visibility (see CsmDashboardPage, AbtDashboardHeader). - */ -export const MOCK_DASHBOARD_META: Record = { - operations: { - description: - "Cross-team case throughput, state distribution, escalations, SLA breach trends.", - scopeBased: false, - }, - iam: { - description: - "Identity Server / Asgardeo case posture, top accounts, vulnerability links.", - scopeBased: false, - }, - security: { - description: - "Vulnerability posture, security report cases, response time.", - scopeBased: false, - }, - team_performance: { - description: - "Per-team throughput, time-card distribution, on-call coverage gaps.", - scopeBased: false, - }, -}; From fccd87100955679bc3cc1356b6ac10a97ebd61a8 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 21:14:49 +0530 Subject: [PATCH 12/16] feat(csm-dashboard): load dashboard config from DASHBOARDS_CONFIG env var instead of Go code --- apps/csm-portal/backend/.env.example | 10 + apps/csm-portal/backend/cmd/server/main.go | 7 + .../backend/internal/dashboard/widgets.go | 178 +++++------------- .../internal/dashboard/widgets_test.go | 128 +++++++++++++ .../internal/handler/dashboards_test.go | 47 +++++ 5 files changed, 239 insertions(+), 131 deletions(-) create mode 100644 apps/csm-portal/backend/internal/dashboard/widgets_test.go diff --git a/apps/csm-portal/backend/.env.example b/apps/csm-portal/backend/.env.example index 55b504b7f6..7cd2260de2 100644 --- a/apps/csm-portal/backend/.env.example +++ b/apps/csm-portal/backend/.env.example @@ -46,6 +46,16 @@ SCIM_SCOPES= # (e.g. http://localhost:3001 for local dev) # CSM_PORTAL_WEB_BASE_URL= +# Dashboard widget registry. Optional — a JSON array of Dashboard objects +# (see internal/dashboard/widgets.go's Dashboard/WidgetTemplate json tags for +# the exact shape). Loaded once at process startup only: there is no +# file-watching, hot-reload, or admin endpoint — changing this value requires +# restarting the backend process. Left unset or malformed, GET /dashboards +# returns an empty list and GET /dashboards/{id} 404s for every id; startup +# and every other endpoint work normally (an error is logged, not a crash). +# Example (the pilot's 5 dashboards): +# DASHBOARDS_CONFIG='[{"id":"agents_pilot","displayName":"Engineer overview","isDefault":true,"targetTeam":"cs_engineers","widgets":[{"id":"my_patches","displayName":"My Patches","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"tags":["patch"],"states":["open","work_in_progress","waiting_on_wso2","reopened","awaiting_info"]}},{"id":"my_reminders","displayName":"My Reminders","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"states":["awaiting_info","solution_proposed"]}},{"id":"open_incident_team","displayName":"Open Incident (Team)","resourceType":"case","shape":"count","gridWidth":3,"filters":{"tags":["s_dip"],"states":["work_in_progress","open","waiting_on_wso2","reopened"]}},{"id":"my_critical_open","displayName":"My Critical & High Cases","resourceType":"case","shape":"list","gridWidth":3,"listLimit":5,"filters":{"assignedUserIds":["__current_user__"],"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]},{"id":"operations","displayName":"Operations","targetTeam":"cs_operations","widgets":[{"id":"p0_p1_open","displayName":"P0/P1 Open","resourceType":"case","shape":"count","gridWidth":4,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}},{"id":"open_critical_incidents","displayName":"Open Critical Incidents","resourceType":"incident","shape":"count","gridWidth":4,"filters":{"priorities":["CRITICAL","HIGH"]}},{"id":"crs_awaiting_approval","displayName":"CRs Awaiting Approval","resourceType":"change_request","shape":"count","gridWidth":4,"filters":{"states":["customer_approval"]}}]},{"id":"iam","displayName":"IAM CS","targetTeam":"iam_cs","widgets":[{"id":"iam_open_cases","displayName":"IAM Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["iam"],"states":["open","work_in_progress","awaiting_info"]}},{"id":"asgardeo_open_cases","displayName":"Asgardeo Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["asgardeo"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"security","displayName":"Security center","targetTeam":"security","widgets":[{"id":"critical_vulns","displayName":"Critical Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"critical"}},{"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}},{"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","widgets":[{"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}},{"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]}]' + # Auth — set to false for local testing (skips JWT signature verification) AUTH_JWKS_ENDPOINT= AUTH_ISSUER= diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 7287a761c0..96af8b2ddb 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "syscall" "time" + "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/entity" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/handler" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/middleware" @@ -43,6 +44,12 @@ func main() { loadDotEnv(".env") middleware.ConfigureLogger() + // The dashboard registry is config-driven and loaded once at startup; a + // missing or malformed DASHBOARDS_CONFIG logs an error (see + // ParseDashboardsConfig) and leaves it empty rather than failing + // startup, since no other endpoint depends on it. + dashboard.Dashboards = dashboard.ParseDashboardsConfig(os.Getenv("DASHBOARDS_CONFIG")) + // All upstream service clients (entity, updates, SCIM, and future notification // channels) authenticate as the same OAuth2 client-credentials app; only the // base URL and scopes differ per service. diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index 9b31b091cb..facd05f8b5 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -14,14 +14,20 @@ // specific language governing permissions and limitations // under the License. -// Package dashboard holds the pilot's static, config-driven dashboard widget +// Package dashboard holds the pilot's config-driven dashboard widget // templates. Each widget resolves to a search against that ResourceType's own // /search endpoint (every resource's search payload shape is // {filters: {...}, pagination: {...}}) — there is no generic filter DSL and -// no database backing this; new widgets are added by extending the -// Dashboards registry below. +// no database backing this; the registry itself is loaded from the +// DASHBOARDS_CONFIG environment variable at process startup (see +// ParseDashboardsConfig and cmd/server/main.go). package dashboard +import ( + "encoding/json" + "log/slog" +) + // CurrentUserPlaceholder marks a filter value that must be resolved to the // requesting user's id before the filters are sent upstream. It never // reaches the entity service: ResolveFilters always substitutes it. @@ -58,142 +64,52 @@ const ( // shape is {filters: {...}, pagination: {...}}). The BE never interprets // filter contents beyond substituting the current-user placeholder. type WidgetTemplate struct { - ID string - DisplayName string - ResourceType ResourceType - Shape Shape - GridWidth int // 1-12, CSS grid columns out of 12 - Filters map[string]any - GroupBy string `json:",omitempty"` // only meaningful for Shape pie/bar — see the caveat on those consts; unused by every widget below - ListLimit int `json:",omitempty"` // only meaningful for Shape list; how many records to show + ID string `json:"id"` + DisplayName string `json:"displayName"` + ResourceType ResourceType `json:"resourceType"` + Shape Shape `json:"shape"` + GridWidth int `json:"gridWidth"` // 1-12, CSS grid columns out of 12 + Filters map[string]any `json:"filters"` + GroupBy string `json:"groupBy,omitempty"` // only meaningful for Shape pie/bar — see the caveat on those consts; unused by every widget below + ListLimit int `json:"listLimit,omitempty"` // only meaningful for Shape list; how many records to show } -// Dashboard is a single dashboard's metadata plus its static widget -// templates. +// Dashboard is a single dashboard's metadata plus its widget templates. type Dashboard struct { - ID string - DisplayName string - IsDefault bool + ID string `json:"id"` + DisplayName string `json:"displayName"` + IsDefault bool `json:"isDefault"` // TargetTeam is purely descriptive metadata (e.g. for a future FE team // picker); it is not enforced anywhere. GET /dashboards still returns // every dashboard to every caller regardless of team membership. - TargetTeam string - Widgets []WidgetTemplate + TargetTeam string `json:"targetTeam"` + Widgets []WidgetTemplate `json:"widgets"` } -// Dashboards is the ordered, static registry of dashboards. Order is -// deterministic and is what the frontend's dashboard picker displays. -var Dashboards = []Dashboard{ - { - ID: "agents_pilot", DisplayName: "Engineer overview", IsDefault: true, TargetTeam: "cs_engineers", - Widgets: []WidgetTemplate{ - { - ID: "my_patches", DisplayName: "My Patches", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, - Filters: map[string]any{ - "assignedUserIds": []string{CurrentUserPlaceholder}, - "tags": []string{"patch"}, - "states": []string{"open", "work_in_progress", "waiting_on_wso2", "reopened", "awaiting_info"}, - }, - }, - { - ID: "my_reminders", DisplayName: "My Reminders", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, - Filters: map[string]any{ - "assignedUserIds": []string{CurrentUserPlaceholder}, - "states": []string{"awaiting_info", "solution_proposed"}, - }, - }, - { - ID: "open_incident_team", DisplayName: "Open Incident (Team)", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 3, - Filters: map[string]any{ - "tags": []string{"s_dip"}, - "states": []string{"work_in_progress", "open", "waiting_on_wso2", "reopened"}, - }, - }, - { - ID: "my_critical_open", DisplayName: "My Critical & High Cases", ResourceType: ResourceCase, Shape: ShapeList, GridWidth: 3, ListLimit: 5, - Filters: map[string]any{ - "assignedUserIds": []string{CurrentUserPlaceholder}, - "severities": []string{"catastrophic", "critical"}, - "states": []string{"open", "work_in_progress"}, - }, - }, - }, - }, - { - ID: "operations", DisplayName: "Operations", TargetTeam: "cs_operations", - Widgets: []WidgetTemplate{ - { - ID: "p0_p1_open", DisplayName: "P0/P1 Open", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{ - "severities": []string{"catastrophic", "critical"}, - "states": []string{"open", "work_in_progress"}, - }, - }, - { - ID: "open_critical_incidents", DisplayName: "Open Critical Incidents", ResourceType: ResourceIncident, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{"priorities": []string{"CRITICAL", "HIGH"}}, - }, - { - ID: "crs_awaiting_approval", DisplayName: "CRs Awaiting Approval", ResourceType: ResourceChangeRequest, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{"states": []string{"customer_approval"}}, - }, - }, - }, - { - ID: "iam", DisplayName: "IAM CS", TargetTeam: "iam_cs", - Widgets: []WidgetTemplate{ - { - ID: "iam_open_cases", DisplayName: "IAM Open Cases", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, - Filters: map[string]any{ - "tags": []string{"iam"}, - "states": []string{"open", "work_in_progress", "awaiting_info"}, - }, - }, - { - ID: "asgardeo_open_cases", DisplayName: "Asgardeo Open Cases", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, - Filters: map[string]any{ - "tags": []string{"asgardeo"}, - "states": []string{"open", "work_in_progress", "awaiting_info"}, - }, - }, - }, - }, - { - ID: "security", DisplayName: "Security center", TargetTeam: "security", - Widgets: []WidgetTemplate{ - { - ID: "critical_vulns", DisplayName: "Critical Vulnerabilities", ResourceType: ResourceProductVulnerability, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{"priority": "critical"}, - }, - { - ID: "high_vulns", DisplayName: "High Vulnerabilities", ResourceType: ResourceProductVulnerability, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{"priority": "high"}, - }, - { - ID: "sra_cases_open", DisplayName: "Open SRAs", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 4, - Filters: map[string]any{ - "types": []string{"security_report_analysis"}, - "states": []string{"open", "work_in_progress", "awaiting_info"}, - }, - }, - }, - }, - { - ID: "team_performance", DisplayName: "Team performance", TargetTeam: "cs_team_leads", - Widgets: []WidgetTemplate{ - { - ID: "time_cards_pending_approval", DisplayName: "Time Cards Pending Approval", ResourceType: ResourceTimeCard, Shape: ShapeCount, GridWidth: 6, - Filters: map[string]any{"states": []string{"pending"}}, - }, - { - ID: "team_open_cases", DisplayName: "Team Open P0/P1", ResourceType: ResourceCase, Shape: ShapeCount, GridWidth: 6, - Filters: map[string]any{ - "severities": []string{"catastrophic", "critical"}, - "states": []string{"open", "work_in_progress"}, - }, - }, - }, - }, +// Dashboards is the ordered registry of dashboards, populated once at process +// startup from the DASHBOARDS_CONFIG environment variable (see +// ParseDashboardsConfig, called from cmd/server/main.go). It is empty (nil) +// until main() populates it; there is no file-watching or hot-reload — a +// config change requires restarting the process. Order is deterministic and +// is what the frontend's dashboard picker displays. +var Dashboards []Dashboard + +// ParseDashboardsConfig decodes DASHBOARDS_CONFIG, a JSON array of Dashboard +// objects (see the Dashboard and WidgetTemplate json tags for the expected +// shape). A missing or malformed value logs an error and yields no +// dashboards rather than failing startup, since callers always check +// dashboard.Dashboards for emptiness (GET /dashboards simply returns an empty +// list; GET /dashboards/{id} 404s) instead of crashing the process. +func ParseDashboardsConfig(raw string) []Dashboard { + if raw == "" { + return nil + } + var dashboards []Dashboard + if err := json.Unmarshal([]byte(raw), &dashboards); err != nil { + slog.Error("failed to parse DASHBOARDS_CONFIG; no dashboards will be available", "err", err) + return nil + } + return dashboards } // DashboardByID looks up a dashboard by id, returning ok=false if the id diff --git a/apps/csm-portal/backend/internal/dashboard/widgets_test.go b/apps/csm-portal/backend/internal/dashboard/widgets_test.go new file mode 100644 index 0000000000..d39477faa2 --- /dev/null +++ b/apps/csm-portal/backend/internal/dashboard/widgets_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package dashboard + +import "testing" + +func TestParseDashboardsConfig_Empty(t *testing.T) { + got := ParseDashboardsConfig("") + if got != nil { + t.Errorf("ParseDashboardsConfig(\"\") = %v, want nil", got) + } +} + +func TestParseDashboardsConfig_Malformed(t *testing.T) { + got := ParseDashboardsConfig("{not valid json") + if got != nil { + t.Errorf("ParseDashboardsConfig(malformed) = %v, want nil", got) + } +} + +func TestParseDashboardsConfig_MalformedShape(t *testing.T) { + // Valid JSON, but not an array of Dashboard objects — must not panic and + // must return nil, not a zero-value slice with garbage entries. + got := ParseDashboardsConfig(`{"id":"not-an-array"}`) + if got != nil { + t.Errorf("ParseDashboardsConfig(wrong shape) = %v, want nil", got) + } +} + +func TestParseDashboardsConfig_ValidRoundTrip(t *testing.T) { + const raw = `[ + { + "id": "agents_pilot", + "displayName": "Engineer overview", + "isDefault": true, + "targetTeam": "cs_engineers", + "widgets": [ + { + "id": "my_patches", + "displayName": "My Patches", + "resourceType": "case", + "shape": "count", + "gridWidth": 3, + "filters": { + "assignedUserIds": ["__current_user__"], + "tags": ["patch"], + "states": ["open", "work_in_progress"] + } + } + ] + } + ]` + + got := ParseDashboardsConfig(raw) + if len(got) != 1 { + t.Fatalf("len(ParseDashboardsConfig(raw)) = %d, want 1", len(got)) + } + + d := got[0] + if d.ID != "agents_pilot" { + t.Errorf("Dashboard.ID = %q, want %q", d.ID, "agents_pilot") + } + if d.DisplayName != "Engineer overview" { + t.Errorf("Dashboard.DisplayName = %q, want %q", d.DisplayName, "Engineer overview") + } + if !d.IsDefault { + t.Errorf("Dashboard.IsDefault = false, want true") + } + if d.TargetTeam != "cs_engineers" { + t.Errorf("Dashboard.TargetTeam = %q, want %q", d.TargetTeam, "cs_engineers") + } + if len(d.Widgets) != 1 { + t.Fatalf("len(Dashboard.Widgets) = %d, want 1", len(d.Widgets)) + } + + w := d.Widgets[0] + if w.ID != "my_patches" { + t.Errorf("WidgetTemplate.ID = %q, want %q", w.ID, "my_patches") + } + if w.ResourceType != ResourceCase { + t.Errorf("WidgetTemplate.ResourceType = %q, want %q", w.ResourceType, ResourceCase) + } + if w.Shape != ShapeCount { + t.Errorf("WidgetTemplate.Shape = %q, want %q", w.Shape, ShapeCount) + } + if w.GridWidth != 3 { + t.Errorf("WidgetTemplate.GridWidth = %d, want 3", w.GridWidth) + } + + // The detail that matters for ResolveFilters' substitution logic + // downstream: a JSON array value unmarshals into map[string]any as + // []any, not []string — assert the actual runtime type, not just + // presence, since substituteCurrentUser's []any and []string cases + // behave identically but are reached via different type switches. + assignedRaw, present := w.Filters["assignedUserIds"] + if !present { + t.Fatalf("Filters has no assignedUserIds key") + } + assigned, ok := assignedRaw.([]any) + if !ok { + t.Fatalf("Filters[assignedUserIds] is %T, want []any", assignedRaw) + } + if len(assigned) != 1 || assigned[0] != CurrentUserPlaceholder { + t.Errorf("Filters[assignedUserIds] = %v, want [%q]", assigned, CurrentUserPlaceholder) + } + + // End-to-end: resolving through the real substitution path yields a + // concrete user id in place of the placeholder. + resolved := ResolveFilters(w, "user-123") + resolvedAssigned, ok := resolved["assignedUserIds"].([]any) + if !ok || len(resolvedAssigned) != 1 || resolvedAssigned[0] != "user-123" { + t.Errorf("ResolveFilters(...)[assignedUserIds] = %v, want [\"user-123\"]", resolved["assignedUserIds"]) + } +} diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index c382ba88b6..61658a4667 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -21,6 +21,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "reflect" "sort" "testing" @@ -28,6 +29,52 @@ import ( "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" ) +// testDashboardsConfigJSON is the pilot's 5-dashboard registry, identical to +// the DASHBOARDS_CONFIG example documented in .env.example. dashboard.Dashboards +// is populated from DASHBOARDS_CONFIG only in cmd/server/main.go, which tests +// never run, so TestMain below seeds it directly via the same parse function +// production uses — every assertion in this file exercises the real +// production parsing/lookup/resolution path, just with the config supplied +// in-process instead of via the environment. +const testDashboardsConfigJSON = `[ + {"id":"agents_pilot","displayName":"Engineer overview","isDefault":true,"targetTeam":"cs_engineers","widgets":[ + {"id":"my_patches","displayName":"My Patches","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"tags":["patch"],"states":["open","work_in_progress","waiting_on_wso2","reopened","awaiting_info"]}}, + {"id":"my_reminders","displayName":"My Reminders","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"states":["awaiting_info","solution_proposed"]}}, + {"id":"open_incident_team","displayName":"Open Incident (Team)","resourceType":"case","shape":"count","gridWidth":3,"filters":{"tags":["s_dip"],"states":["work_in_progress","open","waiting_on_wso2","reopened"]}}, + {"id":"my_critical_open","displayName":"My Critical & High Cases","resourceType":"case","shape":"list","gridWidth":3,"listLimit":5,"filters":{"assignedUserIds":["__current_user__"],"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}} + ]}, + {"id":"operations","displayName":"Operations","targetTeam":"cs_operations","widgets":[ + {"id":"p0_p1_open","displayName":"P0/P1 Open","resourceType":"case","shape":"count","gridWidth":4,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}, + {"id":"open_critical_incidents","displayName":"Open Critical Incidents","resourceType":"incident","shape":"count","gridWidth":4,"filters":{"priorities":["CRITICAL","HIGH"]}}, + {"id":"crs_awaiting_approval","displayName":"CRs Awaiting Approval","resourceType":"change_request","shape":"count","gridWidth":4,"filters":{"states":["customer_approval"]}} + ]}, + {"id":"iam","displayName":"IAM CS","targetTeam":"iam_cs","widgets":[ + {"id":"iam_open_cases","displayName":"IAM Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["iam"],"states":["open","work_in_progress","awaiting_info"]}}, + {"id":"asgardeo_open_cases","displayName":"Asgardeo Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["asgardeo"],"states":["open","work_in_progress","awaiting_info"]}} + ]}, + {"id":"security","displayName":"Security center","targetTeam":"security","widgets":[ + {"id":"critical_vulns","displayName":"Critical Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"critical"}}, + {"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}}, + {"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}} + ]}, + {"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","widgets":[ + {"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}}, + {"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}} + ]} +]` + +// TestMain seeds dashboard.Dashboards before any test in this package runs. +// In production it is populated once at process startup by cmd/server/main.go +// from DASHBOARDS_CONFIG; tests never invoke main(), so they must seed it +// themselves via the same ParseDashboardsConfig function. +func TestMain(m *testing.M) { + dashboard.Dashboards = dashboard.ParseDashboardsConfig(testDashboardsConfigJSON) + if len(dashboard.Dashboards) != 5 { + panic(fmt.Sprintf("TestMain: seeding dashboard.Dashboards failed, got %d dashboards, want 5", len(dashboard.Dashboards))) + } + os.Exit(m.Run()) +} + // dashboardWidgetJSONKeys are the top-level JSON keys openapi.yaml's // DashboardWidget schema declares. Kept in sync with that schema by hand; // the tests below fail if the handler's actual response keys ever diverge From d927912062889f170a7edd673e629396ef00cbd1 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 21:21:51 +0530 Subject: [PATCH 13/16] feat(csm-dashboard): remove the My ABT / All customers toggle Dashboards are selected purely by dropdown now; ABT scoping was never implemented and no dashboard has any other per-dashboard behavior beyond which one is selected. --- .../components/AbtDashboardHeader.tsx | 97 +++++-------------- .../csm-dashboard/pages/CsmDashboardPage.tsx | 28 ++---- .../csm-dashboard/types/abtDashboard.ts | 2 - 3 files changed, 28 insertions(+), 99 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx index f46b8cd5bf..f7fe744a85 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx @@ -14,48 +14,29 @@ // specific language governing permissions and limitations // under the License. -import { - Box, - Button, - FormControl, - MenuItem, - Select, - Tooltip, - Typography, -} from "@wso2/oxygen-ui"; +import { Box, FormControl, MenuItem, Select, Typography } from "@wso2/oxygen-ui"; import type { JSX } from "react"; import type { BeDashboardListItem } from "@api/backend/types"; -import type { - DashboardKey, - DashboardScope, -} from "@features/csm-dashboard/types/abtDashboard"; - -// ABT (Account-Based Team) scoping is not implemented yet, so the My ABT / All -// customers toggle is disabled and the dashboard is locked to "all customers". -// Flip this to re-enable the toggle once ABT membership data is available. -const ABT_SCOPING_ENABLED = false; +import type { DashboardKey } from "@features/csm-dashboard/types/abtDashboard"; interface AbtDashboardHeaderProps { - scope: DashboardScope; - onScopeChange: (scope: DashboardScope) => void; dashboardKey: DashboardKey; onDashboardChange: (key: DashboardKey) => void; /** Every dashboard in the BE registry (GET /dashboards), for the switcher. */ dashboardList: BeDashboardListItem[]; - /** Whether the selected dashboard is scope-relevant (shows the My ABT / All - * customers toggle). Computed by the caller — see CsmDashboardPage — since - * that depends on whether the dashboard has real widgets or is a mock - * placeholder, which the header itself doesn't know about. */ - scopeBased: boolean; } +/** + * Dashboard header: title plus the dashboard switcher. Dashboards are + * selected purely by dropdown — there is no other per-dashboard scoping + * control (the earlier My ABT / All customers toggle was removed; ABT + * scoping was never implemented and dashboards carry no other special + * per-dashboard behavior beyond which one is selected). + */ export default function AbtDashboardHeader({ - scope, - onScopeChange, dashboardKey, onDashboardChange, dashboardList, - scopeBased, }: AbtDashboardHeaderProps): JSX.Element { const currentOption = dashboardList.find((o) => o.id === dashboardKey); @@ -75,53 +56,19 @@ export default function AbtDashboardHeader({ {currentOption?.displayName ?? ""} - - {scopeBased && ( - - - - - - - )} - - - - + + + ); } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index 91a85d0245..8a4571fe51 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -19,24 +19,17 @@ import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; -import type { - DashboardKey, - DashboardScope, -} from "@features/csm-dashboard/types/abtDashboard"; +import type { DashboardKey } from "@features/csm-dashboard/types/abtDashboard"; /** * Top-level CSM dashboard. The dashboard list and the default selection are - * BE-driven: `GET /dashboards` populates the switcher in the header (always - * enabled, see AbtDashboardHeader), and the `isDefault` entry is selected on - * load. Every dashboard in the registry now has at least one real - * (config-driven) widget, so this always renders the real widget grid — the - * earlier mock `DashboardPlaceholder` (pinned KPI numbers per dashboard) is - * gone. + * BE-driven: `GET /dashboards` populates the switcher in the header, and the + * `isDefault` entry is selected on load. Dashboards are selected purely by + * dropdown — there is no other per-dashboard scoping control. Every + * dashboard in the registry has at least one real (config-driven) widget, + * so this always renders the real widget grid. */ export default function CsmDashboardPage(): JSX.Element { - // ABT scoping is not implemented yet, so default to (and stay on) - // all-customers; the My ABT / All customers toggle is disabled in the header. - const [scope, setScope] = useState("all_customers"); // Undefined until the switcher is used; until then the selection derives // from the loaded list's isDefault entry (see `dashboardKey` below), so // there is nothing to synchronize via an effect. @@ -50,12 +43,6 @@ export default function CsmDashboardPage(): JSX.Element { list && list.length > 0 ? (list.find((d) => d.isDefault) ?? list[0]) : undefined; const dashboardKey = manualDashboardKey ?? defaultEntry?.id; - // Only the engineer-overview dashboard is a personal queue (my patches, my - // reminders, ...); every other dashboard is team/org-wide and has no - // scope-relevant My ABT / All customers toggle. Not worth a BE field for - // this single-dashboard UI nuance. - const scopeBased = dashboardKey === "agents_pilot"; - if (dashboardKey === undefined) { return ( @@ -68,12 +55,9 @@ export default function CsmDashboardPage(): JSX.Element { return ( diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index e2320ea8e2..73c1ecbb6b 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -40,8 +40,6 @@ export type CaseWorkState = "ongoing" | "paused"; export type SlaClockType = "ack" | "first_response" | "resolution"; -export type DashboardScope = "my_abt" | "all_customers"; - export interface CsmQueueCase { id: string; caseNumber: string; From 3711ae7965af1489bc9d3218e6aa2aa72abe589a Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 21:39:05 +0530 Subject: [PATCH 14/16] feat(csm-dashboard): add isTeamBased flag and a team selector skeleton A dashboard can now be flagged isTeamBased; when selected, the header shows a team selector sourced from POST /teams/search alongside the dashboard switcher. Selecting a team is UI state only for now - it does not yet scope any widget's data, which is a deliberately deferred follow-up. --- apps/csm-portal/backend/.env.example | 2 +- .../backend/internal/dashboard/widgets.go | 11 ++- .../backend/internal/handler/dashboards.go | 8 +- .../internal/handler/dashboards_test.go | 22 ++++- apps/csm-portal/backend/openapi.yaml | 14 +++ .../webapp/src/api/backend/types.ts | 15 +++ .../webapp/src/constants/apiConstants.ts | 1 + .../csm-dashboard/api/useTeams.test.tsx | 66 +++++++++++++ .../features/csm-dashboard/api/useTeams.ts | 52 ++++++++++ .../components/AbtDashboardHeader.test.tsx | 95 +++++++++++++++++++ .../components/AbtDashboardHeader.tsx | 68 +++++++++---- .../pages/CsmDashboardPage.test.tsx | 19 +++- 12 files changed, 342 insertions(+), 31 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.test.tsx create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.ts create mode 100644 apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.test.tsx diff --git a/apps/csm-portal/backend/.env.example b/apps/csm-portal/backend/.env.example index 7cd2260de2..63bea2f6f8 100644 --- a/apps/csm-portal/backend/.env.example +++ b/apps/csm-portal/backend/.env.example @@ -54,7 +54,7 @@ SCIM_SCOPES= # returns an empty list and GET /dashboards/{id} 404s for every id; startup # and every other endpoint work normally (an error is logged, not a crash). # Example (the pilot's 5 dashboards): -# DASHBOARDS_CONFIG='[{"id":"agents_pilot","displayName":"Engineer overview","isDefault":true,"targetTeam":"cs_engineers","widgets":[{"id":"my_patches","displayName":"My Patches","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"tags":["patch"],"states":["open","work_in_progress","waiting_on_wso2","reopened","awaiting_info"]}},{"id":"my_reminders","displayName":"My Reminders","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"states":["awaiting_info","solution_proposed"]}},{"id":"open_incident_team","displayName":"Open Incident (Team)","resourceType":"case","shape":"count","gridWidth":3,"filters":{"tags":["s_dip"],"states":["work_in_progress","open","waiting_on_wso2","reopened"]}},{"id":"my_critical_open","displayName":"My Critical & High Cases","resourceType":"case","shape":"list","gridWidth":3,"listLimit":5,"filters":{"assignedUserIds":["__current_user__"],"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]},{"id":"operations","displayName":"Operations","targetTeam":"cs_operations","widgets":[{"id":"p0_p1_open","displayName":"P0/P1 Open","resourceType":"case","shape":"count","gridWidth":4,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}},{"id":"open_critical_incidents","displayName":"Open Critical Incidents","resourceType":"incident","shape":"count","gridWidth":4,"filters":{"priorities":["CRITICAL","HIGH"]}},{"id":"crs_awaiting_approval","displayName":"CRs Awaiting Approval","resourceType":"change_request","shape":"count","gridWidth":4,"filters":{"states":["customer_approval"]}}]},{"id":"iam","displayName":"IAM CS","targetTeam":"iam_cs","widgets":[{"id":"iam_open_cases","displayName":"IAM Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["iam"],"states":["open","work_in_progress","awaiting_info"]}},{"id":"asgardeo_open_cases","displayName":"Asgardeo Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["asgardeo"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"security","displayName":"Security center","targetTeam":"security","widgets":[{"id":"critical_vulns","displayName":"Critical Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"critical"}},{"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}},{"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","widgets":[{"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}},{"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]}]' +# DASHBOARDS_CONFIG='[{"id":"agents_pilot","displayName":"Engineer overview","isDefault":true,"targetTeam":"cs_engineers","widgets":[{"id":"my_patches","displayName":"My Patches","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"tags":["patch"],"states":["open","work_in_progress","waiting_on_wso2","reopened","awaiting_info"]}},{"id":"my_reminders","displayName":"My Reminders","resourceType":"case","shape":"count","gridWidth":3,"filters":{"assignedUserIds":["__current_user__"],"states":["awaiting_info","solution_proposed"]}},{"id":"open_incident_team","displayName":"Open Incident (Team)","resourceType":"case","shape":"count","gridWidth":3,"filters":{"tags":["s_dip"],"states":["work_in_progress","open","waiting_on_wso2","reopened"]}},{"id":"my_critical_open","displayName":"My Critical & High Cases","resourceType":"case","shape":"list","gridWidth":3,"listLimit":5,"filters":{"assignedUserIds":["__current_user__"],"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]},{"id":"operations","displayName":"Operations","targetTeam":"cs_operations","widgets":[{"id":"p0_p1_open","displayName":"P0/P1 Open","resourceType":"case","shape":"count","gridWidth":4,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}},{"id":"open_critical_incidents","displayName":"Open Critical Incidents","resourceType":"incident","shape":"count","gridWidth":4,"filters":{"priorities":["CRITICAL","HIGH"]}},{"id":"crs_awaiting_approval","displayName":"CRs Awaiting Approval","resourceType":"change_request","shape":"count","gridWidth":4,"filters":{"states":["customer_approval"]}}]},{"id":"iam","displayName":"IAM CS","targetTeam":"iam_cs","widgets":[{"id":"iam_open_cases","displayName":"IAM Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["iam"],"states":["open","work_in_progress","awaiting_info"]}},{"id":"asgardeo_open_cases","displayName":"Asgardeo Open Cases","resourceType":"case","shape":"count","gridWidth":6,"filters":{"tags":["asgardeo"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"security","displayName":"Security center","targetTeam":"security","widgets":[{"id":"critical_vulns","displayName":"Critical Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"critical"}},{"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}},{"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}}]},{"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","isTeamBased":true,"widgets":[{"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}},{"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}}]}]' # Auth — set to false for local testing (skips JWT signature verification) AUTH_JWKS_ENDPOINT= diff --git a/apps/csm-portal/backend/internal/dashboard/widgets.go b/apps/csm-portal/backend/internal/dashboard/widgets.go index facd05f8b5..2b86287adc 100644 --- a/apps/csm-portal/backend/internal/dashboard/widgets.go +++ b/apps/csm-portal/backend/internal/dashboard/widgets.go @@ -82,8 +82,15 @@ type Dashboard struct { // TargetTeam is purely descriptive metadata (e.g. for a future FE team // picker); it is not enforced anywhere. GET /dashboards still returns // every dashboard to every caller regardless of team membership. - TargetTeam string `json:"targetTeam"` - Widgets []WidgetTemplate `json:"widgets"` + TargetTeam string `json:"targetTeam"` + // IsTeamBased marks a dashboard whose FE view should offer a team + // selector (populated from POST /teams/search) alongside the dashboard + // switcher. This is currently UI skeleton only: selecting a team does + // not yet scope any widget's data. Wiring a selected team into widget + // filters (e.g. resolving its member user IDs into a case widget's + // assignedUserIds) is deliberately deferred to a later increment. + IsTeamBased bool `json:"isTeamBased"` + Widgets []WidgetTemplate `json:"widgets"` } // Dashboards is the ordered registry of dashboards, populated once at process diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 906c9e8748..608fac0a24 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -39,11 +39,14 @@ type dashboardWidgetView struct { } // dashboardListItemView is a dashboard's list-level metadata, returned by -// GET /dashboards. +// GET /dashboards. IsTeamBased is included here (not just on the detail +// view) so the frontend can decide whether to show a team selector for the +// currently-selected dashboard without waiting on a second fetch. type dashboardListItemView struct { ID string `json:"id"` DisplayName string `json:"displayName"` IsDefault bool `json:"isDefault"` + IsTeamBased bool `json:"isTeamBased"` } // dashboardDetailView is a dashboard's full metadata plus its resolved @@ -53,6 +56,7 @@ type dashboardDetailView struct { DisplayName string `json:"displayName"` IsDefault bool `json:"isDefault"` TargetTeam string `json:"targetTeam"` + IsTeamBased bool `json:"isTeamBased"` Widgets []dashboardWidgetView `json:"widgets"` } @@ -79,6 +83,7 @@ func (h *DashboardHandler) GetDashboards(w http.ResponseWriter, r *http.Request) ID: d.ID, DisplayName: d.DisplayName, IsDefault: d.IsDefault, + IsTeamBased: d.IsTeamBased, }) } @@ -119,6 +124,7 @@ func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Req DisplayName: d.DisplayName, IsDefault: d.IsDefault, TargetTeam: d.TargetTeam, + IsTeamBased: d.IsTeamBased, Widgets: widgets, }) } diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index 61658a4667..ef0287cec5 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -57,7 +57,7 @@ const testDashboardsConfigJSON = `[ {"id":"high_vulns","displayName":"High Vulnerabilities","resourceType":"product_vulnerability","shape":"count","gridWidth":4,"filters":{"priority":"high"}}, {"id":"sra_cases_open","displayName":"Open SRAs","resourceType":"case","shape":"count","gridWidth":4,"filters":{"types":["security_report_analysis"],"states":["open","work_in_progress","awaiting_info"]}} ]}, - {"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","widgets":[ + {"id":"team_performance","displayName":"Team performance","targetTeam":"cs_team_leads","isTeamBased":true,"widgets":[ {"id":"time_cards_pending_approval","displayName":"Time Cards Pending Approval","resourceType":"time_card","shape":"count","gridWidth":6,"filters":{"states":["pending"]}}, {"id":"team_open_cases","displayName":"Team Open P0/P1","resourceType":"case","shape":"count","gridWidth":6,"filters":{"severities":["catastrophic","critical"],"states":["open","work_in_progress"]}} ]} @@ -88,11 +88,11 @@ var dashboardWidgetJSONKeys = []string{"widgetId", "displayName", "resourceType" // dashboardListItemJSONKeys are the top-level JSON keys openapi.yaml's // DashboardListItem schema declares. -var dashboardListItemJSONKeys = []string{"id", "displayName", "isDefault"} +var dashboardListItemJSONKeys = []string{"id", "displayName", "isDefault", "isTeamBased"} // dashboardDetailJSONKeys are the top-level JSON keys openapi.yaml's // Dashboard schema declares. -var dashboardDetailJSONKeys = []string{"id", "displayName", "isDefault", "targetTeam", "widgets"} +var dashboardDetailJSONKeys = []string{"id", "displayName", "isDefault", "targetTeam", "isTeamBased", "widgets"} func assertJSONKeys(t *testing.T, obj map[string]json.RawMessage, want []string, context string) { t.Helper() @@ -183,6 +183,22 @@ func TestGetDashboards(t *testing.T) { if got.IsDefault != want.IsDefault { t.Errorf("result[%d].IsDefault = %v, want %v", i, got.IsDefault, want.IsDefault) } + if got.IsTeamBased != want.IsTeamBased { + t.Errorf("result[%d].IsTeamBased = %v, want %v", i, got.IsTeamBased, want.IsTeamBased) + } + } + + teamBasedCount := 0 + for _, res := range results { + if res.IsTeamBased { + teamBasedCount++ + if res.ID != "team_performance" { + t.Errorf("unexpected team-based dashboard %q, want team_performance", res.ID) + } + } + } + if teamBasedCount != 1 { + t.Errorf("teamBasedCount = %d, want exactly 1 (team_performance)", teamBasedCount) } defaultCount := 0 diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index d477ba66db..ada4a82425 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -5335,6 +5335,13 @@ components: isDefault: type: boolean description: Whether this is the default dashboard to show first + isTeamBased: + type: boolean + description: > + Whether the frontend should offer a team selector (from + POST /teams/search) alongside the dashboard switcher when this + dashboard is selected. UI skeleton only today — selecting a team + does not yet scope any widget's data. Dashboard: type: object @@ -5352,6 +5359,13 @@ components: description: > Descriptive metadata for which team this dashboard targets; not enforced — every dashboard is returned to every caller. + isTeamBased: + type: boolean + description: > + Whether the frontend should offer a team selector (from + POST /teams/search) alongside the dashboard switcher when this + dashboard is selected. UI skeleton only today — selecting a team + does not yet scope any widget's data. widgets: type: array items: diff --git a/apps/csm-portal/webapp/src/api/backend/types.ts b/apps/csm-portal/webapp/src/api/backend/types.ts index 9927963110..092236998d 100644 --- a/apps/csm-portal/webapp/src/api/backend/types.ts +++ b/apps/csm-portal/webapp/src/api/backend/types.ts @@ -2651,6 +2651,11 @@ export interface BeDashboardListItem { id: string; displayName: string; isDefault: boolean; + /** Whether this dashboard should show a team selector (from + * `POST /teams/search`) alongside the dashboard switcher when selected. + * UI skeleton only today — selecting a team doesn't yet scope any + * widget's data. */ + isTeamBased: boolean; } /** @@ -2665,5 +2670,15 @@ export interface BeDashboard { /** Descriptive metadata for which team this dashboard targets; not * enforced — every dashboard is returned to every caller. */ targetTeam?: string; + /** See {@link BeDashboardListItem.isTeamBased}. */ + isTeamBased: boolean; widgets: BeDashboardWidget[]; } + +/** One team from `POST /teams/search`. `id` is the registry team key, + * stable across environments (unlike a group id). */ +export interface BeTeam { + id: string; + name: string; + family?: string; +} diff --git a/apps/csm-portal/webapp/src/constants/apiConstants.ts b/apps/csm-portal/webapp/src/constants/apiConstants.ts index 45eb0ca35c..1f115051c6 100644 --- a/apps/csm-portal/webapp/src/constants/apiConstants.ts +++ b/apps/csm-portal/webapp/src/constants/apiConstants.ts @@ -110,6 +110,7 @@ export const ApiQueryKeys = { CSM_DASHBOARD_WIDGET_DATA: "csm-dashboard-widget-data", CSM_DASHBOARD_LIST: "csm-dashboard-list", CSM_DASHBOARD_DETAIL: "csm-dashboard-detail", + CSM_TEAMS: "csm-teams", CSM_CASE_DETAIL: "csm-case-detail", CSM_CASE_COMMENTS: "csm-case-comments", CSM_CASE_ATTACHMENTS: "csm-case-attachments", diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.test.tsx new file mode 100644 index 0000000000..6b378b82ad --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.test.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { ReactNode } from "react"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); + +import { useTeams } from "@features/csm-dashboard/api/useTeams"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +describe("useTeams", () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it("fetches teams via POST /teams/search when enabled", async () => { + postMock.mockResolvedValue({ + teams: [{ id: "cs_team_leads", name: "CS Team Leads" }], + }); + + const { result } = renderHook(() => useTeams(true), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(postMock).toHaveBeenCalledTimes(1); + expect(postMock).toHaveBeenCalledWith("/teams/search", { + pagination: { offset: 0, limit: 100 }, + }); + expect(result.current.data).toEqual([ + { id: "cs_team_leads", name: "CS Team Leads" }, + ]); + }); + + it("does not fetch when disabled", () => { + renderHook(() => useTeams(false), { wrapper }); + expect(postMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.ts new file mode 100644 index 0000000000..319cb1bdf7 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useTeams.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import { useBackendApi } from "@api/backend/client"; +import type { BeTeam } from "@api/backend/types"; + +interface TeamsSearchPayload { + pagination: { offset: number; limit: number }; +} + +interface TeamsSearchResponse { + teams: BeTeam[]; +} + +/** + * Every team from `POST /teams/search`, for the team selector a team-based + * dashboard shows alongside the dashboard switcher (see + * `AbtDashboardHeader`). Selecting a team is UI state only today — it does + * not yet scope any widget's data (see `Dashboard.isTeamBased` on the + * backend). + */ +export function useTeams(enabled: boolean): UseQueryResult { + const api = useBackendApi(); + + return useQuery({ + queryKey: [ApiQueryKeys.CSM_TEAMS], + queryFn: async (): Promise => { + const res = await api.post( + "/teams/search", + { pagination: { offset: 0, limit: 100 } }, + ); + return res.teams ?? []; + }, + enabled, + staleTime: 5 * 60_000, + }); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.test.tsx new file mode 100644 index 0000000000..02de5f8678 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.test.tsx @@ -0,0 +1,95 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import type { ReactNode } from "react"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); + +import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; + +const DASHBOARD_LIST = [ + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, isTeamBased: false }, + { id: "team_performance", displayName: "Team performance", isDefault: false, isTeamBased: true }, +]; + +function renderWithClient(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + +describe("AbtDashboardHeader", () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it("shows no team selector for a non-team-based dashboard", () => { + renderWithClient( + , + ); + + expect(postMock).not.toHaveBeenCalled(); + // Only the dashboard switcher combobox, no second (team) combobox. + expect(screen.getAllByRole("combobox")).toHaveLength(1); + }); + + it("shows a populated team selector for a team-based dashboard", async () => { + postMock.mockResolvedValue({ + teams: [ + { id: "cs_team_leads", name: "CS Team Leads" }, + { id: "cs_operations", name: "CS Operations" }, + ], + }); + + renderWithClient( + , + ); + + expect(postMock).toHaveBeenCalledWith("/teams/search", { + pagination: { offset: 0, limit: 100 }, + }); + + const [teamSelect] = screen.getAllByRole("combobox"); + fireEvent.mouseDown(teamSelect); + const listbox = await screen.findByRole("listbox"); + // The teams query resolves asynchronously; retry (findByText) rather + // than asserting synchronously right after the menu opens. + expect( + await within(listbox).findByText("CS Team Leads"), + ).toBeInTheDocument(); + expect(within(listbox).getByText("CS Operations")).toBeInTheDocument(); + expect(within(listbox).getByText("All teams")).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx index f7fe744a85..0fa2a0ae7c 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx @@ -15,9 +15,10 @@ // under the License. import { Box, FormControl, MenuItem, Select, Typography } from "@wso2/oxygen-ui"; -import type { JSX } from "react"; +import { useState, type JSX } from "react"; import type { BeDashboardListItem } from "@api/backend/types"; import type { DashboardKey } from "@features/csm-dashboard/types/abtDashboard"; +import { useTeams } from "@features/csm-dashboard/api/useTeams"; interface AbtDashboardHeaderProps { dashboardKey: DashboardKey; @@ -27,11 +28,14 @@ interface AbtDashboardHeaderProps { } /** - * Dashboard header: title plus the dashboard switcher. Dashboards are - * selected purely by dropdown — there is no other per-dashboard scoping - * control (the earlier My ABT / All customers toggle was removed; ABT - * scoping was never implemented and dashboards carry no other special - * per-dashboard behavior beyond which one is selected). + * Dashboard header: title, the dashboard switcher, and (for a dashboard + * flagged `isTeamBased`) a team selector sourced from `POST /teams/search`. + * Team selection is UI state only today — it does not yet scope any + * widget's data (see `Dashboard.isTeamBased` on the backend); wiring a + * selected team into widget filters is a later increment. The earlier My + * ABT / All customers toggle was removed entirely — ABT scoping was never + * implemented and dashboards carry no other special behavior beyond which + * one (and, for team-based ones, which team) is selected. */ export default function AbtDashboardHeader({ dashboardKey, @@ -39,6 +43,12 @@ export default function AbtDashboardHeader({ dashboardList, }: AbtDashboardHeaderProps): JSX.Element { const currentOption = dashboardList.find((o) => o.id === dashboardKey); + const isTeamBased = currentOption?.isTeamBased ?? false; + + const [selectedTeamId, setSelectedTeamId] = useState( + undefined, + ); + const teams = useTeams(isTeamBased); return ( - - - + + {isTeamBased && ( + + + + )} + + + + ); } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx index b1dce870c7..abf12f2c7b 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx @@ -24,6 +24,15 @@ vi.mock("@features/csm-dashboard/api/useDashboardList", () => ({ useDashboardList: vi.fn(), })); +// None of these dashboards are team-based, so the header's team selector +// never renders/fetches here, but useTeams still needs mocking since +// AbtDashboardHeader calls it unconditionally (the fetch itself is disabled +// via its `enabled` param) — without this, the real hook reaches the real +// API client, which throws under vitest (no runtime config). +vi.mock("@features/csm-dashboard/api/useTeams", () => ({ + useTeams: vi.fn(() => ({ data: undefined })), +})); + // Keeps this test focused on dashboard selection + the header; the widget // grid itself has its own tests (AgentsLandingPagePilot.test.tsx). vi.mock("@features/csm-dashboard/components/AgentsLandingPagePilot", () => ({ @@ -35,9 +44,9 @@ vi.mock("@features/csm-dashboard/components/AgentsLandingPagePilot", () => ({ const mockedUseDashboardList = vi.mocked(useDashboardList); const DASHBOARD_LIST = [ - { id: "operations", displayName: "Operations", isDefault: false }, - { id: "agents_pilot", displayName: "Engineer overview", isDefault: true }, - { id: "iam", displayName: "IAM CS", isDefault: false }, + { id: "operations", displayName: "Operations", isDefault: false, isTeamBased: false }, + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, isTeamBased: false }, + { id: "iam", displayName: "IAM CS", isDefault: false, isTeamBased: false }, ]; function mockListResult( @@ -93,8 +102,8 @@ describe("CsmDashboardPage", () => { // widgets, so the grid renders regardless of which one is selected. mockListResult({ data: [ - { id: "agents_pilot", displayName: "Engineer overview", isDefault: false }, - { id: "operations", displayName: "Operations", isDefault: true }, + { id: "agents_pilot", displayName: "Engineer overview", isDefault: false, isTeamBased: false }, + { id: "operations", displayName: "Operations", isDefault: true, isTeamBased: false }, ], isLoading: false, }); From d0034751268cbb156f4f51cf49d772c883bb966c Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 21:52:12 +0530 Subject: [PATCH 15/16] fix(csm-dashboard): address CodeRabbit review on cs-tools#1316 - Guard against an unrecognized resourceType from the (now runtime-JSON) dashboard config crashing DashboardWidgetTile's render or useWidgetData's query; render/report "unsupported" instead. - useDashboardList: a null response (api.get's 404 sentinel) now surfaces as a query error instead of silently becoming an empty dashboard registry; CsmDashboardPage shows an error state instead of an infinite skeleton. - Fix a doc-comment name mismatch (assertJSONKeysSubset -> Superset), split a mixed-assertion test loop, and de-shadow a loop variable named the same as an outer *httptest.ResponseRecorder. - Correct two stale openapi.yaml descriptions still referencing "POST /cases/search" and "not user-configurable" from before the generic multi-resource schema and the DASHBOARDS_CONFIG move. - Fix a stale useDashboard.test.tsx fixture still using the removed displayType field instead of resourceType/shape/gridWidth. - Extract shared numberSubjectLabel/stateSecondaryLabel helpers in widgetResourceConfig.ts, removing duplication across 4 resource configs. - Add aria-label to both dashboard header Selects (the dashboard switcher and the new team selector). - Add coverage: dashboard switching via the switcher, the dashboard-list error state, the pie/bar not-yet-supported fallback, and the unrecognized- resourceType guard. Not applied: a suggestion to drop json tags from WidgetTemplate.GroupBy/ ListLimit is stale post-DASHBOARDS_CONFIG -- those tags are load-bearing now (ParseDashboardsConfig unmarshals directly into WidgetTemplate). --- .../internal/handler/dashboards_test.go | 66 +++++++++---------- apps/csm-portal/backend/openapi.yaml | 11 ++-- .../csm-dashboard/api/useDashboard.test.tsx | 9 ++- .../api/useDashboardList.test.tsx | 12 ++++ .../csm-dashboard/api/useDashboardList.ts | 10 ++- .../csm-dashboard/api/useWidgetData.ts | 7 ++ .../components/AbtDashboardHeader.tsx | 2 + .../components/DashboardWidgetTile.test.tsx | 37 +++++++++++ .../components/DashboardWidgetTile.tsx | 17 +++++ .../config/widgetResourceConfig.ts | 53 +++++++-------- .../pages/CsmDashboardPage.test.tsx | 29 ++++++++ .../csm-dashboard/pages/CsmDashboardPage.tsx | 13 +++- 12 files changed, 196 insertions(+), 70 deletions(-) diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index ef0287cec5..10ecf2c8fc 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -108,9 +108,9 @@ func assertJSONKeys(t *testing.T, obj map[string]json.RawMessage, want []string, } } -// assertJSONKeysSubset is like assertJSONKeys but only requires want to be -// present; used for widgets that additionally carry an omitempty field -// (groupBy/listLimit) beyond the base set. +// assertJSONKeysSuperset is like assertJSONKeys but only requires every key in +// want to be present; used for widgets that additionally carry an omitempty +// field (groupBy/listLimit) beyond the base set. func assertJSONKeysSuperset(t *testing.T, obj map[string]json.RawMessage, want []string, context string) { t.Helper() for _, k := range want { @@ -371,33 +371,31 @@ func TestGetDashboardDetail(t *testing.T) { } } - // Widgets with no assignedUserIds field in their template must not - // gain one during substitution: substituteCurrentUser only rewrites - // values already present, it never adds keys. - for _, id := range []string{"open_incident_team", "my_critical_open"} { - idx, ok := byID[id] - if !ok { - t.Fatalf("missing widget %q in response", id) - } - if id == "my_critical_open" { - // my_critical_open DOES carry assignedUserIds (the current - // user's critical/high cases) — verify it resolved cleanly - // instead of asserting absence. - filters := result.Widgets[idx].Filters - assignedRaw, present := filters["assignedUserIds"] - if !present { - t.Fatalf("widget %s filters has no assignedUserIds key", id) - } - assigned, ok := assignedRaw.([]any) - if !ok || len(assigned) != 1 || assigned[0] != testUser.UserID { - t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, assignedRaw, testUser.UserID) - } - continue - } - filters := result.Widgets[idx].Filters - if _, present := filters["assignedUserIds"]; present { - t.Errorf("widget %s filters unexpectedly has an assignedUserIds key: %v", id, filters["assignedUserIds"]) - } + // open_incident_team has no assignedUserIds field in its template and + // must not gain one during substitution: substituteCurrentUser only + // rewrites values already present, it never adds keys. + teamIdx, ok := byID["open_incident_team"] + if !ok { + t.Fatalf("missing widget %q in response", "open_incident_team") + } + teamFilters := result.Widgets[teamIdx].Filters + if v, present := teamFilters["assignedUserIds"]; present { + t.Errorf("widget open_incident_team filters unexpectedly has an assignedUserIds key: %v", v) + } + + // my_critical_open DOES carry assignedUserIds (the current user's + // critical/high cases) — verify it resolved cleanly. + criticalIdx, ok := byID["my_critical_open"] + if !ok { + t.Fatalf("missing widget %q in response", "my_critical_open") + } + assignedRaw, present := result.Widgets[criticalIdx].Filters["assignedUserIds"] + if !present { + t.Fatalf("widget my_critical_open filters has no assignedUserIds key") + } + assigned, ok := assignedRaw.([]any) + if !ok || len(assigned) != 1 || assigned[0] != testUser.UserID { + t.Errorf("widget my_critical_open assignedUserIds = %v, want [%q]", assignedRaw, testUser.UserID) } }) @@ -427,8 +425,8 @@ func TestGetDashboardDetail(t *testing.T) { } byID := make(map[string]dashboardWidgetView) - for _, w := range result.Widgets { - byID[w.WidgetID] = w + for _, wd := range result.Widgets { + byID[wd.WidgetID] = wd } wantTypes := map[string]dashboard.ResourceType{ @@ -481,8 +479,8 @@ func TestGetDashboardDetail(t *testing.T) { } byID := make(map[string]dashboardWidgetView) - for _, w := range result.Widgets { - byID[w.WidgetID] = w + for _, wd := range result.Widgets { + byID[wd.WidgetID] = wd } critical, ok := byID["critical_vulns"] diff --git a/apps/csm-portal/backend/openapi.yaml b/apps/csm-portal/backend/openapi.yaml index ada4a82425..1f3d186877 100644 --- a/apps/csm-portal/backend/openapi.yaml +++ b/apps/csm-portal/backend/openapi.yaml @@ -1630,7 +1630,8 @@ paths: description: > Returns every dashboard registered in the config-driven pilot: its id, display name, and whether it is the default dashboard. This is a - small static registry, not user-configurable. + registry loaded from the DASHBOARDS_CONFIG environment variable at + startup, not user-configurable at runtime. operationId: getDashboards responses: "200": @@ -1666,10 +1667,12 @@ paths: description: > Returns the given dashboard id's display metadata plus every widget template registered for it: its display metadata and the filter - criteria to run against POST /cases/search. The caller resolves each + criteria to run against that widget's own resourceType search + endpoint (POST /{resourceType}s/search). The caller resolves each widget's own data by issuing that search itself; this endpoint does - not touch case data. This is a config-driven pilot: widget templates - are a small static registry, not user-configurable. + not read any resource data. This is a config-driven pilot: widget + templates are a registry loaded from the DASHBOARDS_CONFIG + environment variable at startup, not user-configurable at runtime. operationId: getDashboardDetail parameters: - name: dashboardId diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx index b13b6e978b..fd4992a05c 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboard.test.tsx @@ -48,11 +48,14 @@ describe("useDashboard", () => { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, + isTeamBased: false, widgets: [ { widgetId: "my_patches", displayName: "My Patches", - displayType: "single_score", + resourceType: "case", + shape: "count", + gridWidth: 3, filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, }, ], @@ -70,7 +73,9 @@ describe("useDashboard", () => { { widgetId: "my_patches", displayName: "My Patches", - displayType: "single_score", + resourceType: "case", + shape: "count", + gridWidth: 3, filters: { assignedUserIds: ["user-1"], tags: ["patch"] }, }, ]); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx index a048b45604..c291f2dace 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.test.tsx @@ -69,4 +69,16 @@ describe("useDashboardList", () => { await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error?.message).toBe("boom"); }); + + it("surfaces a query error rather than an empty list when the endpoint 404s", async () => { + // api.get resolves 404 to null; GET /dashboards has no path param and + // always returns 200 in practice, so a null here means the endpoint + // itself is missing (routing/deployment problem) — must not be + // silently treated as "zero dashboards configured". + getMock.mockResolvedValue(null); + + const { result } = renderHook(() => useDashboardList(), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts index cc2c1df5cc..420c455bcc 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useDashboardList.ts @@ -36,7 +36,15 @@ export function useDashboardList(): UseQueryResult< queryKey: [ApiQueryKeys.CSM_DASHBOARD_LIST], queryFn: async (): Promise => { const res = await api.get("/dashboards"); - return res ?? []; + // GET /dashboards has no path param and always returns 200 (an empty + // array when DASHBOARDS_CONFIG is unset) — `api.get` resolving to + // `null` here means the endpoint itself 404'd (a routing/deployment + // problem), not "no dashboards configured". Throw so the query enters + // its error state instead of silently rendering an empty switcher. + if (res === null) { + throw new Error("GET /dashboards returned 404"); + } + return res; }, staleTime: 30_000, }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts index 4c4ff0a70c..a169332a94 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useWidgetData.ts @@ -60,6 +60,13 @@ export function useWidgetData( limit, ], queryFn: async (): Promise => { + if (!config) { + // A widget's resourceType came back from the backend (now a + // runtime-configurable registry, not a compile-time-checked Go + // literal) with no matching entry here — fail this widget's query + // rather than crash on the property accesses below. + throw new Error(`Unsupported widget resourceType: ${resourceType}`); + } const res = await api.post< { filters: Record; pagination: { offset: number; limit: number } }, Record diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx index 0fa2a0ae7c..f7ebed01cd 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/AbtDashboardHeader.tsx @@ -73,6 +73,7 @@ export default function AbtDashboardHeader({ value={selectedTeamId ?? ""} onChange={(e) => setSelectedTeamId(e.target.value || undefined)} displayEmpty + aria-label="Select team" > All teams @@ -90,6 +91,7 @@ export default function AbtDashboardHeader({ value={dashboardKey} onChange={(e) => onDashboardChange(e.target.value as DashboardKey)} displayEmpty + aria-label="Select dashboard" > {dashboardList.map((o) => ( diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx index c88319bbcf..bf16752d9e 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx @@ -153,4 +153,41 @@ describe("DashboardWidgetTile", () => { expect(params.get("severities")).toBe("S1"); expect(params.get("states")).toBe("open"); }); + + it("renders a not-yet-supported message for shape pie/bar instead of crashing", async () => { + postMock.mockResolvedValue({ total: 0, cases: [], limit: 1, offset: 0, hasMore: false }); + + renderWithClient( + , + ); + + await waitFor(() => + expect(screen.getByText("Not yet supported.")).toBeInTheDocument(), + ); + }); + + it("renders an unsupported-widget message instead of crashing for an unrecognized resourceType", () => { + renderWithClient( + , + ); + + expect(screen.getByText("Mystery Widget")).toBeInTheDocument(); + expect(screen.getByText("Unsupported widget type.")).toBeInTheDocument(); + expect(postMock).not.toHaveBeenCalled(); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index 9eb9f52cd3..e878dd6c8b 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -55,6 +55,23 @@ export default function DashboardWidgetTile({ listLimit, ); const config = WIDGET_RESOURCE_CONFIG[resourceType]; + + if (!config) { + // resourceType came from a runtime-configurable backend registry (not a + // compile-time-checked Go literal) — an unrecognized value must not + // crash this tile's render (config.buildHref below would throw). + return ( + + + {displayName} + + + Unsupported widget type. + + + ); + } + const href = config.buildHref(filters); return ( diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts index e47d5de65c..aa3df6a9a6 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/config/widgetResourceConfig.ts @@ -153,6 +153,24 @@ function translateChangeRequestDashboardFilters( return out; } +/** Shared "NUMBER — Subject" primary label, used by every resource whose + * response item carries `number`/`subject` fields (case, incident, + * change_request, problem). */ +function numberSubjectLabel(item: WidgetItem): string { + return ( + [asString(item.number), asString(item.subject)].filter(Boolean).join(" — ") || + "—" + ); +} + +/** Shared humanized-`state` secondary label, used by every resource whose + * response item carries a `state` field (case, change_request, problem — + * NOT incident, which has no state field and uses `priority` instead). */ +function stateSecondaryLabel(item: WidgetItem): string | undefined { + const state = asString(item.state); + return state ? humanizeState(state) : undefined; +} + export const WIDGET_RESOURCE_CONFIG: Record< BeWidgetResourceType, WidgetResourceConfig @@ -160,23 +178,14 @@ export const WIDGET_RESOURCE_CONFIG: Record< case: { searchEndpoint: "/cases/search", itemsKey: "cases", - primaryLabel: (item) => - [asString(item.number), asString(item.subject)] - .filter(Boolean) - .join(" — ") || "—", - secondaryLabel: (item) => { - const state = asString(item.state); - return state ? humanizeState(state) : undefined; - }, + primaryLabel: numberSubjectLabel, + secondaryLabel: stateSecondaryLabel, buildHref: (filters) => casesHref(translateCaseDashboardFilters(filters)), }, incident: { searchEndpoint: "/incidents/search", itemsKey: "incidents", - primaryLabel: (item) => - [asString(item.number), asString(item.subject)] - .filter(Boolean) - .join(" — ") || "—", + primaryLabel: numberSubjectLabel, secondaryLabel: (item) => asString(item.priority), buildHref: (filters) => operationsHref( @@ -190,14 +199,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< change_request: { searchEndpoint: "/change-requests/search", itemsKey: "changeRequests", - primaryLabel: (item) => - [asString(item.number), asString(item.subject)] - .filter(Boolean) - .join(" — ") || "—", - secondaryLabel: (item) => { - const state = asString(item.state); - return state ? humanizeState(state) : undefined; - }, + primaryLabel: numberSubjectLabel, + secondaryLabel: stateSecondaryLabel, buildHref: (filters) => operationsHref( "change_requests", @@ -210,14 +213,8 @@ export const WIDGET_RESOURCE_CONFIG: Record< problem: { searchEndpoint: "/problems/search", itemsKey: "problems", - primaryLabel: (item) => - [asString(item.number), asString(item.subject)] - .filter(Boolean) - .join(" — ") || "—", - secondaryLabel: (item) => { - const state = asString(item.state); - return state ? humanizeState(state) : undefined; - }, + primaryLabel: numberSubjectLabel, + secondaryLabel: stateSecondaryLabel, // No dashboard widget filters problems today; the tab has no URL filter // scheme of its own yet either, so this is unfiltered. buildHref: () => operationsHref("problems"), diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx index abf12f2c7b..80a201975f 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.test.tsx @@ -114,4 +114,33 @@ describe("CsmDashboardPage", () => { "operations", ); }); + + it("switches to another dashboard when picked from the switcher", () => { + mockListResult({ data: DASHBOARD_LIST, isLoading: false }); + + render(); + + expect(screen.getByTestId("agents-landing-pilot")).toHaveTextContent( + "agents_pilot", + ); + + const select = screen.getByRole("combobox"); + fireEvent.mouseDown(select); + fireEvent.click(within(screen.getByRole("listbox")).getByText("Operations")); + + expect(screen.getByTestId("agents-landing-pilot")).toHaveTextContent( + "operations", + ); + }); + + it("shows an error state rather than an infinite skeleton when the list fails to load", () => { + mockListResult({ data: undefined, isLoading: false, isError: true }); + + render(); + + expect( + screen.getByText("Could not load the dashboard list."), + ).toBeInTheDocument(); + expect(screen.queryByTestId("agents-landing-pilot")).not.toBeInTheDocument(); + }); }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx index 8a4571fe51..38f25f10e5 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/CsmDashboardPage.tsx @@ -14,7 +14,7 @@ // specific language governing permissions and limitations // under the License. -import { Box, Skeleton } from "@wso2/oxygen-ui"; +import { Box, Skeleton, Typography } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; import AbtDashboardHeader from "@features/csm-dashboard/components/AbtDashboardHeader"; import AgentsLandingPagePilot from "@features/csm-dashboard/components/AgentsLandingPagePilot"; @@ -43,6 +43,17 @@ export default function CsmDashboardPage(): JSX.Element { list && list.length > 0 ? (list.find((d) => d.isDefault) ?? list[0]) : undefined; const dashboardKey = manualDashboardKey ?? defaultEntry?.id; + if (dashboardList.isError) { + return ( + + Dashboard + + Could not load the dashboard list. + + + ); + } + if (dashboardKey === undefined) { return ( From 5c126a55d2a6b34f760ced365409cb96291bf14c Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Sat, 1 Aug 2026 22:35:17 +0530 Subject: [PATCH 16/16] fix(csm-dashboard): resolve __current_user__ via entity /users/me, not the JWT claim The dashboard handler substituted user.UserID (the raw JWT userid claim, whatever identity value the gateway/IdP embeds) into a widget's assignedUserIds. That is not the platform's own SN/Postgres-backed user id -- GET /users/me resolves a different id via the entity service. Real cases against wso2sndev with a JWT-claim id looked like this: "no active user found for sys_id 'f2d9bf5b-...'" This had been treated as an accepted ServiceNow DEV environment/test-identity limitation throughout this task. It was not: the id substituted was simply wrong. DashboardHandler now calls the same entity GetUserMe the users.go handler already uses for GET /users/me and substitutes that resolved id instead, falling back to the JWT claim only if the entity lookup itself fails (so a transient entity-service error still returns 200, not 500 -- dashboards are best-effort, not core functionality). Verified live against real wso2sndev: My Patches (56), My Reminders (3), and My Critical & High Cases (a real 5-case list) all now resolve real data -- previously all three failed with "Could not load this widget." --- apps/csm-portal/backend/cmd/server/main.go | 2 +- .../backend/internal/handler/dashboards.go | 53 ++++++++- .../internal/handler/dashboards_test.go | 106 ++++++++++++++++-- 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/apps/csm-portal/backend/cmd/server/main.go b/apps/csm-portal/backend/cmd/server/main.go index 96af8b2ddb..eeb12bf8ce 100644 --- a/apps/csm-portal/backend/cmd/server/main.go +++ b/apps/csm-portal/backend/cmd/server/main.go @@ -68,7 +68,7 @@ func main() { customerEntityClient := entity.NewCustomerEntityClient(customerEntityCfg) caseHandler := handler.NewCaseHandler(customerEntityClient) - dashboardHandler := handler.NewDashboardHandler() + dashboardHandler := handler.NewDashboardHandler(customerEntityClient) accountHandler := handler.NewAccountHandler(customerEntityClient) projectHandler := handler.NewProjectHandler(customerEntityClient) productHandler := handler.NewProductHandler(customerEntityClient) diff --git a/apps/csm-portal/backend/internal/handler/dashboards.go b/apps/csm-portal/backend/internal/handler/dashboards.go index 608fac0a24..2466767e86 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards.go +++ b/apps/csm-portal/backend/internal/handler/dashboards.go @@ -17,6 +17,8 @@ package handler import ( + "encoding/json" + "log/slog" "net/http" "github.com/wso2-open-operations/cs-tools/apps/csm-portal/backend/internal/dashboard" @@ -62,11 +64,50 @@ type dashboardDetailView struct { // DashboardHandler handles HTTP requests for the config-driven dashboard // widget pilot. -type DashboardHandler struct{} +type DashboardHandler struct { + entity entityUserClient +} -// NewDashboardHandler creates a DashboardHandler. -func NewDashboardHandler() *DashboardHandler { - return &DashboardHandler{} +// NewDashboardHandler creates a DashboardHandler backed by the given entity +// client, used to resolve the caller's own platform user id (see +// resolveCurrentUserID) for widgets whose filters need it. +func NewDashboardHandler(entity entityUserClient) *DashboardHandler { + return &DashboardHandler{entity: entity} +} + +// resolveCurrentUserID returns the caller's platform user id — the same id +// GET /users/me resolves via the entity service — for substituting +// dashboard.CurrentUserPlaceholder into widget filters. +// +// This is deliberately NOT user.UserID from the JWT: that claim is whatever +// identity value the gateway/IdP embeds (e.g. the Asgardeo subject), which is +// a different id than the platform's own SN/Postgres-backed user record. +// Using the JWT claim directly here was the actual bug behind an +// "identity-mapping gap" this task had, until now, treated as an accepted +// ServiceNow DEV environment limitation: /cases/search correctly rejected +// that id with "no active user found for sys_id ..." because it was never a +// valid sys_id to begin with. Falls back to the JWT claim (rather than an +// empty string) only if the entity lookup itself fails, so a transient +// entity-service error degrades to the previous (broken but non-crashing) +// behavior instead of a hard failure. +func (h *DashboardHandler) resolveCurrentUserID(r *http.Request, user *middleware.UserInfo) string { + raw, err := h.entity.GetUserMe(r.Context()) + if err != nil { + slog.ErrorContext(r.Context(), "entity GetUserMe failed while resolving dashboard current-user id", "userID", user.UserID, "err", err) + return user.UserID + } + var me struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &me); err != nil { + slog.ErrorContext(r.Context(), "entity GetUserMe: parse response failed while resolving dashboard current-user id", "userID", user.UserID, "err", err) + return user.UserID + } + if me.ID == "" { + slog.ErrorContext(r.Context(), "entity GetUserMe returned an empty id while resolving dashboard current-user id", "userID", user.UserID) + return user.UserID + } + return me.ID } // GetDashboards handles GET /dashboards. @@ -105,6 +146,8 @@ func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Req return } + currentUserID := h.resolveCurrentUserID(r, user) + widgets := make([]dashboardWidgetView, 0, len(d.Widgets)) for _, tpl := range d.Widgets { widgets = append(widgets, dashboardWidgetView{ @@ -113,7 +156,7 @@ func (h *DashboardHandler) GetDashboardDetail(w http.ResponseWriter, r *http.Req ResourceType: tpl.ResourceType, Shape: tpl.Shape, GridWidth: tpl.GridWidth, - Filters: dashboard.ResolveFilters(tpl, user.UserID), + Filters: dashboard.ResolveFilters(tpl, currentUserID), GroupBy: tpl.GroupBy, ListLimit: tpl.ListLimit, }) diff --git a/apps/csm-portal/backend/internal/handler/dashboards_test.go b/apps/csm-portal/backend/internal/handler/dashboards_test.go index 10ecf2c8fc..9f02ebc93b 100644 --- a/apps/csm-portal/backend/internal/handler/dashboards_test.go +++ b/apps/csm-portal/backend/internal/handler/dashboards_test.go @@ -17,7 +17,9 @@ package handler import ( + "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -134,9 +136,16 @@ func withDashboardID(r *http.Request, dashboardID string) *http.Request { return r } +// resolvedCurrentUserID is mockEntityUserClient's default GET /users/me id +// (helpers_test.go). It is deliberately NOT testUser.UserID (the JWT claim): +// the handler resolves __current_user__ via the entity service's /users/me, +// the same id GET /users/me itself returns — see +// DashboardHandler.resolveCurrentUserID's doc comment for why. +const resolvedCurrentUserID = "11111111-1111-1111-1111-111111111111" + func TestGetDashboards(t *testing.T) { t.Run("requires authenticated user", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := httptest.NewRequest(http.MethodGet, "/dashboards", nil) w := httptest.NewRecorder() h.GetDashboards(w, r) @@ -146,7 +155,7 @@ func TestGetDashboards(t *testing.T) { }) t.Run("returns all dashboards in registry order with correct isDefault", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withUser(httptest.NewRequest(http.MethodGet, "/dashboards", nil)) w := httptest.NewRecorder() h.GetDashboards(w, r) @@ -231,7 +240,7 @@ func TestAllDashboardsHaveWidgets(t *testing.T) { func TestGetDashboardDetail(t *testing.T) { t.Run("requires authenticated user", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot") w := httptest.NewRecorder() h.GetDashboardDetail(w, r) @@ -241,7 +250,7 @@ func TestGetDashboardDetail(t *testing.T) { }) t.Run("unknown dashboard id returns 404", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/bogus", nil), "bogus")) w := httptest.NewRecorder() h.GetDashboardDetail(w, r) @@ -250,7 +259,7 @@ func TestGetDashboardDetail(t *testing.T) { }) t.Run("agents_pilot returns metadata and its four widgets", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot")) w := httptest.NewRecorder() h.GetDashboardDetail(w, r) @@ -361,8 +370,8 @@ func TestGetDashboardDetail(t *testing.T) { if !ok { t.Fatalf("widget %s assignedUserIds is %T, want []any", id, assignedRaw) } - if len(assigned) != 1 || assigned[0] != testUser.UserID { - t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, assigned, testUser.UserID) + if len(assigned) != 1 || assigned[0] != resolvedCurrentUserID { + t.Errorf("widget %s assignedUserIds = %v, want [%q]", id, assigned, resolvedCurrentUserID) } for _, uid := range assigned { if uid == "__current_user__" { @@ -394,13 +403,13 @@ func TestGetDashboardDetail(t *testing.T) { t.Fatalf("widget my_critical_open filters has no assignedUserIds key") } assigned, ok := assignedRaw.([]any) - if !ok || len(assigned) != 1 || assigned[0] != testUser.UserID { - t.Errorf("widget my_critical_open assignedUserIds = %v, want [%q]", assignedRaw, testUser.UserID) + if !ok || len(assigned) != 1 || assigned[0] != resolvedCurrentUserID { + t.Errorf("widget my_critical_open assignedUserIds = %v, want [%q]", assignedRaw, resolvedCurrentUserID) } }) t.Run("operations dashboard has three resource-type-diverse widgets", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/operations", nil), "operations")) w := httptest.NewRecorder() h.GetDashboardDetail(w, r) @@ -459,7 +468,7 @@ func TestGetDashboardDetail(t *testing.T) { }) t.Run("security dashboard's product_vulnerability widget has a scalar string filter", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/security", nil), "security")) w := httptest.NewRecorder() h.GetDashboardDetail(w, r) @@ -500,7 +509,7 @@ func TestGetDashboardDetail(t *testing.T) { }) t.Run("every dashboard in the registry now has at least one widget", func(t *testing.T) { - h := NewDashboardHandler() + h := NewDashboardHandler(&mockEntityUserClient{}) for _, d := range dashboard.Dashboards { r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/"+d.ID, nil), d.ID)) w := httptest.NewRecorder() @@ -517,3 +526,76 @@ func TestGetDashboardDetail(t *testing.T) { } }) } + +// TestResolveCurrentUserID_UsesEntityUsersMeNotJWTClaim guards against the +// regression this task shipped once already: substituting the raw JWT +// userid claim into a widget's assignedUserIds instead of the platform user +// id GET /users/me resolves via the entity service. The two ids are +// deliberately different here (testUser.UserID vs the mock's GetUserMe id) +// so a reversion back to user.UserID would fail this test immediately +// rather than silently reintroducing the bug. +func TestResolveCurrentUserID_UsesEntityUsersMeNotJWTClaim(t *testing.T) { + const entityResolvedID = "22222222-2222-2222-2222-222222222222" + if entityResolvedID == testUser.UserID { + t.Fatal("test setup bug: entityResolvedID must differ from testUser.UserID") + } + + mock := &mockEntityUserClient{ + getUserMeFn: func(ctx context.Context) ([]byte, error) { + return []byte(`{"id":"` + entityResolvedID + `"}`), nil + }, + } + h := NewDashboardHandler(mock) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardDetail(w, r) + assertStatus(t, w, http.StatusOK) + + var result dashboardDetailView + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.Bytes()) + } + byID := make(map[string]dashboardWidgetView) + for _, wd := range result.Widgets { + byID[wd.WidgetID] = wd + } + + assigned, ok := byID["my_patches"].Filters["assignedUserIds"].([]any) + if !ok || len(assigned) != 1 { + t.Fatalf("my_patches assignedUserIds = %v, want a 1-element array", byID["my_patches"].Filters["assignedUserIds"]) + } + if assigned[0] != entityResolvedID { + t.Errorf("my_patches assignedUserIds[0] = %v, want the entity-resolved id %q (not the JWT claim %q)", + assigned[0], entityResolvedID, testUser.UserID) + } +} + +// TestResolveCurrentUserID_FallsBackToJWTClaimOnEntityError confirms a +// transient entity-service failure degrades to the previous (broken but +// non-crashing) behavior — the endpoint still returns 200, not a 500 — since +// dashboards are best-effort convenience data, not core functionality. +func TestResolveCurrentUserID_FallsBackToJWTClaimOnEntityError(t *testing.T) { + mock := &mockEntityUserClient{ + getUserMeFn: func(ctx context.Context) ([]byte, error) { + return nil, errors.New("entity unavailable") + }, + } + h := NewDashboardHandler(mock) + r := withUser(withDashboardID(httptest.NewRequest(http.MethodGet, "/dashboards/agents_pilot", nil), "agents_pilot")) + w := httptest.NewRecorder() + h.GetDashboardDetail(w, r) + assertStatus(t, w, http.StatusOK) + + var result dashboardDetailView + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode response body: %v; raw: %s", err, w.Body.Bytes()) + } + byID := make(map[string]dashboardWidgetView) + for _, wd := range result.Widgets { + byID[wd.WidgetID] = wd + } + assigned, ok := byID["my_patches"].Filters["assignedUserIds"].([]any) + if !ok || len(assigned) != 1 || assigned[0] != testUser.UserID { + t.Errorf("my_patches assignedUserIds = %v, want the JWT-claim fallback [%q]", byID["my_patches"].Filters["assignedUserIds"], testUser.UserID) + } +}