From 5deb1f607f88e05299622fb3ec6f4c136bc83de4 Mon Sep 17 00:00:00 2001 From: Hesara2003 Date: Thu, 6 Aug 2026 14:50:29 +0530 Subject: [PATCH] [CSM Portal] Add back navigation from dashboard number/pie/bar widgets Clicking a dashboard's count/pie/bar widget tile lands on that resource's own list page (e.g. Cases) with no way back to the dashboard, unlike the dashboard's list-shape widget (whose embedded list already sets a `from` location.state pointing at the dashboard, since it renders directly on that page). DashboardWidgetTile now forwards the same `from` state on every count/pie/bar click-through, and CsmIssuesView (the shared list view backing Cases, Security Center, Operations, Engagements, and the project-issues tab) renders a Back button to it when present. --- .../components/CsmIssuesView.test.tsx | 96 +++++++++++++++++++ .../csm-cases/components/CsmIssuesView.tsx | 27 +++++- .../components/DashboardWidgetTile.test.tsx | 88 ++++++++++++++++- .../components/DashboardWidgetTile.tsx | 13 ++- 4 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.test.tsx diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.test.tsx new file mode 100644 index 0000000000..44b5080825 --- /dev/null +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.test.tsx @@ -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: () =>
FilterBar
, +})); +vi.mock("@features/csm-cases/components/CasesList", () => ({ + default: () =>
CasesList
, +})); +vi.mock("@components/FilteredCsvExportButton", () => ({ + default: () =>
ExportButton
, +})); +vi.mock("@components/RefreshButton", () => ({ + default: () =>
RefreshButton
, +})); + +import CsmIssuesView from "@features/csm-cases/components/CsmIssuesView"; + +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderAt(initialState: unknown) { + return render( + + + } /> + } /> + + , + ); +} + +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"); + }); +}); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.tsx index b6ff552a63..8b63b304d4 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CsmIssuesView.tsx @@ -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, @@ -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"; @@ -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); @@ -349,6 +361,17 @@ export default function CsmIssuesView({ return ( + {backTo && ( + + )} {location.pathname + location.search}; + return ( + <> +
{location.pathname + location.search}
+
{JSON.stringify(location.state ?? null)}
+ + ); } /** For tests that need to observe where a click actually navigated to — @@ -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( + , + "/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 }); @@ -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( + , + "/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 }); @@ -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( + , + "/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 }); 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..8cf4115251 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 @@ -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"; @@ -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 @@ -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 === " ") { @@ -356,6 +363,7 @@ export default function DashboardWidgetTile({ selectedTeamGroupId, ), ), + { state: dashboardReturnState }, ) } /> @@ -432,6 +440,7 @@ export default function DashboardWidgetTile({