diff --git a/apps/customer-portal/webapp/src/App.tsx b/apps/customer-portal/webapp/src/App.tsx index dc0943448c..ea35a5ba02 100644 --- a/apps/customer-portal/webapp/src/App.tsx +++ b/apps/customer-portal/webapp/src/App.tsx @@ -23,6 +23,7 @@ import DashboardPage from "@pages/DashboardPage"; import SupportPage from "@pages/SupportPage"; import UpdatesPage from "@pages/UpdatesPage"; import AllCasesPage from "@pages/AllCasesPage"; +import CaseDetailsPage from "@pages/CaseDetailsPage"; import NoveraChatPage from "@pages/NoveraChatPage"; import CreateCasePage from "@pages/CreateCasePage"; import AppLayout from "@layouts/AppLayout"; @@ -57,7 +58,10 @@ export default function App(): JSX.Element { {/* Support */} } /> - } /> + + } /> + } /> + } /> } /> diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetCaseAttachments.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetCaseAttachments.test.tsx index 13958b8c1f..7652127905 100644 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetCaseAttachments.test.tsx +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetCaseAttachments.test.tsx @@ -63,10 +63,9 @@ describe("useGetCaseAttachments", () => { }); it("should return mock attachments when isMockEnabled is true", async () => { - const { result } = renderHook( - () => useGetCaseAttachments("case-001"), - { wrapper }, - ); + const { result } = renderHook(() => useGetCaseAttachments("case-001"), { + wrapper, + }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdateLevels.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdateLevels.test.tsx index 5ecc6e7f64..7961457432 100644 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdateLevels.test.tsx +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdateLevels.test.tsx @@ -29,7 +29,9 @@ vi.mock("@hooks/useLogger", () => ({ })); vi.mock("@constants/apiConstants", async (importOriginal) => { - const actual = (await importOriginal()) as { ApiQueryKeys: Record }; + const actual = (await importOriginal()) as { + ApiQueryKeys: Record; + }; return { ...actual, API_MOCK_DELAY: 0, @@ -132,8 +134,12 @@ describe("useGetProductUpdateLevels", () => { }, ]; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; @@ -166,8 +172,12 @@ describe("useGetProductUpdateLevels", () => { it("should handle API error when isMockEnabled is false", async () => { mockIsMockEnabled = false; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdatesStats.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdatesStats.test.tsx index 895e8cdd4a..7637c88c81 100644 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdatesStats.test.tsx +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetProductUpdatesStats.test.tsx @@ -29,7 +29,9 @@ vi.mock("@hooks/useLogger", () => ({ })); vi.mock("@constants/apiConstants", async (importOriginal) => { - const actual = (await importOriginal()) as { ApiQueryKeys: Record }; + const actual = (await importOriginal()) as { + ApiQueryKeys: Record; + }; return { ...actual, API_MOCK_DELAY: 0, @@ -78,18 +80,24 @@ describe("useGetProductUpdatesStats", () => { ); it("should return loading state initially", async () => { - const { result } = renderHook(() => useGetProductUpdatesStats("project-1"), { - wrapper, - }); + const { result } = renderHook( + () => useGetProductUpdatesStats("project-1"), + { + wrapper, + }, + ); expect(result.current.isLoading).toBe(true); }); it("should return mock data when isMockEnabled is true", async () => { mockIsMockEnabled = true; - const { result } = renderHook(() => useGetProductUpdatesStats("project-1"), { - wrapper, - }); + const { result } = renderHook( + () => useGetProductUpdatesStats("project-1"), + { + wrapper, + }, + ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -130,8 +138,12 @@ describe("useGetProductUpdatesStats", () => { securityUpdatesPending: 25, }; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; @@ -145,9 +157,12 @@ describe("useGetProductUpdatesStats", () => { } as Response), ); - const { result } = renderHook(() => useGetProductUpdatesStats("project-1"), { - wrapper, - }); + const { result } = renderHook( + () => useGetProductUpdatesStats("project-1"), + { + wrapper, + }, + ); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -166,8 +181,12 @@ describe("useGetProductUpdatesStats", () => { it("should handle API error when isMockEnabled is false", async () => { mockIsMockEnabled = false; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; @@ -181,9 +200,12 @@ describe("useGetProductUpdatesStats", () => { } as Response), ); - const { result } = renderHook(() => useGetProductUpdatesStats("project-1"), { - wrapper, - }); + const { result } = renderHook( + () => useGetProductUpdatesStats("project-1"), + { + wrapper, + }, + ); await waitFor(() => expect(result.current.isError).toBe(true)); expect(result.current.error?.message).toContain( diff --git a/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx b/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx index a6306a984d..ce4de97666 100644 --- a/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx +++ b/apps/customer-portal/webapp/src/api/__tests__/useGetUpdatesStats.test.tsx @@ -29,7 +29,9 @@ vi.mock("@hooks/useLogger", () => ({ })); vi.mock("@constants/apiConstants", async (importOriginal) => { - const actual = (await importOriginal()) as { ApiQueryKeys: Record }; + const actual = (await importOriginal()) as { + ApiQueryKeys: Record; + }; return { ...actual, API_MOCK_DELAY: 0, @@ -126,8 +128,12 @@ describe("useGetUpdatesStats", () => { securityUpdatesPending: 25, }; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; @@ -162,8 +168,12 @@ describe("useGetUpdatesStats", () => { it("should handle API error when isMockEnabled is false", async () => { mockIsMockEnabled = false; - const originalWindowConfig = (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config; - (window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } }).config = { + const originalWindowConfig = ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config; + ( + window as { config?: { CUSTOMER_PORTAL_BACKEND_BASE_URL?: string } } + ).config = { CUSTOMER_PORTAL_BACKEND_BASE_URL: "https://api.example.com", }; diff --git a/apps/customer-portal/webapp/src/api/useGetCaseAttachments.ts b/apps/customer-portal/webapp/src/api/useGetCaseAttachments.ts index 2d31ae7730..0e0b7cae84 100644 --- a/apps/customer-portal/webapp/src/api/useGetCaseAttachments.ts +++ b/apps/customer-portal/webapp/src/api/useGetCaseAttachments.ts @@ -98,8 +98,7 @@ export default function useGetCaseAttachments( throw error; } }, - enabled: - !!caseId && (isMockEnabled || (isSignedIn && !isAuthLoading)), + enabled: !!caseId && (isMockEnabled || (isSignedIn && !isAuthLoading)), staleTime: 5 * 60 * 1000, // 5 minutes }); } diff --git a/apps/customer-portal/webapp/src/api/useGetCaseDetails.ts b/apps/customer-portal/webapp/src/api/useGetCaseDetails.ts index 6b0aa52daa..d0cf341935 100644 --- a/apps/customer-portal/webapp/src/api/useGetCaseDetails.ts +++ b/apps/customer-portal/webapp/src/api/useGetCaseDetails.ts @@ -57,7 +57,7 @@ export default function useGetCaseDetails( } const idToken = await getIdToken(); - const requestUrl = `${baseUrl}/projects/${projectId}/cases/${caseId}`; + const requestUrl = `${baseUrl}/cases/${caseId}`; const response = await fetch(requestUrl, { method: "GET", headers: addApiHeaders(idToken), diff --git a/apps/customer-portal/webapp/src/api/useGetProductUpdateLevels.ts b/apps/customer-portal/webapp/src/api/useGetProductUpdateLevels.ts index 87f7400324..9acb2b67b9 100644 --- a/apps/customer-portal/webapp/src/api/useGetProductUpdateLevels.ts +++ b/apps/customer-portal/webapp/src/api/useGetProductUpdateLevels.ts @@ -40,9 +40,7 @@ export function useGetProductUpdateLevels(): UseQueryResult< return useQuery({ queryKey: [ApiQueryKeys.PRODUCT_UPDATE_LEVELS, isMockEnabled], queryFn: async (): Promise => { - logger.debug( - `Fetching product update levels, mock: ${isMockEnabled}`, - ); + logger.debug(`Fetching product update levels, mock: ${isMockEnabled}`); if (isMockEnabled) { await new Promise((resolve) => setTimeout(resolve, API_MOCK_DELAY)); diff --git a/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesList.tsx b/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesList.tsx index be44d22f1f..f87d66bb5c 100644 --- a/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesList.tsx +++ b/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesList.tsx @@ -31,7 +31,7 @@ import { } from "@wso2/oxygen-ui"; import { ExternalLink, MoreVertical } from "@wso2/oxygen-ui-icons-react"; import { type JSX, type ChangeEvent } from "react"; -import type { CaseSearchResponse } from "@models/responses"; +import type { CaseSearchResponse, CaseListItem } from "@models/responses"; import { getSeverityColor, getStatusColor } from "@utils/casesTable"; import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; import CasesTableSkeleton from "@components/dashboard/cases-table/CasesTableSkeleton"; @@ -44,6 +44,8 @@ interface CasesListProps { rowsPerPage: number; onPageChange: (event: unknown, newPage: number) => void; onRowsPerPageChange: (event: ChangeEvent) => void; + /** When provided, case title is clickable and navigates to case details. */ + onCaseClick?: (caseItem: CaseListItem) => void; } const CasesList = ({ @@ -54,6 +56,7 @@ const CasesList = ({ rowsPerPage, onPageChange, onRowsPerPageChange, + onCaseClick, }: CasesListProps): JSX.Element => { return ( <> @@ -111,17 +114,46 @@ const CasesList = ({ - + { + if ( + e.key === "Enter" || + e.key === " " + ) { + e.preventDefault(); + onCaseClick(row); + } + } + : undefined + } + onClick={ + onCaseClick + ? () => onCaseClick(row) + : undefined + } sx={{ - display: "flex", + display: "inline-flex", alignItems: "center", gap: 0.5, - cursor: "pointer", + cursor: onCaseClick ? "pointer" : "default", fontWeight: 500, - "&:hover": { color: "primary.main" }, + "&:hover": onCaseClick + ? { color: "primary.main" } + : undefined, }} > {row.title || "--"} diff --git a/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesTable.tsx b/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesTable.tsx index 29594db043..946a700fdb 100644 --- a/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesTable.tsx +++ b/apps/customer-portal/webapp/src/components/dashboard/cases-table/CasesTable.tsx @@ -227,6 +227,7 @@ const CasesTable = ({ projectId }: CasesTableProps): JSX.Element => { rowsPerPage={rowsPerPage} onPageChange={handleChangePage} onRowsPerPageChange={handleChangeRowsPerPage} + onCaseClick={(c) => navigate(`/${projectId}/support/cases/${c.id}`)} /> {/* Filter popover */} diff --git a/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesList.test.tsx b/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesList.test.tsx index c99cf95417..53399e9dff 100644 --- a/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesList.test.tsx +++ b/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesList.test.tsx @@ -35,7 +35,11 @@ vi.mock("@wso2/oxygen-ui", () => ({ {children} ), TableRow: ({ children }: any) => {children}, - Typography: ({ children }: any) => {children}, + Typography: ({ children, onClick, ...rest }: any) => ( + + {children} + + ), Chip: ({ label }: any) => {label}, IconButton: ({ children }: any) => , Paper: ({ children }: any) =>
{children}
, @@ -193,6 +197,49 @@ describe("CasesList", () => { expect(mockOnRowsPerPageChange).toHaveBeenCalled(); }); + it("should call onCaseClick with case when case title is clicked", () => { + const mockOnCaseClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("Test Case 1")); + expect(mockOnCaseClick).toHaveBeenCalledTimes(1); + expect(mockOnCaseClick).toHaveBeenCalledWith( + expect.objectContaining({ id: "1", title: "Test Case 1", number: "CS-001" }), + ); + + fireEvent.click(screen.getByText("Test Case 2")); + expect(mockOnCaseClick).toHaveBeenCalledTimes(2); + expect(mockOnCaseClick).toHaveBeenLastCalledWith( + expect.objectContaining({ id: "2", title: "Test Case 2", number: "CS-002" }), + ); + }); + + it("should not call onCaseClick when not provided (title still rendered)", () => { + render( + , + ); + + fireEvent.click(screen.getByText("Test Case 1")); + expect(screen.getByText("Test Case 1")).toBeInTheDocument(); + }); + it("should render '--' for null or undefined values", () => { const dataWithNulls = { cases: [ diff --git a/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesTable.test.tsx b/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesTable.test.tsx index 540968a0bc..cc6a161c81 100644 --- a/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesTable.test.tsx +++ b/apps/customer-portal/webapp/src/components/dashboard/cases-table/__tests__/CasesTable.test.tsx @@ -21,9 +21,11 @@ import useGetProjectCases from "@api/useGetProjectCases"; import useGetCasesFilters from "@api/useGetCasesFilters"; import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; +const mockNavigate = vi.fn(); + // Mock dependencies vi.mock("react-router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, })); vi.mock("@/api/useGetProjectCases"); @@ -87,7 +89,12 @@ vi.mock("../CasesTableHeader", () => ({ })); vi.mock("../CasesList", () => ({ - default: ({ data, onPageChange, onRowsPerPageChange }: any) => ( + default: ({ + data, + onPageChange, + onRowsPerPageChange, + onCaseClick, + }: any) => (
{data?.cases.length || 0} {data?.totalRecords || 0} @@ -95,6 +102,14 @@ vi.mock("../CasesList", () => ({ + {onCaseClick && ( + + )}
), })); @@ -331,6 +346,21 @@ describe("CasesTable", () => { }); }); + it("should navigate to case details when onCaseClick is triggered from list", () => { + render( + + + , + ); + + const caseTitleButton = screen.getByTestId("case-title-click"); + fireEvent.click(caseTitleButton); + + expect(mockNavigate).toHaveBeenCalledWith( + `/${mockProjectId}/support/cases/case-1`, + ); + }); + it("should propagate loading and error states to FilterPopover", () => { mockUseGetCasesFilters.mockReturnValue({ isFetching: true, diff --git a/apps/customer-portal/webapp/src/components/support/all-cases/AllCasesList.tsx b/apps/customer-portal/webapp/src/components/support/all-cases/AllCasesList.tsx index b71e9baf96..4b409f9b8b 100644 --- a/apps/customer-portal/webapp/src/components/support/all-cases/AllCasesList.tsx +++ b/apps/customer-portal/webapp/src/components/support/all-cases/AllCasesList.tsx @@ -37,6 +37,7 @@ import AllCasesListSkeleton from "@components/support/all-cases/AllCasesListSkel export interface AllCasesListProps { cases: CaseListItem[]; isLoading: boolean; + onCaseClick?: (caseItem: CaseListItem) => void; } /** @@ -48,6 +49,7 @@ export interface AllCasesListProps { export default function AllCasesList({ cases, isLoading, + onCaseClick, }: AllCasesListProps): JSX.Element { const theme = useTheme(); @@ -75,6 +77,7 @@ export default function AllCasesList({ return ( onCaseClick(caseItem) : undefined} sx={{ p: 3, display: "flex", diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailField.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailField.tsx new file mode 100644 index 0000000000..efcc4b2066 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailField.tsx @@ -0,0 +1,74 @@ +// 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 { Box, Typography } from "@wso2/oxygen-ui"; +import { type JSX } from "react"; +import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; +import { formatValue } from "@utils/support"; + +export interface CaseDetailFieldProps { + label: string; + value: string | null | undefined; + isLoading: boolean; + isError: boolean; +} + +/** + * Single label-value field row for the case details panel (e.g. Description, Created, Product). + * + * @param {CaseDetailFieldProps} props - Label, value, and loading/error state. + * @returns {JSX.Element} The field row. + */ +export default function CaseDetailField({ + label, + value, + isLoading, + isError, +}: CaseDetailFieldProps): JSX.Element { + return ( + + + {label} + + {isError ? ( + + ) : isLoading ? ( + + ) : ( + + {formatValue(value)} + + )} + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsActionRow.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsActionRow.tsx new file mode 100644 index 0000000000..56e6800589 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsActionRow.tsx @@ -0,0 +1,166 @@ +// 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 { + Avatar, + Box, + Button, + Divider, + Paper, + Skeleton, + Stack, + Typography, + alpha, + useTheme, + type Theme, +} from "@wso2/oxygen-ui"; +import { CirclePlay } from "@wso2/oxygen-ui-icons-react"; +import { type JSX } from "react"; +import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; +import { + CASE_STATUS_ACTIONS, + type CaseStatusPaletteIntent, +} from "@constants/supportConstants"; +import { formatValue } from "@utils/support"; + +const ACTION_BUTTON_ICON_SIZE = 12; + +function getActionButtonSx( + theme: Theme, + intent: CaseStatusPaletteIntent, +): Record { + const light = theme.palette[intent].light; + return { + borderColor: light, + bgcolor: alpha(light, 0.1), + color: light, + fontSize: "0.7rem", + minHeight: 0, + py: 0.5, + px: 1, + "&:hover": { + borderColor: theme.palette[intent].main, + bgcolor: alpha(light, 0.2), + }, + textTransform: "none", + }; +} + +export interface CaseDetailsActionRowProps { + assignedEngineer: string | null | undefined; + engineerInitials: string; + isError: boolean; + isLoading?: boolean; +} + +/** + * Support action row: avatar, name, "Support Engineer" label, and "Manage case status" actions. + * + * @param {CaseDetailsActionRowProps} props - Action display data and error state. + * @returns {JSX.Element} The action row wrapped in Paper. + */ +export default function CaseDetailsActionRow({ + assignedEngineer, + engineerInitials, + isError, + isLoading = false, +}: CaseDetailsActionRowProps): JSX.Element { + const theme = useTheme(); + + return ( + + + {isLoading ? ( + + ) : ( + + {engineerInitials} + + )} + + {isError ? ( + + ) : isLoading ? ( + + ) : ( + + {formatValue(assignedEngineer)} + + )} + + Support Engineer + + + + + + + Manage case status + + + + + + {CASE_STATUS_ACTIONS.map(({ label, Icon, paletteIntent }) => ( + + ))} + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsBackButton.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsBackButton.tsx new file mode 100644 index 0000000000..e1069f3e54 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsBackButton.tsx @@ -0,0 +1,46 @@ +// 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 { Button, type SxProps, type Theme } from "@wso2/oxygen-ui"; +import { ArrowLeft } from "@wso2/oxygen-ui-icons-react"; +import { type JSX } from "react"; + +export interface CaseDetailsBackButtonProps { + onClick: () => void; + sx?: SxProps; +} + +/** + * Back navigation button for case details (e.g. "Back to Support Center"). + * + * @param {CaseDetailsBackButtonProps} props - onClick and optional sx. + * @returns {JSX.Element} The back button. + */ +export default function CaseDetailsBackButton({ + onClick, + sx = { mb: 1.5, ml: -0.5, alignSelf: "flex-start" }, +}: CaseDetailsBackButtonProps): JSX.Element { + return ( + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsContent.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsContent.tsx new file mode 100644 index 0000000000..2087dba71b --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsContent.tsx @@ -0,0 +1,138 @@ +// 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 { Box, Paper, alpha, useTheme } from "@wso2/oxygen-ui"; +import { useMemo, useState, type JSX } from "react"; +import type { CaseDetails } from "@models/responses"; +import { getStatusColor } from "@utils/casesTable"; +import { resolveColorFromTheme, getStatusIconElement } from "@utils/support"; +import CaseDetailsBackButton from "@case-details/CaseDetailsBackButton"; +import CaseDetailsHeader from "@case-details/CaseDetailsHeader"; +import CaseDetailsActionRow from "@case-details/CaseDetailsActionRow"; +import CaseDetailsTabs from "@case-details/CaseDetailsTabs"; +import CaseDetailsTabPanels from "@case-details/CaseDetailsTabPanels"; + +export interface CaseDetailsContentProps { + data: CaseDetails | undefined; + isLoading: boolean; + isError: boolean; + onBack: () => void; +} + +/** + * CaseDetailsContent displays case header and details in a layout similar to the template. + * + * @param {CaseDetailsContentProps} props - Data, loading/error state, and callbacks. + * @returns {JSX.Element} The rendered case details content. + */ +export default function CaseDetailsContent({ + data, + isLoading, + isError, + onBack, +}: CaseDetailsContentProps): JSX.Element { + const theme = useTheme(); + const [activeTab, setActiveTab] = useState(0); + const [focusMode, setFocusMode] = useState(false); + + const statusLabel = data?.state?.label; + const severityLabel = data?.severity?.label; + const statusColorPath = getStatusColor(statusLabel ?? undefined); + const resolvedStatusColor = resolveColorFromTheme(statusColorPath, theme); + const statusChipIcon = getStatusIconElement(statusLabel, 12); + const statusChipSx = useMemo( + () => ({ + bgcolor: alpha(resolvedStatusColor, 0.1), + color: resolvedStatusColor, + height: 20, + fontSize: "0.75rem", + px: 0, + "& .MuiChip-icon": { + color: "inherit", + ml: "6px", + mr: "6px", + }, + "& .MuiChip-label": { + pl: 0, + pr: "6px", + }, + }), + [resolvedStatusColor], + ); + + const assignedEngineer = data?.assignedEngineer; + const engineerInitials = + assignedEngineer && typeof assignedEngineer === "string" + ? assignedEngineer + .trim() + .split(/\s+/) + .filter(Boolean) + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : "--"; + + return ( + + + + + + {!focusMode && ( + <> + + + + + )} + + setActiveTab(newValue)} + onFocusModeToggle={() => setFocusMode((prev) => !prev)} + /> + + + + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsHeader.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsHeader.tsx new file mode 100644 index 0000000000..8aafe5fa0e --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsHeader.tsx @@ -0,0 +1,114 @@ +// 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 { Box, Chip, Stack, Typography } from "@wso2/oxygen-ui"; +import { type ReactElement, type ReactNode, type JSX } from "react"; +import { getSeverityColor } from "@utils/casesTable"; +import ErrorIndicator from "@components/common/error-indicator/ErrorIndicator"; +import { formatValue } from "@utils/support"; +import { CaseDetailsHeaderSkeleton } from "@case-details/CaseDetailsSkeleton"; + +export interface CaseDetailsHeaderProps { + caseNumber: string | null | undefined; + title: string | null | undefined; + severityLabel: string | null | undefined; + statusLabel: string | null | undefined; + statusChipIcon: ReactNode; + statusChipSx: Record; + isError: boolean; + isLoading?: boolean; +} + +/** + * Case details header: case number, severity, status chip, and title. + * + * @param {CaseDetailsHeaderProps} props - Header data and styling. + * @returns {JSX.Element} The header block. + */ +export default function CaseDetailsHeader({ + caseNumber, + title, + severityLabel, + statusLabel, + statusChipIcon, + statusChipSx, + isError, + isLoading = false, +}: CaseDetailsHeaderProps): JSX.Element { + if (isLoading) { + return ; + } + + return ( + + + {isError ? ( + + ) : ( + + {formatValue(caseNumber)} + + )} + {isError ? ( + + ) : ( + + + + {formatValue(severityLabel)} + + + )} + {isError ? ( + + ) : ( + + )} + + {isError ? ( + + ) : ( + + {formatValue(title)} + + )} + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsSkeleton.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsSkeleton.tsx new file mode 100644 index 0000000000..700a2c3970 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsSkeleton.tsx @@ -0,0 +1,119 @@ +// 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 { Box, Divider, Paper, Skeleton, Stack } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; + +/** + * Header-only skeleton: case ID, severity, status chip, and title. + * Used by CaseDetailsHeader when loading and by CaseDetailsSkeleton. + * + * @returns {JSX.Element} The header skeleton block. + */ +export function CaseDetailsHeaderSkeleton(): JSX.Element { + return ( + + + + + + + + + + + + ); +} + +/** + * Skeleton placeholder matching the case details UI: case ID, severity, status chip, title, avatar and engineer row, tabs, panel. + * + * @returns {JSX.Element} The rendered skeleton. + */ +export default function CaseDetailsSkeleton(): JSX.Element { + return ( + + + + {/* Action row: avatar, engineer, manage status, buttons */} + + + + + + + Support Engineer + + + + + + + + + + + {/* Tabs */} + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + {/* Panel content - placeholder text only, no skeleton */} + + + Activity timeline will appear here. + + + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabPanels.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabPanels.tsx new file mode 100644 index 0000000000..6438ac544a --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabPanels.tsx @@ -0,0 +1,86 @@ +// 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 { Box, Typography } from "@wso2/oxygen-ui"; +import { type JSX } from "react"; + +export interface CaseDetailsTabPanelsProps { + activeTab: number; +} + +/** + * Renders the active tab panel for case details (Activity, Details, Attachments, Calls, Knowledge Base). + * + * @param {CaseDetailsTabPanelsProps} props - Active tab index. + * @returns {JSX.Element | null} The panel content. + */ +export default function CaseDetailsTabPanels({ + activeTab, +}: CaseDetailsTabPanelsProps): JSX.Element | null { + const panelBorderSx = { border: 1, borderColor: "divider", p: 2 }; + + if (activeTab === 0) { + return ( + + + Activity timeline will appear here. + + + ); + } + + if (activeTab === 1) { + return ( + + + Details appear here. + + + ); + } + + if (activeTab === 2) { + return ( + + + Attachments will appear here. + + + ); + } + + if (activeTab === 3) { + return ( + + + Calls will appear here. + + + ); + } + + if (activeTab === 4) { + return ( + + + Knowledge Base articles will appear here. + + + ); + } + + return null; +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabs.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabs.tsx new file mode 100644 index 0000000000..1160883a50 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/CaseDetailsTabs.tsx @@ -0,0 +1,94 @@ +// 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 { CASE_DETAILS_TABS } from "@constants/supportConstants"; +import { Box, Button, Tab, Tabs } from "@wso2/oxygen-ui"; +import { Maximize2, Minimize2 } from "@wso2/oxygen-ui-icons-react"; +import { type JSX } from "react"; + +export interface CaseDetailsTabsProps { + value: number; + onChange: (_e: unknown, newValue: number) => void; + focusMode?: boolean; + onFocusModeToggle?: () => void; +} + +/** + * Tab bar for case details: Activity, Details, Attachments, Calls, Knowledge Base. + * + * @param {CaseDetailsTabsProps} props - Controlled value and onChange. + * @returns {JSX.Element} The tabs wrapper. + */ +export default function CaseDetailsTabs({ + value, + onChange, + focusMode = false, + onFocusModeToggle, +}: CaseDetailsTabsProps): JSX.Element { + return ( + + + {CASE_DETAILS_TABS.map(({ label, Icon }) => ( + } + iconPosition="start" + label={label} + /> + ))} + + {onFocusModeToggle && ( + + )} + + ); +} diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailField.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailField.test.tsx new file mode 100644 index 0000000000..5194340fbd --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailField.test.tsx @@ -0,0 +1,91 @@ +// 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 { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailField from "@case-details/CaseDetailField"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +vi.mock("@components/common/error-indicator/ErrorIndicator", () => ({ + default: ({ entityName }: { entityName: string }) => ( + {entityName} + ), +})); + +function renderCaseDetailField(props: { + label: string; + value?: string | null; + isLoading?: boolean; + isError?: boolean; +}) { + return render( + + + , + ); +} + +describe("CaseDetailField", () => { + it("should render label and value when not loading and not error", () => { + renderCaseDetailField({ label: "Product", value: "Choreo" }); + + expect(screen.getByText("Product")).toBeInTheDocument(); + expect(screen.getByText("Choreo")).toBeInTheDocument(); + }); + + it("should render -- for null value", () => { + renderCaseDetailField({ label: "CS Manager", value: null }); + + expect(screen.getByText("CS Manager")).toBeInTheDocument(); + expect(screen.getByText("--")).toBeInTheDocument(); + }); + + it("should render -- for undefined value", () => { + renderCaseDetailField({ label: "Account", value: undefined }); + + expect(screen.getByText("--")).toBeInTheDocument(); + }); + + it("should render error indicator when isError is true", () => { + renderCaseDetailField({ + label: "Description", + value: "Some text", + isError: true, + }); + + expect(screen.getByText("Description")).toBeInTheDocument(); + expect(screen.getByTestId("error-indicator")).toBeInTheDocument(); + expect(screen.getByTestId("error-indicator")).toHaveTextContent( + "case details", + ); + }); + + it("should render loading placeholder when isLoading is true", () => { + renderCaseDetailField({ + label: "Created", + value: "2026-01-01", + isLoading: true, + }); + + expect(screen.getByText("Created")).toBeInTheDocument(); + expect(screen.queryByText("2026-01-01")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsActionRow.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsActionRow.test.tsx new file mode 100644 index 0000000000..3bfa09f422 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsActionRow.test.tsx @@ -0,0 +1,81 @@ +// 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 { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsActionRow from "@case-details/CaseDetailsActionRow"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +vi.mock("@components/common/error-indicator/ErrorIndicator", () => ({ + default: ({ entityName }: { entityName: string }) => ( + {entityName} + ), +})); + +describe("CaseDetailsActionRow", () => { + it("should render engineer name, Support Engineer label and action buttons when not loading", () => { + render( + + + , + ); + expect(screen.getByText("Jane Doe")).toBeInTheDocument(); + expect(screen.getByText("Support Engineer")).toBeInTheDocument(); + expect(screen.getByText("Manage case status")).toBeInTheDocument(); + expect(screen.getByText("Escalate Case")).toBeInTheDocument(); + expect(screen.getByText("Waiting on WSO2")).toBeInTheDocument(); + expect(screen.getByText("Mark as Resolved")).toBeInTheDocument(); + expect(screen.getByText("Close Case")).toBeInTheDocument(); + }); + + it("should show skeletons for avatar and engineer but keep play icon, Manage case status, and action buttons when isLoading", () => { + const { container } = render( + + + , + ); + expect(screen.getByText("Support Engineer")).toBeInTheDocument(); + expect(screen.getByText("Manage case status")).toBeInTheDocument(); + expect(screen.getByText("Escalate Case")).toBeInTheDocument(); + expect(screen.getByText("Waiting on WSO2")).toBeInTheDocument(); + expect(screen.getByText("Mark as Resolved")).toBeInTheDocument(); + expect(screen.getByText("Close Case")).toBeInTheDocument(); + const skeletons = container.querySelectorAll(".MuiSkeleton-root"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("should render error indicator when isError is true", () => { + render( + + + , + ); + expect(screen.getByTestId("error-indicator")).toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsBackButton.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsBackButton.test.tsx new file mode 100644 index 0000000000..78a64181ff --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsBackButton.test.tsx @@ -0,0 +1,43 @@ +// 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 { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsBackButton from "@case-details/CaseDetailsBackButton"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +describe("CaseDetailsBackButton", () => { + it("should render Back to Support Center button", () => { + const onClick = vi.fn(); + render( + + + , + ); + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", () => { + const onClick = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByText("Back to Support Center")); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsContent.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsContent.test.tsx new file mode 100644 index 0000000000..1342ce52c2 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsContent.test.tsx @@ -0,0 +1,152 @@ +// 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 { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsContent from "@case-details/CaseDetailsContent"; +import { mockCaseDetails } from "@models/mockData"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +vi.mock("@utils/casesTable", () => ({ + getStatusColor: vi.fn(() => "warning.main"), + getSeverityColor: vi.fn(() => "error.main"), +})); + +vi.mock("@utils/support", () => ({ + resolveColorFromTheme: vi.fn(() => "#fa7b3f"), + getStatusIcon: vi.fn(() => () => ), + getStatusIconElement: vi.fn(() => ), + formatValue: vi.fn((v: unknown) => (v == null || v === "" ? "--" : String(v))), +})); + +describe("CaseDetailsContent", () => { + const theme = createTheme(); + const onBack = vi.fn(); + + it("should render skeleton when loading", () => { + render( + + + , + ); + + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + // CaseDetailsSkeleton renders multiple Skeleton elements + const skeletons = document.querySelectorAll(".MuiSkeleton-root"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("should render case details when data is provided", () => { + render( + + + , + ); + + expect(screen.getByText(mockCaseDetails.number!)).toBeInTheDocument(); + expect(screen.getByText(mockCaseDetails.title!)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("tab", { name: /details/i })); + expect(screen.getByText("Details appear here.")).toBeInTheDocument(); + }); + + it("should show details placeholder when Details tab is selected", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("tab", { name: /details/i })); + expect(screen.getByText("Details appear here.")).toBeInTheDocument(); + }); + + it("should call onBack when Back button is clicked", () => { + render( + + + , + ); + + fireEvent.click(screen.getByText("Back to Support Center")); + expect(onBack).toHaveBeenCalledTimes(1); + }); + + it("should hide content above sub nav when Focus Mode is toggled", () => { + render( + + + , + ); + + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + expect(screen.getByText(mockCaseDetails.title!)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /focus mode/i })); + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + expect(screen.queryByText(mockCaseDetails.title!)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /exit focus mode/i }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /exit focus mode/i })); + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + expect(screen.getByText(mockCaseDetails.title!)).toBeInTheDocument(); + }); + + it("should render case action buttons in CaseDetailsActionRow", () => { + render( + + + , + ); + + expect(screen.getByText("Escalate Case")).toBeInTheDocument(); + expect(screen.getByText("Waiting on WSO2")).toBeInTheDocument(); + expect(screen.getByText("Mark as Resolved")).toBeInTheDocument(); + expect(screen.getByText("Close Case")).toBeInTheDocument(); + expect(screen.getByText("Manage case status")).toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsHeader.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsHeader.test.tsx new file mode 100644 index 0000000000..5403811fd1 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsHeader.test.tsx @@ -0,0 +1,82 @@ +// 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 { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsHeader from "@case-details/CaseDetailsHeader"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +vi.mock("@utils/casesTable", () => ({ + getSeverityColor: vi.fn(() => "text.secondary"), +})); + +vi.mock("@components/common/error-indicator/ErrorIndicator", () => ({ + default: ({ entityName }: { entityName: string }) => ( + {entityName} + ), +})); + +describe("CaseDetailsHeader", () => { + const defaultProps = { + caseNumber: "CUPRSUB-101", + title: "Test case title", + severityLabel: "S1", + statusLabel: "Open", + statusChipIcon: , + statusChipSx: {}, + isError: false, + }; + + it("should render case number, severity, status chip and title when not loading", () => { + render( + + + , + ); + expect(screen.getByText("CUPRSUB-101")).toBeInTheDocument(); + expect(screen.getByText("Test case title")).toBeInTheDocument(); + expect(screen.getByText("S1")).toBeInTheDocument(); + expect(screen.getByText("Open")).toBeInTheDocument(); + expect(screen.getByTestId("status-chip-icon")).toBeInTheDocument(); + }); + + it("should render skeletons when isLoading is true", () => { + const { container } = render( + + + , + ); + const skeletons = container.querySelectorAll(".MuiSkeleton-root"); + expect(skeletons.length).toBeGreaterThan(0); + expect(screen.queryByText("CUPRSUB-101")).not.toBeInTheDocument(); + expect(screen.queryByText("Test case title")).not.toBeInTheDocument(); + }); + + it("should render error indicator in value fields only when isError is true", () => { + render( + + + , + ); + const errorIndicators = screen.getAllByText("case details"); + expect(errorIndicators.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsSkeleton.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsSkeleton.test.tsx new file mode 100644 index 0000000000..a4feb48981 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsSkeleton.test.tsx @@ -0,0 +1,36 @@ +// 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 { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import CaseDetailsSkeleton from "@case-details/CaseDetailsSkeleton"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +describe("CaseDetailsSkeleton", () => { + it("should render divided skeleton sections", () => { + const { container } = render( + + + , + ); + + const skeletons = container.querySelectorAll(".MuiSkeleton-root"); + expect(skeletons.length).toBeGreaterThan(5); + + const dividers = container.querySelectorAll(".MuiDivider-root"); + expect(dividers.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabPanels.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabPanels.test.tsx new file mode 100644 index 0000000000..31d6e2db54 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabPanels.test.tsx @@ -0,0 +1,55 @@ +// 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 { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import CaseDetailsTabPanels from "@case-details/CaseDetailsTabPanels"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +function renderTabPanels(activeTab: number) { + return render( + + + , + ); +} + +describe("CaseDetailsTabPanels", () => { + it("should show Activity placeholder when activeTab is 0", () => { + renderTabPanels(0); + expect(screen.getByText("Activity timeline will appear here.")).toBeInTheDocument(); + }); + + it("should show Details placeholder when activeTab is 1", () => { + renderTabPanels(1); + expect(screen.getByText("Details appear here.")).toBeInTheDocument(); + }); + + it("should show Attachments placeholder when activeTab is 2", () => { + renderTabPanels(2); + expect(screen.getByText("Attachments will appear here.")).toBeInTheDocument(); + }); + + it("should show Calls placeholder when activeTab is 3", () => { + renderTabPanels(3); + expect(screen.getByText("Calls will appear here.")).toBeInTheDocument(); + }); + + it("should show Knowledge Base placeholder when activeTab is 4", () => { + renderTabPanels(4); + expect(screen.getByText("Knowledge Base articles will appear here.")).toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabs.test.tsx b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabs.test.tsx new file mode 100644 index 0000000000..3a84d18d71 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/support/case-details/header/__tests__/CaseDetailsTabs.test.tsx @@ -0,0 +1,75 @@ +// 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 { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsTabs from "@case-details/CaseDetailsTabs"; +import { ThemeProvider, createTheme } from "@wso2/oxygen-ui"; + +describe("CaseDetailsTabs", () => { + it("should render Activity, Details, Attachments, Calls, Knowledge Base tabs", () => { + const onChange = vi.fn(); + render( + + + , + ); + expect(screen.getByRole("tab", { name: /activity/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /details/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /attachments/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /calls/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /knowledge base/i })).toBeInTheDocument(); + }); + + it("should call onChange when a tab is clicked", () => { + const onChange = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole("tab", { name: /details/i })); + expect(onChange).toHaveBeenCalled(); + }); + + it("should show Focus Mode button when onFocusModeToggle is provided", () => { + const onFocusModeToggle = vi.fn(); + render( + + + , + ); + expect(screen.getByRole("button", { name: /focus mode/i })).toBeInTheDocument(); + }); + + it("should show Exit Focus Mode when focusMode is true", () => { + render( + + + , + ); + expect(screen.getByRole("button", { name: /exit focus mode/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/customer-portal/webapp/src/components/support/support-overview-cards/OutstandingCasesList.tsx b/apps/customer-portal/webapp/src/components/support/support-overview-cards/OutstandingCasesList.tsx index 692a83cd17..40ef671a49 100644 --- a/apps/customer-portal/webapp/src/components/support/support-overview-cards/OutstandingCasesList.tsx +++ b/apps/customer-portal/webapp/src/components/support/support-overview-cards/OutstandingCasesList.tsx @@ -34,6 +34,8 @@ import { formatRelativeTime, resolveColorFromTheme } from "@utils/support"; export interface OutstandingCasesListProps { cases: CaseListItem[]; isLoading?: boolean; + /** When provided, clicking a case card navigates to case details (caller should navigate). */ + onCaseClick?: (caseItem: CaseListItem) => void; } /** @@ -45,6 +47,7 @@ export interface OutstandingCasesListProps { export default function OutstandingCasesList({ cases, isLoading, + onCaseClick, }: OutstandingCasesListProps): JSX.Element { const theme = useTheme(); @@ -71,6 +74,7 @@ export default function OutstandingCasesList({ return ( onCaseClick(c) : undefined} sx={{ p: 2, display: "flex", diff --git a/apps/customer-portal/webapp/src/components/support/support-overview-cards/__tests__/OutstandingCasesList.test.tsx b/apps/customer-portal/webapp/src/components/support/support-overview-cards/__tests__/OutstandingCasesList.test.tsx index 78cdcb245b..b53018ab66 100644 --- a/apps/customer-portal/webapp/src/components/support/support-overview-cards/__tests__/OutstandingCasesList.test.tsx +++ b/apps/customer-portal/webapp/src/components/support/support-overview-cards/__tests__/OutstandingCasesList.test.tsx @@ -14,8 +14,8 @@ // specific language governing permissions and limitations // under the License. -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import OutstandingCasesList from "@components/support/support-overview-cards/OutstandingCasesList"; import type { CaseListItem } from "@models/responses"; @@ -52,4 +52,28 @@ describe("OutstandingCasesList", () => { expect(screen.getByText(/Assigned to Sarah Chen/)).toBeInTheDocument(); expect(screen.getByText("2 hours ago")).toBeInTheDocument(); }); + + it("should call onCaseClick with case when card is clicked", () => { + const onCaseClick = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByText("API Gateway timeout issues")); + expect(onCaseClick).toHaveBeenCalledTimes(1); + expect(onCaseClick).toHaveBeenCalledWith( + expect.objectContaining({ + id: "1", + number: "CASE-2845", + title: "API Gateway timeout issues", + }), + ); + }); + + it("should render without onCaseClick (cards not clickable for navigation)", () => { + render(); + expect(screen.getByText("API Gateway timeout issues")).toBeInTheDocument(); + fireEvent.click(screen.getByText("API Gateway timeout issues")); + // No crash; behavior is just no navigation + }); }); diff --git a/apps/customer-portal/webapp/src/constants/supportConstants.ts b/apps/customer-portal/webapp/src/constants/supportConstants.ts index 5a11b358b9..7724ba9d90 100644 --- a/apps/customer-portal/webapp/src/constants/supportConstants.ts +++ b/apps/customer-portal/webapp/src/constants/supportConstants.ts @@ -16,14 +16,21 @@ import { Activity, + BookOpen, Bot, CircleAlert, CircleCheck, + CirclePause, CircleQuestionMark, + CircleX, Clock, FileText, + Info, MessageCircle, MessageSquare, + Paperclip, + Phone, + TriangleAlert, TrendingUp, Zap, } from "@wso2/oxygen-ui-icons-react"; @@ -178,6 +185,40 @@ export const SUPPORT_STAT_CONFIGS: SupportStatConfig[] = [ }, ]; +/** + * Case details tab configuration (label + icon for Activity, Details, Attachments, etc.). + */ +export interface CaseDetailsTabConfig { + label: string; + Icon: ComponentType<{ size?: number }>; +} + +export const CASE_DETAILS_TABS: CaseDetailsTabConfig[] = [ + { label: "Activity", Icon: MessageSquare }, + { label: "Details", Icon: Info }, + { label: "Attachments (0)", Icon: Paperclip }, + { label: "Calls (0)", Icon: Phone }, + { label: "Knowledge Base (0)", Icon: BookOpen }, +]; + +//Palette intent for case status action buttons. +export type CaseStatusPaletteIntent = "error" | "warning" | "success" | "info"; + +// Case status action (e.g. Escalate, Mark as Resolved) for the action row. +export interface CaseStatusAction { + label: string; + Icon: ComponentType<{ size?: number }>; + paletteIntent: CaseStatusPaletteIntent; +} + +// Case status actions shown in the case details action row. +export const CASE_STATUS_ACTIONS: CaseStatusAction[] = [ + { label: "Escalate Case", Icon: TriangleAlert, paletteIntent: "error" }, + { label: "Waiting on WSO2", Icon: CirclePause, paletteIntent: "warning" }, + { label: "Mark as Resolved", Icon: CircleCheck, paletteIntent: "success" }, + { label: "Close Case", Icon: CircleX, paletteIntent: "info" }, +]; + // Number of outstanding cases to show on support overview cards. export const SUPPORT_OVERVIEW_CASES_LIMIT = 5; diff --git a/apps/customer-portal/webapp/src/layouts/AppLayout.tsx b/apps/customer-portal/webapp/src/layouts/AppLayout.tsx index 90195426ad..f86b2a025b 100644 --- a/apps/customer-portal/webapp/src/layouts/AppLayout.tsx +++ b/apps/customer-portal/webapp/src/layouts/AppLayout.tsx @@ -50,6 +50,9 @@ export default function AppLayout({ children }: AppLayoutProps): JSX.Element { const { isVisible } = useLoader(); const isProjectHub = location.pathname === "/"; + const isCaseDetailsPage = /\/[^/]+\/support\/cases\/[^/]+$/.test( + location.pathname, + ); return ( <> @@ -106,7 +109,9 @@ export default function AppLayout({ children }: AppLayoutProps): JSX.Element { sx={{ flex: 1, overflow: "auto", - p: 3, + ...(isCaseDetailsPage + ? { px: 3, pb: 3, pt: 0 } + : { p: 3 }), }} > {children || ( diff --git a/apps/customer-portal/webapp/src/models/mockFunctions.ts b/apps/customer-portal/webapp/src/models/mockFunctions.ts index e7a3b5d08a..a5388f2a1d 100644 --- a/apps/customer-portal/webapp/src/models/mockFunctions.ts +++ b/apps/customer-portal/webapp/src/models/mockFunctions.ts @@ -314,6 +314,7 @@ export const getMockUpdatesStats = (): UpdatesStats => mockUpdatesStats; export const getMockProductUpdateLevels = (): ProductUpdateLevelsResponse => mockProductUpdateLevels; +/** * Returns mock case classification response. * * @param {CaseClassificationRequest} request - Classification request body. diff --git a/apps/customer-portal/webapp/src/pages/AllCasesPage.tsx b/apps/customer-portal/webapp/src/pages/AllCasesPage.tsx index ca33e9643a..a666f7b480 100644 --- a/apps/customer-portal/webapp/src/pages/AllCasesPage.tsx +++ b/apps/customer-portal/webapp/src/pages/AllCasesPage.tsx @@ -22,6 +22,7 @@ import { type JSX, type ChangeEvent, } from "react"; +import { useLoader } from "@context/linear-loader/LoaderContext"; import { Box, Button, @@ -92,7 +93,9 @@ export default function AllCasesPage(): JSX.Element { fetchNextPage, } = useGetProjectCases(projectId || "", caseSearchRequest); - // Show skeletons until each section has a response (covers query disabled by auth + initial fetch). + const { showLoader, hideLoader } = useLoader(); + + // Show loader only for initial load (until first stats + cases response), not for background refetches or fetchNextPage. const hasStatsResponse = stats !== undefined; const hasCasesResponse = data !== undefined; const isStatsLoading = @@ -100,6 +103,16 @@ export default function AllCasesPage(): JSX.Element { const isCasesAreaLoading = isCasesQueryLoading || (!!projectId && !hasCasesResponse); + const isInitialPageLoading = isStatsLoading || isCasesAreaLoading; + + useEffect(() => { + if (isInitialPageLoading) { + showLoader(); + return () => hideLoader(); + } + hideLoader(); + }, [isInitialPageLoading, showLoader, hideLoader]); + // Background-load all remaining pages so search/filters work on full dataset. useEffect(() => { if (!data || !hasNextPage) { @@ -252,7 +265,11 @@ export default function AllCasesPage(): JSX.Element { {/* Cases list */} - + navigate(`/${projectId}/support/cases/${c.id}`)} + /> {/* Pagination */} {totalPages > 1 && ( diff --git a/apps/customer-portal/webapp/src/pages/CaseDetailsPage.tsx b/apps/customer-portal/webapp/src/pages/CaseDetailsPage.tsx new file mode 100644 index 0000000000..40b3b18287 --- /dev/null +++ b/apps/customer-portal/webapp/src/pages/CaseDetailsPage.tsx @@ -0,0 +1,86 @@ +// 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 { useEffect, useRef, type JSX } from "react"; +import { useNavigate, useParams } from "react-router"; +import { useLoader } from "@context/linear-loader/LoaderContext"; +import { useErrorBanner } from "@context/error-banner/ErrorBannerContext"; +import useGetCaseDetails from "@api/useGetCaseDetails"; +import CaseDetailsContent from "@case-details/CaseDetailsContent"; + +/** + * CaseDetailsPage displays details for a single support case. + * + * @returns {JSX.Element} The rendered Case Details page. + */ +export default function CaseDetailsPage(): JSX.Element { + const navigate = useNavigate(); + const { projectId, caseId } = useParams<{ projectId: string; caseId: string }>(); + const { showLoader, hideLoader } = useLoader(); + const { showError } = useErrorBanner(); + + const { + data, + isLoading, + isFetching, + isError, + } = useGetCaseDetails(projectId || "", caseId || ""); + + // Show skeletons immediately when no data (avoid "-" flash on refresh) and when loading/refetching. + const showSkeletons = + isLoading || + isFetching || + (data === undefined && !isError); + + useEffect(() => { + if (showSkeletons) { + showLoader(); + return () => hideLoader(); + } + hideLoader(); + }, [showSkeletons, showLoader, hideLoader]); + + const hasShownErrorRef = useRef(false); + + // Reset so error can show again when navigating to another case or when error clears. + useEffect(() => { + hasShownErrorRef.current = false; + }, [caseId]); + + useEffect(() => { + if (!isError) { + hasShownErrorRef.current = false; + return; + } + if (!hasShownErrorRef.current) { + hasShownErrorRef.current = true; + showError("case details"); + } + }, [isError, showError]); + + const handleBack = () => { + navigate(`/${projectId}/support/cases`); + }; + + return ( + + ); +} diff --git a/apps/customer-portal/webapp/src/pages/SupportPage.tsx b/apps/customer-portal/webapp/src/pages/SupportPage.tsx index 462bcc764f..7e5c5d0830 100644 --- a/apps/customer-portal/webapp/src/pages/SupportPage.tsx +++ b/apps/customer-portal/webapp/src/pages/SupportPage.tsx @@ -110,7 +110,15 @@ export default function SupportPage(): JSX.Element { footerButtonLabel="View all cases" onFooterClick={() => navigate("cases")} > - + navigate(`/${projectId}/support/cases/${c.id}`) + : undefined + } + /> diff --git a/apps/customer-portal/webapp/src/pages/__tests__/CaseDetailsPage.test.tsx b/apps/customer-portal/webapp/src/pages/__tests__/CaseDetailsPage.test.tsx new file mode 100644 index 0000000000..0358e26e58 --- /dev/null +++ b/apps/customer-portal/webapp/src/pages/__tests__/CaseDetailsPage.test.tsx @@ -0,0 +1,66 @@ +// 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 { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import CaseDetailsPage from "@pages/CaseDetailsPage"; +import useGetCaseDetails from "@api/useGetCaseDetails"; + +vi.mock("react-router", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ projectId: "project-1", caseId: "case-001" }), +})); + +vi.mock("@context/linear-loader/LoaderContext", () => ({ + useLoader: () => ({ + showLoader: vi.fn(), + hideLoader: vi.fn(), + }), +})); + +vi.mock("@context/error-banner/ErrorBannerContext", () => ({ + useErrorBanner: () => ({ + showError: vi.fn(), + }), +})); + +vi.mock("@api/useGetCaseDetails", () => ({ + default: vi.fn(() => ({ + data: undefined, + isLoading: true, + isFetching: false, + isError: false, + })), +})); + +describe("CaseDetailsPage", () => { + it("should render Back to Support Center when loading", () => { + render(); + expect(screen.getByText("Back to Support Center")).toBeInTheDocument(); + }); + + it("should show skeletons when isFetching (refresh) so loader and content stay in sync", () => { + vi.mocked(useGetCaseDetails).mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: true, + isError: false, + } as unknown as ReturnType); + const { container } = render(); + const skeletons = container.querySelectorAll(".MuiSkeleton-root"); + expect(skeletons.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts b/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts index f76690f569..08717a9e37 100644 --- a/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts +++ b/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts @@ -28,6 +28,7 @@ import { getChatStatusColor, getChatActionColor, formatRelativeTime, + formatValue, deriveFilterLabels, getStatusIcon, resolveColorFromTheme, @@ -96,6 +97,25 @@ describe("support utils", () => { }); }); + describe("formatValue", () => { + it("should return '--' for null and undefined", () => { + expect(formatValue(null)).toBe("--"); + expect(formatValue(undefined)).toBe("--"); + }); + + it("should return '--' for empty string", () => { + expect(formatValue("")).toBe("--"); + }); + + it("should return string for non-empty string", () => { + expect(formatValue("Choreo")).toBe("Choreo"); + }); + + it("should return string for number", () => { + expect(formatValue(42)).toBe("42"); + }); + }); + describe("formatRelativeTime", () => { it("should return '--' for undefined date", () => { expect(formatRelativeTime(undefined)).toBe("--"); diff --git a/apps/customer-portal/webapp/src/utils/support.ts b/apps/customer-portal/webapp/src/utils/support.ts index 6bbd5fccc2..0cef9ad096 100644 --- a/apps/customer-portal/webapp/src/utils/support.ts +++ b/apps/customer-portal/webapp/src/utils/support.ts @@ -23,7 +23,7 @@ import { } from "@wso2/oxygen-ui-icons-react"; import { ChatAction, ChatStatus } from "@constants/supportConstants"; import type { Theme } from "@wso2/oxygen-ui"; -import { type ComponentType } from "react"; +import { createElement, type ComponentType, type ReactNode } from "react"; export type ChatActionState = | "primary" @@ -32,6 +32,19 @@ export type ChatActionState = | "warning" | "error"; +/** + * Formats a value for display in case details; null/undefined/empty become "--". + * + * @param value - Raw value from API or state. + * @returns {string} Display string. + */ +export function formatValue( + value: string | number | null | undefined, +): string { + if (value == null || value === "") return "--"; + return String(value); +} + /** * Returns whether to show View or Resume for a chat status. * @@ -162,9 +175,9 @@ export function deriveFilterLabels(id: string): { * Returns the icon component for a given case status label. * * @param statusLabel - The case status label (e.g., "Open", "Working in Progress"). - * @returns {ComponentType} The icon component. + * @returns {ComponentType<{ size?: number }>} The icon component. */ -export function getStatusIcon(statusLabel?: string): ComponentType { +export function getStatusIcon(statusLabel?: string): ComponentType<{ size?: number }> { const normalized = statusLabel?.toLowerCase() || ""; if (normalized.includes("open")) return CircleAlert; if (normalized.includes("progress")) return Clock; @@ -174,3 +187,18 @@ export function getStatusIcon(statusLabel?: string): ComponentType { return CircleCheck; return CircleAlert; } + +/** + * Returns the icon element for a given case status label (avoids creating a component during render). + * + * @param statusLabel - The case status label. + * @param size - Icon size in pixels. + * @returns {ReactNode} The icon element. + */ +export function getStatusIconElement( + statusLabel: string | null | undefined, + size = 12 +): ReactNode { + const Icon = getStatusIcon(statusLabel ?? undefined); + return createElement(Icon, { size }); +} diff --git a/apps/customer-portal/webapp/tsconfig.app.json b/apps/customer-portal/webapp/tsconfig.app.json index 643e24e780..2f4ad75d0e 100644 --- a/apps/customer-portal/webapp/tsconfig.app.json +++ b/apps/customer-portal/webapp/tsconfig.app.json @@ -13,6 +13,8 @@ "@api/*": ["src/api/*"], "@assets/*": ["src/assets/*"], "@components/*": ["src/components/*"], + "@case-details": ["src/components/support/case-details/header"], + "@case-details/*": ["src/components/support/case-details/header/*"], "@config/*": ["src/config/*"], "@constants/*": ["src/constants/*"], "@context/*": ["src/context/*"], diff --git a/apps/customer-portal/webapp/vite.config.ts b/apps/customer-portal/webapp/vite.config.ts index 981ee8af6c..28de26e5ea 100644 --- a/apps/customer-portal/webapp/vite.config.ts +++ b/apps/customer-portal/webapp/vite.config.ts @@ -34,6 +34,9 @@ export default defineConfig({ "@components": fileURLToPath( new URL("./src/components", import.meta.url), ), + "@case-details": fileURLToPath( + new URL("./src/components/support/case-details/header", import.meta.url), + ), "@config": fileURLToPath(new URL("./src/config", import.meta.url)), "@constants": fileURLToPath(new URL("./src/constants", import.meta.url)), "@context": fileURLToPath(new URL("./src/context", import.meta.url)),