diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index e168e898a1..7fd9351d3d 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -293,7 +293,7 @@ const TAB_DEFS: Array<{ // inside this tab, not the people icon "Related" used. { id: "related", label: "Linked Items", icon: }, { id: "watchers", label: "Watchers", icon: }, - { id: "sla", label: "SLAs", icon: , hidden: true }, + { id: "sla", label: "SLAs", icon: }, { id: "attachments", label: "Attachments", icon: }, { id: "time", label: "Time tracking", icon: }, { id: "call-requests", label: "Call requests", icon: }, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx index a447b41fee..8f9bbcfda8 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.test.tsx @@ -329,6 +329,76 @@ describe("DashboardWidgetTile", () => { expect(params.get("assignedUserId")).toBe("@me"); }); + it("resolves the __current_team__ placeholder before it reaches the 'View more' href (list-shape), so the drill-down page never falls back to querying every team", async () => { + postMock.mockResolvedValue({ + total: 6, + cases: [{ id: "11111111-1111-1111-1111-111111111111", number: "CS-1", subject: "Disk full", state: "open" }], + limit: 5, + offset: 0, + hasMore: true, + }); + + renderWithClient( + , + ); + + const viewMoreLink = await screen.findByRole("link", { name: /view more/i }); + const href = viewMoreLink.getAttribute("href") ?? ""; + // The literal placeholder must never reach the URL — the destination + // preview page has no team context of its own to resolve it with, so a + // still-placeholder-carrying filter there silently gets DROPPED + // (fail-open — see teamFilterPlaceholder.ts), widening the query to + // every team's cases instead of just the viewer's own team's. + expect(href).not.toContain(CURRENT_TEAM_PLACEHOLDER); + const params = new URLSearchParams(href.split("?")[1]); + expect(params.get("integrationCsTeam")).toBe("22222222-2222-2222-2222-222222222222"); + }); + + it("drops the integrationCsTeam filter from the 'View more' href (list-shape) rather than sending the literal placeholder when no team groupId is selected", async () => { + postMock.mockResolvedValue({ + total: 6, + cases: [{ id: "11111111-1111-1111-1111-111111111111", number: "CS-1", subject: "Disk full", state: "open" }], + limit: 5, + offset: 0, + hasMore: true, + }); + + renderWithClient( + , + ); + + const viewMoreLink = await screen.findByRole("link", { name: /view more/i }); + const href = viewMoreLink.getAttribute("href") ?? ""; + expect(href).not.toContain(CURRENT_TEAM_PLACEHOLDER); + const params = new URLSearchParams(href.split("?")[1]); + expect(params.get("integrationCsTeam")).toBeNull(); + expect(params.get("state")).toBe("open"); + }); + it("navigates to /cases with translated filters when a case-resource tile is clicked", async () => { postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false }); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx index 8654574509..ea72ec317c 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/DashboardWidgetTile.tsx @@ -253,7 +253,13 @@ export default function DashboardWidgetTile({ previewSlug: config.previewSlug, widgetId, displayName: resolvedDisplayName, - filters, + // Resolved the same way the count-shape tile's own + // click-through href is (see `href` above) — the preview + // page has no team context of its own, so an unresolved + // placeholder here used to get silently dropped there + // (teamFilterPlaceholder.ts's fail-open), returning + // every team's cases instead of the viewer's own. + filters: resolveTeamPlaceholder(filters, selectedTeamGroupId), currentUserId: user?.id, })} size="small" diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx index e8543b030b..9989b1acab 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.test.tsx @@ -175,6 +175,88 @@ describe("DashboardWidgetPreviewPage", () => { ); }); + it("renders a visible summary of the active filter criteria (flat filter shape)", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "11111111-1111-1111-1111-111111111111", number: "CS-1", subject: "Disk full", state: "open" }], + limit: 10, + offset: 0, + hasMore: false, + }); + + renderAt( + buildWidgetPreviewHref({ + previewSlug: "cases", + widgetId: "my_critical_open", + displayName: "My Critical & High Cases", + filters: { severities: ["critical", "high"] }, + }), + ); + + await waitFor(() => expect(screen.getByText("CS-1")).toBeInTheDocument()); + const group = screen.getByRole("group", { name: "Active filters" }); + expect(group).toHaveTextContent("severities: critical, high"); + }); + + it("renders a visible summary of the active filter criteria (case field/op/values DSL shape), including the resolved team filter", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "11111111-1111-1111-1111-111111111111", number: "CS-1", subject: "Disk full", state: "open" }], + limit: 10, + offset: 0, + hasMore: false, + }); + + renderAt( + buildWidgetPreviewHref({ + previewSlug: "cases", + widgetId: "team_open_cases", + displayName: "Team Open Cases", + filters: { + filters: [ + { field: "state", op: "in", values: ["open"] }, + { field: "tag", op: "notIn", values: ["s_dip"] }, + { + field: "integrationCsTeam", + op: "in", + values: ["22222222-2222-2222-2222-222222222222"], + }, + ], + }, + }), + ); + + await waitFor(() => expect(screen.getByText("CS-1")).toBeInTheDocument()); + const group = screen.getByRole("group", { name: "Active filters" }); + expect(group).toHaveTextContent("state: open"); + expect(group).toHaveTextContent("tag (notIn): s_dip"); + expect(group).toHaveTextContent( + "integrationCsTeam: 22222222-2222-2222-2222-222222222222", + ); + }); + + it("does not render an active-filters summary when the widget has no filters", async () => { + postMock.mockResolvedValue({ + total: 1, + cases: [{ id: "11111111-1111-1111-1111-111111111111", number: "CS-1", subject: "Disk full", state: "open" }], + limit: 10, + offset: 0, + hasMore: false, + }); + + renderAt( + buildWidgetPreviewHref({ + previewSlug: "cases", + widgetId: "my_critical_open", + displayName: "My Critical & High Cases", + filters: {}, + }), + ); + + await waitFor(() => expect(screen.getByText("CS-1")).toBeInTheDocument()); + expect(screen.queryByRole("group", { name: "Active filters" })).not.toBeInTheDocument(); + }); + it("returns to the dashboard when Back is clicked", () => { renderAt( buildWidgetPreviewHref({ diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.tsx index 4e8773d009..914a985248 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/pages/DashboardWidgetPreviewPage.tsx @@ -14,7 +14,7 @@ // specific language governing permissions and limitations // under the License. -import { Box, Button, TablePagination, TextField, Typography } from "@wso2/oxygen-ui"; +import { Box, Button, Chip, TablePagination, TextField, Typography } from "@wso2/oxygen-ui"; import { ArrowLeft } from "@wso2/oxygen-ui-icons-react"; import { useMemo, useState, type ChangeEvent, type JSX } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router"; @@ -27,6 +27,7 @@ import RefreshButton from "@components/RefreshButton"; import { WIDGET_LIST_RENDERERS } from "@features/csm-dashboard/config/widgetListConfig"; import { resourceTypeForPreviewSlug } from "@features/csm-dashboard/config/widgetResourceConfig"; import { + describeWidgetFilters, parseWidgetPreviewFilters, resolveCurrentUserSentinels, } from "@features/csm-dashboard/utils/widgetPreviewUrl"; @@ -145,6 +146,13 @@ function DashboardWidgetPreviewContent({ return trimmed ? { ...filters, searchQuery: trimmed } : filters; }, [filters, debouncedSearch]); + // What's actually being queried, made visible rather than trusted + // silently — the exact filters this page is about to send, in the same + // already-resolved shape `useWidgetData` below queries with (no + // `__current_team__`/`@me` placeholders left to decode). Excludes the + // free-text search term, which the search box right below already shows. + const filterSummary = useMemo(() => describeWidgetFilters(filters), [filters]); + const { data, isLoading, isError, isFetching, refetch, dataUpdatedAt } = useWidgetData( widgetId, resourceType, @@ -177,6 +185,25 @@ function DashboardWidgetPreviewContent({ label={`Refresh ${displayName}`} /> + {filterSummary.length > 0 && ( + + + Filtered by: + + {filterSummary.map((entry) => ( + + ))} + + )} { expect(entries).toEqual(input); }); }); + +describe("describeWidgetFilters", () => { + it("flattens the flat resourceType filter shape into readable field: value entries", () => { + expect( + describeWidgetFilters({ severities: ["critical", "high"], states: ["open"] }), + ).toEqual([ + { field: "severities", value: "critical, high" }, + { field: "states", value: "open" }, + ]); + }); + + it("flattens the case field/op/values DSL shape, omitting the op for the default 'in'", () => { + expect( + describeWidgetFilters({ + filters: [ + { field: "state", op: "in", values: ["open"] }, + { field: "tag", op: "notIn", values: ["s_dip"] }, + ], + }), + ).toEqual([ + { field: "state", op: undefined, value: "open" }, + { field: "tag", op: "notIn", value: "s_dip" }, + ]); + }); + + it("still shows a value-less op (isEmpty/isNotEmpty) rather than silently dropping it", () => { + expect( + describeWidgetFilters({ + filters: [{ field: "escalation", op: "isNotEmpty", values: [] }], + }), + ).toEqual([{ field: "escalation", op: "isNotEmpty", value: "(no value)" }]); + }); + + it("shows an already-resolved team filter's real groupId value, not a placeholder", () => { + expect( + describeWidgetFilters({ + filters: [ + { + field: "integrationCsTeam", + op: "in", + values: ["22222222-2222-2222-2222-222222222222"], + }, + ], + }), + ).toEqual([ + { + field: "integrationCsTeam", + op: undefined, + value: "22222222-2222-2222-2222-222222222222", + }, + ]); + }); + + it("returns an empty list for empty/absent filters", () => { + expect(describeWidgetFilters({})).toEqual([]); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetPreviewUrl.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetPreviewUrl.ts index 94ee177083..c09e816c98 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetPreviewUrl.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/widgetPreviewUrl.ts @@ -144,6 +144,65 @@ export function buildWidgetPreviewHref(params: { return `/dashboard/${params.previewSlug}?${q.toString()}`; } +/** One human-readable "what's actually being queried" entry — a single + * filter field and the value(s) it's currently set to, `op` set only for a + * non-default (non-`in`) operator so a plain `field: value` reads cleanly + * for the common case. Field names are the raw camelCase filter key (e.g. + * `integrationCsTeam`); no friendly-label lookup exists for every filter + * field across every resourceType, so this deliberately stays literal + * rather than inventing a large label-mapping table for partial coverage. */ +export interface WidgetFilterSummaryEntry { + field: string; + op?: string; + value: string; +} + +/** + * Flattens a widget's (already fully-resolved — no `__current_team__`/`@me` + * placeholders left in it) filters object into a readable list of active + * filter criteria, for display on `DashboardWidgetPreviewPage` so a viewer + * can see exactly what's being queried rather than trusting it silently. + * Handles both filter shapes this app's widgets use: the case-search + * generic field/op/values DSL (`{ filters: BeCaseFieldFilter[] }` — see + * `isCaseFieldFilterArray`) and every other resourceType's flat + * `{ fieldName: string[] }` record — the same two shapes + * `buildWidgetPreviewHref` already branches on, reusing its own + * value-less-op handling (`VALUELESS_OPS`) so an `isEmpty`/`isNotEmpty` + * entry still shows up here instead of being silently skipped for + * "having nothing to read". + */ +export function describeWidgetFilters( + filters: Record, +): WidgetFilterSummaryEntry[] { + const entries: WidgetFilterSummaryEntry[] = []; + const fieldFilters = filters.filters; + + if (isCaseFieldFilterArray(fieldFilters)) { + for (const entry of fieldFilters) { + const op = entry.op || "in"; + const values = entry.values ?? []; + if (values.length === 0 && !VALUELESS_OPS.has(op)) continue; + entries.push({ + field: entry.field, + op: op === "in" ? undefined : op, + value: values.length > 0 ? values.join(", ") : "(no value)", + }); + } + return entries; + } + + for (const [key, value] of Object.entries(filters)) { + if (RESERVED_PARAMS.has(key)) continue; + if (isStringArray(value)) { + if (value.length === 0) continue; + entries.push({ field: key, value: value.join(", ") }); + } else if (typeof value === "string" && value.length > 0) { + entries.push({ field: key, value }); + } + } + return entries; +} + export interface ParsedWidgetPreviewFilters { filters: Record; /** True if a filter value still carries the `@me` sentinel and needs