From 97c2dde26ac3e67604a6dec37f9c4b59ad57f1f4 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 11 Aug 2026 07:47:14 +0530 Subject: [PATCH 1/4] [CSM Portal] fix page content clipping and dark-mode comment readability Case-detail (and other) pages silently clipped content on the right edge below roughly 1780px window width: oxygen-ui's AppShell never sets minWidth: 0 on its
flex item, so it locks to its content's intrinsic width instead of shrinking, and the surrounding row's overflow:hidden then clips the excess instead of scrolling. Replace the oxygen-ui AppShell wiring in AppLayout with a hand-built AppShellLayout that cascades minWidth: 0 through every flex ancestor, mirroring the fix already shipped in the customer portal. Separately, dark-mode comment rendering only stripped near-white inline backgrounds from ServiceNow-authored HTML, so a light/pastel background (e.g. a call-note callout box) survived into dark mode with the app's light default text on top, reading as low-contrast. Switch the check to a WCAG relative-luminance threshold so any sufficiently light background gets neutralized, not just near-white ones. --- .../webapp/src/layouts/AppLayout.tsx | 141 ++++++++---------- .../webapp/src/layouts/AppShellLayout.tsx | 112 ++++++++++++++ .../webapp/src/utils/sanitizeHtml.test.ts | 23 +++ .../webapp/src/utils/sanitizeHtml.ts | 89 +++++++++-- 4 files changed, 274 insertions(+), 91 deletions(-) create mode 100644 apps/csm-portal/webapp/src/layouts/AppShellLayout.tsx diff --git a/apps/csm-portal/webapp/src/layouts/AppLayout.tsx b/apps/csm-portal/webapp/src/layouts/AppLayout.tsx index 26608646c6..2fbb433d47 100644 --- a/apps/csm-portal/webapp/src/layouts/AppLayout.tsx +++ b/apps/csm-portal/webapp/src/layouts/AppLayout.tsx @@ -15,13 +15,7 @@ // under the License. import { notificationBannerConfig } from "@config/notificationBannerConfig"; -import { - AppShell, - Box, - useAppShell, - LinearProgress, - Typography, -} from "@wso2/oxygen-ui"; +import { Box, useAppShell, LinearProgress, Typography } from "@wso2/oxygen-ui"; import { type JSX, type ReactNode, @@ -43,6 +37,7 @@ import TopBanner from "@components/top-banner/TopBanner"; import Header from "@components/header/Header"; import CsmSideBar from "@components/side-nav-bar/CsmSideBar"; import RouteSuspenseFallback from "@components/route-fallback/RouteSuspenseFallback"; +import AppShellLayout from "@layouts/AppShellLayout"; const SIDEBAR_COLLAPSED_KEY = "csm.sidebar.collapsed"; @@ -135,95 +130,91 @@ export default function AppLayout({ children }: AppLayoutProps): JSX.Element { - - + - - - {hasInitialized && isSignedIn && !isErrorPageDisplayed && ( - + } + sidebar={ + hasInitialized && isSignedIn && !isErrorPageDisplayed ? ( - - )} - - + ) : undefined + } + > + + {isVisible && ( + + )} - {isVisible && ( - + > + + + {loadingMessage} + + + ) : ( + }> + {children || } + )} - - {!hasInitialized ? ( - - - - {loadingMessage} - - - ) : ( - }> - {children || } - - )} - - - + + ); diff --git a/apps/csm-portal/webapp/src/layouts/AppShellLayout.tsx b/apps/csm-portal/webapp/src/layouts/AppShellLayout.tsx new file mode 100644 index 0000000000..ebc35d2909 --- /dev/null +++ b/apps/csm-portal/webapp/src/layouts/AppShellLayout.tsx @@ -0,0 +1,112 @@ +// 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 } from "@wso2/oxygen-ui"; +import type { JSX, ReactNode } from "react"; + +export interface AppShellLayoutProps { + header: ReactNode; + sidebar?: ReactNode; + children: ReactNode; +} + +/** + * Application shell with a flex layout that constrains main content to the + * viewport width remaining after the sidebar (Oxygen AppShell omits + * minWidth: 0 on the main column, which prevents inner content from sizing + * to the screen and silently clips it on any viewport narrower than the + * content's intrinsic width). Ported from the customer portal's + * `AppShellLayout` (`apps/customer-portal/webapp/src/layouts/AppShellLayout.tsx`), + * trimmed to what CSM actually uses today: no footer slot and no + * overlay/mobile-drawer sidebar mode (CSM's sidebar only ever renders + * inline, collapsed/expanded via `CsmSideBar`'s own `collapsed` prop). + * + * @param {AppShellLayoutProps} props - Shell regions and page content. + * @returns {JSX.Element} The app shell layout. + */ +export default function AppShellLayout({ + header, + sidebar, + children, +}: AppShellLayoutProps): JSX.Element { + return ( + + + {header} + + + + {sidebar ? ( + + {sidebar} + + ) : null} + + + {children} + + + + ); +} diff --git a/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts b/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts index e6ea720372..4ccf8c206c 100644 --- a/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts +++ b/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts @@ -121,4 +121,27 @@ describe("stripLightModeInlineStyles", () => { const cyan = stripLightModeInlineStyles('x'); expect(cyan).toContain("color: #2fffff"); }); + + it("removes a light pastel background (e.g. a ServiceNow call-note highlight)", () => { + const out = stripLightModeInlineStyles( + '
x
', + ); + expect(out).not.toContain("background-color"); + expect(out).toContain("padding: 0.01em 16px"); + }); + + it("still removes near-white backgrounds (no regression)", () => { + const hex = stripLightModeInlineStyles('
x
'); + expect(hex).not.toContain("background-color"); + const rgb = stripLightModeInlineStyles('
x
'); + expect(rgb).not.toContain("rgb(250"); + }); + + it("leaves a dark/saturated background alone", () => { + const out = stripLightModeInlineStyles( + '
x
', + ); + expect(out).toContain("background-color: #1a1a1a"); + expect(out).toContain("color: red"); + }); }); diff --git a/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts b/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts index e47b4af214..9a7e4057ba 100644 --- a/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts +++ b/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts @@ -58,13 +58,17 @@ export function sanitizeDescriptionHtml(html: string): string { } /** - * Strips pure-white inline background declarations from style attributes so - * dark-mode containers no longer render white boxes on a dark background. - * Everything else (code-block backgrounds, borders, shadows, text colors) is - * intentionally left untouched so light-mode and structural styling stay intact. + * Strips light/pastel inline background declarations from style attributes so + * dark-mode containers don't end up with washed-out, low-contrast backgrounds + * (default dark-mode text is light, so any sufficiently light background — + * not just near-white — reads poorly against it; a ServiceNow call note with + * e.g. a light pastel teal background is a real example that a pure-white-only + * check misses). Everything else (code-block backgrounds, borders, shadows, + * text colors) is intentionally left untouched so light-mode and structural + * styling stay intact. * * @param html - Raw HTML string. - * @returns HTML with pure-white background declarations removed. + * @returns HTML with light background declarations removed. */ export function stripLightModeInlineStyles(html: string): string { return html.replace( @@ -74,13 +78,7 @@ export function stripLightModeInlineStyles(html: string): string { const filtered = declarations.filter((decl) => { const normalized = decl.toLowerCase().replace(/\s+/g, " ").trim(); if (!normalized) return false; - if ( - /^background(-color)?\s*:\s*(#fff(fff)?|white|#f4f4f4|#f5f5f5|#f0f0f0|#f9f9f9|#f8f8f8|#fafafa|#e9e9e9)\s*$/.test( - normalized, - ) - ) - return false; - if (/^background(-color)?\s*:/.test(normalized) && isNearWhiteRgb(normalized)) + if (/^background(-color)?\s*:/.test(normalized) && isLightBackground(normalized)) return false; if (/^color\s*:/.test(normalized) && isDarkColor(normalized)) return false; @@ -93,13 +91,72 @@ export function stripLightModeInlineStyles(html: string): string { ); } -function isNearWhiteRgb(bgDecl: string): boolean { +// Small set of named CSS colors that show up in ServiceNow-authored HTML +// backgrounds; not a full CSS color table, just enough to mirror the parsing +// coverage (hex3/hex6/rgb/named) already used for the dark-text-color check. +const NAMED_BACKGROUND_COLORS: Record = { + white: [255, 255, 255], + black: [0, 0, 0], + whitesmoke: [245, 245, 245], + silver: [192, 192, 192], + gainsboro: [220, 220, 220], +}; + +/** + * WCAG relative luminance (0 = black, 1 = white) of an sRGB color, used to + * catch any background light enough to wash out light dark-mode text — + * not just backgrounds near pure white. + */ +function relativeLuminance(r: number, g: number, b: number): number { + const [rl, gl, bl] = [r, g, b].map((c) => { + const cs = c / 255; + return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; +} + +// Above this, a background reads as "light" against light dark-mode text. +// Pure/near-white (~1.0) and #bce4e8-style pastels (~0.72) both clear it; +// genuinely dark or saturated backgrounds (that already contrast fine with +// light text) stay well under it. +const LIGHT_BACKGROUND_LUMINANCE_THRESHOLD = 0.55; + +function isLightBackground(bgDecl: string): boolean { + const rgb = parseBackgroundColorRgb(bgDecl); + if (!rgb) return false; + return relativeLuminance(...rgb) > LIGHT_BACKGROUND_LUMINANCE_THRESHOLD; +} + +function parseBackgroundColorRgb(bgDecl: string): [number, number, number] | null { const rgbMatch = bgDecl.match( /^background(?:-color)?\s*:\s*rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)\s*$/, ); - if (!rgbMatch) return false; - const [, r, g, b] = rgbMatch.map(Number); - return r > 230 && g > 230 && b > 230; + if (rgbMatch) { + const [, r, g, b] = rgbMatch.map(Number); + return [r, g, b]; + } + const hex6Match = bgDecl.match(/^background(?:-color)?\s*:\s*#([0-9a-f]{6})\s*$/); + if (hex6Match) { + const hex = hex6Match[1]; + return [ + parseInt(hex.slice(0, 2), 16), + parseInt(hex.slice(2, 4), 16), + parseInt(hex.slice(4, 6), 16), + ]; + } + const hex3Match = bgDecl.match(/^background(?:-color)?\s*:\s*#([0-9a-f]{3})\s*$/); + if (hex3Match) { + return hex3Match[1].split("").map((c) => parseInt(c + c, 16)) as [ + number, + number, + number, + ]; + } + const namedMatch = bgDecl.match(/^background(?:-color)?\s*:\s*([a-z]+)\s*$/); + if (namedMatch && namedMatch[1] in NAMED_BACKGROUND_COLORS) { + return NAMED_BACKGROUND_COLORS[namedMatch[1]]; + } + return null; } function isDarkColor(colorDecl: string): boolean { From e2a6f1a4af59021bbff0bf965f75fd9979051179 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 11 Aug 2026 08:28:24 +0530 Subject: [PATCH 2/4] address CodeRabbit: derive the light-background threshold from WCAG contrast The fixed 0.55 luminance cutoff still preserved backgrounds like #808080 (luminance ~0.22, ~3.95:1 contrast against white text) that fall below the 4.5:1 WCAG AA minimum for normal text. Derive the threshold from the actual contrast formula instead of an eyeballed constant. --- .../webapp/src/utils/sanitizeHtml.test.ts | 10 ++++++++++ .../webapp/src/utils/sanitizeHtml.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts b/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts index 4ccf8c206c..ea546e4d7b 100644 --- a/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts +++ b/apps/csm-portal/webapp/src/utils/sanitizeHtml.test.ts @@ -130,6 +130,16 @@ describe("stripLightModeInlineStyles", () => { expect(out).toContain("padding: 0.01em 16px"); }); + it("removes a mid-gray background whose contrast against light text is below WCAG AA", () => { + // #808080 (luminance ~0.216) contrasts with white text at ~3.95:1, below + // the 4.5:1 AA minimum for normal text — a fixed 0.55 luminance cutoff + // missed this; the contrast-derived threshold must catch it. + const out = stripLightModeInlineStyles( + '
x
', + ); + expect(out).not.toContain("background-color"); + }); + it("still removes near-white backgrounds (no regression)", () => { const hex = stripLightModeInlineStyles('
x
'); expect(hex).not.toContain("background-color"); diff --git a/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts b/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts index 9a7e4057ba..f91aeb9c54 100644 --- a/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts +++ b/apps/csm-portal/webapp/src/utils/sanitizeHtml.ts @@ -115,11 +115,20 @@ function relativeLuminance(r: number, g: number, b: number): number { return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl; } -// Above this, a background reads as "light" against light dark-mode text. -// Pure/near-white (~1.0) and #bce4e8-style pastels (~0.72) both clear it; -// genuinely dark or saturated backgrounds (that already contrast fine with -// light text) stay well under it. -const LIGHT_BACKGROUND_LUMINANCE_THRESHOLD = 0.55; +// WCAG AA minimum contrast ratio for normal-size text. +const MIN_CONTRAST_RATIO = 4.5; +// Dark-mode default text renders effectively white; used only to derive the +// background threshold below, not to special-case any particular text color. +const DARK_MODE_TEXT_LUMINANCE = 1; + +// A background is stripped once its own contrast against dark-mode text would +// drop below MIN_CONTRAST_RATIO — i.e. WCAG contrast = (L_text + 0.05) / +// (L_bg + 0.05) solved for the L_bg at which that ratio equals the minimum. +// Deriving it this way (rather than an eyeballed constant) means a background +// like #808080 (luminance ~0.22, ~3.95:1 against white — below AA) is caught: +// a fixed 0.55 threshold missed it. +const LIGHT_BACKGROUND_LUMINANCE_THRESHOLD = + (DARK_MODE_TEXT_LUMINANCE + 0.05) / MIN_CONTRAST_RATIO - 0.05; function isLightBackground(bgDecl: string): boolean { const rgb = parseBackgroundColorRgb(bgDecl); From 78357ca19b9641fc8d7a5894acedee380b90568e Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 11 Aug 2026 08:02:07 +0530 Subject: [PATCH 3/4] Render unset case severity as a distinct "Unset" badge, not S3 A case with no severity value (empty/missing from the source) was rendered and filtered as if it were S3 (Medium) -- a false signal, since "we don't know the severity" and "the severity really is Medium" are different facts. severityFromBe (renamed from severityFromPriority, which no longer matched what it consumes) now maps falsy/unrecognized input to a new "unset" state instead of collapsing it into S3. SeverityChip renders "unset" as a distinct outlined "Unset" badge, visually unlike any real S0-S4 chip. Filtering by a specific severity is unaffected since the filter dropdown never offers "unset" as an option and the backend query already excludes non-matching cases, so unset-severity cases only ever show up in the unfiltered list. Also fixes a related discrepancy in CreateCallRequestDialog's lead-time table, which previously enforced 90 min (S3's lead time) for a null-severity case instead of the backend's own 300 min fallback for null/unknown. --- .../webapp/src/api/backend/mappers.test.ts | 35 +++++++++------- .../webapp/src/api/backend/mappers.ts | 20 +++++---- .../src/components/SeverityChip.test.tsx | 42 +++++++++++++++++++ .../webapp/src/components/SeverityChip.tsx | 20 ++++++++- .../csm-cases/api/useGetCsmCaseDetail.ts | 4 +- .../csm-cases/api/useQuickCaseSearch.ts | 8 ++-- .../csm-cases/api/useSearchChildCases.ts | 11 +++-- .../components/CallRequestsWidget.tsx | 9 ++-- .../csm-cases/components/CaseActionBar.tsx | 11 ++++- .../components/ChangeSeverityDialog.tsx | 27 +++++++++--- .../components/CreateCallRequestDialog.tsx | 32 +++++++++----- .../csm-cases/pages/CsmCaseDetailPage.tsx | 6 ++- .../src/features/csm-cases/types/csmCases.ts | 8 +++- .../csm-cases/utils/caseSearchPayload.ts | 4 +- .../api/useGetMyAssignedOpenCases.ts | 4 +- .../csm-dashboard/types/abtDashboard.ts | 9 ++++ .../components/LogTimeCardDialog.tsx | 12 ++++-- 17 files changed, 196 insertions(+), 66 deletions(-) create mode 100644 apps/csm-portal/webapp/src/components/SeverityChip.test.tsx diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts index 8d3f15cc85..881b1de31e 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts @@ -20,36 +20,39 @@ import { beStateFromUi, commentTypeFromInternal, priorityFromSeverity, - severityFromPriority, + severityFromBe, uiCommentFromBe, uiStateFromBe, } from "./mappers"; -describe("severityFromPriority", () => { +describe("severityFromBe", () => { it("maps legacy English names onto the S0-S4 scale", () => { - expect(severityFromPriority("catastrophic")).toBe("S0"); - expect(severityFromPriority("critical")).toBe("S1"); - expect(severityFromPriority("high")).toBe("S2"); - expect(severityFromPriority("medium")).toBe("S3"); - expect(severityFromPriority("low")).toBe("S4"); + expect(severityFromBe("catastrophic")).toBe("S0"); + expect(severityFromBe("critical")).toBe("S1"); + expect(severityFromBe("high")).toBe("S2"); + expect(severityFromBe("medium")).toBe("S3"); + expect(severityFromBe("low")).toBe("S4"); }); it("maps the backend display-string format 'Label (Px)' onto S0-S4", () => { - expect(severityFromPriority("Catastrophic (P0)")).toBe("S0"); - expect(severityFromPriority("Critical (P1)")).toBe("S1"); - expect(severityFromPriority("High (P2)")).toBe("S2"); - expect(severityFromPriority("Medium (P3)")).toBe("S3"); - expect(severityFromPriority("Low (P4)")).toBe("S4"); + expect(severityFromBe("Catastrophic (P0)")).toBe("S0"); + expect(severityFromBe("Critical (P1)")).toBe("S1"); + expect(severityFromBe("High (P2)")).toBe("S2"); + expect(severityFromBe("Medium (P3)")).toBe("S3"); + expect(severityFromBe("Low (P4)")).toBe("S4"); }); - it("falls back to S3 for an unknown/undefined priority", () => { - expect(severityFromPriority(undefined)).toBe("S3"); - expect(severityFromPriority("unknown_value")).toBe("S3"); + it("maps a falsy/unrecognized severity to the distinct 'unset' state, never to S3", () => { + // A case with no severity value is NOT the same fact as "the severity + // really is Medium" — it must never collapse into a real severity. + expect(severityFromBe(undefined)).toBe("unset"); + expect(severityFromBe("")).toBe("unset"); + expect(severityFromBe("unknown_value")).toBe("unset"); }); }); describe("priorityFromSeverity", () => { - it("is the inverse of severityFromPriority for the known set", () => { + it("is the inverse of severityFromBe for the known set", () => { expect(priorityFromSeverity("S0")).toBe("catastrophic"); expect(priorityFromSeverity("S1")).toBe("critical"); expect(priorityFromSeverity("S2")).toBe("high"); diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.ts b/apps/csm-portal/webapp/src/api/backend/mappers.ts index 80baede9ae..725d582823 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.ts @@ -37,6 +37,7 @@ import type { UserReference } from "@/types/userReference"; import type { CaseState, Severity, + SeverityOrUnset, } from "@features/csm-dashboard/types/abtDashboard"; // --------------------------------------------------------------------------- @@ -44,20 +45,25 @@ import type { // --------------------------------------------------------------------------- /** - * Best-effort mapping from the backend's five-step priority taxonomy onto the - * UI's S0-S4 severity scale. Until the BE adds explicit severity, priority - * doubles as the source. + * Best-effort mapping from the backend's severity string onto the UI's + * S0-S4 severity scale — plus the explicit `"unset"` state. + * + * A falsy value (the backend sends `""` for a case with no severity — it + * never defaults one to Medium/S3) and an unrecognized string both map to + * `"unset"`, never to `"S3"`: "we don't know the severity" and "the severity + * really is Medium" are different facts and must never render/filter + * identically. See `SeverityChip` for the distinct "Unset" badge. */ -export function severityFromPriority(priority: string | undefined): Severity { - if (!priority) return "S3"; - const s = priority.toLowerCase(); +export function severityFromBe(severity: string | undefined): SeverityOrUnset { + if (!severity) return "unset"; + const s = severity.toLowerCase(); // Match both P-notation ("Low (P4)", "P4") and legacy English names. if (s.includes("p0") || s === "catastrophic") return "S0"; if (s.includes("p1") || s === "critical") return "S1"; if (s.includes("p2") || s === "high") return "S2"; if (s.includes("p3") || s === "medium") return "S3"; if (s.includes("p4") || s === "low") return "S4"; - return "S3"; + return "unset"; } export function priorityFromSeverity(severity: Severity): BeCaseSeverity { diff --git a/apps/csm-portal/webapp/src/components/SeverityChip.test.tsx b/apps/csm-portal/webapp/src/components/SeverityChip.test.tsx new file mode 100644 index 0000000000..b13fb02b7e --- /dev/null +++ b/apps/csm-portal/webapp/src/components/SeverityChip.test.tsx @@ -0,0 +1,42 @@ +// 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 "@testing-library/jest-dom/vitest"; + +import SeverityChip from "@components/SeverityChip"; + +describe("SeverityChip", () => { + it("renders a real severity as a bold, solid badge", () => { + render(); + const chip = screen.getByText("S3"); + expect(chip).toBeInTheDocument(); + // Filled variant (MuiChip-filled), not outlined. + expect(chip.closest(".MuiChip-root")).toHaveClass("MuiChip-filled"); + }); + + it("renders 'unset' as a distinct outlined 'Unset' badge, never as S3", () => { + render(); + expect(screen.getByText("Unset")).toBeInTheDocument(); + expect(screen.queryByText("S3")).not.toBeInTheDocument(); + const chip = screen.getByText("Unset").closest(".MuiChip-root"); + // Outlined, not filled — visually distinct from every real severity chip + // (including S4, which also uses the "default" grey role but filled). + expect(chip).toHaveClass("MuiChip-outlined"); + expect(chip).not.toHaveClass("MuiChip-filled"); + }); +}); diff --git a/apps/csm-portal/webapp/src/components/SeverityChip.tsx b/apps/csm-portal/webapp/src/components/SeverityChip.tsx index c4c125635a..a35b085b7d 100644 --- a/apps/csm-portal/webapp/src/components/SeverityChip.tsx +++ b/apps/csm-portal/webapp/src/components/SeverityChip.tsx @@ -15,7 +15,7 @@ // under the License. import type { JSX } from "react"; -import type { Severity } from "@features/csm-dashboard/types/abtDashboard"; +import type { SeverityOrUnset } from "@features/csm-dashboard/types/abtDashboard"; import { SEVERITY_COLOR, SEVERITY_LABEL, @@ -23,7 +23,7 @@ import { import SemanticChip from "@components/SemanticChip"; interface SeverityChipProps { - severity: Severity; + severity: SeverityOrUnset; /** Append the descriptive label, e.g. "S1 — Critical" (used on the case header). */ withLabel?: boolean; size?: "small" | "medium"; @@ -36,6 +36,11 @@ interface SeverityChipProps { * the highest-priority scan signal in the portal, so it renders as a bold, * solid {@link SemanticChip} (which guarantees WCAG AA) — visually out-ranking * the quieter outlined state chip. + * + * `"unset"` (the source has no severity value) renders as a distinct + * outlined, non-bold "Unset" badge — deliberately unlike any of the solid + * S0-S4 badges (in particular S4's grey "default" role) so it can never be + * mistaken for a real severity. */ export default function SeverityChip({ severity, @@ -43,6 +48,17 @@ export default function SeverityChip({ size = "small", clickable = false, }: SeverityChipProps): JSX.Element { + if (severity === "unset") { + return ( + + ); + } return ( (["S0", "S1", "S2", "S3"]); +const ACKNOWLEDGEABLE_SEVERITIES = new Set(["S0", "S1", "S2", "S3"]); /** * Whether the acknowledge action applies to this case: nobody has claimed it diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/ChangeSeverityDialog.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/ChangeSeverityDialog.tsx index 3bf41e6169..088836e342 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/ChangeSeverityDialog.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/ChangeSeverityDialog.tsx @@ -27,13 +27,22 @@ import { Typography, } from "@wso2/oxygen-ui"; import { useState, type JSX } from "react"; -import type { Severity } from "@features/csm-dashboard/types/abtDashboard"; +import type { + Severity, + SeverityOrUnset, +} from "@features/csm-dashboard/types/abtDashboard"; import { SEVERITY_LABEL } from "@features/csm-dashboard/utils/abtDashboard"; const SEVERITIES: Severity[] = ["S0", "S1", "S2", "S3", "S4"]; interface ChangeSeverityDialogProps { - currentSeverity: Severity; + /** + * May be `"unset"` when the case has no severity value at all — the radio + * group then starts with nothing selected (none of `SEVERITIES` match), + * which correctly forces the engineer to pick a real value before "Change + * severity" enables. + */ + currentSeverity: SeverityOrUnset; /** True when the case's project is a Managed Cloud subscription — S0 is * reserved for those, same rule as case creation (see CsmCaseCreatePage.tsx). */ isManagedCloud: boolean; @@ -59,7 +68,7 @@ export default function ChangeSeverityDialog({ onClose, onChange, }: ChangeSeverityDialogProps): JSX.Element { - const [selected, setSelected] = useState(currentSeverity); + const [selected, setSelected] = useState(currentSeverity); const changed = selected !== currentSeverity; @@ -70,7 +79,9 @@ export default function ChangeSeverityDialog({ Current severity:{" "} - {currentSeverity} · {SEVERITY_LABEL[currentSeverity]} + {currentSeverity === "unset" + ? "Not set" + : `${currentSeverity} · ${SEVERITY_LABEL[currentSeverity]}`} @@ -104,9 +115,13 @@ export default function ChangeSeverityDialog({ diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx index 0ac7294498..b669e63c0f 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CreateCallRequestDialog.tsx @@ -28,7 +28,10 @@ import { } from "@wso2/oxygen-ui"; import { Plus } from "@wso2/oxygen-ui-icons-react"; import { useState, type JSX } from "react"; -import type { CaseState, Severity } from "@features/csm-dashboard/types/abtDashboard"; +import type { + CaseState, + SeverityOrUnset, +} from "@features/csm-dashboard/types/abtDashboard"; import { callRequestCaseStateBlockReason } from "@features/csm-cases/utils/callRequestState"; import { formatDateTimeLocal, @@ -71,12 +74,15 @@ const MAX_TIME_SLOTS = 3; * catastrophic=14, critical=10, high=11, medium=12, low=13 * SN priority id -> offset (SN CallRequestUtils._PRIORITY_TIME_OFFSETS): * 14=15, 10=30, 11=60, 12=90, 13=120; null/unknown -> 300 - * Caveat: the mapper collapses null-priority cases to S3, so a genuinely - * null-priority case is enforced at 90 min here vs 300 min at the backend - * (lenient: it may still 400, but never falsely blocks a valid time). * If these backend maps change, this table must change with them. + * A genuinely unset-severity case (`severity === "unset"`) is enforced at + * `DEFAULT_LEAD_TIME_MINUTES` below, matching the backend's null/unknown + * fallback exactly — see the `severity` prop's doc comment. */ -const LEAD_TIME_MINUTES_BY_SEVERITY: Record = { +const LEAD_TIME_MINUTES_BY_SEVERITY: Record< + Exclude, + number +> = { S0: 15, S1: 30, S2: 60, @@ -167,8 +173,13 @@ export interface CreateDialogProps { open: boolean; submitting: boolean; error: string | null; - /** Case severity (S0-S4) — drives the minimum lead time for each proposed slot. */ - severity?: Severity; + /** + * Case severity — drives the minimum lead time for each proposed slot. + * `"unset"` (no severity value at all) is treated like `undefined`: the + * most conservative `DEFAULT_LEAD_TIME_MINUTES`, matching the backend's + * own null/unknown fallback. + */ + severity?: SeverityOrUnset; /** * Case's current state — the data source only accepts a call request while * the case is in one of a fixed set of states (see @@ -214,9 +225,10 @@ export function CreateCallRequestDialog({ // Times are entered in the user's timezone and stored/submitted as UTC. const timeZone = resolveDisplayTimeZone(); - const leadMinutes = severity - ? LEAD_TIME_MINUTES_BY_SEVERITY[severity] - : DEFAULT_LEAD_TIME_MINUTES; + const leadMinutes = + severity && severity !== "unset" + ? LEAD_TIME_MINUTES_BY_SEVERITY[severity] + : DEFAULT_LEAD_TIME_MINUTES; const minAllowedMs = earliestAllowedMs(leadMinutes); const minLocal = utcMsToZonedInputValue(minAllowedMs, timeZone); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx index 8e0579f348..37a2efc510 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/pages/CsmCaseDetailPage.tsx @@ -913,7 +913,11 @@ export default function CsmCaseDetailPage(): JSX.Element { relatedCaseNumber: data.caseNumber, deploymentId: data.productContext.deploymentId, deployedProductId: data.productContext.deployedProductId, - severity: data.severity, + // The related case's severity only prefills the new-case form + // when it's a real S0-S4 value — an "unset" source severity + // leaves the (required) field blank so the engineer must pick + // one, same as any other case with no severity to carry over. + severity: data.severity === "unset" ? undefined : data.severity, issueType: data.issueType, subject: `Related Case : ${data.subject}`, }; diff --git a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts index e37395af49..6cb0169628 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts @@ -18,6 +18,7 @@ import type { CaseState, CaseWorkState, Severity, + SeverityOrUnset, SlaClockType, } from "@features/csm-dashboard/types/abtDashboard"; import type { @@ -56,7 +57,12 @@ export interface CsmCaseRow { projectName: string; /** Affected WSO2 product (e.g. "WSO2 Identity Server"). Used for list filtering. */ product: string; - severity: Severity; + /** + * `"unset"` when the source has no severity value at all (empty/missing) — + * a distinct fact from "the severity really is S3/Medium", never collapsed + * into a real severity. See `severityFromBe` in `api/backend/mappers.ts`. + */ + severity: SeverityOrUnset; state: CaseState; /** * Case type (BE `typeKey` / search `caseType`). Optional: a legacy row may diff --git a/apps/csm-portal/webapp/src/features/csm-cases/utils/caseSearchPayload.ts b/apps/csm-portal/webapp/src/features/csm-cases/utils/caseSearchPayload.ts index abc4334282..4ed6595b8c 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/utils/caseSearchPayload.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/utils/caseSearchPayload.ts @@ -18,7 +18,7 @@ import type { BackendApi } from "@api/backend/client"; import { beStateFromUi, priorityFromSeverity, - severityFromPriority, + severityFromBe, uiStateFromBe, } from "@api/backend/mappers"; import { ASSIGNEE_ME_TOKEN } from "@features/csm-cases/utils/assignee"; @@ -254,7 +254,7 @@ export function mapCaseSearchViewToRow( projectId, projectName: c.project?.name ?? "-", product: c.deployedProduct?.name ?? c.product?.name ?? "-", - severity: severityFromPriority(c.severity), + severity: severityFromBe(c.severity), state: uiStateFromBe(c.state), caseType: c.type, workState: c.workState ?? null, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts index 75e789fcac..b1c4ec9d36 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useGetMyAssignedOpenCases.ts @@ -21,7 +21,7 @@ import { } from "@tanstack/react-query"; import { ApiQueryKeys } from "@constants/apiConstants"; import { useBackendApi } from "@api/backend/client"; -import { severityFromPriority, uiStateFromBe } from "@api/backend/mappers"; +import { severityFromBe, uiStateFromBe } from "@api/backend/mappers"; import { useCurrentUser } from "@context/current-user/CurrentUserContext"; import { useIdTokenClaims } from "@hooks/useIdTokenClaims"; import type { @@ -139,7 +139,7 @@ export function useGetMyAssignedOpenCases( projectId: c.project?.id ?? "", projectName: c.project?.name ?? "-", product: c.deployedProduct?.name ?? "-", - severity: severityFromPriority(c.severity), + severity: severityFromBe(c.severity), state: uiStateFromBe(c.state), caseType: c.type, workState: c.workState ?? null, diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index 73c1ecbb6b..ceaae37db8 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -16,6 +16,15 @@ export type Severity = "S0" | "S1" | "S2" | "S3" | "S4"; +/** + * Severity plus the "no value" state: a case whose source has no severity set + * (empty/missing, never sent to us as `"medium"`) is a distinct fact from + * "the severity really is S3/Medium" and must never render/filter as one. + * Not one of the S0-S4 filter options by design — see `severityFromBe` in + * `api/backend/mappers.ts`. + */ +export type SeverityOrUnset = Severity | "unset"; + export type CaseState = | "open" | "work_in_progress" diff --git a/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx b/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx index 9463d69981..17379c907a 100644 --- a/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx +++ b/apps/csm-portal/webapp/src/features/csm-timecards/components/LogTimeCardDialog.tsx @@ -51,7 +51,7 @@ import { type NormalizedUser, } from "@features/csm-users/types/csmUsers"; import TimeCardStatusChip from "@features/csm-timecards/components/TimeCardStatusChip"; -import type { Severity } from "@features/csm-dashboard/types/abtDashboard"; +import type { SeverityOrUnset } from "@features/csm-dashboard/types/abtDashboard"; import { ACTIVITY_BUCKETS, DEFAULT_BILLABLE, @@ -94,8 +94,10 @@ interface LogTimeCardDialogProps { * severity to hand in; the switch stays enabled there rather than being * force-disabled on a guess, and the backend's own business rule still * enforces the real non-billable-severities constraint server-side either - * way (see NON_BILLABLE_SEVERITIES's doc comment). */ - caseSeverity?: Severity; + * way (see NON_BILLABLE_SEVERITIES's doc comment). Also `"unset"` when the + * case has no severity value at all — treated the same as "no severity to + * hand in" below (switch stays enabled, backend still enforces server-side). */ + caseSeverity?: SeverityOrUnset; projectId: string; projectName: string; /** True while the create/edit mutation is in flight. */ @@ -202,7 +204,9 @@ export default function LogTimeCardDialog({ const isEditMode = !!editingCard; const isAlwaysNonBillable = - !!caseSeverity && NON_BILLABLE_SEVERITIES.includes(caseSeverity); + !!caseSeverity && + caseSeverity !== "unset" && + NON_BILLABLE_SEVERITIES.includes(caseSeverity); const [date, setDate] = useState(editingCard?.workDate ?? localTodayIso()); const [issueComplexity, setIssueComplexity] = useState( From 9d49f714667012bf98813900d74ec1ce503e961b Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Tue, 11 Aug 2026 08:34:52 +0530 Subject: [PATCH 4/4] address CodeRabbit: align SeverityOrUnset docs with severityFromBe's real contract Both doc comments described "unset" as only empty/missing severity, but severityFromBe also falls back to "unset" for any unrecognized value. --- .../webapp/src/features/csm-cases/types/csmCases.ts | 5 +++-- .../src/features/csm-dashboard/types/abtDashboard.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts index 6cb0169628..238c6afb9c 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/types/csmCases.ts @@ -58,8 +58,9 @@ export interface CsmCaseRow { /** Affected WSO2 product (e.g. "WSO2 Identity Server"). Used for list filtering. */ product: string; /** - * `"unset"` when the source has no severity value at all (empty/missing) — - * a distinct fact from "the severity really is S3/Medium", never collapsed + * `"unset"` when the source has no severity value at all (empty/missing), + * or the value doesn't match anything `severityFromBe` recognizes — a + * distinct fact from "the severity really is S3/Medium", never collapsed * into a real severity. See `severityFromBe` in `api/backend/mappers.ts`. */ severity: SeverityOrUnset; diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index ceaae37db8..2c3906095e 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts @@ -18,10 +18,10 @@ export type Severity = "S0" | "S1" | "S2" | "S3" | "S4"; /** * Severity plus the "no value" state: a case whose source has no severity set - * (empty/missing, never sent to us as `"medium"`) is a distinct fact from - * "the severity really is S3/Medium" and must never render/filter as one. - * Not one of the S0-S4 filter options by design — see `severityFromBe` in - * `api/backend/mappers.ts`. + * (empty/missing, or a value `severityFromBe` doesn't recognize — never sent + * to us as `"medium"`) is a distinct fact from "the severity really is + * S3/Medium" and must never render/filter as one. Not one of the S0-S4 filter + * options by design — see `severityFromBe` in `api/backend/mappers.ts`. */ export type SeverityOrUnset = Severity | "unset";