From 3af852fe77e20685e85d2a8b231398917fb0436a Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:21:32 +0530 Subject: [PATCH 01/43] Add dompurify dependency Add dompurify (^3.3.1) to the customer-portal webapp dependencies and update pnpm-lock.yaml accordingly. The lockfile now contains the dompurify package entry, a @types/trusted-types entry, and related snapshot/optional dependency metadata. --- apps/customer-portal/webapp/package.json | 1 + apps/customer-portal/webapp/pnpm-lock.yaml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/apps/customer-portal/webapp/package.json b/apps/customer-portal/webapp/package.json index b08c1cb327..875e526cfc 100644 --- a/apps/customer-portal/webapp/package.json +++ b/apps/customer-portal/webapp/package.json @@ -16,6 +16,7 @@ "@wso2/oxygen-ui": "^0.4.0", "@wso2/oxygen-ui-charts-react": "^0.4.0", "@wso2/oxygen-ui-icons-react": "^0.4.0", + "dompurify": "^3.3.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "^7.1.5", diff --git a/apps/customer-portal/webapp/pnpm-lock.yaml b/apps/customer-portal/webapp/pnpm-lock.yaml index 6c4271d06f..6323527641 100644 --- a/apps/customer-portal/webapp/pnpm-lock.yaml +++ b/apps/customer-portal/webapp/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@wso2/oxygen-ui-icons-react': specifier: ^0.4.0 version: 0.4.0(react@19.2.4) + dompurify: + specifier: ^3.3.1 + version: 3.3.1 react: specifier: ^19.2.0 version: 19.2.4 @@ -1275,6 +1278,9 @@ packages: '@types/react@19.2.10': resolution: {integrity: sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -1716,6 +1722,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3904,6 +3913,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/use-sync-external-store@0.0.6': {} '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': @@ -4447,6 +4459,10 @@ snapshots: '@babel/runtime': 7.28.6 csstype: 3.2.3 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 From e431032c23fedcc6bdf6c6ba239b33e22f2c3d1d Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:21:50 +0530 Subject: [PATCH 02/43] Render slogan icons as components Treat sloganListItems.icon as a React component and render it with its className prop instead of rendering a raw node. Update LoginSlogan to map items by assigning ItemIcon = item.icon and calling . Adjust ContactInfoCard tests to mock icons as functions (components) returning null instead of strings to match the new shape. --- .../src/components/login-page/LoginSlogan.tsx | 29 ++++++++++--------- .../__tests__/ContactInfoCard.test.tsx | 4 +-- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/login-page/LoginSlogan.tsx b/apps/customer-portal/webapp/src/components/login-page/LoginSlogan.tsx index cca601e3a6..5b523c73db 100644 --- a/apps/customer-portal/webapp/src/components/login-page/LoginSlogan.tsx +++ b/apps/customer-portal/webapp/src/components/login-page/LoginSlogan.tsx @@ -30,19 +30,22 @@ export default function LoginSlogan(): JSX.Element { - {sloganListItems.map((item) => ( - -
- {item.icon} -
- {item.title} -
- ))} + {sloganListItems.map((item) => { + const ItemIcon = item.icon; + return ( + +
+ +
+ {item.title} +
+ ); + })}
); diff --git a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactInfoCard.test.tsx b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactInfoCard.test.tsx index 8d72dccf6f..15d7f6112a 100644 --- a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactInfoCard.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactInfoCard.test.tsx @@ -38,13 +38,13 @@ vi.mock("@/constants/projectDetailsConstants", () => ({ { role: "Role 1", email: "email1@example.com", - icon: "icon-1", + icon: () => null, bgColor: "#000", }, { role: "Role 2", email: "email2@example.com", - icon: "icon-2", + icon: () => null, bgColor: "#FFF", }, ], From 8a429d4a5d4697afa5eecf811014cfb98662ac65 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:22:01 +0530 Subject: [PATCH 03/43] Use icon components for contact/stat cards Treat icon props as components rather than pre-rendered elements. ContactRow was refactored to extract the icon component and render it with a size prop (also converted to a block-bodied component). Tests updated accordingly: ContactRow.test now passes the User component instead of , and ProjectStatisticsCard tests provide icon as a function (() => null) instead of null. This standardizes icon usage and allows controlling icon props (e.g. size). --- .../contact-info/ContactRow.tsx | 45 ++++++++++--------- .../__tests__/ContactRow.test.tsx | 2 +- .../__tests__/ProjectStatisticsCard.test.tsx | 4 +- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/ContactRow.tsx b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/ContactRow.tsx index eb6161e1b7..853b2673d4 100644 --- a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/ContactRow.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/ContactRow.tsx @@ -23,26 +23,28 @@ interface ContactRowProps { contact: Contact; } -const ContactRow = ({ contact }: ContactRowProps): JSX.Element => ( - - - {contact.role} - - - - {contact.icon} - +const ContactRow = ({ contact }: ContactRowProps): JSX.Element => { + const Icon = contact.icon; + return ( + + + {contact.role} + + + + + ( -); + ); +}; export default ContactRow; diff --git a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactRow.test.tsx b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactRow.test.tsx index 0260ce3380..a397563bac 100644 --- a/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactRow.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/project-overview/contact-info/__tests__/ContactRow.test.tsx @@ -43,7 +43,7 @@ describe("ContactRow", () => { const mockContact: Contact = { role: "Test Role", email: "test@example.com", - icon: , + icon: User, bgColor: "#123456", }; diff --git a/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/__tests__/ProjectStatisticsCard.test.tsx b/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/__tests__/ProjectStatisticsCard.test.tsx index 6df259304c..9c5ccdae18 100644 --- a/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/__tests__/ProjectStatisticsCard.test.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/__tests__/ProjectStatisticsCard.test.tsx @@ -54,13 +54,13 @@ vi.mock("@/constants/projectDetailsConstants", () => ({ { label: "Active Chats", key: "activeChats", - icon: null, + icon: () => null, iconColor: "primary", }, { label: "Open Cases", key: "openCases", - icon: null, + icon: () => null, iconColor: "success", }, ], From 994e08881803d62b84b357ac9e807e6da2e41fad Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:22:13 +0530 Subject: [PATCH 04/43] Render stat icons; simplify click; remove test Instantiate stat icon components with a fixed size prop in ProjectStatisticsCard so icons render correctly (icon={}) instead of passing the component reference. Simplify AllCasesList onClick handler to use optional chaining when invoking onCaseClick. Remove the obsolete AIInfoCard unit test file under case-creation-layout/header/__tests__. --- .../ProjectStatisticsCard.tsx | 39 +++++++------- .../support/all-cases/AllCasesList.tsx | 2 +- .../header/__tests__/AIInfoCard.test.tsx | 54 ------------------- 3 files changed, 22 insertions(+), 73 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/AIInfoCard.test.tsx diff --git a/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/ProjectStatisticsCard.tsx b/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/ProjectStatisticsCard.tsx index 79ea962ab0..44b6e1ac1d 100644 --- a/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/ProjectStatisticsCard.tsx +++ b/apps/customer-portal/webapp/src/components/project-details/project-overview/project-statistics/ProjectStatisticsCard.tsx @@ -55,24 +55,27 @@ const ProjectStatisticsCard = ({ - {statItems.map((stat) => ( - - - ) as any) - : isError - ? (() as any) - : (stats?.[stat.key] ?? "--").toString() - } - icon={stat.icon} - iconColor={stat.iconColor} - /> - - ))} + {statItems.map((stat) => { + const StatIcon = stat.icon; + return ( + + + ) as any) + : isError + ? (() as any) + : (stats?.[stat.key] ?? "--").toString() + } + icon={} + iconColor={stat.iconColor} + /> + + ); + })} 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 4b409f9b8b..794fef2676 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 @@ -77,7 +77,7 @@ export default function AllCasesList({ return ( onCaseClick(caseItem) : undefined} + onClick={() => onCaseClick?.(caseItem)} sx={{ p: 3, display: "flex", diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/AIInfoCard.test.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/AIInfoCard.test.tsx deleted file mode 100644 index 185fcb1ab5..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/AIInfoCard.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// 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 { AIInfoCard } from "@components/support/case-creation-layout/header/AIInfoCard"; - -// Mock @wso2/oxygen-ui components -vi.mock("@wso2/oxygen-ui", () => ({ - Box: ({ children }: any) =>
{children}
, - Card: ({ children }: any) =>
{children}
, - CardContent: ({ children }: any) => ( -
{children}
- ), - colors: { - orange: { 700: "#C2410C" }, - }, - Typography: ({ children }: any) => {children}, -})); - -// Mock icons -vi.mock("@wso2/oxygen-ui-icons-react", () => ({ - CircleCheck: () => , -})); - -describe("AIInfoCard", () => { - it("should render correctly", () => { - render(); - - expect(screen.getByTestId("card")).toBeInTheDocument(); - expect( - screen.getByText("Case details auto-populated from your conversation"), - ).toBeInTheDocument(); - expect( - screen.getByText( - "All fields below have been filled based on your chat with Novera. Please review and edit as needed.", - ), - ).toBeInTheDocument(); - expect(screen.getByTestId("icon-check")).toBeInTheDocument(); - }); -}); From 16be0480da4ad1f6da10d48e425a2372f2aaeaa6 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:22:25 +0530 Subject: [PATCH 05/43] Remove case creation header and AI card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete AIInfoCard.tsx, CaseCreationHeader.tsx, and the corresponding test (CaseCreationHeader.test.tsx) from apps/customer-portal/webapp/src/components/support/case-creation-layout/header. These files (the AI info card, the case creation header UI, and its unit test) were removed as part of a cleanup/refactor—update any imports or references to these components elsewhere in the codebase. --- .../header/AIInfoCard.tsx | 47 ------------ .../header/CaseCreationHeader.tsx | 73 ------------------- .../__tests__/CaseCreationHeader.test.tsx | 67 ----------------- 3 files changed, 187 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/header/AIInfoCard.tsx delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/header/CaseCreationHeader.tsx delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/CaseCreationHeader.test.tsx diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/AIInfoCard.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/AIInfoCard.tsx deleted file mode 100644 index 5c6c62fc1c..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/AIInfoCard.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// 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, Card, CardContent, colors, Typography } from "@wso2/oxygen-ui"; -import { CircleCheck } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; - -/** - * AI Info Card component showing that details are auto-populated. - * - * @returns {JSX.Element} The rendered AI info card. - */ -export const AIInfoCard = (): JSX.Element => ( - - {/* AI info card wrapper */} - - - {/* AI info text container */} - - - Case details auto-populated from your conversation - - - All fields below have been filled based on your chat with Novera. - Please review and edit as needed. - - - - -); diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/CaseCreationHeader.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/CaseCreationHeader.tsx deleted file mode 100644 index 1098326cd7..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/CaseCreationHeader.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// 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, Button, Chip, Typography } from "@wso2/oxygen-ui"; -import { ArrowLeft, Sparkles } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; - -interface CaseCreationHeaderProps { - onBack: () => void; -} - -/** - * Header section for the Case Creation page. - * - * @param {CaseCreationHeaderProps} props - Component props. - * @returns {JSX.Element} The rendered header. - */ -export const CaseCreationHeader = ({ - onBack, -}: CaseCreationHeaderProps): JSX.Element => ( - - {/* navigation button container */} - - {/* header content container */} - - {/* title and description container */} - - - Review Case Details - - - Please review and edit the auto-populated information before - submitting - - - } - label="AI Generated" - color="warning" - variant="outlined" - sx={{ p: 0.5 }} - /> - - -); diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/CaseCreationHeader.test.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/CaseCreationHeader.test.tsx deleted file mode 100644 index 0ee3ad341a..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/header/__tests__/CaseCreationHeader.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// 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 { CaseCreationHeader } from "@components/support/case-creation-layout/header/CaseCreationHeader"; - -// Mock @wso2/oxygen-ui components -vi.mock("@wso2/oxygen-ui", () => ({ - Box: ({ children }: any) =>
{children}
, - Button: ({ children, onClick, startIcon }: any) => ( - - ), - Chip: ({ label, icon }: any) => ( -
- {icon} - {label} -
- ), - Typography: ({ children }: any) => {children}, -})); - -// Mock icons -vi.mock("@wso2/oxygen-ui-icons-react", () => ({ - ArrowLeft: () => , - Sparkles: () => , -})); - -describe("CaseCreationHeader", () => { - it("should render titles and description", () => { - const onBack = vi.fn(); - render(); - - expect(screen.getByText("Review Case Details")).toBeInTheDocument(); - expect( - screen.getByText(/Please review and edit the auto-populated information/), - ).toBeInTheDocument(); - expect(screen.getByText("AI Generated")).toBeInTheDocument(); - expect(screen.getByTestId("icon-sparkles")).toBeInTheDocument(); - }); - - it("should call onBack when back button is clicked", () => { - const onBack = vi.fn(); - render(); - - const backButton = screen.getByText("Back to Chat"); - fireEvent.click(backButton); - expect(onBack).toHaveBeenCalledTimes(1); - expect(screen.getByTestId("icon-arrow-left")).toBeInTheDocument(); - }); -}); From 77ffb939b523c8f5caba538a66091fbeba20370e Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:22:48 +0530 Subject: [PATCH 06/43] Improve token refresh and support utils BackgroundTokenRefresh: add failure tracking, logging and a failure threshold that triggers sign-out to force re-authentication; use useRef and useLogger and include signOut in effect deps. support.ts: add getInitials and hasDisplayableContent helpers; improve formatSlaResponseTime to use Math.floor and singular/plural labels; tighten resolveColorFromTheme typing; enhance replaceInlineImageSources to handle single/double/unquoted src attributes, match attachments by id/sys_id, preserve quotes and sanitize output with DOMPurify; add normalization for ServiceNow timestamps and update formatCommentDate accordingly. support tests: update SLA formatting expectations and add comprehensive tests for initials, code wrapper stripping, displayable-content detection, inline image replacement, and comment date formatting. --- .../src/providers/BackgroundTokenRefresh.tsx | 31 +++- .../src/utils/__tests__/support.test.ts | 175 +++++++++++++++++- .../webapp/src/utils/support.ts | 106 +++++++++-- 3 files changed, 284 insertions(+), 28 deletions(-) diff --git a/apps/customer-portal/webapp/src/providers/BackgroundTokenRefresh.tsx b/apps/customer-portal/webapp/src/providers/BackgroundTokenRefresh.tsx index 1d241bda0e..bf45fcd7aa 100644 --- a/apps/customer-portal/webapp/src/providers/BackgroundTokenRefresh.tsx +++ b/apps/customer-portal/webapp/src/providers/BackgroundTokenRefresh.tsx @@ -14,32 +14,53 @@ // under the License. import { useAsgardeo } from "@asgardeo/react"; -import { useEffect, type JSX } from "react"; +import { useEffect, useRef, type JSX } from "react"; +import { useLogger } from "@hooks/useLogger"; // Interval in ms for background token refresh (4 minutes). const TOKEN_REFRESH_INTERVAL_MS = 4 * 60 * 1000; +// Consecutive failures before forcing re-authentication. +const FAILURE_THRESHOLD = 3; + /** * Invisible component that periodically calls getIdToken() while the user is signed in. * This keeps the session alive and allows the Asgardeo SDK to refresh tokens in the background * so API calls do not fail with expired tokens when the user stays on the page. + * On repeated failures (e.g. session expiry), triggers sign-out for re-authentication. * * @returns {JSX.Element} Renders nothing (null). */ export function BackgroundTokenRefresh(): JSX.Element | null { - const { getIdToken, isSignedIn, isLoading } = useAsgardeo(); + const logger = useLogger(); + const { getIdToken, signOut, isSignedIn, isLoading } = useAsgardeo(); + const failureCountRef = useRef(0); useEffect(() => { if (!isSignedIn || isLoading) return; const refreshToken = (): void => { - getIdToken().catch(() => { - }); + getIdToken() + .then(() => { + failureCountRef.current = 0; + }) + .catch((error: unknown) => { + logger.error("Background token refresh failed", error); + failureCountRef.current += 1; + if (failureCountRef.current >= FAILURE_THRESHOLD) { + logger.warn( + `Token refresh failed ${FAILURE_THRESHOLD} times; signing out for re-authentication`, + ); + signOut().catch((signOutError: unknown) => { + logger.error("Sign-out after token refresh failure", signOutError); + }); + } + }); }; const intervalId = setInterval(refreshToken, TOKEN_REFRESH_INTERVAL_MS); return () => clearInterval(intervalId); - }, [getIdToken, isSignedIn, isLoading]); + }, [getIdToken, signOut, isSignedIn, isLoading, logger]); return null; } 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 5b3926ff9c..0fb8a29e7e 100644 --- a/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts +++ b/apps/customer-portal/webapp/src/utils/__tests__/support.test.ts @@ -35,7 +35,13 @@ import { getStatusIcon, getAttachmentFileCategory, resolveColorFromTheme, + stripCodeWrapper, + replaceInlineImageSources, + formatCommentDate, + getInitials, + hasDisplayableContent, } from "@utils/support"; +import type { CaseComment } from "@models/responses"; import { createTheme } from "@wso2/oxygen-ui"; describe("support utils", () => { @@ -106,17 +112,28 @@ describe("support utils", () => { expect(formatSlaResponseTime(undefined)).toBe("--"); }); - it("should format milliseconds to hours", () => { - expect(formatSlaResponseTime(3600000)).toBe("1 hours"); + it("should format milliseconds to hours with singular/plural", () => { + expect(formatSlaResponseTime(3600000)).toBe("1 hour"); expect(formatSlaResponseTime(7200000)).toBe("2 hours"); }); + it("should format milliseconds to minutes with singular/plural", () => { + expect(formatSlaResponseTime(60000)).toBe("1 minute"); + expect(formatSlaResponseTime(120000)).toBe("2 minutes"); + }); + + it("should use Math.floor so values just under threshold stay in current unit", () => { + expect(formatSlaResponseTime(3599999)).toBe("59 minutes"); + expect(formatSlaResponseTime(3599999)).not.toBe("1 hour"); + }); + it("should format milliseconds to days when >= 24 hours", () => { expect(formatSlaResponseTime("129671000")).toBe("2 days"); }); - it("should format milliseconds to days", () => { - expect(formatSlaResponseTime(86400000)).toBe("1 days"); + it("should format milliseconds to days with singular/plural", () => { + expect(formatSlaResponseTime(86400000)).toBe("1 day"); + expect(formatSlaResponseTime(172800000)).toBe("2 days"); }); }); @@ -268,4 +285,154 @@ describe("support utils", () => { expect(getStatusIcon("Closed")).toBe(CircleCheck); }); }); + + describe("stripCodeWrapper", () => { + it("should return empty string for empty or invalid input", () => { + expect(stripCodeWrapper("")).toBe(""); + expect(stripCodeWrapper(null as unknown as string)).toBe(""); + }); + + it("should return content unchanged when no [code] wrapper", () => { + expect(stripCodeWrapper("plain text")).toBe("plain text"); + expect(stripCodeWrapper("[code]only start")).toBe("[code]only start"); + expect(stripCodeWrapper("only end[/code]")).toBe("only end[/code]"); + }); + + it("should strip [code]...[/code] wrapper", () => { + expect(stripCodeWrapper("[code]x[/code]")).toBe("x"); + expect(stripCodeWrapper("[code] hello [/code]")).toBe("hello"); + }); + }); + + describe("hasDisplayableContent", () => { + it("returns true for comment with code wrapper and meaningful content", () => { + const comment: CaseComment = { + id: "1", + content: "[code]

Hello world

[/code]", + type: "comments", + createdOn: "2026-02-12 10:00:00", + createdBy: "user@example.com", + isEscalated: false, + }; + expect(hasDisplayableContent(comment)).toBe(true); + }); + + it("returns true for comment with plain text", () => { + const comment: CaseComment = { + id: "2", + content: "Some text here", + type: "comments", + createdOn: "2026-02-12 10:00:00", + createdBy: "user@example.com", + isEscalated: false, + }; + expect(hasDisplayableContent(comment)).toBe(true); + }); + + it("returns false for comment with only code wrapper and Customer comment added", () => { + const comment: CaseComment = { + id: "3", + content: "[code]

Customer comment added

[/code]", + type: "comments", + createdOn: "2026-02-12 10:00:00", + createdBy: "user@example.com", + isEscalated: false, + }; + expect(hasDisplayableContent(comment)).toBe(false); + }); + + it("returns false for empty content", () => { + const comment: CaseComment = { + id: "4", + content: "", + type: "comments", + createdOn: "2026-02-12 10:00:00", + createdBy: "user@example.com", + isEscalated: false, + }; + expect(hasDisplayableContent(comment)).toBe(false); + }); + + it("returns false for null content", () => { + const comment: CaseComment = { + id: "5", + content: undefined as unknown as string, + type: "comments", + createdOn: "2026-02-12 10:00:00", + createdBy: "user@example.com", + isEscalated: false, + }; + expect(hasDisplayableContent(comment)).toBe(false); + }); + }); + + describe("replaceInlineImageSources", () => { + it("should return empty string for empty html", () => { + expect(replaceInlineImageSources("")).toBe(""); + }); + + it("should return sanitized html when no attachments", () => { + const html = 'x'; + expect(replaceInlineImageSources(html, null)).toContain("src"); + }); + + it("should replace src by sys_id when attachment matches", () => { + const html = 'x'; + const attachments = [{ sys_id: "sys123", url: "https://example.com/img.png" }]; + const result = replaceInlineImageSources(html, attachments); + expect(result).toContain("https://example.com/img.png"); + }); + + it("should replace src by id when attachment matches", () => { + const html = ''; + const attachments = [{ id: "att456", downloadUrl: "https://cdn.example.com/att.png" }]; + const result = replaceInlineImageSources(html, attachments); + expect(result).toContain("https://cdn.example.com/att.png"); + }); + + it("should handle single-quoted src", () => { + const html = ""; + const attachments = [{ sys_id: "sys99", url: "https://example.com/a.png" }]; + const result = replaceInlineImageSources(html, attachments); + expect(result).toContain("https://example.com/a.png"); + }); + }); + + describe("getInitials", () => { + it("should derive initials from name", () => { + expect(getInitials("John Doe")).toBe("JD"); + expect(getInitials("Alice")).toBe("A"); + }); + + it("should return -- for null, undefined, empty", () => { + expect(getInitials(null)).toBe("--"); + expect(getInitials(undefined)).toBe("--"); + expect(getInitials("")).toBe("--"); + }); + }); + + describe("formatCommentDate", () => { + it("should return -- for null and undefined", () => { + expect(formatCommentDate(null)).toBe("--"); + expect(formatCommentDate(undefined)).toBe("--"); + }); + + it("should return -- for invalid date", () => { + expect(formatCommentDate("not-a-date")).toBe("--"); + }); + + it("should format valid date string", () => { + const result = formatCommentDate("2026-02-13T15:45:00Z"); + expect(result).toMatch(/Feb/); + expect(result).toMatch(/13/); + expect(result).toMatch(/2026/); + }); + + it("should normalize ServiceNow timestamp YYYY-MM-DD HH:MM:SS as UTC", () => { + const result = formatCommentDate("2026-02-13 15:45:00"); + expect(result).toMatch(/Feb/); + expect(result).toMatch(/13/); + expect(result).toMatch(/2026/); + }); + }); }); diff --git a/apps/customer-portal/webapp/src/utils/support.ts b/apps/customer-portal/webapp/src/utils/support.ts index deea0d82c1..2bc0a879a7 100644 --- a/apps/customer-portal/webapp/src/utils/support.ts +++ b/apps/customer-portal/webapp/src/utils/support.ts @@ -22,7 +22,9 @@ import { MessageCircle, } from "@wso2/oxygen-ui-icons-react"; import { ChatAction, ChatStatus } from "@constants/supportConstants"; +import type { CaseComment } from "@models/responses"; import type { Theme } from "@wso2/oxygen-ui"; +import DOMPurify from "dompurify"; import { createElement, type ComponentType, type ReactNode } from "react"; export type ChatActionState = @@ -43,6 +45,25 @@ export function formatValue(value: string | number | null | undefined): string { return String(value); } +/** + * Derives initials from a name string (e.g. "John Doe" -> "JD"). + * + * @param name - Full name string. + * @returns {string} Up to 2 uppercase initials, or "--" if empty/invalid. + */ +export function getInitials(name: string | null | undefined): string { + if (!name || typeof name !== "string") return "--"; + const initials = name + .trim() + .split(/\s+/) + .filter(Boolean) + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + return initials || "--"; +} + /** * Formats SLA response time from milliseconds (string or number) to human-readable (e.g. "4 hours", "2 days"). * @@ -54,10 +75,20 @@ export function formatSlaResponseTime( ): string { const n = typeof ms === "string" ? parseInt(ms, 10) : ms; if (n == null || Number.isNaN(n) || n < 0) return "--"; - if (n < 60_000) return `${Math.round(n / 1000)} seconds`; - if (n < 3600_000) return `${Math.round(n / 60_000)} minutes`; - if (n < 86400_000) return `${Math.round(n / 3600_000)} hours`; - return `${Math.round(n / 86400_000)} days`; + if (n < 60_000) { + const seconds = Math.floor(n / 1000); + return `${seconds} second${seconds === 1 ? "" : "s"}`; + } + if (n < 3600_000) { + const minutes = Math.floor(n / 60_000); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } + if (n < 86400_000) { + const hours = Math.floor(n / 3600_000); + return `${hours} hour${hours === 1 ? "" : "s"}`; + } + const days = Math.floor(n / 86400_000); + return `${days} day${days === 1 ? "" : "s"}`; } /** @@ -130,9 +161,10 @@ export function resolveColorFromTheme(path: string, theme: Theme): string { return ( (path .split(".") - .reduce( - (acc: any, part: string) => acc?.[part], - theme.palette, + .reduce( + (acc, part) => + (acc as Record | null | undefined)?.[part], + theme.palette as unknown, ) as string) || path ); } @@ -200,7 +232,7 @@ export function deriveFilterLabels(id: string): { return { allLabel, label }; } -/** Attachment file category for icon selection. */ +/** Attachment file category for icon selection. TODO: Replace with enum in a later PR. */ export type AttachmentFileCategory = | "image" | "pdf" @@ -303,6 +335,21 @@ export function stripCustomerCommentAddedLabel(html: string): string { .trim(); } +/** + * Returns true if the comment has content worth displaying (after stripping code wrapper and + * "Customer comment added" label). Used to hide backend entries that render as empty bubbles. + * + * @param comment - Case comment from API. + * @returns {boolean} True when comment has non-empty displayable content. + */ +export function hasDisplayableContent(comment: CaseComment): boolean { + const raw = comment.content ?? ""; + const stripped = stripCodeWrapper(raw); + const withoutLabel = stripCustomerCommentAddedLabel(stripped); + const textOnly = withoutLabel.replace(/<[^>]+>/g, "").trim(); + return textOnly.length > 0; +} + /** Inline attachment item for image src replacement (supports API id/downloadUrl or legacy sys_id/url). */ export interface InlineAttachment { id?: string; @@ -314,46 +361,67 @@ export interface InlineAttachment { /** * Replaces inline image sources in HTML (e.g. /sys_id.iix or /id.iix) with URLs from attachments. * Matches by id or sys_id; uses downloadUrl or url for the replacement. + * Sanitizes the result with DOMPurify to prevent XSS. * * @param html - HTML string with img tags. * @param inlineAttachments - Optional list of attachments (id/downloadUrl or sys_id/url). - * @returns {string} HTML with img src replaced where matching. + * @returns {string} Sanitized HTML with img src replaced where matching. */ export function replaceInlineImageSources( html: string, inlineAttachments?: InlineAttachment[] | null, ): string { if (!html || typeof html !== "string") return ""; - if (!inlineAttachments?.length) return html; + if (!inlineAttachments?.length) { + return DOMPurify.sanitize(html); + } - return html.replace( - /]*)\ssrc="([^"]+)"([^>]*)>/gi, - (_match, before, src, after) => { + const replaced = html.replace( + /]*?)\s+src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))([^>]*)>/gi, + (_match, before, doubleSrc, singleSrc, bareSrc, after) => { + const src = (doubleSrc ?? singleSrc ?? bareSrc ?? "") as string; const refId = src.replace(/^\//, "").replace(/\.iix$/i, "").trim(); const attachment = inlineAttachments.find( (a) => a.id === refId || a.sys_id === refId || - src.includes(a.id ?? "") || - src.includes(a.sys_id ?? ""), + (a?.id && src.includes(a.id)) || + (a?.sys_id && src.includes(a.sys_id)), ); const newSrc = attachment?.downloadUrl ?? attachment?.url ?? src; - return ``; + const quote = doubleSrc !== undefined ? '"' : singleSrc !== undefined ? "'" : '"'; + return ``; }, ); + return DOMPurify.sanitize(replaced); +} + +/** + * Normalizes ServiceNow-style timestamp "YYYY-MM-DD HH:MM:SS" to ISO UTC for parsing. + * + * @param dateStr - Raw date string (ISO, ServiceNow, or parseable). + * @returns {string} Normalized string for Date constructor. + */ +function normalizeCommentDateString(dateStr: string): string { + const trimmed = dateStr.trim(); + if (/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/.test(trimmed)) { + return trimmed.replace(" ", "T") + "Z"; + } + return trimmed; } /** * Formats a comment date string for display (e.g. "Feb 13, 2026 3:45 PM"). * - * @param date - Date string from API. + * @param date - Date string from API (ISO or ServiceNow "YYYY-MM-DD HH:MM:SS"). * @returns {string} Formatted date string. */ export function formatCommentDate(date: string | null | undefined): string { if (!date) return "--"; - const d = new Date(date); - if (isNaN(d.getTime())) return "--"; + const normalized = normalizeCommentDateString(date); + const d = new Date(normalized); + if (Number.isNaN(d.getTime())) return "--"; return d.toLocaleString(undefined, { month: "short", day: "numeric", From a2d725cc014f6e2d4d35207da6d09b9acf7a90a3 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:23:00 +0530 Subject: [PATCH 07/43] Remove projectDetailsConstants & adjust overflow Delete projectDetailsConstants.tsx (removes tab/contact/stat interfaces, activity helpers, enums and related UI icon imports). Simplify comments in supportConstants.ts (remove redundant parenthetical notes for MAX_ATTACHMENT_SIZE_BYTES and CASE_ATTACHMENTS_INITIAL_LIMIT). Adjust AppLayout.tsx to always use overflow: "auto" (remove special-case hidden overflow for case details pages) to ensure consistent scrolling behavior. --- .../src/constants/projectDetailsConstants.tsx | 197 ------------------ .../webapp/src/constants/supportConstants.ts | 4 +- .../webapp/src/layouts/AppLayout.tsx | 2 +- 3 files changed, 3 insertions(+), 200 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/constants/projectDetailsConstants.tsx diff --git a/apps/customer-portal/webapp/src/constants/projectDetailsConstants.tsx b/apps/customer-portal/webapp/src/constants/projectDetailsConstants.tsx deleted file mode 100644 index 401717eb46..0000000000 --- a/apps/customer-portal/webapp/src/constants/projectDetailsConstants.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// 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 { - CircleAlert, - Clock, - Info, - Rocket, - Server, -} from "@wso2/oxygen-ui-icons-react"; -import type { TabOption } from "@components/common/tab-bar/TabBar"; -import { colors } from "@wso2/oxygen-ui"; -import { User, Shield } from "@wso2/oxygen-ui-icons-react"; -import type { JSX } from "react"; -import type { ProjectStatsResponse } from "@models/responses"; -import { getSystemHealthColor } from "@utils/projectStats"; - -export interface Contact { - role: string; - email: string; - icon: JSX.Element; - bgColor: string; -} - -export interface Stat { - label: string; - icon: JSX.Element; - iconColor: "primary" | "success" | "warning"; - key: keyof NonNullable; -} - -export const PROJECT_DETAILS_TABS: TabOption[] = [ - { - id: "overview", - label: "Overview", - icon: Info, - }, - { - id: "deployments", - label: "Deployments", - icon: Server, - }, - { - id: "time-tracking", - label: "Time Tracking", - icon: Clock, - }, -]; - -export const contacts: Contact[] = [ - { - role: "Account Manager", - email: "TODO:[EMAIL_ADDRESS]", - icon: , - bgColor: colors.blue[700], - }, - { - role: "Technical Owner", - email: "TODO:[EMAIL_ADDRESS]", - icon: , - bgColor: colors.purple[400], - }, -]; - -export interface ActivityItem { - label: string; - value: string; - type?: "text" | "chip"; - chipColor?: "success" | "warning" | "error" | "default" | "primary" | "info"; -} - -export const statItems: Stat[] = [ - { - label: "Open Cases", - icon: , - iconColor: "primary", - key: "openCases", - }, - { - label: "Active Chats", - icon: , - iconColor: "success", - key: "activeChats", - }, - { - label: "Deployments", - icon: , - iconColor: "warning", - key: "deployments", - }, -]; - -export const getRecentActivityItems = ( - activity?: ProjectStatsResponse["recentActivity"], -): ActivityItem[] => [ - { - label: "Total Time Logged", - value: - activity?.totalTimeLogged !== undefined - ? `${activity.totalTimeLogged}h` - : "N/A", - type: "text", - }, - { - label: "Billable Hours", - value: - activity?.billableHours !== undefined - ? `${activity.billableHours}h` - : "N/A", - type: "text", - }, - { - label: "Last Deployment", - value: activity?.lastDeploymentOn - ? new Date(activity.lastDeploymentOn).toLocaleDateString() - : "N/A", - type: "text", - }, - { - label: "System Health", - value: activity?.systemHealth || "N/A", - type: "chip", - chipColor: getSystemHealthColor(activity?.systemHealth || ""), - }, -]; - -export const SUBSCRIPTION_STATUS = { - EXPIRED: "Expired", - EXPIRING_SOON: "Expiring Soon", - ACTIVE: "Active", -} as const; - -export type SubscriptionStatus = - (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS]; - -export const SUPPORT_TIER = { - ENTERPRISE: "Enterprise", - STANDARD: "Standard", -} as const; - -export type SupportTier = (typeof SUPPORT_TIER)[keyof typeof SUPPORT_TIER]; - -export const PROJECT_TYPE = { - SUBSCRIPTION: "Subscription", - FREE: "Free", -} as const; - -export type ProjectType = (typeof PROJECT_TYPE)[keyof typeof PROJECT_TYPE]; - -export const SYSTEM_HEALTH = { - HEALTHY: "Healthy", - CRITICAL: "Critical", -} as const; - -export type SystemHealth = (typeof SYSTEM_HEALTH)[keyof typeof SYSTEM_HEALTH]; - -export const SLA_STATUS = { - GOOD: "All Good", - BAD: "Bad", -} as const; - -export type SLAStatus = (typeof SLA_STATUS)[keyof typeof SLA_STATUS]; - -export const CASE_SEVERITY = { - S0: "S0", - S1: "S1", - S2: "S2", - S3: "S3", - S4: "S4", -} as const; - -export type CaseSeverity = (typeof CASE_SEVERITY)[keyof typeof CASE_SEVERITY]; - -export const CASE_STATUS = { - OPEN: "Open", - WORK_IN_PROGRESS: "Work In Progress", - AWAITING_INFO: "Awaiting Info", - WAITING_ON_WSO2: "Waiting On WSO2", - SOLUTION_PROPOSED: "Solution Proposed", - CLOSED: "Closed", - REOPENED: "Reopened", -} as const; - -export type CaseStatus = (typeof CASE_STATUS)[keyof typeof CASE_STATUS]; diff --git a/apps/customer-portal/webapp/src/constants/supportConstants.ts b/apps/customer-portal/webapp/src/constants/supportConstants.ts index e30b5c1801..df79f58e5f 100644 --- a/apps/customer-portal/webapp/src/constants/supportConstants.ts +++ b/apps/customer-portal/webapp/src/constants/supportConstants.ts @@ -59,10 +59,10 @@ export const ChatStatus = { export type ChatStatus = (typeof ChatStatus)[keyof typeof ChatStatus]; -// Maximum allowed attachment file size in bytes (15 MB). +// Maximum allowed attachment file size in bytes. export const MAX_ATTACHMENT_SIZE_BYTES = 15 * 1024 * 1024; -// Initial limit for case attachments list (first page). +// Initial limit for case attachments list. export const CASE_ATTACHMENTS_INITIAL_LIMIT = 50; // Interface for support statistics card configuration. diff --git a/apps/customer-portal/webapp/src/layouts/AppLayout.tsx b/apps/customer-portal/webapp/src/layouts/AppLayout.tsx index c61131925a..5fbdeba831 100644 --- a/apps/customer-portal/webapp/src/layouts/AppLayout.tsx +++ b/apps/customer-portal/webapp/src/layouts/AppLayout.tsx @@ -112,7 +112,7 @@ export default function AppLayout({ children }: AppLayoutProps): JSX.Element { sx={{ flex: 1, minHeight: isCaseDetailsPage ? "60vh" : 0, - overflow: isCaseDetailsPage ? "hidden" : "auto", + overflow: "auto", display: isCaseDetailsPage ? "flex" : "block", flexDirection: isCaseDetailsPage ? "column" : undefined, ...(isCaseDetailsPage From 3bafd8b5db46c78c254f105874e4929538759ea7 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:23:10 +0530 Subject: [PATCH 08/43] Refactor login constants; add project details Rename loginScreenConstants.tsx to .ts and refactor the slogans list to use typed icon components instead of JSX: introduce SloganListItem (icon as ElementType, optional iconClassName) and update entries to pass icon components + class names. Add new projectDetailsConstants.ts defining types and constants for project details (tabs, contacts, stats, activity mapping/getRecentActivityItems, status/enum-like constants and related types), with imports for icons, colors, ProjectStatsResponse, and getSystemHealthColor. --- ...nConstants.tsx => loginScreenConstants.ts} | 23 +- .../src/constants/projectDetailsConstants.ts | 197 ++++++++++++++++++ 2 files changed, 212 insertions(+), 8 deletions(-) rename apps/customer-portal/webapp/src/constants/{loginScreenConstants.tsx => loginScreenConstants.ts} (67%) create mode 100644 apps/customer-portal/webapp/src/constants/projectDetailsConstants.ts diff --git a/apps/customer-portal/webapp/src/constants/loginScreenConstants.tsx b/apps/customer-portal/webapp/src/constants/loginScreenConstants.ts similarity index 67% rename from apps/customer-portal/webapp/src/constants/loginScreenConstants.tsx rename to apps/customer-portal/webapp/src/constants/loginScreenConstants.ts index 123c655e42..cd250f479e 100644 --- a/apps/customer-portal/webapp/src/constants/loginScreenConstants.tsx +++ b/apps/customer-portal/webapp/src/constants/loginScreenConstants.ts @@ -20,26 +20,33 @@ import { LifeBuoy, MessageSquareQuote, } from "@wso2/oxygen-ui-icons-react"; -import { type JSX } from "react"; +import type { ElementType } from "react"; -export const sloganListItems: { - icon: JSX.Element; +export interface SloganListItem { + icon: ElementType<{ className?: string }>; + iconClassName?: string; title: string; -}[] = [ +} + +export const sloganListItems: SloganListItem[] = [ { - icon: , + icon: LayoutDashboard, + iconClassName: "text-muted-foreground", title: "Real time Project Insights", }, { - icon: , + icon: LifeBuoy, + iconClassName: "text-muted-foreground", title: "Streamlined Case Management", }, { - icon: , + icon: MessageSquareQuote, + iconClassName: "text-muted-foreground", title: "Collaborative Discussion Forums", }, { - icon: , + icon: AlertCircle, + iconClassName: "text-muted-foreground", title: "Instant Issue Reporting", }, ]; diff --git a/apps/customer-portal/webapp/src/constants/projectDetailsConstants.ts b/apps/customer-portal/webapp/src/constants/projectDetailsConstants.ts new file mode 100644 index 0000000000..b9a093e61b --- /dev/null +++ b/apps/customer-portal/webapp/src/constants/projectDetailsConstants.ts @@ -0,0 +1,197 @@ +// 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 { + CircleAlert, + Clock, + Info, + Rocket, + Server, +} from "@wso2/oxygen-ui-icons-react"; +import { User, Shield } from "@wso2/oxygen-ui-icons-react"; +import type { ElementType } from "react"; +import type { TabOption } from "@components/common/tab-bar/TabBar"; +import { colors } from "@wso2/oxygen-ui"; +import type { ProjectStatsResponse } from "@models/responses"; +import { getSystemHealthColor } from "@utils/projectStats"; + +export interface Contact { + role: string; + email: string; + icon: ElementType<{ size?: number }>; + bgColor: string; +} + +export interface Stat { + label: string; + icon: ElementType<{ size?: number }>; + iconColor: "primary" | "success" | "warning"; + key: keyof NonNullable; +} + +export const PROJECT_DETAILS_TABS: TabOption[] = [ + { + id: "overview", + label: "Overview", + icon: Info, + }, + { + id: "deployments", + label: "Deployments", + icon: Server, + }, + { + id: "time-tracking", + label: "Time Tracking", + icon: Clock, + }, +]; + +export const contacts: Contact[] = [ + { + role: "Account Manager", + email: "TODO:[EMAIL_ADDRESS]", + icon: User, + bgColor: colors.blue[700], + }, + { + role: "Technical Owner", + email: "TODO:[EMAIL_ADDRESS]", + icon: Shield, + bgColor: colors.purple[400], + }, +]; + +export interface ActivityItem { + label: string; + value: string; + type?: "text" | "chip"; + chipColor?: "success" | "warning" | "error" | "default" | "primary" | "info"; +} + +export const statItems: Stat[] = [ + { + label: "Open Cases", + icon: Clock, + iconColor: "primary", + key: "openCases", + }, + { + label: "Active Chats", + icon: CircleAlert, + iconColor: "success", + key: "activeChats", + }, + { + label: "Deployments", + icon: Rocket, + iconColor: "warning", + key: "deployments", + }, +]; + +export const getRecentActivityItems = ( + activity?: ProjectStatsResponse["recentActivity"], +): ActivityItem[] => [ + { + label: "Total Time Logged", + value: + activity?.totalTimeLogged !== undefined + ? `${activity.totalTimeLogged}h` + : "N/A", + type: "text", + }, + { + label: "Billable Hours", + value: + activity?.billableHours !== undefined + ? `${activity.billableHours}h` + : "N/A", + type: "text", + }, + { + label: "Last Deployment", + value: activity?.lastDeploymentOn + ? new Date(activity.lastDeploymentOn).toLocaleDateString() + : "N/A", + type: "text", + }, + { + label: "System Health", + value: activity?.systemHealth || "N/A", + type: "chip", + chipColor: getSystemHealthColor(activity?.systemHealth || ""), + }, +]; + +export const SUBSCRIPTION_STATUS = { + EXPIRED: "Expired", + EXPIRING_SOON: "Expiring Soon", + ACTIVE: "Active", +} as const; + +export type SubscriptionStatus = + (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS]; + +export const SUPPORT_TIER = { + ENTERPRISE: "Enterprise", + STANDARD: "Standard", +} as const; + +export type SupportTier = (typeof SUPPORT_TIER)[keyof typeof SUPPORT_TIER]; + +export const PROJECT_TYPE = { + SUBSCRIPTION: "Subscription", + FREE: "Free", +} as const; + +export type ProjectType = (typeof PROJECT_TYPE)[keyof typeof PROJECT_TYPE]; + +export const SYSTEM_HEALTH = { + HEALTHY: "Healthy", + CRITICAL: "Critical", +} as const; + +export type SystemHealth = (typeof SYSTEM_HEALTH)[keyof typeof SYSTEM_HEALTH]; + +export const SLA_STATUS = { + GOOD: "All Good", + BAD: "Bad", +} as const; + +export type SLAStatus = (typeof SLA_STATUS)[keyof typeof SLA_STATUS]; + +export const CASE_SEVERITY = { + S0: "S0", + S1: "S1", + S2: "S2", + S3: "S3", + S4: "S4", +} as const; + +export type CaseSeverity = (typeof CASE_SEVERITY)[keyof typeof CASE_SEVERITY]; + +export const CASE_STATUS = { + OPEN: "Open", + WORK_IN_PROGRESS: "Work In Progress", + AWAITING_INFO: "Awaiting Info", + WAITING_ON_WSO2: "Waiting On WSO2", + SOLUTION_PROPOSED: "Solution Proposed", + CLOSED: "Closed", + REOPENED: "Reopened", +} as const; + +export type CaseStatus = (typeof CASE_STATUS)[keyof typeof CASE_STATUS]; From 43773164d4c7be4899ec382a18b7814b622ae7a1 Mon Sep 17 00:00:00 2001 From: "K.Dileepa Thushan Peiris" Date: Fri, 13 Feb 2026 18:23:23 +0530 Subject: [PATCH 09/43] Remove BasicInformationSection and tests Delete the BasicInformationSection component and its unit tests, and remove an associated CaseDetailsSection test. Files removed: - apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection.tsx - apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/__tests__/BasicInformationSection.test.tsx - apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/case-details-section/__tests__/CaseDetailsSection.test.tsx This cleans up the Basic Information UI implementation and related tests (likely in preparation for a refactor or replacement). --- .../BasicInformationSection.tsx | 222 ------------------ .../BasicInformationSection.test.tsx | 167 ------------- .../__tests__/CaseDetailsSection.test.tsx | 185 --------------- 3 files changed, 574 deletions(-) delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection.tsx delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/__tests__/BasicInformationSection.test.tsx delete mode 100644 apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/case-details-section/__tests__/CaseDetailsSection.test.tsx diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection.tsx deleted file mode 100644 index 2e1e85fe12..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection.tsx +++ /dev/null @@ -1,222 +0,0 @@ -// 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, - FormControl, - Grid, - MenuItem, - Paper, - Select, - Skeleton, - Typography, - TextField, - IconButton, -} from "@wso2/oxygen-ui"; -import { PencilLine, Sparkles } from "@wso2/oxygen-ui-icons-react"; -import { useState, type JSX } from "react"; - -interface BasicInformationSectionProps { - project: string; - product: string; - setProduct: (value: string) => void; - deployment: string; - setDeployment: (value: string) => void; - metadata: { deploymentTypes?: string[]; products?: string[] }; - isDeploymentLoading?: boolean; - isProductDropdownDisabled?: boolean; - isProductLoading?: boolean; - extraDeploymentOptions?: string[]; - extraProductOptions?: string[]; -} - -/** - * Renders the Basic Information section used during case creation. - * - * This section allows users to select project, product, and deployment - * details and displays related metadata. - * - * @returns The Basic Information section JSX element. - */ -export const BasicInformationSection = ({ - project, - product, - setProduct, - deployment, - setDeployment, - metadata, - isDeploymentLoading = false, - isProductDropdownDisabled = false, - isProductLoading = false, - extraDeploymentOptions, - extraProductOptions, -}: BasicInformationSectionProps): JSX.Element => { - const [isEditing, setIsEditing] = useState(false); - const deploymentOptions = Array.from( - new Set( - [ - ...(metadata?.deploymentTypes ?? []), - ...(extraDeploymentOptions ?? []), - ].filter((value) => value && value.trim() !== ""), - ), - ); - const productOptions = Array.from( - new Set( - [...(metadata?.products ?? []), ...(extraProductOptions ?? [])].filter( - (value) => value && value.trim() !== "", - ), - ), - ); - - return ( - - {/* section header container */} - - Basic Information - - setIsEditing(true)} /> - - - - {/* project card grid layout */} - - {/* project selection field wrapper */} - - {/* project field label container */} - - Project - } - sx={{ height: 20, fontSize: "0.65rem", p: 0.5 }} - /> - - - - - {/* deployment selection field wrapper */} - - {/* deployment field label container */} - - - Deployment Type{" "} - - * - - - } - sx={{ height: 20, fontSize: "0.65rem", p: 0.5 }} - /> - - {isDeploymentLoading ? ( - - ) : ( - - - - )} - - - {/* product selection field wrapper */} - - {/* product field label container */} - - - Product & Version{" "} - - * - - - } - sx={{ height: 20, fontSize: "0.65rem", p: 0.5 }} - /> - - {isProductLoading ? ( - - ) : ( - - - - )} - - - - ); -}; diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/__tests__/BasicInformationSection.test.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/__tests__/BasicInformationSection.test.tsx deleted file mode 100644 index 9e90dcb6d9..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/basic-information-section/__tests__/BasicInformationSection.test.tsx +++ /dev/null @@ -1,167 +0,0 @@ -// 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 { BasicInformationSection } from "@components/support/case-creation-layout/sections/basic-information-section/BasicInformationSection"; -import React from "react"; - -// Mock @wso2/oxygen-ui components -vi.mock("@wso2/oxygen-ui", () => ({ - Box: ({ children, onClick }: any) => ( -
- {children} -
- ), - Chip: ({ label }: any) =>
{label}
, - FormControl: ({ children, disabled }: any) => ( -
- {vi.isMockFunction(children) - ? children - : React.Children.map(children, (child: any) => - child && React.isValidElement(child) - ? React.cloneElement(child, { - disabled: disabled || (child.props as any).disabled, - } as any) - : child, - )} -
- ), - Grid: ({ children }: any) =>
{children}
, - MenuItem: ({ children, value }: any) => ( - - ), - Paper: ({ children }: any) =>
{children}
, - Select: ({ children, value, onChange, disabled, renderValue }: any) => ( -
-
- {renderValue ? renderValue(value) : value} -
- -
- ), - TextField: ({ value, placeholder, disabled }: any) => ( - - ), - Typography: ({ children }: any) => {children}, - IconButton: ({ children, onClick }: any) => ( - - ), - Skeleton: () =>
, -})); - -// Mock icons -vi.mock("@wso2/oxygen-ui-icons-react", () => ({ - PencilLine: ({ onClick }: any) => ( - - ), - Sparkles: () => , -})); - -describe("BasicInformationSection", () => { - const mockMetadata = { - projects: ["Project 1", "Project 2"], - products: ["Product 1", "Product 2"], - deploymentTypes: ["Dev", "Prod"], - }; - - const defaultProps = { - project: "Project 1", - product: "Product 1", - setProduct: vi.fn(), - deployment: "Dev", - setDeployment: vi.fn(), - metadata: mockMetadata, - isDeploymentLoading: false, - isProductDropdownDisabled: false, - isProductLoading: false, - }; - - it("should render all fields correctly", () => { - render(); - - expect(screen.getByText("Basic Information")).toBeInTheDocument(); - expect(screen.getByText("Project")).toBeInTheDocument(); - expect(screen.getByText("Product & Version")).toBeInTheDocument(); - expect(screen.getByText("Deployment Type")).toBeInTheDocument(); - - // Project should be a text field - const textField = screen.getByTestId("text-field"); - expect(textField).toBeInTheDocument(); - expect(textField).toHaveValue("Project 1"); - // Project field is always disabled/read-only in this context (passed disabled={true}) - expect(textField).toBeDisabled(); - - // Products and Deployment should be Selects - const selects = screen.getAllByTestId("select"); - expect(selects).toHaveLength(2); // Product & Deployment - expect(selects[0]).toHaveValue("Dev"); // Deployment (reordered first) - expect(selects[1]).toHaveValue("Product 1"); // Product - }); - - it("should have fields disabled by default (Edit Mode off)", () => { - render(); - - const selects = screen.getAllByTestId("select"); - selects.forEach((select) => { - expect(select).toBeDisabled(); - }); - }); - - it("should enable fields when edit mode is toggled", () => { - render(); - - const pencilIcon = screen.getByTestId("icon-pencil"); - fireEvent.click(pencilIcon); - - const selects = screen.getAllByTestId("select"); - selects.forEach((select) => { - expect(select).not.toBeDisabled(); - }); - }); - - it("should call setter functions when values change in edit mode", () => { - render(); - - // Enable edit mode - fireEvent.click(screen.getByTestId("icon-pencil")); - - const selects = screen.getAllByTestId("select"); - - // Change Deployment - fireEvent.change(selects[0], { target: { value: "Prod" } }); - expect(defaultProps.setDeployment).toHaveBeenCalledWith("Prod"); - - // Change Product - fireEvent.change(selects[1], { target: { value: "Product 2" } }); - expect(defaultProps.setProduct).toHaveBeenCalledWith("Product 2"); - }); -}); diff --git a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/case-details-section/__tests__/CaseDetailsSection.test.tsx b/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/case-details-section/__tests__/CaseDetailsSection.test.tsx deleted file mode 100644 index 493c3dd6a1..0000000000 --- a/apps/customer-portal/webapp/src/components/support/case-creation-layout/sections/case-details-section/__tests__/CaseDetailsSection.test.tsx +++ /dev/null @@ -1,185 +0,0 @@ -// 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 React from "react"; -import { describe, expect, it, vi } from "vitest"; -import { CaseDetailsSection } from "@components/support/case-creation-layout/sections/case-details-section/CaseDetailsSection"; - -// Mock @wso2/oxygen-ui components -vi.mock("@wso2/oxygen-ui", () => ({ - Box: ({ children }: any) =>
{children}
, - Chip: ({ label }: any) =>
{label}
, - Form: { - ElementWrapper: ({ children }: any) =>
{children}
, - }, - FormControl: ({ children, disabled }: any) => ( -
- {React.Children.map(children, (child: any) => - child && React.isValidElement(child) - ? React.cloneElement(child, { - disabled: disabled || (child.props as any).disabled, - } as any) - : child, - )} -
- ), - Grid: ({ children }: any) =>
{children}
, - MenuItem: ({ children, value }: any) => ( - - ), - Paper: ({ children }: any) =>
{children}
, - Select: ({ children, value, onChange, disabled, renderValue, id }: any) => ( -
-
- {renderValue ? renderValue(value) : value} -
- -
- ), - TextField: ({ value, onChange, disabled, placeholder, id }: any) => ( - - ), - Typography: ({ children }: any) => {children}, - IconButton: ({ children, onClick }: any) => ( - - ), -})); - -// Mock RichTextEditor as a simple textarea for tests -vi.mock( - "@components/support/case-creation-layout/sections/case-details-section/rich-text-editor/RichTextEditor", - () => ({ - RichTextEditor: ({ - value, - onChange, - disabled, - "data-testid": dataTestId, - }: any) => ( -