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
@@ -0,0 +1,96 @@
// 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 { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import "@testing-library/jest-dom/vitest";

vi.mock("@api/backend/client", () => ({
useBackendApi: () => ({ post: vi.fn() }),
}));
vi.mock("@config/apiConfig", () => ({
apiConfig: { backendUrl: "https://example.test" },
}));
vi.mock("@context/current-user/CurrentUserContext", () => ({
useCurrentUser: () => ({ user: { id: "user-1" }, isLoading: false, isError: false }),
}));
vi.mock("@context/error-banner/ErrorBannerContext", () => ({
useErrorBanner: () => ({ showError: vi.fn() }),
}));
vi.mock("@hooks/useIdTokenClaims", () => ({
useIdTokenClaims: () => ({ email: "user@example.test" }),
}));
vi.mock("@api/useDirectoryUsers", () => ({
useDirectoryUsers: () => ({ data: [] }),
}));
vi.mock("@features/csm-cases/api/useGetCsmCases", () => ({
useGetCsmCases: () => ({
data: { cases: [], total: 0, hasMore: false },
isLoading: false,
isFetching: false,
isError: false,
error: null,
refetch: vi.fn(),
dataUpdatedAt: 0,
}),
}));
vi.mock("@features/csm-cases/components/CasesFilterBar", () => ({
default: () => <div>FilterBar</div>,
}));
vi.mock("@features/csm-cases/components/CasesList", () => ({
default: () => <div>CasesList</div>,
}));
vi.mock("@components/FilteredCsvExportButton", () => ({
default: () => <div>ExportButton</div>,
}));
vi.mock("@components/RefreshButton", () => ({
default: () => <div>RefreshButton</div>,
}));

import CsmIssuesView from "@features/csm-cases/components/CsmIssuesView";

function LocationProbe() {
const location = useLocation();
return <div data-testid="location-probe">{location.pathname}</div>;
}

