Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions apps/csm-portal/webapp/src/api/backend/mappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
20 changes: 13 additions & 7 deletions apps/csm-portal/webapp/src/api/backend/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,27 +37,33 @@ import type { UserReference } from "@/types/userReference";
import type {
CaseState,
Severity,
SeverityOrUnset,
} from "@features/csm-dashboard/types/abtDashboard";

// ---------------------------------------------------------------------------
// Cases
// ---------------------------------------------------------------------------

/**
* 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 {
Expand Down
42 changes: 42 additions & 0 deletions apps/csm-portal/webapp/src/components/SeverityChip.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SeverityChip severity="S3" />);
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(<SeverityChip severity="unset" />);
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");
});
});
20 changes: 18 additions & 2 deletions apps/csm-portal/webapp/src/components/SeverityChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
// 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,
} from "@features/csm-dashboard/utils/abtDashboard";
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";
Expand All @@ -36,13 +36,29 @@ 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,
withLabel = false,
size = "small",
clickable = false,
}: SeverityChipProps): JSX.Element {
if (severity === "unset") {
return (
<SemanticChip
role="default"
label="Unset"
variant="outlined"
size={size}
clickable={clickable}
/>
);
}
return (
<SemanticChip
role={SEVERITY_COLOR[severity]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useIdTokenClaims } from "@hooks/useIdTokenClaims";
import { ApiQueryKeys } from "@constants/apiConstants";
import { useBackendApi } from "@api/backend/client";
import {
severityFromPriority,
severityFromBe,
uiStateFromBe,
userReferenceFromBe,
} from "@api/backend/mappers";
Expand Down Expand Up @@ -79,7 +79,7 @@ function detailFromBeCase(
projectId: c.project?.id ?? "",
projectName: c.project?.name ?? "—",
product,
severity: severityFromPriority(c.severity),
severity: severityFromBe(c.severity),
state: uiStateFromBe(c.state),
workState: c.workState ?? null,
nextStates: (c.nextStates ?? []).map(uiStateFromBe),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { useQuery, type UseQueryResult } 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 type {
BeCaseFieldFilter,
BeCaseSearchFilters,
Expand All @@ -28,7 +28,7 @@ import type {
import type {
CaseState,
CaseWorkState,
Severity,
SeverityOrUnset,
} from "@features/csm-dashboard/types/abtDashboard";
import { ALL_CASE_TYPES } from "@features/csm-cases/utils/caseType";

Expand Down Expand Up @@ -83,7 +83,7 @@ export interface QuickCaseHit {
caseNumber?: string;
wso2CaseId?: string;
subject: string;
severity: Severity;
severity: SeverityOrUnset;
state: CaseState;
workState?: CaseWorkState | null;
caseType?: BeCaseType;
Expand Down Expand Up @@ -147,7 +147,7 @@ export function useQuickCaseSearch(
caseNumber: c.number,
wso2CaseId: c.internalId,
subject: c.subject ?? "(no subject)",
severity: severityFromPriority(c.severity),
severity: severityFromBe(c.severity),
state: uiStateFromBe(c.state),
workState: c.workState,
caseType: c.type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,20 @@
import { useQuery, type UseQueryResult } 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 type { BeCaseSearchPayload, BeCaseSearchResponse } from "@api/backend/types";
import type { CaseState, Severity } from "@features/csm-dashboard/types/abtDashboard";
import type {
CaseState,
SeverityOrUnset,
} from "@features/csm-dashboard/types/abtDashboard";

const CHILD_CASES_LIMIT = 20;

export interface ChildCaseRow {
id: string;
caseNumber?: string;
subject: string;
severity: Severity;
severity: SeverityOrUnset;
state: CaseState;
assigneeName?: string;
}
Expand Down Expand Up @@ -68,7 +71,7 @@ export function useSearchChildCases(
id: c.id,
caseNumber: c.number,
subject: c.subject ?? "(no subject)",
severity: severityFromPriority(c.severity),
severity: severityFromBe(c.severity),
state: uiStateFromBe(c.state),
assigneeName: c.assignedEngineer?.name ?? undefined,
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ import {
import { Phone, Plus, RefreshCw } from "@wso2/oxygen-ui-icons-react";
import { useEffect, useState, type JSX } from "react";
import type { BeCallRequestView, BeCallRequestStateKey } from "@api/backend/types";
import type { CaseState, Severity } from "@features/csm-dashboard/types/abtDashboard";
import type {
CaseState,
SeverityOrUnset,
} from "@features/csm-dashboard/types/abtDashboard";
import {
useGetCsmCaseCallRequests,
usePostCsmCaseCallRequest,
Expand All @@ -58,8 +61,8 @@ import RefreshButton from "@components/RefreshButton";

interface CallRequestsWidgetProps {
caseId: string;
/** Case severity (S0-S4) — passed to the create dialog to enforce the lead-time rule. */
severity?: Severity;
/** Case severity — passed to the create dialog to enforce the lead-time rule. */
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. Gates both the "Create call
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ import type {
CaseLifecycleAction,
CsmCaseDetail,
} from "@features/csm-cases/types/csmCases";
import type { CaseState, Severity } from "@features/csm-dashboard/types/abtDashboard";
import type {
CaseState,
SeverityOrUnset,
} from "@features/csm-dashboard/types/abtDashboard";
import { stateLabel } from "@features/csm-dashboard/utils/abtDashboard";
import UserRefLink from "@components/UserRefLink";

Expand Down Expand Up @@ -424,8 +427,12 @@ interface CaseActionBarProps {
* it mirrors which cases the out-of-band acknowledgement notifications are
* raised for, so the button appears on exactly the cases an engineer could
* already have acknowledged from a notification, and on no others.
* `caseDetail.severity` may be `"unset"` (case has no severity value) —
* typed `SeverityOrUnset` so `.has()` accepts it directly; it is never a
* member of this set, so an unset-severity case is correctly never
* acknowledgeable.
*/
const ACKNOWLEDGEABLE_SEVERITIES = new Set<Severity>(["S0", "S1", "S2", "S3"]);
const ACKNOWLEDGEABLE_SEVERITIES = new Set<SeverityOrUnset>(["S0", "S1", "S2", "S3"]);

/**
* Whether the acknowledge action applies to this case: nobody has claimed it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -59,7 +68,7 @@ export default function ChangeSeverityDialog({
onClose,
onChange,
}: ChangeSeverityDialogProps): JSX.Element {
const [selected, setSelected] = useState<Severity>(currentSeverity);
const [selected, setSelected] = useState<SeverityOrUnset>(currentSeverity);

const changed = selected !== currentSeverity;

Expand All @@ -70,7 +79,9 @@ export default function ChangeSeverityDialog({
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Current severity:{" "}
<Typography component="span" variant="body2" sx={{ fontWeight: 600, color: "text.primary" }}>
{currentSeverity} · {SEVERITY_LABEL[currentSeverity]}
{currentSeverity === "unset"
? "Not set"
: `${currentSeverity} · ${SEVERITY_LABEL[currentSeverity]}`}
</Typography>
</Typography>

Expand Down Expand Up @@ -104,9 +115,13 @@ export default function ChangeSeverityDialog({
</Button>
<Button
variant="contained"
disabled={!changed || isChanging}
disabled={!changed || isChanging || selected === "unset"}
loading={isChanging}
onClick={() => onChange(selected)}
onClick={() => {
// Guard is redundant with `disabled` above (the radio group never
// offers "unset" as an option) but keeps this call type-safe.
if (selected !== "unset") onChange(selected);
}}
>
Change severity
</Button>
Expand Down
Loading