Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ const TAB_DEFS: Array<{
// inside this tab, not the people icon "Related" used.
{ id: "related", label: "Linked Items", icon: <LinkIcon size={16} /> },
{ id: "watchers", label: "Watchers", icon: <Eye size={16} /> },
{ id: "sla", label: "SLAs", icon: <Clock size={16} />, hidden: true },
{ id: "sla", label: "SLAs", icon: <Clock size={16} /> },
{ id: "attachments", label: "Attachments", icon: <Paperclip size={16} /> },
{ id: "time", label: "Time tracking", icon: <Layers size={16} /> },
{ id: "call-requests", label: "Call requests", icon: <Phone size={16} /> },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DashboardWidgetTile
widgetId="team_open_cases"
displayName="Team Open Cases"
resourceType="case"
shape="list"
filters={{
filters: [
{ field: "integrationCsTeam", op: "in", values: [CURRENT_TEAM_PLACEHOLDER] },
],
}}
listLimit={5}
selectedTeamGroupId="22222222-2222-2222-2222-222222222222"
/>,
);

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(
<DashboardWidgetTile
widgetId="team_open_cases"
displayName="Team Open Cases"
resourceType="case"
shape="list"
filters={{
filters: [
{ field: "state", op: "in", values: ["open"] },
{ field: "integrationCsTeam", op: "in", values: [CURRENT_TEAM_PLACEHOLDER] },
],
}}
listLimit={5}
/>,
);

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 });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -177,6 +185,25 @@ function DashboardWidgetPreviewContent({
label={`Refresh ${displayName}`}
/>
</Box>
{filterSummary.length > 0 && (
<Box
role="group"
aria-label="Active filters"
sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1 }}
>
<Typography variant="caption" color="text.secondary">
Filtered by:
</Typography>
{filterSummary.map((entry) => (
<Chip
key={`${entry.field}-${entry.op ?? "in"}`}
size="small"
variant="outlined"
label={`${entry.field}${entry.op ? ` (${entry.op})` : ""}: ${entry.value}`}
/>
))}
</Box>
)}
<TextField
size="small"
label="Search"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import { describe, expect, it } from "vitest";
import {
buildWidgetPreviewHref,
describeWidgetFilters,
parseWidgetPreviewFilters,
resolveCurrentUserSentinels,
} from "./widgetPreviewUrl";
Expand Down Expand Up @@ -205,3 +206,60 @@ describe("widget preview URL — filter op round-trip", () => {
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([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): 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<string, unknown>;
/** True if a filter value still carries the `@me` sentinel and needs
Expand Down