diff --git a/apps/csm-portal/webapp/public/config.js.example b/apps/csm-portal/webapp/public/config.js.example index dabe833fdb..8f5041983c 100644 --- a/apps/csm-portal/webapp/public/config.js.example +++ b/apps/csm-portal/webapp/public/config.js.example @@ -37,14 +37,15 @@ window.config = { // Available ids: dashboard, support, operations{.service-requests, // .change-requests, .incidents, .problems}, engagements, security-center // {.reports, .vulnerabilities}, updates, time-cards, announcements, - // customers{.accounts, .projects}, admin{.users, .roles, .groups, - // .permissions}. Unknown ids are ignored with a console warning. + // customers{.accounts, .projects}, admin{.user-management{.users, .roles, + // .groups, .teams, .permissions}, .dashboards}. Unknown ids are ignored + // with a console warning. CSM_PORTAL_FEATURE_OVERRIDES: { "operations": "wip", "operations.incidents": "enabled", "customers": "wip", "admin": "wip", - "admin.users": "enabled", + "admin.user-management.users": "enabled", }, // Mock data: when true, data hooks short-circuit to seeded fixtures instead diff --git a/apps/csm-portal/webapp/src/App.tsx b/apps/csm-portal/webapp/src/App.tsx index 25a05a120e..5bca44b872 100644 --- a/apps/csm-portal/webapp/src/App.tsx +++ b/apps/csm-portal/webapp/src/App.tsx @@ -118,6 +118,15 @@ const CsmTeamsPage = lazy( const TeamMembersPage = lazy( () => import("@features/csm-admin/pages/TeamMembersPage"), ); +const DashboardBuilderRouteGuard = lazy( + () => import("@features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard"), +); +const CsmDashboardBuilderListPage = lazy( + () => import("@features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage"), +); +const CsmDashboardBuilderEditorPage = lazy( + () => import("@features/csm-admin/dashboards/pages/CsmDashboardBuilderEditorPage"), +); const CsmCustomersLayout = lazy( () => import("@features/csm-customers/pages/CsmCustomersLayout"), ); @@ -323,19 +332,27 @@ export default function App(): JSX.Element { element={} /> - {/* Administration — Users/Roles/Groups/Teams are real, - Permissions is still WIP. */} + {/* Administration — "User management" groups the + Users/Roles/Groups/Teams/Permissions directory pages + (Users/Roles/Groups/Teams are real, Permissions is still + WIP) under one nested tab; Dashboards is a sibling. */} }> } /> - } /> - } /> - } /> - } /> + } + /> + } /> + } /> + } /> + } /> + } /> + {/* Dashboard builder — admin-role-gated, unlike every + sibling tab above (see DashboardBuilderRouteGuard's + own doc comment for why). Persists to localStorage + only; there is no backend behind this feature. */} + }> + } /> + } /> + } /> + + {/* Legacy Settings paths kept alive so a pinned/deep link to + the pre-"User management" layout doesn't dead-end. Not + requested explicitly — a judgment call to match the + /accounts, /projects legacy-redirect convention above; + revert this block alone if unwanted. */} + } + /> + } + /> + } + /> + } + /> + } + /> + {/* Role/group/team member lists, one level below the directory pages above. Not admin-permission-gated: standing project rule is to show the action and let the - backend reject it, never gate in the frontend. */} + backend reject it, never gate in the frontend. Left at + their original /admin//:id paths — out of scope + for the User management nesting above — so every + `routeBase="/admin/roles"` etc. link elsewhere in the app + keeps working unchanged. */} } /> } /> } /> diff --git a/apps/csm-portal/webapp/src/components/section-tabs/SectionTabs.tsx b/apps/csm-portal/webapp/src/components/section-tabs/SectionTabs.tsx index 95f8a47a7e..75a47b198e 100644 --- a/apps/csm-portal/webapp/src/components/section-tabs/SectionTabs.tsx +++ b/apps/csm-portal/webapp/src/components/section-tabs/SectionTabs.tsx @@ -24,10 +24,22 @@ interface SectionTabsProps extends SectionTabsState { /** Accessible name for the strip, e.g. "Operations tabs". */ ariaLabel: string; scrollable?: boolean; + /** + * Visual weight. `"primary"` (default) is a section's own tab strip. + * `"secondary"` renders smaller and indented, for a strip that belongs to + * one of those tabs rather than to the section itself — e.g. Settings' + * "User management" tab has its own row of sub-tabs underneath the primary + * strip. + */ + variant?: "primary" | "secondary"; } /** - * A section's second-level tab strip, rendered from the navigation tree. + * A section's tab strip, rendered from the navigation tree. Also doubles as a + * nested tab's own strip via `variant="secondary"` — the underlying data + * (`useRouteTabs`/`useQueryTabs`) is already resolved per nav-node id, so a + * second level is just a second `` fed by a second hook call, + * not a different component. * * A tab the deployment marked WIP stays in the strip but is disabled and * chipped, so the section still advertises what is coming without offering a @@ -41,17 +53,38 @@ export default function SectionTabs({ select, ariaLabel, scrollable = false, + variant = "primary", }: SectionTabsProps): JSX.Element | null { if (tabs.length === 0) return null; + const isSecondary = variant === "secondary"; return ( - + select(key)} variant={scrollable ? "scrollable" : "standard"} scrollButtons={scrollable ? "auto" : false} + sx={ + isSecondary + ? { + minHeight: 36, + "& .MuiTab-root": { + minHeight: 36, + paddingTop: 0.5, + paddingBottom: 0.5, + fontSize: "0.8125rem", + }, + } + : undefined + } > {tabs.map((tab) => tab.state === "wip" ? ( diff --git a/apps/csm-portal/webapp/src/config/csmNavItems.test.ts b/apps/csm-portal/webapp/src/config/csmNavItems.test.ts index b285545a2b..67655e4fb5 100644 --- a/apps/csm-portal/webapp/src/config/csmNavItems.test.ts +++ b/apps/csm-portal/webapp/src/config/csmNavItems.test.ts @@ -91,6 +91,6 @@ describe("rendersOwnWipPage", () => { const flagged = flattenNavNodes() .filter((node) => node.rendersOwnWipPage) .map((node) => node.id); - expect(flagged).toEqual(["admin.permissions"]); + expect(flagged).toEqual(["admin.user-management.permissions"]); }); }); diff --git a/apps/csm-portal/webapp/src/config/csmNavItems.ts b/apps/csm-portal/webapp/src/config/csmNavItems.ts index e2fe9bb046..9e84f26559 100644 --- a/apps/csm-portal/webapp/src/config/csmNavItems.ts +++ b/apps/csm-portal/webapp/src/config/csmNavItems.ts @@ -204,17 +204,55 @@ export const CSM_NAV_ITEMS: CsmNavSection[] = [ href: "/admin", icon: Settings, children: [ - { id: "admin.users", label: "Users", href: "/admin/users" }, - { id: "admin.roles", label: "Roles", href: "/admin/roles" }, - { id: "admin.groups", label: "Groups", href: "/admin/groups" }, - { id: "admin.teams", label: "Teams", href: "/admin/teams" }, - // Routes to a placeholder that already names its backend blocker, so it - // renders itself rather than the generic WIP page. { - id: "admin.permissions", - label: "Permissions", - href: "/admin/permissions", - rendersOwnWipPage: true, + id: "admin.user-management", + label: "User management", + href: "/admin/user-management", + children: [ + { + id: "admin.user-management.users", + label: "Users", + href: "/admin/user-management/users", + }, + { + id: "admin.user-management.roles", + label: "Roles", + href: "/admin/user-management/roles", + }, + { + id: "admin.user-management.groups", + label: "Groups", + href: "/admin/user-management/groups", + }, + { + id: "admin.user-management.teams", + label: "Teams", + href: "/admin/user-management/teams", + }, + // Routes to a placeholder that already names its backend blocker, so + // it renders itself rather than the generic WIP page. + { + id: "admin.user-management.permissions", + label: "Permissions", + href: "/admin/user-management/permissions", + rendersOwnWipPage: true, + }, + ], + }, + // Admin-role-gated (see `isDashboardBuilderVisibleForRoles` in + // `csmAdminAccess.ts`) — unlike every sibling tab above, this one is + // hidden from a non-admin signed-in user rather than merely relying + // on the backend to reject the action. Deliberate exception to this + // section's usual "show the action, let the backend reject it" rule + // (see App.tsx's own comment on the roles/groups/teams member + // routes): the dashboard builder exposes no privileged backend + // action at all (everything it does is local to the browser), so + // there is nothing for a backend gate to enforce here — the ONLY + // gate is this frontend one. + { + id: "admin.dashboards", + label: "Dashboards", + href: "/admin/dashboards", }, ], }, diff --git a/apps/csm-portal/webapp/src/config/featureFlags.test.ts b/apps/csm-portal/webapp/src/config/featureFlags.test.ts index 97cd24d79a..75b94f6f2a 100644 --- a/apps/csm-portal/webapp/src/config/featureFlags.test.ts +++ b/apps/csm-portal/webapp/src/config/featureFlags.test.ts @@ -50,7 +50,7 @@ describe("featureState", () => { it("treats anything absent from the config as a working feature", () => { expect(featureState("operations")).toBe("enabled"); expect(featureState("operations.incidents")).toBe("enabled"); - expect(featureState("admin.roles")).toBe("enabled"); + expect(featureState("admin.user-management.roles")).toBe("enabled"); }); it("treats an id that isn't in the nav tree as enabled", () => { @@ -95,13 +95,13 @@ describe("featureState", () => { describe("override parsing", () => { it("accepts the map as a JSON string, for string-only config injection", () => { - setOverrides(JSON.stringify({ "admin.roles": "hidden" })); - expect(featureState("admin.roles")).toBe("hidden"); + setOverrides(JSON.stringify({ "admin.user-management.roles": "hidden" })); + expect(featureState("admin.user-management.roles")).toBe("hidden"); }); it("ignores a malformed JSON string and warns", () => { setOverrides("{not json"); - expect(featureState("admin.roles")).toBe("enabled"); + expect(featureState("admin.user-management.roles")).toBe("enabled"); expect(console.warn).toHaveBeenCalled(); }); @@ -159,15 +159,18 @@ describe("navigation helpers", () => { }); it("separates visible tabs from usable ones", () => { - setOverrides({ "admin.roles": "wip", "admin.groups": "hidden" }); - const admin = navNodeById("admin"); - expect(admin).toBeDefined(); - const visible = visibleNavChildren(admin!).map((child) => child.id); - const enabled = enabledNavChildren(admin!).map((child) => child.id); - expect(visible).toContain("admin.roles"); - expect(visible).not.toContain("admin.groups"); - expect(enabled).not.toContain("admin.roles"); - expect(enabled).toContain("admin.users"); + setOverrides({ + "admin.user-management.roles": "wip", + "admin.user-management.groups": "hidden", + }); + const userManagement = navNodeById("admin.user-management"); + expect(userManagement).toBeDefined(); + const visible = visibleNavChildren(userManagement!).map((child) => child.id); + const enabled = enabledNavChildren(userManagement!).map((child) => child.id); + expect(visible).toContain("admin.user-management.roles"); + expect(visible).not.toContain("admin.user-management.groups"); + expect(enabled).not.toContain("admin.user-management.roles"); + expect(enabled).toContain("admin.user-management.users"); }); it("offers only usable destinations to the quick-nav palette", () => { @@ -186,6 +189,20 @@ describe("navigation helpers", () => { expect(incidents?.sublabel).toBe("Operations"); expect(incidents?.href).toBe("/operations?tab=incidents"); }); + + it("flattens a grandchild tab too, labelled by its immediate parent", () => { + const users = navigableNavNodes().find( + (node) => node.id === "admin.user-management.users", + ); + expect(users?.label).toBe("Users"); + expect(users?.sublabel).toBe("User management"); + expect(users?.href).toBe("/admin/user-management/users"); + + const userManagement = navigableNavNodes().find( + (node) => node.id === "admin.user-management", + ); + expect(userManagement?.sublabel).toBe("Settings"); + }); }); describe("firstEnabledDestination", () => { diff --git a/apps/csm-portal/webapp/src/config/featureFlags.ts b/apps/csm-portal/webapp/src/config/featureFlags.ts index f97ff340fd..a62500b605 100644 --- a/apps/csm-portal/webapp/src/config/featureFlags.ts +++ b/apps/csm-portal/webapp/src/config/featureFlags.ts @@ -223,9 +223,32 @@ export interface NavigableNavNode { } /** - * Every enabled destination, sections and their tabs alike, flattened for the - * Quick-nav palette. Tabs inherit their section's icon and carry its label as a - * sublabel so "Users" reads as "Users / Settings" rather than as a bare word. + * Every enabled descendant of `node`, flattened, each carrying its immediate + * parent's label as its sublabel (so a grandchild reads as "Users / User + * management" rather than "Users / Settings") and inheriting an icon down the + * chain until a node declares its own. + */ +function navigableDescendants( + node: CsmNavNode, + icon: ComponentType<{ size?: number | string }>, +): NavigableNavNode[] { + return enabledNavChildren(node).flatMap((child) => { + const self: NavigableNavNode = { + id: child.id, + label: child.label, + sublabel: node.label, + href: child.href, + icon: child.icon ?? icon, + }; + return [self, ...navigableDescendants(child, self.icon)]; + }); +} + +/** + * Every enabled destination — sections and every level of their tabs — + * flattened for the Quick-nav palette. Tabs inherit their parent's icon and + * carry its label as a sublabel so "Users" reads as "Users / User management" + * rather than as a bare word. */ export function navigableNavNodes(): NavigableNavNode[] { return CSM_NAV_ITEMS.flatMap((section) => { @@ -236,14 +259,7 @@ export function navigableNavNodes(): NavigableNavNode[] { href: section.href, icon: section.icon, }; - const tabs: NavigableNavNode[] = enabledNavChildren(section).map((child) => ({ - id: child.id, - label: child.label, - sublabel: section.label, - href: child.href, - icon: child.icon ?? section.icon, - })); - return [self, ...tabs]; + return [self, ...navigableDescendants(section, section.icon)]; }); } diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx new file mode 100644 index 0000000000..5378ae5ae8 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.test.tsx @@ -0,0 +1,569 @@ +// 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 { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter } from "react-router"; +import type { ComponentProps } from "react"; +import type { BeDashboardWidget } from "@api/backend/types"; + +const postMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ post: postMock }), +})); +vi.mock("@config/apiConfig", () => ({ + apiConfig: { backendUrl: "https://example.test" }, +})); +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ user: { id: "user-1" }, isLoading: false, isError: false }), +})); + +import WidgetEditorDialog from "@features/csm-admin/dashboards/components/WidgetEditorDialog"; + +function renderDialog(props: Partial> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onSave = vi.fn(); + const onClose = vi.fn(); + render( + + + + + , + ); + return { onSave, onClose }; +} + +describe("WidgetEditorDialog", () => { + beforeEach(() => { + postMock.mockReset(); + }); + + it("disables Add widget until a display name is entered", () => { + renderDialog(); + expect(screen.getByRole("button", { name: "Add widget" })).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "My widget" }, + }); + expect(screen.getByRole("button", { name: "Add widget" })).toBeEnabled(); + }); + + it("saves a new widget with the filters entered in the condition editor", () => { + const { onSave } = renderDialog(); + + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Open cases" }, + }); + fireEvent.click(screen.getByRole("button", { name: /add filter/i })); + fireEvent.change(screen.getByLabelText("Filter field"), { target: { value: "state" } }); + fireEvent.click(screen.getByRole("button", { name: "Add widget" })); + + expect(onSave).toHaveBeenCalledTimes(1); + const saved = onSave.mock.calls[0][0] as BeDashboardWidget; + expect(saved.displayName).toBe("Open cases"); + expect(saved.resourceType).toBe("case"); + expect(saved.shape).toBe("count"); + expect(saved.query).toEqual({ + filters: [{ field: "state", op: "eq", values: [] }], + }); + }); + + it("runs the in-progress config through the real widget-data resolution path when Preview is clicked", async () => { + postMock.mockResolvedValue({ total: 7, cases: [], limit: 1, offset: 0, hasMore: false }); + renderDialog(); + + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Open cases" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + await waitFor(() => expect(screen.getByText("7")).toBeInTheDocument()); + expect(postMock).toHaveBeenCalledWith("/cases/search", { + filters: {}, + pagination: { offset: 0, limit: 1 }, + }); + }); + + it("shows nothing fetched until Preview is explicitly clicked", () => { + renderDialog(); + expect(postMock).not.toHaveBeenCalled(); + expect( + screen.getByText(/click "preview" to run this widget's current settings/i), + ).toBeInTheDocument(); + }); + + it("pre-fills the form from an existing widget when editing, and offers Delete instead of Add", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My Patches", + resourceType: "case", + shape: "count", + gridWidth: 4, + query: { filters: [{ field: "tag", op: "in", values: ["patch"] }] }, + }; + const onDelete = vi.fn(); + renderDialog({ widget: existing, onDelete }); + + expect(screen.getByDisplayValue("My Patches")).toBeInTheDocument(); + expect(screen.getByText("patch")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save widget" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Delete widget" })); + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("threads selectedTeamGroupId/selectedTeamLabel into the Preview tile, exactly as the live dashboard grid does", async () => { + postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); + renderDialog({ selectedTeamGroupId: "team-group-1", selectedTeamLabel: "Castor" }); + + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Cases — {{currentTeam}}" }, + }); + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + // The Preview tile resolves the widget's own `{{currentTeam}}` text + // token using the team label passed in — before this fix, no team + // props reached the Preview tile at all, so a team-scoped widget's + // display name (or filters) previewed unresolved/unfiltered instead of + // what the admin would actually see on the live dashboard. + await waitFor(() => expect(screen.getByText("Cases — Castor")).toBeInTheDocument()); + }); + + it("resolves an integrationCsTeam __current_team__ filter placeholder in Preview using the given selectedTeamGroupId", async () => { + postMock.mockResolvedValue({ total: 0, cases: [], limit: 1, offset: 0, hasMore: false }); + renderDialog({ selectedTeamGroupId: "team-group-1", selectedTeamLabel: "Castor" }); + + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "My team's cases" }, + }); + fireEvent.click(screen.getByRole("button", { name: /add filter/i })); + fireEvent.change(screen.getByLabelText("Filter field"), { + target: { value: "integrationCsTeam" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Operator" })); + fireEvent.click(screen.getByRole("option", { name: "is any of" })); + fireEvent.change(screen.getByLabelText("Filter value"), { + target: { value: "__current_team__" }, + }); + fireEvent.keyDown(screen.getByLabelText("Filter value"), { key: "Enter" }); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + await waitFor(() => + expect(postMock).toHaveBeenCalledWith("/cases/search", { + filters: { filters: [{ field: "integrationCsTeam", op: "in", values: ["team-group-1"] }] }, + pagination: { offset: 0, limit: 1 }, + }), + ); + }); + + it("clears filter conditions when the resource type changes, rather than carrying over a shape the new endpoint won't accept", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My Patches", + resourceType: "case", + shape: "count", + gridWidth: 4, + query: { filters: [{ field: "tag", op: "in", values: ["patch"] }] }, + }; + renderDialog({ widget: existing }); + + expect(screen.getByText("patch")).toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Resource type" })); + fireEvent.click(screen.getByRole("option", { name: "incident" })); + + expect(screen.queryByText("patch")).not.toBeInTheDocument(); + }); + + it("clears configured columns when the resource type changes, since a column path is resource-specific", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My list", + resourceType: "case", + shape: "list", + gridWidth: 4, + query: {}, + columns: [{ path: "project.key", label: "Project" }], + }; + renderDialog({ widget: existing }); + + expect(screen.getByLabelText("Column path")).toHaveValue("project.key"); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Resource type" })); + fireEvent.click(screen.getByRole("option", { name: "incident" })); + + expect(screen.queryByLabelText("Column path")).not.toBeInTheDocument(); + }); + + it("clamps Row limit to a minimum of 1, same as Grid width, rather than accepting zero/negative", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My list", + resourceType: "case", + shape: "list", + gridWidth: 4, + query: {}, + listLimit: 5, + }; + renderDialog({ widget: existing }); + + const rowLimitInput = screen.getByLabelText(/row limit/i); + fireEvent.change(rowLimitInput, { target: { value: "0" } }); + expect(rowLimitInput).toHaveValue(1); + + fireEvent.change(rowLimitInput, { target: { value: "-5" } }); + expect(rowLimitInput).toHaveValue(1); + }); + + it("truncates a decimal Row limit rather than saving a fractional value", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My list", + resourceType: "case", + shape: "list", + gridWidth: 4, + query: {}, + listLimit: 5, + }; + const { onSave } = renderDialog({ widget: existing }); + + const rowLimitInput = screen.getByLabelText(/row limit/i); + fireEvent.change(rowLimitInput, { target: { value: "7.9" } }); + expect(rowLimitInput).toHaveValue(7); + + fireEvent.click(screen.getByRole("button", { name: "Save widget" })); + const saved = onSave.mock.calls[0][0] as BeDashboardWidget; + expect(saved.listLimit).toBe(7); + expect(Number.isNaN(saved.listLimit)).toBe(false); + }); + + it("hides the Columns section for count/pie shapes, and shows it for list", () => { + renderDialog(); + // Default shape is "count". + expect(screen.queryByRole("button", { name: /add column/i })).not.toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "pie" })); + expect(screen.queryByRole("button", { name: /add column/i })).not.toBeInTheDocument(); + + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + expect(screen.getByRole("button", { name: /add column/i })).toBeInTheDocument(); + }); + + it("adds and removes column rows", () => { + renderDialog(); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + expect(screen.getAllByLabelText("Column path")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + expect(screen.getAllByLabelText("Column path")).toHaveLength(2); + + fireEvent.click(screen.getAllByRole("button", { name: /^Remove column/ })[0]); + expect(screen.getAllByLabelText("Column path")).toHaveLength(1); + }); + + it("wires configured columns into buildWidget, omitting the field entirely when none are set", () => { + const { onSave } = renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: "Add widget" })); + const savedWithoutColumns = onSave.mock.calls[0][0] as BeDashboardWidget; + expect(savedWithoutColumns.columns).toBeUndefined(); + }); + + it("wires configured columns into buildWidget when rows are filled in", () => { + const { onSave } = renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + fireEvent.change(screen.getByLabelText("Column path"), { + target: { value: "project.key" }, + }); + fireEvent.change(screen.getByLabelText("Column label"), { + target: { value: "Project" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Format" })); + fireEvent.click(screen.getByRole("option", { name: "date" })); + + fireEvent.click(screen.getByRole("button", { name: "Add widget" })); + const saved = onSave.mock.calls[0][0] as BeDashboardWidget; + expect(saved.columns).toEqual([{ path: "project.key", label: "Project", format: "date" }]); + }); + + it("passes the configured columns to the Preview tile so it exercises the generic column renderer", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "c-1", project: { key: "PROJ-1" } }], + limit: 4, + offset: 0, + hasMore: false, + }); + renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + fireEvent.change(screen.getByLabelText("Column path"), { + target: { value: "project.key" }, + }); + fireEvent.change(screen.getByLabelText("Column label"), { + target: { value: "Project" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + // "Project" is this configured column's own header, only rendered by + // `GenericColumnList`/`DashboardMiniTable` — the hardcoded per- + // resourceType `CasesList` renderer has no such header, so seeing it + // proves the `columns` prop actually reached the Preview tile. + await waitFor(() => expect(screen.getByText("Project")).toBeInTheDocument()); + expect(screen.getByText("PROJ-1")).toBeInTheDocument(); + }); + + it("renders the list-shape Preview at full width, without the old fixed 420px cap", async () => { + postMock.mockResolvedValue({ total: 0, cases: [], limit: 4, offset: 0, hasMore: false }); + const { container } = render( + + + + + , + ); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + await waitFor(() => + expect(screen.getAllByText("Case list").length).toBeGreaterThan(0), + ); + // No element in the dialog carries the old fixed-width cap any more — + // a list-shape preview now sizes to the dialog's own content width + // instead. + expect(container.querySelector('[style*="max-width: 420px"]')).toBeNull(); + }); + + it("shows a helper hint instead of a populated dropdown before Preview has ever run", () => { + renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + + expect(screen.getByText("Preview to see available fields")).toBeInTheDocument(); + + // Still a plain text field — free text works even with no discovered + // options, so column configuration is never blocked on previewing first. + fireEvent.change(screen.getByLabelText("Column path"), { + target: { value: "some.made.up.path" }, + }); + expect(screen.getByLabelText("Column path")).toHaveValue("some.made.up.path"); + expect(postMock).not.toHaveBeenCalled(); + }); + + it("populates the Column path autocomplete with real paths discovered from Preview data, and still accepts free text", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "c-1", project: { key: "PROJ-1", name: "Foo" } }], + limit: 4, + offset: 0, + hasMore: false, + }); + renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + await waitFor(() => + expect(screen.queryByText("Preview to see available fields")).not.toBeInTheDocument(), + ); + + const pathInput = screen.getByLabelText("Column path"); + fireEvent.mouseDown(pathInput); + expect(await screen.findByRole("option", { name: "project.key" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "project.name" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "id" })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("option", { name: "project.key" })); + expect(pathInput).toHaveValue("project.key"); + + // Free text still works — not every real field shows up in a sampled + // preview (e.g. null/absent on every sampled row but valid elsewhere). + fireEvent.change(pathInput, { target: { value: "some.other.field" } }); + expect(pathInput).toHaveValue("some.other.field"); + }); + + it("fetches Preview data for column-path discovery exactly once, sharing the Preview tile's own request rather than firing a second one", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "c-1", project: { key: "PROJ-1" } }], + limit: 4, + offset: 0, + hasMore: false, + }); + renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + fireEvent.mouseDown(screen.getByLabelText("Column path")); + await screen.findByRole("option", { name: "project.key" }); + + expect(postMock).toHaveBeenCalledTimes(1); + }); + + it("clears the stale Preview snapshot when resourceType changes, so a switched resource type never offers paths from the old one", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "c-1", project: { key: "PROJ-1" } }], + limit: 4, + offset: 0, + hasMore: false, + }); + renderDialog(); + fireEvent.change(screen.getByLabelText("Widget display name"), { + target: { value: "Case list" }, + }); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Shape" })); + fireEvent.click(screen.getByRole("option", { name: "list" })); + + fireEvent.click(screen.getByRole("button", { name: /^preview$/i })); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + await waitFor(() => + expect(screen.queryByText("Preview to see available fields")).not.toBeInTheDocument(), + ); + const pathInput = screen.getByLabelText("Column path"); + fireEvent.mouseDown(pathInput); + expect(await screen.findByRole("option", { name: "project.key" })).toBeInTheDocument(); + + postMock.mockClear(); + fireEvent.mouseDown(screen.getByRole("combobox", { name: "Resource type" })); + fireEvent.click(screen.getByRole("option", { name: "incident" })); + + // `fireEvent` only flushes the synchronous React commit — it doesn't wait + // for TanStack Query's own (microtask-scheduled) notify/fetch machinery, + // so a regression that re-enabled a query on this state change could + // still fire `post` a tick later, after a bare synchronous assertion here + // would already have passed. A bare `await waitFor(() => + // expect(postMock).not.toHaveBeenCalled())` doesn't fix that either: + // `waitFor` only *retries while the assertion keeps failing*, so a + // negative assertion that's already true on the very first check resolves + // immediately and never actually gives a delayed call a chance to land. + // Instead, force a real macrotask turn first — which fully drains any + // pending microtask-queued query work ahead of it — then assert. Wrapped + // in `act` so any state update that flush triggers (e.g. TanStack Query + // actually firing a fetch) is applied and settled before we assert, + // rather than logging an "update not wrapped in act" warning. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // No re-fetch just from switching resourceType — the stale snapshot is + // cleared client-side, not replaced by a fresh Preview call. + expect(postMock).not.toHaveBeenCalled(); + // The Preview tile itself reverts to its un-run state. + expect( + screen.getByText(/click "preview" to run this widget's current settings/i), + ).toBeInTheDocument(); + // The resourceType switch also clears columnDrafts (a pre-existing, + // separately-tested reset) so the previous "Column path" row is gone — + // re-add one to confirm it starts with no stale discovered paths. + expect(screen.queryByLabelText("Column path")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /add column/i })); + expect(screen.getByText("Preview to see available fields")).toBeInTheDocument(); + const newPathInput = screen.getByLabelText("Column path"); + fireEvent.mouseDown(newPathInput); + expect(screen.queryByRole("option", { name: "project.key" })).not.toBeInTheDocument(); + + // Free text is still accepted while unpreviewed for the new resourceType. + fireEvent.change(newPathInput, { target: { value: "new.field" } }); + expect(newPathInput).toHaveValue("new.field"); + }); + + it("clearing Row limit entirely unsets it, rather than writing NaN through", () => { + const existing: BeDashboardWidget = { + widgetId: "w1", + displayName: "My list", + resourceType: "case", + shape: "list", + gridWidth: 4, + query: {}, + listLimit: 5, + }; + // `` sanitizes genuinely non-numeric text (verified + // directly against jsdom's own `HTMLInputElement` — it never lets a + // change event carry a value `Number()` would turn into `NaN`) down to + // an empty string before a change event ever fires, so the reachable + // "invalid input" case through this field is the empty string, which + // this asserts resolves to `undefined` (no limit), not `NaN` (which + // `JSON.stringify`s to `null` and would silently corrupt the deployable + // widget JSON — see `Number.isFinite` guard in the field's own + // `onChange`). + const { onSave } = renderDialog({ widget: existing }); + + const rowLimitInput = screen.getByLabelText(/row limit/i); + fireEvent.change(rowLimitInput, { target: { value: "" } }); + expect(rowLimitInput).toHaveValue(null); + + fireEvent.click(screen.getByRole("button", { name: "Save widget" })); + const saved = onSave.mock.calls[0][0] as BeDashboardWidget; + expect(saved.listLimit).toBeUndefined(); + expect(Number.isNaN(saved.listLimit)).toBe(false); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.tsx new file mode 100644 index 0000000000..2977ad8651 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetEditorDialog.tsx @@ -0,0 +1,614 @@ +// 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 { + Autocomplete, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + MenuItem, + TextField, + Typography, +} from "@wso2/oxygen-ui"; +import { Eye, Plus, Trash2 } from "@wso2/oxygen-ui-icons-react"; +import { useMemo, useState, type JSX } from "react"; +import type { + BeDashboardPieSlice, + BeDashboardWidget, + BeDashboardWidgetColumn, + BeDashboardWidgetColumnFormat, + BeWidgetPaletteColor, + BeWidgetResourceType, + BeWidgetShape, +} from "@api/backend/types"; +import { WIDGET_RESOURCE_CONFIG } from "@features/csm-dashboard/config/widgetResourceConfig"; +import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; +import { useWidgetData } from "@features/csm-dashboard/api/useWidgetData"; +import { useCurrentUser } from "@context/current-user/CurrentUserContext"; +import WidgetFilterConditionEditor from "@features/csm-admin/dashboards/components/WidgetFilterConditionEditor"; +import { newWidgetId } from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; +import { discoverAttributePaths } from "@features/csm-admin/dashboards/utils/discoverAttributePaths"; +import { + filterConditionsFromQuery, + queryFromFilterConditions, + type FilterCondition, +} from "@features/csm-admin/dashboards/utils/widgetQueryConditions"; + +const RESOURCE_TYPES = Object.keys(WIDGET_RESOURCE_CONFIG) as BeWidgetResourceType[]; +const SHAPES: BeWidgetShape[] = ["count", "list", "pie", "bar"]; +const PALETTE_COLORS: BeWidgetPaletteColor[] = [ + "primary", + "secondary", + "success", + "error", + "info", + "warning", +]; +const COLUMN_FORMATS: BeDashboardWidgetColumnFormat[] = ["text", "date"]; + +interface SliceDraft { + label: string; + color?: BeWidgetPaletteColor; + conditions: FilterCondition[]; +} + +/** A `columns` row while it's being edited — same shape as + * `BeDashboardWidgetColumn` but `format` is normalized to `""` (rather than + * `undefined`) so it round-trips cleanly through the `TextField select` + * below, which needs a defined `value` to stay a controlled input. */ +interface ColumnDraft { + path: string; + label: string; + format: BeDashboardWidgetColumnFormat | ""; +} + +function columnsToDrafts(columns: BeDashboardWidgetColumn[] | undefined): ColumnDraft[] { + return (columns ?? []).map((c) => ({ path: c.path, label: c.label, format: c.format ?? "" })); +} + +// Mirrors `columns?: []` being a no-op on the wire (see +// `BeDashboardWidget.columns`'s own doc comment): a row missing either half +// of its identity (`path`/`label`) can't resolve or label a cell, so it's +// dropped rather than saved as a broken column. +function draftsToColumns(drafts: ColumnDraft[]): BeDashboardWidgetColumn[] { + return drafts + .filter((d) => d.path.trim().length > 0 && d.label.trim().length > 0) + .map((d) => ({ + path: d.path.trim(), + label: d.label.trim(), + format: d.format || undefined, + })); +} + +function slicesToDrafts(resourceType: BeWidgetResourceType, slices: BeDashboardPieSlice[] | undefined): SliceDraft[] { + return (slices ?? []).map((s) => ({ + label: s.label, + color: s.color, + conditions: filterConditionsFromQuery(resourceType, s.query), + })); +} + +function draftsToSlices(resourceType: BeWidgetResourceType, drafts: SliceDraft[]): BeDashboardPieSlice[] { + return drafts + .filter((d) => d.label.trim().length > 0) + .map((d) => ({ + label: d.label, + color: d.color, + query: queryFromFilterConditions(resourceType, d.conditions), + })); +} + +interface WidgetEditorDialogProps { + /** `undefined` when creating a brand-new widget. */ + widget: BeDashboardWidget | undefined; + /** Pre-fills the section field for a brand-new widget created via a + * specific section's own "Add widget" action (see the editor page) — + * ignored when `widget` is set (editing keeps that widget's own + * section). */ + defaultSection?: string; + /** Existing section names on this dashboard draft, offered as + * autocomplete suggestions (freeform text is still accepted — a widget + * can also start a brand-new section right here). */ + sectionSuggestions: string[]; + /** The team the Preview tile below should scope its data to, threaded + * through exactly as `DashboardWidgetGrid` threads it to every real tile + * (see that component's own doc comment) — otherwise a widget using the + * `__current_team__` filter placeholder or a `{{currentTeam}}` display-text + * token previews unfiltered data / an unresolved placeholder instead of + * what an admin would actually see on the live dashboard. `undefined` for + * a non-team-based dashboard, or while the team isn't resolved yet — see + * the editor page's own doc comment for where this comes from. */ + selectedTeamGroupId?: string | string[]; + /** See `selectedTeamGroupId` above; the human-readable counterpart for the + * `{{currentTeam}}` text token — see `DashboardWidgetGrid`. */ + selectedTeamLabel?: string; + onClose: () => void; + onSave: (widget: BeDashboardWidget) => void; + onDelete?: () => void; +} + +/** + * Modal editor for a single dashboard widget: a form for everything a + * `BeDashboardWidget` carries (display metadata, resourceType/shape, + * filters, and shape-specific fields), plus a "Preview" button that renders + * the in-progress config through the exact same `DashboardWidgetTile` the + * live dashboard (and this builder's own grid) render with — so a "run the + * current draft through the real resolution path" preview needs no + * parallel fetch/render logic of its own. + */ +export default function WidgetEditorDialog({ + widget, + defaultSection, + sectionSuggestions, + selectedTeamGroupId, + selectedTeamLabel, + onClose, + onSave, + onDelete, +}: WidgetEditorDialogProps): JSX.Element { + const isNew = widget === undefined; + const [widgetId] = useState(() => widget?.widgetId ?? newWidgetId()); + const [displayName, setDisplayName] = useState(widget?.displayName ?? ""); + const [description, setDescription] = useState(widget?.description ?? ""); + const [resourceType, setResourceType] = useState( + widget?.resourceType ?? "case", + ); + const [shape, setShape] = useState(widget?.shape ?? "count"); + const [section, setSection] = useState(widget?.section ?? defaultSection ?? ""); + const [gridWidth, setGridWidth] = useState(widget?.gridWidth ?? 3); + const [listLimit, setListLimit] = useState(widget?.listLimit); + const [groupBy, setGroupBy] = useState(widget?.groupBy ?? ""); + const [conditions, setConditions] = useState(() => + filterConditionsFromQuery(widget?.resourceType ?? "case", widget?.query), + ); + const [sliceDrafts, setSliceDrafts] = useState(() => + slicesToDrafts(widget?.resourceType ?? "case", widget?.slices), + ); + const [columnDrafts, setColumnDrafts] = useState(() => + columnsToDrafts(widget?.columns), + ); + const [previewSnapshot, setPreviewSnapshot] = useState(); + + // A resourceType switch invalidates the previous filter shape entirely + // (see widgetQueryConditions.ts's own doc comment) — rather than silently + // reinterpreting stale rows against a contract they were never written + // for, clear them and let the admin rebuild for the new resourceType. + // Column `path`s are resource-specific too (e.g. `project.key` only + // resolves for a case) — a stale path after switching resourceType would + // render an empty cell under a now-misleading header, so those are + // cleared right alongside the filter/slice conditions. The previous + // Preview snapshot is stale for the same reason (its `discoveredColumnPaths` + // are only valid for the resourceType they were fetched under), so it's + // cleared too — the admin must re-run Preview for the new resourceType + // before any column paths are offered again. + const handleResourceTypeChange = (next: BeWidgetResourceType): void => { + setResourceType(next); + setConditions([]); + setSliceDrafts((prev) => prev.map((d) => ({ ...d, conditions: [] }))); + setColumnDrafts([]); + setPreviewSnapshot(undefined); + }; + + const { user } = useCurrentUser(); + // Only meaningful for a list-shape widget (columns are the only thing + // that needs real attribute paths) — and gated on `previewSnapshot` + // rather than fetched eagerly on dialog open, so this piggybacks on the + // exact same "admin clicked Preview" trigger the `DashboardWidgetTile` + // below already reacts to instead of firing a second, earlier request. + // Called with the *same* arguments `DashboardWidgetTile` passes to this + // same hook internally (see that component's own `useWidgetData` call) — + // matching args means a matching TanStack Query cache key, so this never + // costs a second real network request; it just reads the one the Preview + // tile is already making (or about to make). + const columnPathSampleEnabled = previewSnapshot?.shape === "list"; + const { data: columnPathSampleData } = useWidgetData( + previewSnapshot?.widgetId ?? widgetId, + previewSnapshot?.resourceType ?? resourceType, + previewSnapshot?.query ?? {}, + previewSnapshot?.shape ?? shape, + previewSnapshot?.listLimit, + 0, + columnPathSampleEnabled, + selectedTeamGroupId, + previewSnapshot?.sortBy, + user?.id, + ); + // Paths actually reachable in the widget's own real Preview data, offered + // as autocomplete options for a column's `path` field below — empty until + // Preview has been run at least once for a list-shape widget, in which + // case the path field just behaves like a plain text input (see its own + // helper text). + const discoveredColumnPaths = useMemo( + () => (columnPathSampleData ? discoverAttributePaths(columnPathSampleData.items) : []), + [columnPathSampleData], + ); + + const canSave = displayName.trim().length > 0 && gridWidth >= 1 && gridWidth <= 12; + + const buildWidget = (): BeDashboardWidget => { + // Absent (not an empty array) when unconfigured — the same "no-op, + // existing hardcoded renderer applies" convention + // `BeDashboardWidget.columns`'s own doc comment documents, and the one + // `DashboardWidgetGrid`'s passthrough (`columns={widget.columns}`) and + // `DashboardWidgetTile`'s `hasColumns` check both rely on. + const builtColumns = shape === "list" ? draftsToColumns(columnDrafts) : []; + return { + widgetId, + displayName: displayName.trim(), + description: description.trim() || undefined, + resourceType, + shape, + gridWidth, + query: queryFromFilterConditions(resourceType, conditions), + section: section.trim() || undefined, + groupBy: groupBy.trim() || undefined, + listLimit: shape === "list" ? listLimit : undefined, + slices: + shape === "pie" || shape === "bar" ? draftsToSlices(resourceType, sliceDrafts) : undefined, + columns: builtColumns.length > 0 ? builtColumns : undefined, + }; + }; + + const handlePreview = (): void => setPreviewSnapshot(buildWidget()); + + const handleSave = (): void => { + if (!canSave) return; + onSave(buildWidget()); + }; + + const addSlice = (): void => setSliceDrafts((prev) => [...prev, { label: "", conditions: [] }]); + const removeSlice = (index: number): void => + setSliceDrafts((prev) => prev.filter((_, i) => i !== index)); + const updateSlice = (index: number, patch: Partial): void => + setSliceDrafts((prev) => prev.map((d, i) => (i === index ? { ...d, ...patch } : d))); + + const addColumn = (): void => + setColumnDrafts((prev) => [...prev, { path: "", label: "", format: "" }]); + const removeColumn = (index: number): void => + setColumnDrafts((prev) => prev.filter((_, i) => i !== index)); + const updateColumn = (index: number, patch: Partial): void => + setColumnDrafts((prev) => prev.map((d, i) => (i === index ? { ...d, ...patch } : d))); + + const isChartShape = shape === "pie" || shape === "bar"; + const isListShape = shape === "list"; + const previewKey = useMemo( + () => (previewSnapshot ? JSON.stringify(previewSnapshot) : undefined), + [previewSnapshot], + ); + + return ( + + {isNew ? "Add widget" : "Edit widget"} + + + + setDisplayName(e.target.value)} + required + size="small" + sx={{ flex: "1 1 260px" }} + slotProps={{ htmlInput: { "aria-label": "Widget display name" } }} + /> + setDescription(e.target.value)} + size="small" + sx={{ flex: "1 1 260px" }} + /> + + + + handleResourceTypeChange(e.target.value as BeWidgetResourceType)} + size="small" + sx={{ minWidth: 200 }} + > + {RESOURCE_TYPES.map((rt) => ( + + {rt.replace(/_/g, " ")} + + ))} + + setShape(e.target.value as BeWidgetShape)} + size="small" + sx={{ minWidth: 160 }} + > + {SHAPES.map((s) => ( + + {s} + + ))} + + setSection(value)} + sx={{ minWidth: 200, flex: "1 1 200px" }} + renderInput={(params) => ( + + )} + /> + setGridWidth(Math.min(12, Math.max(1, Number(e.target.value) || 1)))} + size="small" + sx={{ width: 160 }} + slotProps={{ htmlInput: { min: 1, max: 12 } }} + /> + {shape === "list" && ( + { + const raw = e.target.value; + if (raw === "") { + setListLimit(undefined); + return; + } + const parsed = Number(raw); + // Same clamp `gridWidth` above applies, plus a guard + // `gridWidth` doesn't need: an invalid (non-numeric, e.g. + // pasted text) keystroke is ignored outright rather than + // written through as `NaN` — `JSON.stringify`s a `NaN` to + // `null` in the deployable widget JSON, silently corrupting + // it. Falls back to the previous valid value, not a + // default, since "no explicit limit" (`undefined`) is + // already reachable via the empty-string case above. + if (Number.isFinite(parsed)) setListLimit(Math.max(1, Math.trunc(parsed))); + }} + size="small" + sx={{ width: 180 }} + slotProps={{ htmlInput: { min: 1 } }} + /> + )} + setGroupBy(e.target.value)} + size="small" + sx={{ minWidth: 180 }} + helperText="Present on the wire; not used by the frontend today." + /> + + + + + Filters + + + {isListShape && ( + <> + + + Columns — leave empty to use this resource type's default list rendering + + {columnDrafts.map((column, index) => ( + + updateColumn(index, { path: value })} + sx={{ flex: "1 1 200px" }} + renderInput={(params) => ( + + )} + /> + updateColumn(index, { label: e.target.value })} + size="small" + sx={{ flex: "1 1 160px" }} + slotProps={{ htmlInput: { "aria-label": "Column label" } }} + /> + + updateColumn(index, { + format: e.target.value as BeDashboardWidgetColumnFormat | "", + }) + } + size="small" + sx={{ minWidth: 140 }} + > + Default (text) + {COLUMN_FORMATS.map((f) => ( + + {f} + + ))} + + removeColumn(index)} + > + + + + ))} + + + )} + + {isChartShape && ( + <> + + + Slices — one search per slice, each merged under the filters above + + {sliceDrafts.map((slice, index) => ( + + + updateSlice(index, { label: e.target.value })} + size="small" + sx={{ flex: "1 1 200px" }} + /> + + updateSlice(index, { + color: (e.target.value || undefined) as BeWidgetPaletteColor | undefined, + }) + } + size="small" + sx={{ minWidth: 160 }} + > + Default rotation + {PALETTE_COLORS.map((c) => ( + + {c} + + ))} + + removeSlice(index)} + > + + + + updateSlice(index, { conditions: next })} + /> + + ))} + + + )} + + + + + Preview + + + {previewSnapshot ? ( + // A list-shape widget renders a real multi-column table (same as + // `DashboardWidgetGrid`'s own `widgetGridColumnSx`, which spans a + // list tile the full row regardless of its configured + // `gridWidth`) — it should preview at the dialog's actual + // content width, not a fixed cap. Count/pie/bar tiles are + // compact by design (a big number, a small chart); capping them + // keeps the preview from stretching those shapes edge-to-edge, + // which would look wrong next to how they actually render on a + // real dashboard grid cell. + + + + ) : ( + + Click "Preview" to run this widget's current settings against real data before + saving. + + )} + + + + + {!isNew && onDelete && ( + + )} + + + + + + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetFilterConditionEditor.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetFilterConditionEditor.test.tsx new file mode 100644 index 0000000000..0c853030d2 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/components/WidgetFilterConditionEditor.test.tsx @@ -0,0 +1,134 @@ +// 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 { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { useState } from "react"; +import WidgetFilterConditionEditor from "@features/csm-admin/dashboards/components/WidgetFilterConditionEditor"; +import type { FilterCondition } from "@features/csm-admin/dashboards/utils/widgetQueryConditions"; + +function Harness({ + initial, + resourceType = "case", + onChangeSpy, +}: { + initial: FilterCondition[]; + resourceType?: "case" | "incident"; + onChangeSpy?: (next: FilterCondition[]) => void; +}) { + const [conditions, setConditions] = useState(initial); + return ( + { + setConditions(next); + onChangeSpy?.(next); + }} + /> + ); +} + +describe("WidgetFilterConditionEditor", () => { + it("shows an empty-filters message and no rows when there are no conditions", () => { + render(); + expect(screen.getByText(/matches every case record/i)).toBeInTheDocument(); + expect(screen.queryByLabelText("Filter field")).not.toBeInTheDocument(); + }); + + it("adds a new empty row when 'Add filter' is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add filter/i })); + expect(screen.getAllByLabelText("Filter field")).toHaveLength(1); + }); + + it("removes a row when its own remove button is clicked", () => { + render( + , + ); + expect(screen.getAllByLabelText("Filter field")).toHaveLength(2); + + fireEvent.click(screen.getAllByRole("button", { name: "Remove filter" })[0]); + + expect(screen.getAllByLabelText("Filter field")).toHaveLength(1); + expect(screen.getByDisplayValue("severity")).toBeInTheDocument(); + }); + + it("hides the value input for a value-less op (isEmpty)", () => { + render(); + expect(screen.queryByLabelText("Filter value")).not.toBeInTheDocument(); + }); + + it("shows the value input for a value-carrying op", () => { + render(); + expect(screen.getByLabelText("Filter value")).toBeInTheDocument(); + }); + + it("calls onChange with an updated op when the operator select changes", () => { + const onChangeSpy = vi.fn(); + render( + , + ); + // MUI Select renders its current value in a `role="combobox"` element — + // open it and pick the option, rather than firing a raw DOM `change` + // (there's no native setPreviewTeamOverride(e.target.value || undefined)} + displayEmpty + aria-label="Preview team" + > + All ABTs + {(teams.data ?? []).map((t) => ( + + {t.name} + + ))} + + + )} + + + + + + + + } + > + {working.widgets.length === 0 && emptySectionsToRender.length === 0 ? ( + + This dashboard has no widgets yet — use "Add widget" above to add the first one. + + ) : ( + ( + + + setEditingWidget({ widget })} + sx={{ bgcolor: "background.paper", boxShadow: 1 }} + > + + + + + + setPendingRemoval({ + kind: "widget", + widgetId: widget.widgetId, + label: widget.displayName, + }) + } + sx={{ bgcolor: "background.paper", boxShadow: 1 }} + > + + + + + )} + renderSectionActions={(section, sectionTitle, widgetIds) => + section ? ( + <> + + + setPendingRemoval({ kind: "section", section })} + > + + + + + ) : undefined + } + trailingContent={ + emptySectionsToRender.length > 0 && ( + + {emptySectionsToRender.map((section) => ( + + + {section} + + + + setPendingRemoval({ kind: "section", section })} + > + + + + + ))} + + ) + } + /> + )} + + + {editingWidget && ( + setEditingWidget(undefined)} + onSave={handleSaveWidget} + onDelete={ + editingWidget.widget + ? () => { + setPendingRemoval({ + kind: "widget", + widgetId: editingWidget.widget!.widgetId, + label: editingWidget.widget!.displayName, + }); + setEditingWidget(undefined); + } + : undefined + } + /> + )} + + setPendingRemoval(undefined)} maxWidth="xs" fullWidth> + + {pendingRemoval?.kind === "section" ? "Remove section?" : "Remove widget?"} + + + + {pendingRemoval?.kind === "section" + ? `This removes "${pendingRemoval.section}" and every widget in it from this draft. This only affects your local draft.` + : pendingRemoval?.kind === "widget" + ? `This removes "${pendingRemoval.label}" from this draft. This only affects your local draft.` + : ""} + + + + + + + + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.test.tsx new file mode 100644 index 0000000000..8899399b14 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.test.tsx @@ -0,0 +1,187 @@ +// 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 { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router"; + +const getMock = vi.fn(); + +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: getMock }), +})); + +import CsmDashboardBuilderListPage from "@features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage"; +import { saveDashboardDraft } from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderPage() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + } /> + } /> + + + , + ); +} + +describe("CsmDashboardBuilderListPage", () => { + beforeEach(() => { + getMock.mockReset(); + localStorage.clear(); + }); + + it("lists every deployed dashboard and links each to its own editor route", async () => { + getMock.mockResolvedValue([ + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, isTeamBased: false }, + { id: "team_perf", displayName: "Team performance", isDefault: false, isTeamBased: true }, + ]); + + renderPage(); + + await waitFor(() => expect(screen.getByText("Engineer overview")).toBeInTheDocument()); + expect(screen.getByText("Team performance")).toBeInTheDocument(); + + const editButtons = screen.getAllByRole("button", { name: "Edit" }); + fireEvent.click(editButtons[0]); + + await waitFor(() => + expect(screen.getByTestId("location-probe")).toHaveTextContent("/admin/dashboards/agents_pilot"), + ); + }); + + it("shows an empty state instead of a blank list when no dashboards are registered", async () => { + getMock.mockResolvedValue([]); + renderPage(); + expect(await screen.findByText(/no dashboards are registered/i)).toBeInTheDocument(); + }); + + it("navigates to a freshly generated draft id when 'Create new dashboard' is clicked", async () => { + getMock.mockResolvedValue([]); + renderPage(); + await waitFor(() => expect(screen.getByText(/no dashboards are registered/i)).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: /create new dashboard/i })); + + await waitFor(() => expect(screen.getByTestId("location-probe")).toBeInTheDocument()); + expect(screen.getByTestId("location-probe").textContent).toMatch(/^\/admin\/dashboards\/draft-/); + }); + + it("lists a local draft that has no matching deployed dashboard, and lets it be discarded", async () => { + getMock.mockResolvedValue([]); + saveDashboardDraft({ + id: "draft-orphan-1", + displayName: "My new dashboard", + isDefault: false, + isTeamBased: false, + widgets: [], + emptySections: [], + }); + + renderPage(); + + expect(await screen.findByText("My new dashboard")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /discard draft/i })); + + await waitFor(() => expect(screen.queryByText("My new dashboard")).not.toBeInTheDocument()); + }); + + it("flags a deployed dashboard that has a local draft with unsaved-to-deployment changes", async () => { + // The list response (`GET /dashboards`) and the drift chip's own detail + // fetch (`GET /dashboards/agents_pilot`) share the same mocked `get` — + // differentiate by path so the drift chip sees a REAL, materially + // different live dashboard, not the list-shaped payload. + getMock.mockImplementation((path: string) => { + if (path === "/dashboards") { + return Promise.resolve([ + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, isTeamBased: false }, + ]); + } + if (path === "/dashboards/agents_pilot") { + return Promise.resolve({ + id: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + isTeamBased: false, + widgets: [], + }); + } + return Promise.resolve(null); + }); + saveDashboardDraft({ + id: "agents_pilot", + sourceDashboardId: "agents_pilot", + displayName: "Engineer overview (renamed locally)", + isDefault: true, + isTeamBased: false, + widgets: [], + emptySections: [], + }); + + renderPage(); + + expect(await screen.findByText("Engineer overview")).toBeInTheDocument(); + expect(await screen.findByText("Local draft")).toBeInTheDocument(); + }); + + it("does NOT flag a deployed dashboard whose local draft is byte-identical to what's deployed", async () => { + getMock.mockImplementation((path: string) => { + if (path === "/dashboards") { + return Promise.resolve([ + { id: "agents_pilot", displayName: "Engineer overview", isDefault: true, isTeamBased: false }, + ]); + } + if (path === "/dashboards/agents_pilot") { + return Promise.resolve({ + id: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + isTeamBased: false, + widgets: [], + }); + } + return Promise.resolve(null); + }); + // Same content as the live dashboard above — merely having a local + // draft record must not, by itself, imply divergence. + saveDashboardDraft({ + id: "agents_pilot", + sourceDashboardId: "agents_pilot", + displayName: "Engineer overview", + isDefault: true, + isTeamBased: false, + widgets: [], + emptySections: [], + }); + + renderPage(); + + expect(await screen.findByText("Engineer overview")).toBeInTheDocument(); + await waitFor(() => expect(getMock).toHaveBeenCalledWith("/dashboards/agents_pilot")); + expect(screen.queryByText("Local draft")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.tsx new file mode 100644 index 0000000000..9ceb5c6d22 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/CsmDashboardBuilderListPage.tsx @@ -0,0 +1,207 @@ +// 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, + Button, + Card, + Chip, + IconButton, + Skeleton, + Tooltip, + Typography, +} from "@wso2/oxygen-ui"; +import { LayoutGrid, Plus, Trash2 } from "@wso2/oxygen-ui-icons-react"; +import { useMemo, type JSX } from "react"; +import { useNavigate } from "react-router"; +import { useDashboardList } from "@features/csm-dashboard/api/useDashboardList"; +import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; +import { isDraftDrifted } from "@features/csm-admin/dashboards/utils/dashboardDrift"; +import { + deleteDashboardDraft, + newDraftId, + useDashboardDraft, + useDashboardDrafts, +} from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; + +/** + * Per-row drift indicator for a deployed dashboard that also has a local + * draft. `CsmDashboardBuilderListPage` only knows draft EXISTENCE + * (`draftIds`, from the cheap `useDashboardDrafts()` list) — this component + * fetches that one dashboard's own live detail (only for rows that actually + * have a draft, gated by the caller) and reuses the same `isDraftDrifted` + * comparison the editor page's own banner uses, so the badge only appears + * when the draft ACTUALLY differs from what's deployed, not merely because a + * draft record exists (e.g. one saved byte-identical to the live dashboard, + * or re-saved after "open, look, do nothing"). + */ +function LocalDraftDriftChip({ dashboardId }: { dashboardId: string }): JSX.Element | null { + const draft = useDashboardDraft(dashboardId); + const live = useDashboard(dashboardId); + if (!draft || live.isLoading || live.isError) return null; + if (!isDraftDrifted(draft, live.data ?? undefined)) return null; + return ( + + + + ); +} + +/** + * Admin-only landing page for the dashboard builder: every deployed + * dashboard (`GET /dashboards`), each openable for edit, plus any local + * draft that hasn't (yet) been opened from — or matched to — a deployed + * one. There is no dashboard CRUD API; "Edit" always opens the builder + * against a `localStorage` draft, seeded from the live dashboard the first + * time it's opened (see `CsmDashboardBuilderEditorPage`). + */ +export default function CsmDashboardBuilderListPage(): JSX.Element { + const navigate = useNavigate(); + const { data: dashboards, isLoading, isError } = useDashboardList(); + const drafts = useDashboardDrafts(); + + const liveIds = useMemo(() => new Set((dashboards ?? []).map((d) => d.id)), [dashboards]); + const draftIds = useMemo(() => new Set(drafts.map((d) => d.id)), [drafts]); + // Drafts that don't (yet) correspond to any deployed dashboard — either a + // brand-new dashboard that's never been deployed, or a draft whose + // deployed source has since been removed from the registry. + const orphanDrafts = useMemo(() => drafts.filter((d) => !liveIds.has(d.id)), [drafts, liveIds]); + + const handleCreate = (): void => { + navigate(`/admin/dashboards/${newDraftId()}`); + }; + + const handleDiscardDraft = (id: string): void => { + deleteDashboardDraft(id); + }; + + return ( + + + + Build or adjust a dashboard's widgets, then hand the exported JSON to a maintainer to + deploy — this builder never writes to the live registry itself. + + + + + {isError ? ( + + Could not load the dashboard list. + + ) : isLoading ? ( + + {Array.from({ length: 3 }, (_, i) => ( + + ))} + + ) : (dashboards ?? []).length === 0 ? ( + + No dashboards are registered in this deployment yet. + + ) : ( + + {(dashboards ?? []).map((d) => ( + + + + + + {d.displayName} + + + {d.id} + + + {d.isDefault && } + {d.isTeamBased && } + {d.type && } + {draftIds.has(d.id) && } + + + + ))} + + )} + + {orphanDrafts.length > 0 && ( + + + Local drafts not yet deployed + + {orphanDrafts.map((d) => ( + + + + {d.displayName || "Untitled dashboard"} + + + Saved locally {new Date(d.updatedAt).toLocaleString()} + + + + + + handleDiscardDraft(d.id)} + > + + + + + + ))} + + )} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.test.tsx new file mode 100644 index 0000000000..18b123cc1f --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.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 } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter, Route, Routes } from "react-router"; + +let mockUser: { roles?: string[] } | undefined = { roles: ["admin"] }; +let mockIsLoading = false; +let mockIsError = false; + +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ user: mockUser, isLoading: mockIsLoading, isError: mockIsError }), +})); + +import DashboardBuilderRouteGuard from "@features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard"; + +function renderGuard() { + return render( + + + }> + Dashboard builder content} /> + + + , + ); +} + +describe("DashboardBuilderRouteGuard", () => { + it("renders the guarded route for an admin user", () => { + mockUser = { roles: ["admin"] }; + mockIsLoading = false; + mockIsError = false; + renderGuard(); + expect(screen.getByText("Dashboard builder content")).toBeInTheDocument(); + }); + + it("renders a 403 instead of the route for a non-admin user", () => { + mockUser = { roles: ["agent"] }; + mockIsLoading = false; + mockIsError = false; + renderGuard(); + expect(screen.queryByText("Dashboard builder content")).not.toBeInTheDocument(); + expect(screen.getByText(/admin role/i)).toBeInTheDocument(); + }); + + it("shows a loading state rather than a premature 403 while the profile is still loading", () => { + mockUser = undefined; + mockIsLoading = true; + mockIsError = false; + const { container } = renderGuard(); + expect(screen.queryByText("Dashboard builder content")).not.toBeInTheDocument(); + expect(screen.queryByText(/admin role/i)).not.toBeInTheDocument(); + expect(container.querySelector(".MuiSkeleton-root")).toBeInTheDocument(); + }); + + it("denies rather than hangs forever when the profile fetch errored", () => { + mockUser = undefined; + mockIsLoading = true; + mockIsError = true; + renderGuard(); + expect(screen.getByText(/admin role/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.tsx b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.tsx new file mode 100644 index 0000000000..4aba58241a --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/pages/DashboardBuilderRouteGuard.tsx @@ -0,0 +1,47 @@ +// 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 JSX } from "react"; +import { Outlet } from "react-router"; +import { Skeleton } from "@wso2/oxygen-ui"; +import Error403Page from "@components/error/Error403Page"; +import { useCurrentUser } from "@context/current-user/CurrentUserContext"; +import { hasDashboardBuilderAccess } from "@features/csm-admin/dashboards/utils/dashboardBuilderAccess"; + +/** + * Route guard for every `/admin/dashboards*` route: hiding the nav tab (see + * `CsmAdminLayout`) stops a click, not a direct/bookmarked URL — this is + * the actual enforcement. Still frontend-only (see + * `dashboardBuilderAccess.ts`): there is no backend endpoint behind this + * feature to fall back on, everything it does is local `localStorage`. + */ +export default function DashboardBuilderRouteGuard(): JSX.Element { + const { user, isLoading, isError } = useCurrentUser(); + + // Hold the render open (rather than flash a 403 then swap to the real + // page) while the profile — the only place the admin role lives — is + // still in flight. A failed profile fetch (isError) must not hang this + // forever, so it falls through to the (denying) check below. + if (isLoading && !isError) { + return ; + } + + if (!hasDashboardBuilderAccess(user?.roles)) { + return ; + } + + return ; +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.test.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.test.ts new file mode 100644 index 0000000000..2810025178 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.test.ts @@ -0,0 +1,37 @@ +// 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 { describe, expect, it } from "vitest"; +import { hasDashboardBuilderAccess } from "@features/csm-admin/dashboards/utils/dashboardBuilderAccess"; + +describe("hasDashboardBuilderAccess", () => { + it("is true when roles include admin", () => { + expect(hasDashboardBuilderAccess(["agent", "admin"])).toBe(true); + }); + + it("matches case-insensitively", () => { + expect(hasDashboardBuilderAccess(["Admin"])).toBe(true); + }); + + it("is false without the admin role", () => { + expect(hasDashboardBuilderAccess(["agent", "commenter"])).toBe(false); + }); + + it("is false for undefined/empty roles", () => { + expect(hasDashboardBuilderAccess(undefined)).toBe(false); + expect(hasDashboardBuilderAccess([])).toBe(false); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.ts new file mode 100644 index 0000000000..59e8d71b64 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardBuilderAccess.ts @@ -0,0 +1,37 @@ +// 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. + +/** + * The platform's own admin role key, as returned in `GET /users/me`'s + * `roles` (see `UsersMeResponse.roles`) — the same catalogue `GET /roles` + * offers (see `CsmRolesPage`). Matched case-insensitively, same as + * `useTimecardRole` does for its own group keys. + */ +const ADMIN_ROLE_KEY = "admin"; + +/** + * True when the given `GET /users/me` roles include the platform admin + * role. Frontend-only gate for the dashboard builder (see + * `csmNavItems.ts`'s own comment on `admin.dashboards`): unlike every other + * `/admin` tab, this one has no privileged backend action to fall back on + * for enforcement — everything the builder does is local to the browser + * (`localStorage` only), so hiding it here IS the whole gate. Every other + * `/admin` page deliberately does NOT do this (see App.tsx), so don't reuse + * this helper to gate anything else without re-checking that reasoning. + */ +export function hasDashboardBuilderAccess(roles: string[] | undefined): boolean { + return (roles ?? []).some((r) => r.toLowerCase() === ADMIN_ROLE_KEY); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.test.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.test.ts new file mode 100644 index 0000000000..88645bb630 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.test.ts @@ -0,0 +1,141 @@ +// 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, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + deleteDashboardDraft, + getDashboardDraft, + listDashboardDrafts, + newDraftId, + newWidgetId, + saveDashboardDraft, + useDashboardDraft, + useDashboardDrafts, +} from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; + +const BASE = { + displayName: "Engineer overview", + isDefault: false, + isTeamBased: false, + widgets: [], + emptySections: [], +}; + +describe("dashboardDraftsStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns undefined for a draft that was never saved", () => { + expect(getDashboardDraft("nope")).toBeUndefined(); + }); + + it("saves and reads back a draft, stamping updatedAt", () => { + saveDashboardDraft({ id: "d1", ...BASE }); + const read = getDashboardDraft("d1"); + expect(read?.id).toBe("d1"); + expect(read?.displayName).toBe("Engineer overview"); + expect(typeof read?.updatedAt).toBe("string"); + }); + + it("overwrites a draft saved again under the same id", () => { + saveDashboardDraft({ id: "d1", ...BASE, displayName: "First" }); + saveDashboardDraft({ id: "d1", ...BASE, displayName: "Second" }); + expect(getDashboardDraft("d1")?.displayName).toBe("Second"); + expect(listDashboardDrafts()).toHaveLength(1); + }); + + it("deletes a draft", () => { + saveDashboardDraft({ id: "d1", ...BASE }); + deleteDashboardDraft("d1"); + expect(getDashboardDraft("d1")).toBeUndefined(); + }); + + it("newDraftId and newWidgetId never collide across calls", () => { + const ids = new Set(Array.from({ length: 20 }, () => newDraftId())); + expect(ids.size).toBe(20); + const widgetIds = new Set(Array.from({ length: 20 }, () => newWidgetId())); + expect(widgetIds.size).toBe(20); + }); + + it("lists drafts most-recently-updated first", async () => { + saveDashboardDraft({ id: "older", ...BASE }); + await new Promise((r) => setTimeout(r, 2)); + saveDashboardDraft({ id: "newer", ...BASE }); + const ids = listDashboardDrafts().map((d) => d.id); + expect(ids[0]).toBe("newer"); + expect(ids[1]).toBe("older"); + }); + + it("useDashboardDrafts reacts to a save made elsewhere", () => { + const { result } = renderHook(() => useDashboardDrafts()); + expect(result.current).toHaveLength(0); + + act(() => { + saveDashboardDraft({ id: "d1", ...BASE }); + }); + + expect(result.current).toHaveLength(1); + expect(result.current[0].id).toBe("d1"); + }); + + it("useDashboardDraft reacts to a save of its own id, ignoring saves of other ids", () => { + const { result } = renderHook(() => useDashboardDraft("d1")); + expect(result.current).toBeUndefined(); + + act(() => { + saveDashboardDraft({ id: "other", ...BASE }); + }); + expect(result.current).toBeUndefined(); + + act(() => { + saveDashboardDraft({ id: "d1", ...BASE, displayName: "Mine" }); + }); + expect(result.current?.displayName).toBe("Mine"); + }); + + it("ignores corrupt JSON in localStorage rather than throwing", () => { + localStorage.setItem("csm.dashboardBuilder.drafts.v1", "{not json"); + expect(listDashboardDrafts()).toEqual([]); + expect(() => saveDashboardDraft({ id: "d1", ...BASE })).not.toThrow(); + }); + + it("silently ignores a syntactically-valid-JSON but incomplete stored draft, rather than crashing the sort", () => { + // A record missing `updatedAt` (e.g. hand-edited, or written by an + // older/newer shape of this feature) used to pass the old `isDraft` + // check, then crash `listDashboardDrafts`'s own + // `a.updatedAt.localeCompare(b.updatedAt)` sort by calling + // `.localeCompare` on `undefined`. + localStorage.setItem( + "csm.dashboardBuilder.drafts.v1", + JSON.stringify({ + incomplete: { + id: "incomplete", + displayName: "Missing required fields", + widgets: [], + // isDefault, isTeamBased, emptySections, updatedAt all absent. + }, + d1: { id: "d1", ...BASE, updatedAt: "2026-08-11T00:00:00.000Z" }, + }), + ); + + expect(() => listDashboardDrafts()).not.toThrow(); + const ids = listDashboardDrafts().map((d) => d.id); + expect(ids).toEqual(["d1"]); + expect(getDashboardDraft("incomplete")).toBeUndefined(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.ts new file mode 100644 index 0000000000..f9d36f5aa1 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDraftsStorage.ts @@ -0,0 +1,191 @@ +// 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 { useEffect, useState } from "react"; +import type { BeDashboardWidget } from "@api/backend/types"; + +/** + * A dashboard the builder is editing, persisted to `localStorage` only — + * there is no dashboard CRUD API and none is added by this feature (the + * deployed dashboard registry is a static config file, redeployed + * out-of-band by a maintainer). See `DashboardBuilderEditorPage`'s own doc + * comment for the deploy story. + */ +export interface DashboardDraft { + /** + * Local draft id — the storage key. For a draft opened from a deployed + * dashboard this equals that dashboard's own `id` (so returning to + * "Edit" on the same dashboard always resumes the same in-progress + * draft instead of starting over); for a brand-new dashboard this is a + * generated id (see `newDraftId`) that never collides with a real + * deployed dashboard id. + */ + id: string; + /** + * The deployed dashboard this draft was opened from, if any — used only + * to fetch its live `GET /dashboards/{id}` for the drift check (see + * `useDashboardDrift`). Absent for a draft that was never opened from a + * deployed dashboard (a brand-new, not-yet-deployed dashboard). + */ + sourceDashboardId?: string; + displayName: string; + type?: "cre" | "sre" | "cs"; + isDefault: boolean; + isTeamBased: boolean; + targetTeam?: string; + widgets: BeDashboardWidget[]; + /** + * Section names with no widgets in them yet — UI-only scaffolding so + * "Add section" can create an empty section shell before any widget is + * placed in it. A name here that later gains a widget (some + * `widget.section === name`) becomes redundant and is dropped on the + * next save. Never appears in the deployed dashboard JSON shape, which + * has no first-class section concept — sections there are purely an + * emergent grouping of `widget.section` values. + */ + emptySections: string[]; + /** ISO timestamp of the last local save, shown in the editor and used to + * order the builder's own "local drafts" list. */ + updatedAt: string; +} + +const STORAGE_KEY = "csm.dashboardBuilder.drafts.v1"; +const STORAGE_EVENT = "csm:dashboard-drafts-changed"; + +// Every REQUIRED `DashboardDraft` field is checked here — a record missing +// any of them (e.g. hand-edited in devtools, or written by an older/newer +// version of this feature with a different shape) is dropped by +// `readDraftsMap` rather than accepted and handed back to a caller that +// assumes the full shape. `updatedAt` in particular: `listDashboardDrafts` +// sorts on `a.updatedAt.localeCompare(b.updatedAt)`, which throws on +// `undefined` — a record missing it used to crash that sort outright rather +// than being silently ignored. +function isDraft(v: unknown): v is DashboardDraft { + if (typeof v !== "object" || v === null) return false; + const d = v as DashboardDraft; + return ( + typeof d.id === "string" && + typeof d.displayName === "string" && + typeof d.isDefault === "boolean" && + typeof d.isTeamBased === "boolean" && + Array.isArray(d.widgets) && + Array.isArray(d.emptySections) && + typeof d.updatedAt === "string" + ); +} + +function readDraftsMap(): Record { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(parsed as Record)) { + if (isDraft(value)) out[id] = value; + } + return out; + } catch { + return {}; + } +} + +function writeDraftsMap(map: Record): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); + // In-tab listeners (the native `storage` event only fires cross-tab). + window.dispatchEvent(new CustomEvent(STORAGE_EVENT)); + } catch { + // ignore quota / serialization errors — the in-memory draft the editor + // is holding is unaffected, only persistence silently fails. + } +} + +/** A new, never-colliding draft id for a brand-new (not-yet-deployed) + * dashboard. */ +export function newDraftId(): string { + return `draft-${crypto.randomUUID()}`; +} + +/** A new, never-colliding widget id, for a widget created in the builder. */ +export function newWidgetId(): string { + return `widget-${crypto.randomUUID()}`; +} + +/** Reads a single draft by id, or `undefined` if none is saved. */ +export function getDashboardDraft(id: string): DashboardDraft | undefined { + return readDraftsMap()[id]; +} + +/** Every locally saved draft, most recently updated first. */ +export function listDashboardDrafts(): DashboardDraft[] { + return Object.values(readDraftsMap()).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +/** Saves (creates or overwrites) a draft, stamping `updatedAt` to now. */ +export function saveDashboardDraft(draft: Omit): DashboardDraft { + const stamped: DashboardDraft = { ...draft, updatedAt: new Date().toISOString() }; + const map = readDraftsMap(); + map[draft.id] = stamped; + writeDraftsMap(map); + return stamped; +} + +/** Deletes a draft by id. No-op if it doesn't exist. */ +export function deleteDashboardDraft(id: string): void { + const map = readDraftsMap(); + if (!(id in map)) return; + delete map[id]; + writeDraftsMap(map); +} + +/** Reactive list of every locally saved draft — updates across components + * and browser tabs. Mirrors `useSavedFilterViews`'s own storage-event + * idiom (see `savedFilterViews.ts`). */ +export function useDashboardDrafts(): DashboardDraft[] { + const [drafts, setDrafts] = useState(() => listDashboardDrafts()); + useEffect(() => { + const sync = () => setDrafts(listDashboardDrafts()); + window.addEventListener(STORAGE_EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(STORAGE_EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, []); + return drafts; +} + +/** Reactive single draft, kept in sync across components/tabs the same way + * `useDashboardDrafts` is — used by the editor page so its own `saveDraft` + * elsewhere (or another tab's edit of the same draft id) is reflected + * without a manual re-read. */ +export function useDashboardDraft(id: string | undefined): DashboardDraft | undefined { + const [draft, setDraft] = useState(() => + id ? getDashboardDraft(id) : undefined, + ); + useEffect(() => { + const sync = () => setDraft(id ? getDashboardDraft(id) : undefined); + sync(); + window.addEventListener(STORAGE_EVENT, sync); + window.addEventListener("storage", sync); + return () => { + window.removeEventListener(STORAGE_EVENT, sync); + window.removeEventListener("storage", sync); + }; + }, [id]); + return draft; +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.test.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.test.ts new file mode 100644 index 0000000000..24ef5ab4de --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.test.ts @@ -0,0 +1,106 @@ +// 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 { describe, expect, it } from "vitest"; +import { isDraftDrifted } from "@features/csm-admin/dashboards/utils/dashboardDrift"; +import type { DashboardDraft } from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; +import type { BeDashboard } from "@api/backend/types"; + +const WIDGET = { + widgetId: "w1", + displayName: "My Widget", + resourceType: "case", + shape: "count", + gridWidth: 3, + query: { filters: [{ field: "state", op: "in", values: ["open"] }] }, +} as const; + +function draft(overrides: Partial = {}): DashboardDraft { + return { + id: "d1", + sourceDashboardId: "live1", + displayName: "Engineer overview", + isDefault: false, + isTeamBased: false, + widgets: [WIDGET], + emptySections: [], + updatedAt: "2026-08-11T00:00:00.000Z", + ...overrides, + }; +} + +function live(overrides: Partial = {}): BeDashboard { + return { + id: "live1", + displayName: "Engineer overview", + isDefault: false, + isTeamBased: false, + widgets: [WIDGET], + ...overrides, + }; +} + +describe("isDraftDrifted", () => { + it("is not drifted when the draft matches the live dashboard exactly", () => { + expect(isDraftDrifted(draft(), live())).toBe(false); + }); + + it("is not drifted merely because keys are in a different order", () => { + const reordered: BeDashboard = { + widgets: [WIDGET], + isTeamBased: false, + isDefault: false, + displayName: "Engineer overview", + id: "live1", + }; + expect(isDraftDrifted(draft(), reordered)).toBe(false); + }); + + it("is drifted when a widget's filters differ", () => { + const changed = draft({ + widgets: [{ ...WIDGET, query: { filters: [{ field: "state", op: "in", values: ["closed"] }] } }], + }); + expect(isDraftDrifted(changed, live())).toBe(true); + }); + + it("is drifted when displayName differs", () => { + expect(isDraftDrifted(draft({ displayName: "Renamed" }), live())).toBe(true); + }); + + it("is always drifted (nothing deployed to compare against) when there's no live dashboard", () => { + expect(isDraftDrifted(draft({ sourceDashboardId: undefined }), undefined)).toBe(true); + }); + + it("is drifted when there's no sourceDashboardId, even if a live dashboard is passed and matches by content", () => { + // A draft that was never actually opened FROM a deployed dashboard is + // "not yet tied to any deployed dashboard" (this function's own doc + // comment) — a caller passing a live dashboard alongside it anyway + // (e.g. matched only by a shared id) must not fall through to a + // content-equality check. + const neverDeployed = draft({ sourceDashboardId: undefined }); + expect(isDraftDrifted(neverDeployed, live())).toBe(true); + }); + + it("ignores builder-only bookkeeping fields (id, sourceDashboardId, emptySections, updatedAt)", () => { + const withBookkeeping = draft({ + id: "some-other-id", + sourceDashboardId: "live1", + emptySections: ["Not yet populated"], + updatedAt: "2099-01-01T00:00:00.000Z", + }); + expect(isDraftDrifted(withBookkeeping, live())).toBe(false); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.ts new file mode 100644 index 0000000000..5d06cd9d00 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/dashboardDrift.ts @@ -0,0 +1,100 @@ +// 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 { BeDashboard, BeDashboardWidget } from "@api/backend/types"; +import type { DashboardDraft } from "@features/csm-admin/dashboards/utils/dashboardDraftsStorage"; + +/** + * Deterministic stringification (object keys sorted recursively) so two + * structurally-equal-but-differently-ordered objects compare equal — a + * plain `JSON.stringify` would treat `{a:1,b:2}` and `{b:2,a:1}` as + * different, which would false-positive a drift warning on every draft + * whose widgets/filters happen to have been rebuilt in a different key + * order than what the backend returns. + */ +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = canonicalize((value as Record)[key]); + } + return out; + } + return value; +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} + +/** The subset of `BeDashboard`/`DashboardDraft` that actually gets deployed + * — comparing on this (rather than the full draft object, which also + * carries builder-only bookkeeping like `id`/`sourceDashboardId`/ + * `emptySections`/`updatedAt`) is what makes the comparison meaningful: + * those fields differ by construction and would make every draft look + * perpetually drifted. */ +interface DeployableDashboardShape { + displayName: string; + isDefault: boolean; + isTeamBased: boolean; + targetTeam?: string; + widgets: BeDashboardWidget[]; +} + +// `type` is deliberately excluded: it's on `BeDashboardListItem` (the list +// response) but NOT on `BeDashboard` (the detail response this compares +// against) — comparing it would false-positive drift on every draft, since +// there's nothing on the live side to compare it to. +function deployableShapeFromDraft(draft: DashboardDraft): DeployableDashboardShape { + return { + displayName: draft.displayName, + isDefault: draft.isDefault, + isTeamBased: draft.isTeamBased, + targetTeam: draft.targetTeam, + widgets: draft.widgets, + }; +} + +function deployableShapeFromLive(live: BeDashboard): DeployableDashboardShape { + return { + displayName: live.displayName, + isDefault: live.isDefault, + isTeamBased: live.isTeamBased, + targetTeam: live.targetTeam, + widgets: live.widgets, + }; +} + +/** + * True when `draft` no longer matches what `GET /dashboards/{id}` currently + * returns — i.e. the local draft has unsaved-to-deployment changes. Always + * `true` for a draft with no `sourceDashboardId` (nothing deployed to + * compare against): a brand-new dashboard is drifted from "deployed" by + * definition until a maintainer first ships it. + */ +export function isDraftDrifted(draft: DashboardDraft, live: BeDashboard | undefined): boolean { + if (!live) return true; + // A draft not yet tied to ANY deployed dashboard is drifted by + // definition (see this function's own doc comment above) — checked + // before the shape comparison below, so a caller that happens to pass a + // live dashboard alongside a draft that was never actually opened FROM + // it (e.g. matched only by a shared id) can't fall through to a + // content-equality check that was never the right comparison to begin + // with. + if (!draft.sourceDashboardId) return true; + return canonicalJson(deployableShapeFromDraft(draft)) !== canonicalJson(deployableShapeFromLive(live)); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.test.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.test.ts new file mode 100644 index 0000000000..143d01f7e4 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.test.ts @@ -0,0 +1,116 @@ +// 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 { describe, expect, it } from "vitest"; +import { discoverAttributePaths } from "@features/csm-admin/dashboards/utils/discoverAttributePaths"; + +describe("discoverAttributePaths", () => { + it("returns top-level scalar paths", () => { + expect(discoverAttributePaths([{ id: "CS-1", state: "open" }])).toEqual(["id", "state"]); + }); + + it("walks nested objects into dot-separated paths", () => { + expect( + discoverAttributePaths([ + { id: "CS-1", project: { key: "ABC", name: "Foo" } }, + ]), + ).toEqual(["id", "project.key", "project.name"]); + }); + + it("walks arbitrarily deep nesting, up to the depth cap", () => { + expect( + discoverAttributePaths([{ project: { account: { tier: "gold" } } }]), + ).toEqual(["project.account.tier"]); + }); + + it("treats an array of scalars as a leaf path, not indexed into", () => { + expect(discoverAttributePaths([{ id: "CS-1", tags: ["a", "b"] }])).toEqual(["id", "tags"]); + }); + + it("treats an array of objects as a leaf path too, not indexed into", () => { + expect( + discoverAttributePaths([ + { id: "CS-1", comments: [{ id: "c-1", body: "hi" }, { id: "c-2", body: "there" }] }, + ]), + ).toEqual(["comments", "id"]); + }); + + it("treats an empty array the same as a non-empty one — a leaf path, not dropped", () => { + expect(discoverAttributePaths([{ id: "CS-1", tags: [] }])).toEqual(["id", "tags"]); + }); + + it("treats null and undefined leaves as valid, still-offered paths", () => { + expect( + discoverAttributePaths([{ id: "CS-1", closedAt: null, resolvedBy: undefined }]), + ).toEqual(["closedAt", "id", "resolvedBy"]); + }); + + it("treats an empty object as a leaf rather than dropping its path", () => { + expect(discoverAttributePaths([{ id: "CS-1", metadata: {} }])).toEqual(["id", "metadata"]); + }); + + it("unions paths across sampled rows with varying shapes, not just the first row", () => { + const rows = [ + { id: "CS-1", project: { key: "ABC" } }, + { id: "CS-2", assignee: { name: "Jane Doe" } }, + { id: "CS-3", project: { key: "DEF", name: "Foo" } }, + ]; + expect(discoverAttributePaths(rows)).toEqual([ + "assignee.name", + "id", + "project.key", + "project.name", + ]); + }); + + it("deduplicates a path that recurs across multiple sampled rows", () => { + const rows = [{ id: "CS-1" }, { id: "CS-2" }, { id: "CS-3" }]; + expect(discoverAttributePaths(rows)).toEqual(["id"]); + }); + + it("samples at most the first 20 rows, ignoring a field only present beyond that", () => { + const rows = Array.from({ length: 25 }, (_, i) => ({ id: `CS-${i}` })); + rows[24] = { id: "CS-24", onlyOnLastRow: "x" } as unknown as { id: string }; + expect(discoverAttributePaths(rows)).toEqual(["id"]); + }); + + it("still picks up a field present within the first 20 rows even if absent from row 0", () => { + const rows = Array.from({ length: 25 }, (_, i) => ({ id: `CS-${i}` })); + rows[5] = { id: "CS-5", onlyOnRow5: "x" } as unknown as { id: string; onlyOnRow5?: string }; + expect(discoverAttributePaths(rows)).toEqual(["id", "onlyOnRow5"]); + }); + + it("caps recursion depth rather than looping forever on a self-referential structure", () => { + const cyclic: Record = { id: "CS-1" }; + cyclic.self = cyclic; + expect(() => discoverAttributePaths([cyclic])).not.toThrow(); + const result = discoverAttributePaths([cyclic]); + expect(result).toContain("id"); + expect(result.some((p) => p.startsWith("self."))).toBe(true); + // The deepest path stops growing once the cap is hit — 6 "self" hops + // then it's cut off as a leaf, not an unbounded "self.self.self...". + const deepest = result.filter((p) => p.startsWith("self.")).sort().pop(); + expect(deepest?.split(".").length).toBeLessThanOrEqual(6); + }); + + it("returns an empty list for an empty rows array", () => { + expect(discoverAttributePaths([])).toEqual([]); + }); + + it("skips a non-object row rather than throwing", () => { + expect(discoverAttributePaths([null as unknown as Record])).toEqual([]); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.ts new file mode 100644 index 0000000000..73ba361cb7 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/discoverAttributePaths.ts @@ -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. + +/** Only the first this-many rows of a preview response are walked — a + * widget's search response can carry many rows, and unioning paths across + * every one of them buys nothing beyond a small sample (optional fields + * either show up in the first handful of rows or they don't), so this caps + * the work rather than walking a potentially large `items` array on every + * Preview click. */ +const MAX_SAMPLE_ROWS = 20; + +/** Recursion depth cap, counted in nested-object hops from a sampled row's + * own root. A real search-response item is a handful of levels deep at + * most (e.g. `project.account.tier`) — this exists purely as a backstop + * against an unexpectedly deep or (in principle, since this walks + * `Record` data of unknown provenance) self-referential + * structure, not because 6 levels is itself meaningful. */ +const MAX_DEPTH = 6; + +/** + * Walks one sampled row and adds every dot-separated path reachable in it + * to `paths`, matching exactly what `resolveColumnPath` + * (`features/csm-dashboard/utils/resolveWidgetColumn.ts`) can resolve a + * widget column `path` against: + * + * - A plain object is walked into, one path segment per key, recursively. + * - An array is treated as a leaf, not indexed into — `resolveColumnPath` + * walks a path by object-key segment only, and offering a + * numeric-index path (`tags.0`) would be a path meaningful for exactly + * one row's array length, not a column definition an admin could reuse + * across every row. The path up to (and including) the array itself is + * still offered, since it's what a column path could target. + * - `null`/`undefined`/a scalar (string, number, boolean) is a leaf. + * - An empty object (no keys) is treated as a leaf too — there's nothing + * further to union in from it, and dropping the path outright would + * make a real field that happens to be empty in every sampled row + * silently disappear from the offered list. + * - Recursion stops at `MAX_DEPTH`, treating whatever's at that depth as a + * leaf, rather than continuing indefinitely (see `MAX_DEPTH`'s own doc + * comment). + */ +function collectPaths(value: unknown, prefix: string, depth: number, paths: Set): void { + if (Array.isArray(value)) { + if (prefix) paths.add(prefix); + return; + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value as Record); + if (entries.length === 0 || depth >= MAX_DEPTH) { + if (prefix) paths.add(prefix); + return; + } + for (const [key, child] of entries) { + collectPaths(child, prefix ? `${prefix}.${key}` : key, depth + 1, paths); + } + return; + } + // Scalar, null, or undefined. + if (prefix) paths.add(prefix); +} + +/** + * Discovers the real dot-separated attribute paths reachable in a widget's + * own Preview data, for the "Columns" editor's path autocomplete + * (`WidgetEditorDialog`) — so an admin picks a path that's actually present + * on this resourceType's search response instead of guessing/typing one + * blind. + * + * Samples up to `MAX_SAMPLE_ROWS` rows and unions the paths found across all + * of them (not just the first), since two rows of the same resourceType can + * have different optional fields present/absent — a path only the third + * sampled row happens to carry would otherwise never surface. Returns a + * deduplicated, alphabetically sorted list. + */ +export function discoverAttributePaths(rows: Record[]): string[] { + const paths = new Set(); + for (const row of rows.slice(0, MAX_SAMPLE_ROWS)) { + if (row === null || typeof row !== "object") continue; + collectPaths(row, "", 0, paths); + } + return Array.from(paths).sort(); +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.test.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.test.ts new file mode 100644 index 0000000000..c14d4c47a1 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.test.ts @@ -0,0 +1,193 @@ +// 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 { describe, expect, it } from "vitest"; +import { + filterConditionsFromQuery, + operatorsForResourceType, + queryFromFilterConditions, + usesCaseFieldFilterDsl, +} from "@features/csm-admin/dashboards/utils/widgetQueryConditions"; + +describe("usesCaseFieldFilterDsl", () => { + it("is true for case and every case-type-variant resourceType", () => { + for (const rt of [ + "case", + "service_request", + "security_report_analysis", + "announcement", + "engagement", + ] as const) { + expect(usesCaseFieldFilterDsl(rt)).toBe(true); + } + }); + + it("is false for a non-case resourceType", () => { + expect(usesCaseFieldFilterDsl("incident")).toBe(false); + expect(usesCaseFieldFilterDsl("account")).toBe(false); + }); +}); + +describe("filterConditionsFromQuery / queryFromFilterConditions — case-like resourceType", () => { + it("round-trips the case field/op/values DSL", () => { + const query = { + filters: [ + { field: "severity", op: "in", values: ["critical", "high"] }, + { field: "state", op: "in", values: ["open"] }, + ], + }; + const conditions = filterConditionsFromQuery("case", query); + expect(conditions).toEqual([ + { field: "severity", op: "in", values: ["critical", "high"] }, + { field: "state", op: "in", values: ["open"] }, + ]); + expect(queryFromFilterConditions("case", conditions)).toEqual(query); + }); + + it("drops values for a value-less op (isEmpty/isNotEmpty) on serialize", () => { + const conditions = [{ field: "escalation", op: "isNotEmpty" as const, values: [] }]; + expect(queryFromFilterConditions("case", conditions)).toEqual({ + filters: [{ field: "escalation", op: "isNotEmpty" }], + }); + }); + + it("returns an empty query for zero conditions, not an empty filters array", () => { + expect(queryFromFilterConditions("case", [])).toEqual({}); + }); + + it("returns no conditions for a query with no filters array", () => { + expect(filterConditionsFromQuery("case", {})).toEqual([]); + expect(filterConditionsFromQuery("case", undefined)).toEqual([]); + }); + + it("skips a malformed entry (no field) rather than crashing", () => { + const query = { filters: [{ op: "in", values: ["x"] }, { field: "state", op: "eq" }] }; + expect(filterConditionsFromQuery("case", query)).toEqual([ + { field: "state", op: "eq", values: [] }, + ]); + }); + + it("drops a row with an empty field on serialize", () => { + const conditions = [ + { field: "", op: "eq" as const, values: ["x"] }, + { field: "state", op: "eq" as const, values: ["open"] }, + ]; + expect(queryFromFilterConditions("case", conditions)).toEqual({ + filters: [{ field: "state", op: "eq", values: ["open"] }], + }); + }); +}); + +describe("filterConditionsFromQuery / queryFromFilterConditions — non-case resourceType", () => { + it("reads a flat, non-DSL query into one row per key", () => { + const query = { priorities: ["HIGH", "CRITICAL"], slaViolated: true, number: "INC0001" }; + const conditions = filterConditionsFromQuery("incident", query); + expect(conditions).toEqual( + expect.arrayContaining([ + { field: "priorities", op: "in", values: ["HIGH", "CRITICAL"] }, + { field: "slaViolated", op: "eq", values: ["true"] }, + { field: "number", op: "eq", values: ["INC0001"] }, + ]), + ); + expect(conditions).toHaveLength(3); + }); + + it("serializes back to flat top-level keys, array for 'in', scalar otherwise", () => { + const conditions = [ + { field: "priorities", op: "in" as const, values: ["HIGH", "CRITICAL"] }, + { field: "number", op: "eq" as const, values: ["INC0001"] }, + ]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ + priorities: ["HIGH", "CRITICAL"], + number: "INC0001", + }); + }); + + it("never nests a non-case resourceType's query under 'filters'", () => { + const conditions = [{ field: "priorities", op: "in" as const, values: ["HIGH"] }]; + const query = queryFromFilterConditions("incident", conditions); + expect(query).not.toHaveProperty("filters"); + }); + + it("serializes a boolean-looking eq value as a real boolean, not the string 'true'/'false'", () => { + const conditions = [{ field: "slaViolated", op: "eq" as const, values: ["true"] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ slaViolated: true }); + expect( + queryFromFilterConditions("incident", [ + { field: "slaViolated", op: "eq" as const, values: ["false"] }, + ]), + ).toEqual({ slaViolated: false }); + }); + + it("serializes a numeric-looking eq value as a real number, not a string", () => { + const conditions = [{ field: "someCount", op: "eq" as const, values: ["42"] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ someCount: 42 }); + }); + + it("leaves a non-numeric, non-boolean value as a plain string (e.g. a case/incident number)", () => { + const conditions = [{ field: "number", op: "eq" as const, values: ["INC0001"] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ number: "INC0001" }); + }); + + it("type-recovers each element of an 'in' array too", () => { + const conditions = [{ field: "flags", op: "in" as const, values: ["true", "1", "no"] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ + flags: [true, 1, "no"], + }); + }); + + it("drops a row whose op the flat non-case contract can't express (legacy/hand-edited data), rather than silently reinterpreting it as eq", () => { + // The flat contract has no per-field op of its own — `notIn` here has + // no way to be written as "not X" and must NOT be silently rewritten + // to `eq` ("is X"), which would flip its real meaning the moment the + // admin saves without ever touching this row. + const conditions = [ + { field: "slaViolated", op: "notIn" as const, values: ["true", "false"] }, + { field: "number", op: "eq" as const, values: ["INC0001"] }, + ]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ number: "INC0001" }); + }); + + it("preserves a leading-zero identifier as a string, rather than silently dropping the leading zeros", () => { + const conditions = [{ field: "number", op: "eq" as const, values: ["0090472"] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ number: "0090472" }); + }); + + it("preserves a value above Number.MAX_SAFE_INTEGER as a string, rather than silently losing precision", () => { + const huge = "99999999999999999999"; + const conditions = [{ field: "number", op: "eq" as const, values: [huge] }]; + expect(queryFromFilterConditions("incident", conditions)).toEqual({ number: huge }); + }); +}); + +describe("operatorsForResourceType", () => { + it("offers every op for a case-like resourceType", () => { + expect(operatorsForResourceType("case")).toEqual([ + "eq", + "in", + "notIn", + "gte", + "lte", + "isEmpty", + "isNotEmpty", + ]); + }); + + it("offers only eq/in for a non-case resourceType, since no other op has a real, proven query shape", () => { + expect(operatorsForResourceType("incident")).toEqual(["eq", "in"]); + expect(operatorsForResourceType("account")).toEqual(["eq", "in"]); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.ts b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.ts new file mode 100644 index 0000000000..d5f81cb0a2 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/dashboards/utils/widgetQueryConditions.ts @@ -0,0 +1,251 @@ +// 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 { BeCaseFieldFilterOp, BeWidgetResourceType } from "@api/backend/types"; + +/** + * A widget's `query` (opaque to every other part of this app — see + * `BeDashboardWidget.query`) is NOT one uniform shape across every + * resourceType's own `POST /{resourceType}s/search` contract: + * + * - `case` and its four `type`-variant resourceTypes (`service_request`, + * `security_report_analysis`, `announcement`, `engagement`) all route to + * `/cases/search`, whose filters are the generic field/op/values DSL + * nested under `query.filters` (see `BeCaseFieldFilter`). + * - Every other resourceType (`incident`, `change_request`, `account`, …) + * has its own bespoke named-field filter shape, flat under `query` + * itself — there is no single generic DSL for these anywhere in this + * app. A widget's `query` here corresponds to the INNER `filters` object + * of that resourceType's own search payload (e.g. `query.priorities` + * maps onto `BeIncidentSearchPayload.filters.priorities`, NOT + * `BeIncidentSearchPayload.filters.filters.priorities`) — the outer + * `{ filters: , pagination, sortBy }` envelope is added by + * `useWidgetData` at request time, not stored as part of the widget's own + * `query`. Nesting `query` itself under one more `filters` key here would + * double-wrap it and produce a request the real endpoint doesn't + * recognize. + * + * This module gives the widget editor ONE condition-row UI (field, + * operator, value(s)) that round-trips through whichever of those two + * shapes actually matches the widget's own `resourceType`, rather than + * forcing every resourceType's filters into the case DSL (which its real + * search endpoint would reject) or exposing raw JSON. + */ + +export type FilterConditionOp = BeCaseFieldFilterOp; + +export const FILTER_CONDITION_OPS: FilterConditionOp[] = [ + "eq", + "in", + "notIn", + "gte", + "lte", + "isEmpty", + "isNotEmpty", +]; + +/** One editable filter row. `values` is ignored for `isEmpty`/`isNotEmpty` + * (those two ops are value-less predicates — see `BeCaseFieldFilter`). */ +export interface FilterCondition { + field: string; + op: FilterConditionOp; + values: string[]; +} + +const NO_VALUE_OPS = new Set(["isEmpty", "isNotEmpty"]); + +/** resourceTypes that route to `/cases/search` and therefore use the + * generic case field/op/values DSL — see this module's own doc comment. */ +const CASE_FIELD_DSL_RESOURCE_TYPES = new Set([ + "case", + "service_request", + "security_report_analysis", + "announcement", + "engagement", +]); + +export function usesCaseFieldFilterDsl(resourceType: BeWidgetResourceType): boolean { + return CASE_FIELD_DSL_RESOURCE_TYPES.has(resourceType); +} + +/** + * The only two ops a non-case resourceType's own flat named-key search + * contract is proven to support anywhere in this app (see every + * `BeXxxSearchPayload.filters` shape in `types.ts`: `priorities`/`states`/ + * `impacts` as plain arrays, `slaViolated`/`number` etc. as plain scalars — + * there is no generic `notIn`/`gte`/`lte`/`isEmpty`/`isNotEmpty` key + * convention across those bespoke, per-field contracts the way the case DSL + * has one uniform `field`/`op`/`values` shape). Offering the other five ops + * for a non-case resourceType would let the admin build a filter this app + * cannot serialize correctly — see `queryFromFilterConditions`'s own doc + * comment on what happens to an unsupported op that slips through anyway + * (legacy/hand-edited data only; the editor never creates one). + */ +const NON_CASE_SUPPORTED_OPS: FilterConditionOp[] = ["eq", "in"]; + +/** The operators that make sense to offer in the condition editor for a + * given resourceType — every op for the case DSL, only `eq`/`in` for + * anything else (see `NON_CASE_SUPPORTED_OPS`). */ +export function operatorsForResourceType( + resourceType: BeWidgetResourceType, +): FilterConditionOp[] { + return usesCaseFieldFilterDsl(resourceType) ? FILTER_CONDITION_OPS : NON_CASE_SUPPORTED_OPS; +} + +/** + * Best-effort scalar type recovery for a condition row's freeform text + * value(s): a non-case resourceType's own contract carries real JSON types + * (e.g. `BeIncidentSearchPayload.filters.slaViolated: boolean`), not the + * string this editor's text inputs always produce — writing the raw string + * back (`"true"` instead of `true`) either fails that endpoint's own + * validation or silently never matches. `"true"`/`"false"` (case-sensitive, + * matching how a boolean stringifies) become real booleans, a value that + * parses as a plain integer or decimal becomes a real number, everything + * else stays a string. Never applied to the case field/op/values DSL, whose + * `values` are always `string[]` on the wire regardless of the field's own + * semantic type (see `BeCaseFieldFilter`). + */ +function coerceScalar(value: string): unknown { + if (value === "true") return true; + if (value === "false") return false; + const trimmed = value.trim(); + if (trimmed.length > 0 && /^-?\d+(\.\d+)?$/.test(trimmed)) { + const n = Number(trimmed); + // Only converted when the number round-trips back to the EXACT same + // text — this naturally rejects a leading-zero identifier (`"0090472"`, + // `Number("0090472")` -> `90472` -> `String(90472)` -> `"90472"` !== + // `"0090472"`) and a value that lost precision above + // `Number.MAX_SAFE_INTEGER`, both of which would otherwise be silently + // corrupted straight into the deployable widget JSON. + if (Number.isFinite(n) && String(n) === trimmed) return n; + } + return value; +} + +/** Every field the case-search DSL accepts (mirrors `BeCaseFieldFilterField` + * — see `types.ts`), offered as autocomplete suggestions in the field + * picker for a case-like resourceType. Freeform text is still accepted: + * this is a suggestion list, not a hard allowlist, since the backend (not + * this list) is the source of truth for what it accepts. */ +export const CASE_FIELD_OPTIONS: string[] = [ + "type", + "state", + "severity", + "engagementType", + "issueType", + "workState", + "tag", + "projectId", + "deploymentId", + "assignedUserId", + "createdBy", + "createdOn", + "updatedOn", + "closedOn", + "product", + "projectOnboardingStatus", + "projectType", + "integrationCsTeam", + "resolutionNotes", + "parentId", + "taskSLABusinessElapsedPercent", + "escalationLevel", + "escalation", + "number", + "internalId", +]; + +function isFilterOp(v: unknown): v is FilterConditionOp { + return typeof v === "string" && (FILTER_CONDITION_OPS as string[]).includes(v); +} + +/** Reads a widget's own `query` into editable condition rows, per + * `usesCaseFieldFilterDsl`. An unrecognized/malformed entry is skipped + * rather than crashing the editor — the admin can always still delete/ + * retype a row that came out empty. */ +export function filterConditionsFromQuery( + resourceType: BeWidgetResourceType, + query: Record | undefined, +): FilterCondition[] { + if (!query) return []; + + if (usesCaseFieldFilterDsl(resourceType)) { + const raw = query.filters; + if (!Array.isArray(raw)) return []; + return raw + .filter((e): e is Record => !!e && typeof e === "object") + .map((e) => ({ + field: typeof e.field === "string" ? e.field : "", + op: isFilterOp(e.op) ? e.op : "eq", + values: Array.isArray(e.values) ? e.values.map(String) : [], + })) + .filter((c) => c.field.length > 0); + } + + // Every other resourceType's own search contract is flat named top-level + // keys, not this app's field/op/values DSL — one row per key. `in` for an + // array value (e.g. `priorities: ["HIGH"]`), `eq` for a scalar (e.g. + // `number: "INC0090472"`). + return Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([field, v]) => ({ + field, + op: (Array.isArray(v) ? "in" : "eq") as FilterConditionOp, + values: Array.isArray(v) ? v.map(String) : [String(v)], + })); +} + +/** The inverse of `filterConditionsFromQuery` — serializes edited condition + * rows back into the `query` shape that resourceType's own search endpoint + * actually accepts. Rows with an empty `field` are dropped. */ +export function queryFromFilterConditions( + resourceType: BeWidgetResourceType, + conditions: FilterCondition[], +): Record { + const valid = conditions.filter((c) => c.field.trim().length > 0); + + if (usesCaseFieldFilterDsl(resourceType)) { + if (valid.length === 0) return {}; + return { + filters: valid.map((c) => + NO_VALUE_OPS.has(c.op) + ? { field: c.field, op: c.op } + : { field: c.field, op: c.op, values: c.values }, + ), + }; + } + + const out: Record = {}; + for (const c of valid) { + // Non-case resourceTypes' own contracts only ever use a scalar or an + // array (see this module's own doc comment), with no per-field op of + // their own at all — `in` writes the array, `eq` writes a single + // type-recovered scalar. Any OTHER op (`notIn`/`gte`/`lte`/`isEmpty`/ + // `isNotEmpty`) can only reach here from data the editor itself never + // produces (`operatorsForResourceType` never offers them for a + // non-case resourceType) — most likely a hand-edited deployed widget + // JSON. There is no flat-contract encoding of those ops' real meaning + // ("not X", a range, ...), so this row is dropped from the output + // rather than reinterpreted as `eq`: a dropped filter is a visible, + // recoverable gap (temporarily unenforced, admin can re-add it); a + // `notIn` silently rewritten to `eq` on the very next save would flip + // its real meaning ("not X" -> "is X") without the admin ever having + // touched that row. + if (c.op !== "eq" && c.op !== "in") continue; + out[c.field] = c.op === "in" ? c.values.map(coerceScalar) : coerceScalar(c.values[0] ?? ""); + } + return out; +} diff --git a/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.test.tsx new file mode 100644 index 0000000000..3e38f6b99c --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.test.tsx @@ -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. + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { vi } from "vitest"; +import { MemoryRouter, Route, Routes } from "react-router"; + +let mockRoles: string[] | undefined = ["admin"]; + +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ user: { roles: mockRoles }, isLoading: false, isError: false }), +})); +// `CsmAdminLayout` transitively imports the dashboard builder's own admin +// routes (via the nav tree/`useRouteTabs`), some of which reach real API +// hooks — mocked up front, before the component import below, per this +// repo's own convention for anything that transitively imports +// `CsmAdminLayout` (see e.g. `adminRoutes.redirects.test.tsx`). +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: vi.fn(), post: vi.fn() }), +})); +vi.mock("@config/apiConfig", () => ({ + apiConfig: { backendUrl: "https://example.test" }, +})); + +import CsmAdminLayout from "@features/csm-admin/pages/CsmAdminLayout"; + +function renderLayout(initialEntry: string) { + return render( + + + }> + Users content} /> + Roles content} /> + Groups content} /> + Teams content} /> + Permissions content} /> + Dashboards content} /> + + + , + ); +} + +describe("CsmAdminLayout — top-level tabs", () => { + it("shows only User management and Dashboards at the top level, for an admin user", () => { + mockRoles = ["admin"]; + // Dashboards active, so the nested User management strip is absent — + // isolates the top-level strip's own two tabs from its sub-tabs. + renderLayout("/admin/dashboards"); + expect(screen.getByRole("tab", { name: "User management" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Dashboards" })).toBeInTheDocument(); + // The five directory pages are only reachable as User management's nested + // sub-tabs, never as top-level tabs. + expect(screen.queryByRole("tab", { name: "Users" })).not.toBeInTheDocument(); + }); + + it("hides the Dashboards tab for a non-admin user, while User management still shows", () => { + mockRoles = ["agent"]; + renderLayout("/admin/user-management/users"); + expect(screen.queryByRole("tab", { name: "Dashboards" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "User management" })).toBeInTheDocument(); + }); +}); + +describe("CsmAdminLayout — nested User management tabs", () => { + it("shows User management's five sub-tabs when it is the active top-level tab", () => { + mockRoles = ["admin"]; + renderLayout("/admin/user-management/users"); + expect(screen.getByRole("tab", { name: "User management" })).toHaveAttribute( + "aria-selected", + "true", + ); + for (const label of ["Users", "Roles", "Groups", "Teams", "Permissions"]) { + expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); + } + }); + + it("marks the sub-tab matching the current route as active", () => { + mockRoles = ["admin"]; + renderLayout("/admin/user-management/roles"); + expect(screen.getByRole("tab", { name: "Roles" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText("Roles content")).toBeInTheDocument(); + }); + + it("deep-links directly to a sub-page with both levels active and the right outlet rendered", () => { + mockRoles = ["admin"]; + renderLayout("/admin/user-management/groups"); + expect(screen.getByRole("tab", { name: "User management" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("tab", { name: "Groups" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByText("Groups content")).toBeInTheDocument(); + }); + + it("does not render the nested strip when Dashboards is the active top-level tab", () => { + mockRoles = ["admin"]; + renderLayout("/admin/dashboards"); + expect(screen.getByRole("tab", { name: "Dashboards" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.queryByRole("tab", { name: "Users" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Roles" })).not.toBeInTheDocument(); + expect(screen.getByText("Dashboards content")).toBeInTheDocument(); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.tsx b/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.tsx index fb69bca9b3..9d1387722c 100644 --- a/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.tsx +++ b/apps/csm-portal/webapp/src/features/csm-admin/pages/CsmAdminLayout.tsx @@ -15,26 +15,75 @@ // under the License. import { Box, Typography } from "@wso2/oxygen-ui"; -import { type JSX, Suspense } from "react"; +import { type JSX, Suspense, useMemo } from "react"; import { Outlet } from "react-router"; import RouteSuspenseFallback from "@components/route-fallback/RouteSuspenseFallback"; import SectionTabs from "@components/section-tabs/SectionTabs"; import { useRouteTabs } from "@hooks/useSectionTabs"; +import { useCurrentUser } from "@context/current-user/CurrentUserContext"; +import { hasDashboardBuilderAccess } from "@features/csm-admin/dashboards/utils/dashboardBuilderAccess"; + +/** The nav-node id of the "User management" tab — the only one with its own nested strip. */ +const USER_MANAGEMENT_ID = "admin.user-management"; /** - * Settings shell. The tabs come from the navigation tree, so which of Users / - * Roles / Groups / Permissions this deployment offers (and which are chipped as - * work in progress) is decided by `CSM_PORTAL_FEATURE_OVERRIDES` rather than - * hardcoded here. + * Settings shell. Two tab levels, both driven by the navigation tree: a + * top-level strip for [User management, Dashboards], and — only while User + * management is the active top-level tab — a second, visually subordinate + * strip underneath it for its own Users / Roles / Groups / Teams / Permissions + * tabs. Which of those a deployment offers (and which are chipped as work in + * progress) is decided by `CSM_PORTAL_FEATURE_OVERRIDES` rather than hardcoded + * here. + * + * The second strip is not a bespoke nesting mechanism: `useRouteTabs` already + * resolves a section id's children generically, so getting User management's + * own tabs is just calling it again with `admin.user-management` instead of + * `admin`. + * + * One exception: the "Dashboards" tab is additionally filtered by the + * signed-in user's own admin role (frontend-only — see + * `dashboardBuilderAccess.ts` for why this tab specifically needs it, unlike + * every sibling tab here). This never removes a tab `CSM_PORTAL_FEATURE_OVERRIDES` + * itself hid/marked WIP — it only ever narrows what a non-admin sees further. */ export default function CsmAdminLayout(): JSX.Element { - const tabs = useRouteTabs("admin"); + const { user } = useCurrentUser(); + const isAdmin = hasDashboardBuilderAccess(user?.roles); + const allTabs = useRouteTabs("admin"); + const tabs = useMemo(() => { + const visible = allTabs.tabs.filter((tab) => tab.node.id !== "admin.dashboards" || isAdmin); + // `allTabs.activeKey` was resolved against the UNFILTERED list — if + // filtering it out here just removed the active one (a non-admin whose + // URL still names it), fall back to this narrower list's own first tab + // rather than handing `` a `value` with no matching ``. + const activeKey = visible.some((tab) => tab.key === allTabs.activeKey) + ? allTabs.activeKey + : (visible[0]?.key ?? ""); + return { ...allTabs, tabs: visible, activeKey }; + }, [allTabs, isAdmin]); + + // Always resolved (rules of hooks) — only rendered once User management is + // the active top-level tab. Cheap: it's the same route/location read the + // top-level hook call already does, just matched against a different node's + // children. + const userManagementTabs = useRouteTabs(USER_MANAGEMENT_ID); + const activeTopNode = tabs.tabs.find((tab) => tab.key === tabs.activeKey)?.node; + const showUserManagementTabs = activeTopNode?.id === USER_MANAGEMENT_ID; return ( Settings - + + + {showUserManagementTabs && ( + + )} + }> diff --git a/apps/csm-portal/webapp/src/features/csm-admin/pages/adminRoutes.redirects.test.tsx b/apps/csm-portal/webapp/src/features/csm-admin/pages/adminRoutes.redirects.test.tsx new file mode 100644 index 0000000000..1feb16c08b --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-admin/pages/adminRoutes.redirects.test.tsx @@ -0,0 +1,119 @@ +// 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. + +/** + * Exercises the /admin route block exactly as declared in App.tsx (index + * redirect, the User management nesting, and the legacy-path redirects) — + * without pulling in App.tsx's full provider/lazy-loading tree, which has no + * test harness of its own. Any change to that block's shape should be + * mirrored here. + */ + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter, Navigate, Route, Routes } from "react-router"; +import { SectionIndexRedirect } from "@components/section-tabs/SectionTabs"; + +vi.mock("@context/current-user/CurrentUserContext", () => ({ + useCurrentUser: () => ({ + user: { roles: ["admin"] }, + isLoading: false, + isError: false, + }), +})); +// `CsmAdminLayout` transitively imports API-backed hooks (via the nav +// tree/dashboard builder routes) — mocked up front, before the component +// import below, per this repo's own convention for anything that +// transitively imports `CsmAdminLayout` (see `CsmAdminLayout.test.tsx`). +vi.mock("@api/backend/client", () => ({ + useBackendApi: () => ({ get: vi.fn(), post: vi.fn() }), +})); +vi.mock("@config/apiConfig", () => ({ + apiConfig: { backendUrl: "https://example.test" }, +})); + +import CsmAdminLayout from "@features/csm-admin/pages/CsmAdminLayout"; + +function renderAdminRoutes(initialEntry: string) { + return render( + + + }> + } /> + } + /> + Users content} /> + Roles content} /> + Groups content} /> + Teams content} /> + Permissions content} + /> + Dashboards content} /> + + } + /> + } + /> + } + /> + } + /> + } + /> + + , + ); +} + +describe("admin index redirects", () => { + it("lands /admin on User management's first tab, via the section index chain", () => { + renderAdminRoutes("/admin"); + expect(screen.getByText("Users content")).toBeInTheDocument(); + }); + + it("lands /admin/user-management on its own first tab", () => { + renderAdminRoutes("/admin/user-management"); + expect(screen.getByText("Users content")).toBeInTheDocument(); + }); +}); + +describe("legacy /admin/ redirects", () => { + it.each([ + ["/admin/users", "Users content"], + ["/admin/roles", "Roles content"], + ["/admin/groups", "Groups content"], + ["/admin/teams", "Teams content"], + ["/admin/permissions", "Permissions content"], + ])("redirects %s to the new User management path", (oldPath, expectedContent) => { + renderAdminRoutes(oldPath); + expect(screen.getByText(expectedContent)).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 664efa1811..e1e95e5166 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,73 +14,16 @@ // specific language governing permissions and limitations // under the License. -import { Box, Card, Divider, Skeleton, Typography } from "@wso2/oxygen-ui"; -import { useQueryClient } from "@tanstack/react-query"; -import { Fragment, useState, type JSX } from "react"; -import { ApiQueryKeys } from "@constants/apiConstants"; -import type { BeDashboardWidget } from "@api/backend/types"; +import { Box, Card, Skeleton, Typography } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; import { useDashboard } from "@features/csm-dashboard/api/useDashboard"; -import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; +import DashboardWidgetGrid from "@features/csm-dashboard/components/DashboardWidgetGrid"; import SectionCard from "@features/csm-dashboard/components/SectionCard"; -import RefreshButton from "@components/RefreshButton"; -import { resolveWidgetText } from "@features/csm-dashboard/utils/widgetTextPlaceholder"; +import { WIDGET_GRID_SX } from "@features/csm-dashboard/utils/dashboardWidgetGridLayout"; /** Placeholder tile count while the dashboard detail is in flight. */ const PILOT_TILE_COUNT = 3; -/** 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. */ -const WIDGET_GRID_SX = { - display: "grid", - gap: 1.5, - gridTemplateColumns: { - xs: "repeat(4, minmax(0, 1fr))", - sm: "repeat(12, minmax(0, 1fr))", - }, -} as const; - -interface WidgetGroup { - /** `undefined` for the untitled/default group — every widget with no - * `section` set lands here, rendered exactly as before this field - * existed (no heading). */ - section?: string; - widgets: BeDashboardWidget[]; -} - -/** Groups widgets by `section`, preserving the order each distinct section - * value (including the untitled default) first appears among `widgets` — - * see `BeDashboardWidget.section`. */ -function groupWidgetsBySection(widgets: BeDashboardWidget[]): WidgetGroup[] { - const groups: WidgetGroup[] = []; - const indexBySection = new Map(); - for (const widget of widgets) { - const key = widget.section || undefined; - let index = indexBySection.get(key); - if (index === undefined) { - index = groups.length; - indexBySection.set(key, index); - groups.push({ section: key, widgets: [] }); - } - groups[index].widgets.push(widget); - } - return groups; -} - -function widgetGridColumnSx(widget: BeDashboardWidget) { - // A list-shape widget renders a real table (4 rows, several columns) — - // its configured `gridWidth` was sized for the old compact text list, so - // it always spans the full row here regardless of that value. - return widget.shape === "list" - ? { gridColumn: "1 / -1" } - : { - gridColumn: { - xs: `span ${Math.min(widget.gridWidth, 4)}`, - sm: `span ${widget.gridWidth}`, - }, - }; -} - interface AgentsLandingPagePilotProps { /** Id of the dashboard to render (e.g. "agents_pilot"). */ dashboardId: string; @@ -115,43 +58,7 @@ export default function AgentsLandingPagePilot({ selectedTeamGroupId, selectedTeamLabel, }: AgentsLandingPagePilotProps): JSX.Element { - const queryClient = useQueryClient(); const { data, isLoading, isError } = useDashboard(dashboardId); - // Per-section refresh (below) tracks its own in-flight state, keyed by - // section, independently of the dashboard-metadata refresh above — a - // section refresh never touches `useDashboard`'s own refetch (config is - // not re-pulled), only that section's own widgets' data queries. - const [refreshingSections, setRefreshingSections] = useState>(new Set()); - - /** - * Invalidates only the widget-data queries belonging to `widgetIds` — - * both shapes' query keys carry a widget id, just at a different - * position: `[KEY, widgetId, ...]` for count/list (see `useWidgetData`), - * `[KEY, "pie-slice", widgetId, ...]` for pie/bar (see - * `useWidgetPieData`). Never touches `useDashboard`'s own metadata query. - */ - const invalidateWidgets = (widgetIds: Set): Promise => - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - if (key[0] !== ApiQueryKeys.CSM_DASHBOARD_WIDGET_DATA) return false; - const widgetId = key[1] === "pie-slice" ? key[2] : key[1]; - return typeof widgetId === "string" && widgetIds.has(widgetId); - }, - }); - - const handleSectionRefresh = async (sectionKey: string, widgetIds: Set): Promise => { - setRefreshingSections((prev) => new Set(prev).add(sectionKey)); - try { - await invalidateWidgets(widgetIds); - } finally { - setRefreshingSections((prev) => { - const next = new Set(prev); - next.delete(sectionKey); - return next; - }); - } - }; return ( @@ -168,91 +75,11 @@ export default function AgentsLandingPagePilot({ ))} ) : ( - (() => { - const widgets = data?.widgets ?? []; - const renderTile = (widget: BeDashboardWidget) => { - return ( - - - - ); - }; - - const groups = groupWidgetsBySection(widgets); - - return ( - - {groups.map((group, i) => { - // Chart-shaped widgets (pie/bar) are visually grouped into - // their own row below this group's count/list tiles, rather - // than sharing one undifferentiated grid with them. - const mainWidgets = group.widgets.filter((w) => w.shape !== "pie" && w.shape !== "bar"); - const chartWidgets = group.widgets.filter((w) => w.shape === "pie" || w.shape === "bar"); - if (mainWidgets.length === 0 && chartWidgets.length === 0) return null; - - const sectionKey = group.section ?? `__default_${i}`; - const sectionWidgetIds = new Set(group.widgets.map((w) => w.widgetId)); - // Section titles support the same {{currentTeam}} text token as - // an individual widget's own displayName/description (see - // widgetTextPlaceholder.ts) — resolve it once here so both the - // visible heading and the refresh button's label stay in sync. - const resolvedSectionTitle = resolveWidgetText(group.section, selectedTeamLabel); - - return ( - - {i > 0 && } - - - {resolvedSectionTitle && ( - - {resolvedSectionTitle} - - )} - void handleSectionRefresh(sectionKey, sectionWidgetIds)} - isFetching={refreshingSections.has(sectionKey)} - label={ - resolvedSectionTitle - ? `Refresh ${resolvedSectionTitle}` - : "Refresh section" - } - /> - - {mainWidgets.length > 0 && ( - {mainWidgets.map(renderTile)} - )} - {chartWidgets.length > 0 && ( - <> - {mainWidgets.length > 0 && } - {chartWidgets.map(renderTile)} - - )} - - - ); - })} - - ); - })() + )} ); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.tsx new file mode 100644 index 0000000000..f35fd2b4b1 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetGrid.tsx @@ -0,0 +1,231 @@ +// 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, Divider, Typography } from "@wso2/oxygen-ui"; +import { useQueryClient } from "@tanstack/react-query"; +import { Fragment, useState, type JSX, type ReactNode } from "react"; +import { ApiQueryKeys } from "@constants/apiConstants"; +import type { BeDashboardWidget } from "@api/backend/types"; +import DashboardWidgetTile from "@features/csm-dashboard/components/DashboardWidgetTile"; +import RefreshButton from "@components/RefreshButton"; +import { resolveWidgetText } from "@features/csm-dashboard/utils/widgetTextPlaceholder"; +import { + WIDGET_GRID_SX, + groupWidgetsBySection, +} from "@features/csm-dashboard/utils/dashboardWidgetGridLayout"; + +function widgetGridColumnSx(widget: BeDashboardWidget) { + // A list-shape widget renders a real table (4 rows, several columns) — + // its configured `gridWidth` was sized for the old compact text list, so + // it always spans the full row here regardless of that value. + return widget.shape === "list" + ? { gridColumn: "1 / -1" } + : { + gridColumn: { + xs: `span ${Math.min(widget.gridWidth, 4)}`, + sm: `span ${widget.gridWidth}`, + }, + }; +} + +export interface DashboardWidgetGridProps { + widgets: BeDashboardWidget[]; + /** The currently selected team's own `groupId` (see `BeTeam.groupId`), or + * an array of every team's `groupId` in the current dashboard's family + * when the "All ABTs" option is selected (see `ALL_TEAMS_SENTINEL` in + * `teamFilterPlaceholder.ts`) — only meaningful for an `isTeamBased` + * dashboard, threaded straight through to every tile so each can resolve + * its own `__current_team__` filter placeholder. `undefined` for a + * non-team-based dashboard, or while the team isn't resolved yet. */ + selectedTeamGroupId?: string | string[]; + /** Human-readable label for the selected team (its own display `name`, + * or the literal `"All ABTs"`) — threaded down for each tile's own + * `{{currentTeam}}` widget text placeholder (see + * `widgetTextPlaceholder.ts`). `undefined` in the same cases + * `selectedTeamGroupId` is. */ + selectedTeamLabel?: string; + /** Per-widget action rendered as a small overlay on that widget's own + * tile (e.g. the dashboard builder's "Edit widget" gear) — absent + * renders every tile exactly as the live dashboard does, with no overlay + * at all. Positioned by the caller; this component only decides where in + * the DOM it renders (a positioned wrapper around the tile). */ + renderWidgetAction?: (widget: BeDashboardWidget) => ReactNode; + /** Per-section actions rendered in that section's own header row, + * alongside its refresh button (e.g. the builder's "Add widget to this + * section" / "Remove section"). Receives the section's own RAW, + * unresolved `widget.section` value (`undefined` for the untitled default + * group) — the same identity `groupWidgetsBySection` groups by and the + * draft's own `widget.section`/`emptySections` are keyed on — followed by + * the display-resolved title (post `{{currentTeam}}` substitution, for + * rendering only) and every widget id currently in the section. A caller + * that uses the resolved title as an identity key instead of the raw one + * splits a placeholder-named section in two the moment it's edited — see + * `groupWidgetsBySection` in `dashboardWidgetGridLayout.ts`. */ + renderSectionActions?: ( + rawSection: string | undefined, + resolvedSectionTitle: string | undefined, + sectionWidgetIds: Set, + ) => ReactNode; + /** Rendered once, after every existing section — e.g. the builder's own + * "Add section" entry point, or an empty section shell that has no + * widgets in it yet. */ + trailingContent?: ReactNode; +} + +/** + * The dashboard widget grid: groups `widgets` by `section` and renders one + * `DashboardWidgetTile` per widget, each resolving its own data + * independently. Extracted out of `AgentsLandingPagePilot` (the live + * dashboard's own renderer) so the dashboard builder can render an + * in-progress draft's widgets through the exact same component instead of a + * forked copy — the only two things that differ between "live" and + * "editing" are the optional `renderWidgetAction`/`renderSectionActions`/ + * `trailingContent` overlays, which are no-ops when omitted. + */ +export default function DashboardWidgetGrid({ + widgets, + selectedTeamGroupId, + selectedTeamLabel, + renderWidgetAction, + renderSectionActions, + trailingContent, +}: DashboardWidgetGridProps): JSX.Element { + const queryClient = useQueryClient(); + // Per-section refresh tracks its own in-flight state, keyed by section. + const [refreshingSections, setRefreshingSections] = useState>(new Set()); + + /** + * Invalidates only the widget-data queries belonging to `widgetIds` — + * both shapes' query keys carry a widget id, just at a different + * position: `[KEY, widgetId, ...]` for count/list (see `useWidgetData`), + * `[KEY, "pie-slice", widgetId, ...]` for pie/bar (see + * `useWidgetPieData`). + */ + const invalidateWidgets = (widgetIds: Set): Promise => + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + if (key[0] !== ApiQueryKeys.CSM_DASHBOARD_WIDGET_DATA) return false; + const widgetId = key[1] === "pie-slice" ? key[2] : key[1]; + return typeof widgetId === "string" && widgetIds.has(widgetId); + }, + }); + + const handleSectionRefresh = async (sectionKey: string, widgetIds: Set): Promise => { + setRefreshingSections((prev) => new Set(prev).add(sectionKey)); + try { + await invalidateWidgets(widgetIds); + } finally { + setRefreshingSections((prev) => { + const next = new Set(prev); + next.delete(sectionKey); + return next; + }); + } + }; + + const renderTile = (widget: BeDashboardWidget) => { + const action = renderWidgetAction?.(widget); + return ( + + + {action && ( + {action} + )} + + ); + }; + + const groups = groupWidgetsBySection(widgets); + + return ( + + {groups.map((group, i) => { + // Chart-shaped widgets (pie/bar) are visually grouped into their + // own row below this group's count/list tiles, rather than sharing + // one undifferentiated grid with them. + const mainWidgets = group.widgets.filter((w) => w.shape !== "pie" && w.shape !== "bar"); + const chartWidgets = group.widgets.filter((w) => w.shape === "pie" || w.shape === "bar"); + if (mainWidgets.length === 0 && chartWidgets.length === 0) return null; + + const sectionKey = group.section ?? `__default_${i}`; + const sectionWidgetIds = new Set(group.widgets.map((w) => w.widgetId)); + // Section titles support the same {{currentTeam}} text token as an + // individual widget's own displayName/description (see + // widgetTextPlaceholder.ts) — resolve it once here so both the + // visible heading and the refresh button's label stay in sync. + const resolvedSectionTitle = resolveWidgetText(group.section, selectedTeamLabel); + + return ( + + {i > 0 && } + + + {resolvedSectionTitle && ( + + {resolvedSectionTitle} + + )} + + {renderSectionActions?.(group.section, resolvedSectionTitle, sectionWidgetIds)} + void handleSectionRefresh(sectionKey, sectionWidgetIds)} + isFetching={refreshingSections.has(sectionKey)} + label={ + resolvedSectionTitle + ? `Refresh ${resolvedSectionTitle}` + : "Refresh section" + } + /> + + + {mainWidgets.length > 0 && ( + {mainWidgets.map(renderTile)} + )} + {chartWidgets.length > 0 && ( + <> + {mainWidgets.length > 0 && } + {chartWidgets.map(renderTile)} + + )} + + + ); + })} + {trailingContent} + + ); +} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/dashboardWidgetGridLayout.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/dashboardWidgetGridLayout.ts new file mode 100644 index 0000000000..0447e69781 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/dashboardWidgetGridLayout.ts @@ -0,0 +1,61 @@ +// 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 { BeDashboardWidget } from "@api/backend/types"; + +/** 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. Shared by + * `DashboardWidgetGrid` (the real grid) and every page that shows a + * same-shaped loading skeleton before that grid's own data has resolved. */ +export const WIDGET_GRID_SX = { + display: "grid", + gap: 1.5, + gridTemplateColumns: { + xs: "repeat(4, minmax(0, 1fr))", + sm: "repeat(12, minmax(0, 1fr))", + }, +} as const; + +export interface WidgetGroup { + /** `undefined` for the untitled/default group — every widget with no + * `section` set lands here, rendered exactly as before this field + * existed (no heading). */ + section?: string; + widgets: BeDashboardWidget[]; +} + +/** Groups widgets by `section`, preserving the order each distinct section + * value (including the untitled default) first appears among `widgets` — + * see `BeDashboardWidget.section`. Exported (in its own non-component + * module, not `DashboardWidgetGrid.tsx` itself) so the dashboard builder + * can derive the same section-name list a live dashboard would render, + * without duplicating this grouping rule. */ +export function groupWidgetsBySection(widgets: BeDashboardWidget[]): WidgetGroup[] { + const groups: WidgetGroup[] = []; + const indexBySection = new Map(); + for (const widget of widgets) { + const key = widget.section || undefined; + let index = indexBySection.get(key); + if (index === undefined) { + index = groups.length; + indexBySection.set(key, index); + groups.push({ section: key, widgets: [] }); + } + groups[index].widgets.push(widget); + } + return groups; +}