function renderAt(initialState: unknown) {
return render(
<MemoryRouter initialEntries={[{ pathname: "/cases", state: initialState }]}>
<Routes>
<Route path="/cases" element={<CsmIssuesView title="Cases" />} />
<Route path="/dashboard" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
}

describe("CsmIssuesView back navigation", () => {
it("renders no Back button when it wasn't reached from a dashboard widget", () => {
renderAt(null);
expect(screen.queryByRole("button", { name: "Back" })).not.toBeInTheDocument();
});

it("renders a Back button that returns to the dashboard when reached via a dashboard widget's `from` state", () => {
renderAt({ from: "/dashboard" });

const backButton = screen.getByRole("button", { name: "Back" });
fireEvent.click(backButton);

expect(screen.getByTestId("location-probe")).toHaveTextContent("/dashboard");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
// specific language governing permissions and limitations
// under the License.

import { Box, Chip, TablePagination, Typography } from "@wso2/oxygen-ui";
import { Box, Button, Chip, TablePagination, Typography } from "@wso2/oxygen-ui";
import { ArrowLeft } from "@wso2/oxygen-ui-icons-react";
import {
useCallback,
useEffect,
Expand All @@ -25,11 +26,12 @@ import {
type JSX,
type ReactNode,
} from "react";
import { useSearchParams } from "react-router";
import { useLocation, useSearchParams } from "react-router";
import { useErrorBanner } from "@context/error-banner/ErrorBannerContext";
import { useCurrentUser } from "@context/current-user/CurrentUserContext";
import { useDebouncedValue } from "@hooks/useDebouncedValue";
import { useIdTokenClaims } from "@hooks/useIdTokenClaims";
import { useNavTransition } from "@hooks/useNavTransition";
import { formatBackendTimestampForDisplay } from "@utils/dateTime";
import { useBackendApi } from "@api/backend/client";
import FilteredCsvExportButton from "@components/FilteredCsvExportButton";
Expand Down Expand Up @@ -126,6 +128,16 @@ export default function CsmIssuesView({
[searchParams],
);

const location = useLocation();
const navigate = useNavTransition();
// Set by DashboardWidgetTile's count/pie/bar click-throughs, since this
// view has no dashboard context of its own (unlike the dashboard's
// list-shape widget, whose embedded CasesList sets the same `from` shape
// pointing at the dashboard itself). Absent for every other way of
// reaching this view (nav-bar tab, project-issues tab, etc.), so the Back
// button only ever appears when there's somewhere meaningful to return to.
const backTo = (location.state as { from?: string } | null)?.from;

const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(DEFAULT_ROWS_PER_PAGE);
const [isFiltersOpen, setIsFiltersOpen] = useState(true);
Expand Down Expand Up @@ -349,6 +361,17 @@ export default function CsmIssuesView({

return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{backTo && (
<Button
variant="text"
size="small"
startIcon={<ArrowLeft size={16} />}
onClick={() => navigate(backTo)}
sx={{ alignSelf: "flex-start" }}
>
Back
</Button>
)}
<Box
sx={{
display: "flex",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,12 @@ function renderWithClient(ui: ReactNode) {

function LocationProbe() {
const location = useLocation();
return <div data-testid="location-probe">{location.pathname + location.search}</div>;
return (
<>
<div data-testid="location-probe">{location.pathname + location.search}</div>
<div data-testid="location-state-probe">{JSON.stringify(location.state ?? null)}</div>
</>
);
}

/** For tests that need to observe where a click actually navigated to —
Expand Down Expand Up @@ -357,6 +362,29 @@ describe("DashboardWidgetTile", () => {
expect(params.get("states")).toBe("open");
});

it("shape count: click-through carries a `from` location.state pointing back to this dashboard page, so the destination list can offer a Back button", async () => {
postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false });

renderWithRoutes(
<DashboardWidgetTile
widgetId="my_patches"
displayName="My Patches"
resourceType="case"
shape="count"
filters={{ filters: [{ field: "state", op: "in", values: ["open"] }] }}
/>,
"/cases",
);

await waitFor(() => expect(screen.getByText("3")).toBeInTheDocument());
fireEvent.click(screen.getByRole("link"));

await waitFor(() => expect(screen.getByTestId("location-probe")).toBeInTheDocument());
expect(screen.getByTestId("location-state-probe").textContent).toBe(
JSON.stringify({ from: "/" }),
);
});

it("resolves the __current_team__ placeholder with the selected team's groupId in both the /search request and the count tile's own click-through href", async () => {
postMock.mockResolvedValue({ total: 3, cases: [], limit: 1, offset: 0, hasMore: false });

Expand Down Expand Up @@ -652,6 +680,35 @@ describe("DashboardWidgetTile", () => {
expect(params.get("states")).toBe("open");
});

it("shape pie: clicking a slice carries a `from` location.state pointing back to this dashboard page", async () => {
postMock.mockResolvedValue({ total: 2 });

renderWithRoutes(
<DashboardWidgetTile
widgetId="cases-by-severity"
displayName="Cases by severity"
resourceType="case"
shape="pie"
filters={{}}
slices={[
{
label: "Critical",
query: { filters: [{ field: "severity", op: "in", values: ["critical"] }] },
},
]}
/>,
"/cases",
);

await waitFor(() => expect(screen.getByText("slice:Critical:2")).toBeInTheDocument());
fireEvent.click(screen.getByText("slice:Critical:2"));

await waitFor(() => expect(screen.getByTestId("location-probe")).toBeInTheDocument());
expect(screen.getByTestId("location-state-probe").textContent).toBe(
JSON.stringify({ from: "/" }),
);
});

it("shape pie: clicking a legend row navigates the same way as clicking the slice", async () => {
postMock.mockResolvedValue({ total: 2 });

Expand Down Expand Up @@ -715,6 +772,35 @@ describe("DashboardWidgetTile", () => {
expect(params.get("severities")).toBeNull();
});

it("shape pie: clicking the tile itself carries a `from` location.state pointing back to this dashboard page", async () => {
postMock.mockResolvedValue({ total: 2 });

renderWithRoutes(
<DashboardWidgetTile
widgetId="cases-by-severity"
displayName="Cases by severity"
resourceType="case"
shape="pie"
filters={{}}
slices={[
{
label: "Critical",
query: { filters: [{ field: "severity", op: "in", values: ["critical"] }] },
},
]}
/>,
"/cases",
);

await waitFor(() => expect(screen.getByText("slice:Critical:2")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: "View all cases for Cases by severity" }));

await waitFor(() => expect(screen.getByTestId("location-probe")).toBeInTheDocument());
expect(screen.getByTestId("location-state-probe").textContent).toBe(
JSON.stringify({ from: "/" }),
);
});

it("shape pie: Enter on the focused tile activates the same tile-level click-through as a click", async () => {
postMock.mockResolvedValue({ total: 2 });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { Box, Button, Card, IconButton, Skeleton, Tooltip, Typography, alpha, useTheme } from "@wso2/oxygen-ui";
import { ArrowRight, Info } from "@wso2/oxygen-ui-icons-react";
import type { JSX, KeyboardEvent, ReactNode } from "react";
import { Link as RouterLink, useNavigate } from "react-router";
import { Link as RouterLink, useLocation, useNavigate } from "react-router";
import type { BeDashboardPieSlice, BeWidgetResourceType, BeWidgetShape } from "@api/backend/types";
import { useCurrentUser } from "@context/current-user/CurrentUserContext";
import { useWidgetData } from "@features/csm-dashboard/api/useWidgetData";
Expand Down Expand Up @@ -101,7 +101,14 @@ export default function DashboardWidgetTile({
}: DashboardWidgetTileProps): JSX.Element {
const theme = useTheme();
const navigate = useNavigate();
const location = useLocation();
const { user } = useCurrentUser();
// Carried on every count/pie/bar click-through below so the resource's own
// list page (which has no dashboard context of its own) can offer a Back
// button straight to this exact dashboard — mirroring the list-shape
// widget, whose embedded list already sets this same `from` shape (see
// CasesList) because it lives directly on this page.
const dashboardReturnState = { from: `${location.pathname}${location.search}` };
// Resolve the `{{currentTeam}}` text placeholder before anything below
// renders/reads `displayName`/`description` — every other use of those two
// props in this component reads the resolved value, never the raw one, so
Expand Down Expand Up @@ -294,7 +301,7 @@ export default function DashboardWidgetTile({
const ChartComponent = shape === "pie" ? DashboardPieChart : DashboardBarChart;
const tileHref = config.buildHref(resolveTeamPlaceholder(filters, selectedTeamGroupId));
const handleTileClick = (): void => {
void navigate(tileHref);
void navigate(tileHref, { state: dashboardReturnState });
};
const handleTileKeyDown = (e: KeyboardEvent): void => {
if (e.key === "Enter" || e.key === " ") {
Expand Down Expand Up @@ -356,6 +363,7 @@ export default function DashboardWidgetTile({
selectedTeamGroupId,
),
),
{ state: dashboardReturnState },
)
}
/>
Expand Down Expand Up @@ -432,6 +440,7 @@ export default function DashboardWidgetTile({
<Box
component={RouterLink}
to={href}
state={dashboardReturnState}
// The visible count + label sit in the pointer-events-none content
// layer above this anchor, not inside it as descendant text anymore
// (that's the whole point -- see the comment above), so it needs its
Expand Down