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 (
+
+ );
+}
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
+ );
+}
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.
+
+ } onClick={handleCreate}>
+ Create new dashboard
+
+
+
+ {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;
+}