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