From 7ff245e29de075869b241613a44e7d2379fbe4f0 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Fri, 12 Jun 2026 07:01:15 +0000 Subject: [PATCH 1/2] [CSM Portal] drive case action bar from backend nextStates; unify reopened spelling The case state machine was duplicated client-side and the FE used a different spelling for one state than the backend/entity service. - CaseActionBar now renders lifecycle buttons solely from the case's backend-supplied nextStates. Removed FALLBACK_TARGETS (the duplicated client-side transition graph): an absent or empty nextStates yields no lifecycle buttons rather than guessing from a second copy of the graph. - Mock case-detail fixtures now carry nextStates (computed from a mock that stands in for the backend) so mock mode keeps showing the right buttons. - Aligned the CaseState vocabulary to the backend spelling 'reopened' (was 'reopen') across types, labels, colours, filters and mocks. uiStateFromBe / beStateFromUi are now identity at the type level; uiStateFromBe keeps a runtime guard defaulting unknown/undefined to 'open'. Build, lint and the webapp test suite (47 tests) pass. --- .../webapp/src/api/backend/mappers.test.ts | 7 ++-- .../webapp/src/api/backend/mappers.ts | 33 ++++++++------- .../csm-cases/api/mocks/casesMocks.ts | 33 +++++++++++++-- .../components/CaseActionBar.test.tsx | 14 ++++--- .../csm-cases/components/CaseActionBar.tsx | 42 +++++-------------- .../csm-cases/components/CasesFilterBar.tsx | 2 +- .../csm-cases/utils/casesFiltersUrl.ts | 2 +- .../csm-dashboard/api/mocks/dashboardMocks.ts | 2 +- .../csm-dashboard/api/useCaseCountsMatrix.ts | 2 +- .../components/MyQueueSection.tsx | 2 +- .../csm-dashboard/types/abtDashboard.ts | 2 +- .../csm-dashboard/utils/abtDashboard.ts | 6 +-- 12 files changed, 78 insertions(+), 69 deletions(-) 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 126d2b5ee5..d592c55b33 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts @@ -50,9 +50,9 @@ describe("priorityFromSeverity", () => { }); describe("uiStateFromBe / beStateFromUi", () => { - it("normalises reopened <-> reopen across the boundary", () => { - expect(uiStateFromBe("reopened")).toBe("reopen"); - expect(beStateFromUi("reopen")).toBe("reopened"); + it("passes reopened through unchanged (UI and backend now share the spelling)", () => { + expect(uiStateFromBe("reopened")).toBe("reopened"); + expect(beStateFromUi("reopened")).toBe("reopened"); }); it("passes through shared states unchanged", () => { @@ -61,6 +61,7 @@ describe("uiStateFromBe / beStateFromUi", () => { "work_in_progress", "waiting_on_wso2", "awaiting_info", + "reopened", "solution_proposed", "closed", ] as const) { diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.ts b/apps/csm-portal/webapp/src/api/backend/mappers.ts index d1e3b58452..fb096b721d 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.ts @@ -81,27 +81,28 @@ export function priorityFromSeverity(severity: Severity): BeCasePriority { } /** - * The backend state list and the UI state list overlap except for the trailing - * "ed" on `reopened`. Normalise both directions. + * The UI and backend state vocabularies are identical (`CaseState` === + * `BeCaseState`), so these are identity maps at the type level. They survive as + * the single API boundary: `uiStateFromBe` still guards against an undefined or + * unexpected runtime value by defaulting to `open`, and `beStateFromUi` keeps + * the call sites symmetric and ready if the two vocabularies ever diverge again. */ +const KNOWN_STATES: readonly CaseState[] = [ + "open", + "work_in_progress", + "waiting_on_wso2", + "awaiting_info", + "reopened", + "solution_proposed", + "closed", +]; + export function uiStateFromBe(state: BeCaseState | undefined): CaseState { - switch (state) { - case "reopened": - return "reopen"; - case "open": - case "work_in_progress": - case "waiting_on_wso2": - case "awaiting_info": - case "solution_proposed": - case "closed": - return state; - default: - return "open"; - } + return state && KNOWN_STATES.includes(state) ? state : "open"; } export function beStateFromUi(state: CaseState): BeCaseState { - return state === "reopen" ? "reopened" : (state as BeCaseState); + return state; } // --------------------------------------------------------------------------- diff --git a/apps/csm-portal/webapp/src/features/csm-cases/api/mocks/casesMocks.ts b/apps/csm-portal/webapp/src/features/csm-cases/api/mocks/casesMocks.ts index 6f712c0008..b9eb6054ec 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/api/mocks/casesMocks.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/api/mocks/casesMocks.ts @@ -14,7 +14,10 @@ // specific language governing permissions and limitations // under the License. -import type { DashboardScope } from "@features/csm-dashboard/types/abtDashboard"; +import type { + CaseState, + DashboardScope, +} from "@features/csm-dashboard/types/abtDashboard"; import type { CaseAttachment, CaseAuditEntry, @@ -355,7 +358,7 @@ const ABT_CASE_SEEDS: CaseSeed[] = [ projectId: "prj-acme-iam-prod", projectName: "IAM Production", severity: "S2", - state: "reopen", + state: "reopened", assignee: "Sajith Ekanayaka", assigneeIsMe: true, slaClockType: "ack", @@ -803,7 +806,7 @@ function deriveTags(c: CsmCaseRow): CaseTag[] { tags.push({ id: "t-priority", label: "high-priority", color: "error" }); if (c.minutesToBreach < 0) tags.push({ id: "t-breach", label: "sla-breached", color: "warning" }); - if (c.state === "reopen") + if (c.state === "reopened") tags.push({ id: "t-reopen", label: "reopened", color: "warning" }); const subjLower = c.subject.toLowerCase(); if (subjLower.includes("ldap")) tags.push({ id: "t-ldap", label: "ldap", color: "info" }); @@ -912,7 +915,7 @@ function deriveAudit(c: CsmCaseRow): CaseAuditEntry[] { createdAt: c.updatedAt, }); } - if (c.state === "reopen") { + if (c.state === "reopened") { events.push({ id: `a-${c.id}-3`, kind: "state_change", @@ -1141,6 +1144,27 @@ function deriveAttachments(c: CsmCaseRow): CaseAttachment[] { return sets[seedIdx % 4] ?? []; } +/** + * Mirror of the backend transition graph in + * `apps/csm-portal/backend/internal/handler/state.go` (`nextStates`). The mock + * stands in for the backend, so it owns this copy the way the real backend does; + * the webapp itself never re-derives the graph — it renders from `nextStates`. + */ +const MOCK_NEXT_STATES: Record = { + open: ["work_in_progress"], + work_in_progress: [ + "waiting_on_wso2", + "awaiting_info", + "solution_proposed", + "closed", + ], + waiting_on_wso2: ["work_in_progress"], + awaiting_info: ["waiting_on_wso2"], + reopened: ["waiting_on_wso2"], + solution_proposed: ["closed", "waiting_on_wso2"], + closed: [], +}; + export function getMockCsmCaseDetailById( idOrNumber: string, ): CsmCaseDetail | undefined { @@ -1149,6 +1173,7 @@ export function getMockCsmCaseDetailById( const customerContext = CUSTOMER_CONTEXTS[row.accountId] ?? FALLBACK_CUSTOMER; return { ...row, + nextStates: MOCK_NEXT_STATES[row.state] ?? [], description: describe(row), assignmentGroup: row.projectName.toLowerCase().includes("choreo") ? "grp.choreo_sre" diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx index 38c8f0ad5f..cb6f4ef848 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx @@ -124,17 +124,21 @@ describe("CaseActionBar — nextStates-driven buttons", () => { expect(onAction).toHaveBeenCalledWith("close", "closed"); }); - it("falls back to the known graph when nextStates is absent", () => { + it("renders no lifecycle buttons when nextStates is absent (no client-side graph)", () => { + // The bar is driven solely by the backend `nextStates`; there is no longer a + // duplicated client-side fallback graph, so an absent field yields only the + // state-independent "More" overflow. render( {}} />, ); - expect(screen.getByRole("button", { name: /solution proposed/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /awaiting info/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /^closed$/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /solution proposed/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /awaiting info/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /waiting on wso2/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^closed$/i })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument(); }); it("shows no state-change buttons when nextStates is empty (terminal case)", () => { diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx index 41c5e36404..5a340072c7 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.tsx @@ -124,7 +124,7 @@ const TARGET_CONFIG: Record = { confirmColor: "primary", }, }, - reopen: { action: "reopen", color: "primary", icon: }, + reopened: { action: "reopen", color: "primary", icon: }, closed: { action: "close", color: "warning", @@ -138,28 +138,6 @@ function buttonFor(target: CaseState): PrimaryButton { return { targetState: target, label: STATE_LABEL[target], ...TARGET_CONFIG[target] }; } -/** - * Fallback target lists, mirroring the backend `nextStates` graph in - * `state.go`. Used only when the case carries no `nextStates` (e.g. mock data, - * or a backend that hasn't populated the field) so the bar still shows the - * expected actions. When the case *does* carry `nextStates`, that drives the - * buttons directly and this map is ignored. - */ -const FALLBACK_TARGETS: Record = { - open: ["work_in_progress"], - work_in_progress: [ - "solution_proposed", - "awaiting_info", - "waiting_on_wso2", - "closed", - ], - solution_proposed: ["waiting_on_wso2", "closed"], - awaiting_info: ["waiting_on_wso2"], - waiting_on_wso2: ["work_in_progress"], - reopen: ["waiting_on_wso2"], - closed: [], -}; - /** * Display order for the lifecycle buttons. The forward/safe action sits first * (it gets the contained emphasis); Close is always last so a destructive @@ -171,7 +149,7 @@ const DISPLAY_ORDER: CaseState[] = [ "work_in_progress", "awaiting_info", "waiting_on_wso2", - "reopen", + "reopened", "open", "closed", ]; @@ -186,11 +164,11 @@ function orderRank(s: CaseState): number { * `nextStates` flow — the backend reports a closed case as terminal * (`nextStates: []`) — so it is appended for the `closed` state only when the * caller grants the `canReopenClosed` capability. Labelled by the BE state it - * lands in (`reopen` → "Reopened") like every other button. + * lands in (`reopened` → "Reopened") like every other button. */ const REOPEN_BUTTON: PrimaryButton = { - targetState: "reopen", - label: STATE_LABEL.reopen, + targetState: "reopened", + label: STATE_LABEL.reopened, action: "reopen", color: "warning", icon: , @@ -276,11 +254,11 @@ export default function CaseActionBar({ ); const from = caseDetail.state; - // Render a button for every state the backend says the case can move to. When - // the field is absent (undefined — mock data / not yet populated) fall back to - // the known graph so the bar isn't empty. An explicit empty list (a terminal - // case, e.g. Closed) correctly yields no lifecycle buttons. - const targets = caseDetail.nextStates ?? FALLBACK_TARGETS[from] ?? []; + // Render a button for every state the backend says the case can move to. The + // backend `nextStates` is the single source of truth: an empty/terminal list + // (e.g. Closed) yields no lifecycle buttons, and a missing field also yields + // none rather than guessing from a duplicated client-side graph. + const targets = caseDetail.nextStates ?? []; const lifecycle = [...new Set(targets)] .sort((a, b) => orderRank(a) - orderRank(b)) .map(buttonFor); diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesFilterBar.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesFilterBar.tsx index 5ea44ce342..1d52370e1f 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesFilterBar.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesFilterBar.tsx @@ -108,7 +108,7 @@ const PRIMARY_STATES: CaseState[] = [ "awaiting_info", "solution_proposed", "waiting_on_wso2", - "reopen", + "reopened", "closed", ]; const SLA_OPTIONS: { value: SlaFilter; label: string }[] = [ diff --git a/apps/csm-portal/webapp/src/features/csm-cases/utils/casesFiltersUrl.ts b/apps/csm-portal/webapp/src/features/csm-cases/utils/casesFiltersUrl.ts index faea221d72..4519fd0486 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/utils/casesFiltersUrl.ts +++ b/apps/csm-portal/webapp/src/features/csm-cases/utils/casesFiltersUrl.ts @@ -43,7 +43,7 @@ const VALID_STATES: CaseState[] = [ "solution_proposed", "awaiting_info", "waiting_on_wso2", - "reopen", + "reopened", "closed", ]; const VALID_SLA: SlaFilter[] = ["any", "at_risk", "breached"]; diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/mocks/dashboardMocks.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/mocks/dashboardMocks.ts index b9fde26dac..ab92aebec2 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/mocks/dashboardMocks.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/mocks/dashboardMocks.ts @@ -293,7 +293,7 @@ const sumQueue = (cases: CsmQueueCase[]) => { let inProgress = 0; let awaitingInfo = 0; for (const c of cases) { - if (c.state === "open" || c.state === "reopen") actionRequired += 1; + if (c.state === "open" || c.state === "reopened") actionRequired += 1; else if (c.state === "work_in_progress") inProgress += 1; else if (c.state === "awaiting_info") awaitingInfo += 1; } diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useCaseCountsMatrix.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useCaseCountsMatrix.ts index 200876f065..5d1f8b0e8c 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/api/useCaseCountsMatrix.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/api/useCaseCountsMatrix.ts @@ -38,7 +38,7 @@ export const MATRIX_STATES: CaseState[] = [ "waiting_on_wso2", "awaiting_info", "solution_proposed", - "reopen", + "reopened", "closed", ]; diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyQueueSection.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyQueueSection.tsx index e52f96b35f..02d2ca1150 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyQueueSection.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/MyQueueSection.tsx @@ -104,7 +104,7 @@ export default function MyQueueSection({ = { solution_proposed: "Solution proposed", awaiting_info: "Awaiting info", waiting_on_wso2: "Waiting on WSO2", - reopen: "Reopened", + reopened: "Reopened", closed: "Closed", }; @@ -55,7 +55,7 @@ export const STATE_LABEL: Record = { // brand orange (the old `isClosed ? "success" : "primary"`, which also failed // WCAG contrast). The four buckets: // info (blue) = active, on us, normal -> open, work_in_progress -// warning (amber)= active, on us, elevated -> waiting_on_wso2, reopen +// warning (amber)= active, on us, elevated -> waiting_on_wso2, reopened // default (grey) = waiting on the customer -> solution_proposed, awaiting_info // success (green)= done -> closed // All four roles have a dark `contrastText` in this theme, so filled chips pass @@ -68,7 +68,7 @@ export const STATE_COLOR: Record< open: "info", work_in_progress: "info", waiting_on_wso2: "warning", - reopen: "warning", + reopened: "warning", solution_proposed: "default", awaiting_info: "default", closed: "success", From fedf542122bad577d7624e5a412261a532e24045 Mon Sep 17 00:00:00 2001 From: Sajith Ekanayaka Date: Fri, 12 Jun 2026 07:19:38 +0000 Subject: [PATCH 2/2] [CSM Portal] gracefully render case states the frontend does not know A new case state or transition introduced on the backend should not require a frontend change to appear and work. Previously an unrecognized state would build a broken action-bar button (undefined label, dispatching an undefined action) and render an empty status chip. - uiStateFromBe now passes an unknown backend state through unchanged instead of collapsing it to 'open' (only a genuinely absent value defaults to 'open'), so the value reaches the UI. - Added stateLabel()/stateColor()/humanizeState() helpers that fall back to a title-cased key and neutral colour for any state without a curated entry. Status chips (cases list, dashboard queue/SLA sections, related cases) use them. - CaseActionBar renders a usable button for an unknown state via a neutral DEFAULT_TARGET_CONFIG and a generic 'transition' lifecycle action. The PATCH target still comes from the backend nextStates value, so the transition works; the action only drives the post-transition toast. This keeps the action bar driven solely by backend nextStates while making the no-FE-change-for-new-states property hold instead of silently filtering unknown states out (which would have required an FE update for every new state). Build, lint and the webapp test suite (49 tests) pass. --- .../webapp/src/api/backend/mappers.test.ts | 9 ++++- .../webapp/src/api/backend/mappers.ts | 25 +++++--------- .../components/CaseActionBar.test.tsx | 21 ++++++++++++ .../csm-cases/components/CaseActionBar.tsx | 26 +++++++++++--- .../csm-cases/components/CasesList.tsx | 8 ++--- .../csm-cases/pages/CsmCaseDetailPage.tsx | 10 +++--- .../src/features/csm-cases/types/csmCases.ts | 6 +++- .../components/MyQueueSection.tsx | 4 +-- .../components/SlaAtRiskSection.tsx | 4 +-- .../csm-dashboard/utils/abtDashboard.ts | 34 +++++++++++++++++++ 10 files changed, 113 insertions(+), 34 deletions(-) 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 d592c55b33..69f17c8253 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.test.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.test.ts @@ -70,9 +70,16 @@ describe("uiStateFromBe / beStateFromUi", () => { } }); - it("defaults an unknown/undefined backend state to open", () => { + it("defaults an absent backend state to open", () => { expect(uiStateFromBe(undefined)).toBe("open"); }); + + it("passes an unknown backend state through so the UI can render it", () => { + // A state the frontend has not been taught about must still reach the UI + // (it renders with a humanized label) rather than being collapsed to a + // known state — that is what lets the backend add a state with no FE change. + expect(uiStateFromBe("pending_review")).toBe("pending_review"); + }); }); describe("commentTypeFromInternal", () => { diff --git a/apps/csm-portal/webapp/src/api/backend/mappers.ts b/apps/csm-portal/webapp/src/api/backend/mappers.ts index fb096b721d..22cf18950a 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.ts @@ -82,23 +82,16 @@ export function priorityFromSeverity(severity: Severity): BeCasePriority { /** * The UI and backend state vocabularies are identical (`CaseState` === - * `BeCaseState`), so these are identity maps at the type level. They survive as - * the single API boundary: `uiStateFromBe` still guards against an undefined or - * unexpected runtime value by defaulting to `open`, and `beStateFromUi` keeps - * the call sites symmetric and ready if the two vocabularies ever diverge again. + * `BeCaseState`), so these are identity maps. They survive as the single API + * boundary and, deliberately, pass an *unknown* backend state straight through + * rather than collapsing it to a known one: a state the frontend has not been + * taught about must still reach the UI so it can render with a humanized label + * (see `stateLabel`/`stateColor`). That is what lets the backend introduce a new + * state with no frontend change. Only a genuinely absent value defaults to + * `open`. */ -const KNOWN_STATES: readonly CaseState[] = [ - "open", - "work_in_progress", - "waiting_on_wso2", - "awaiting_info", - "reopened", - "solution_proposed", - "closed", -]; - -export function uiStateFromBe(state: BeCaseState | undefined): CaseState { - return state && KNOWN_STATES.includes(state) ? state : "open"; +export function uiStateFromBe(state: string | undefined): CaseState { + return (state ?? "open") as CaseState; } export function beStateFromUi(state: CaseState): BeCaseState { diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx index cb6f4ef848..5bb6938031 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CaseActionBar.test.tsx @@ -141,6 +141,27 @@ describe("CaseActionBar — nextStates-driven buttons", () => { expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument(); }); + it("renders a usable button for a state it has no curated config for", () => { + // Rollout skew / a newly added backend state: the bar must still render the + // transition (humanized label, neutral styling) and dispatch correctly, + // rather than building a broken button — so a new state needs no FE change. + const onAction = vi.fn(); + render( + , + ); + const button = screen.getByRole("button", { name: /pending review/i }); + expect(button).toBeInTheDocument(); + // The generic transition action drives only the toast; the PATCH target is + // the backend state itself, so the transition still works. + fireEvent.click(button); + expect(onAction).toHaveBeenCalledWith("transition", "pending_review"); + }); + it("shows no state-change buttons when nextStates is empty (terminal case)", () => { render( = { }, }; +/** + * Presentation for a transition into a state the bar has no curated config for + * (e.g. a state added on the backend). Keeps the button renderable and safe to + * click — neutral styling, a generic `transition` action for the toast — so a + * new backend state needs no frontend change to appear and work. The PATCH + * target still comes from the state itself, not from this action. + */ +const DEFAULT_TARGET_CONFIG: TargetConfig = { + action: "transition", + color: "primary", + icon: , +}; + /** Build the button for a transition into `target`, labelled by the BE state. */ function buttonFor(target: CaseState): PrimaryButton { - return { targetState: target, label: STATE_LABEL[target], ...TARGET_CONFIG[target] }; + return { + targetState: target, + label: stateLabel(target), + ...(TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG), + }; } /** @@ -168,7 +186,7 @@ function orderRank(s: CaseState): number { */ const REOPEN_BUTTON: PrimaryButton = { targetState: "reopened", - label: STATE_LABEL.reopened, + label: stateLabel("reopened"), action: "reopen", color: "warning", icon: , diff --git a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx index 2938f45304..8c50d12f40 100644 --- a/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx +++ b/apps/csm-portal/webapp/src/features/csm-cases/components/CasesList.tsx @@ -20,9 +20,9 @@ import { useNavigate } from "react-router"; import { SEVERITY_COLOR, SLA_CLOCK_LABEL, - STATE_COLOR, - STATE_LABEL, formatTimeToBreach, + stateColor, + stateLabel, } from "@features/csm-dashboard/utils/abtDashboard"; import RelativeTime from "@components/RelativeTime"; import type { CsmCaseRow } from "@features/csm-cases/types/csmCases"; @@ -181,8 +181,8 @@ export default function CasesList({ {c.assigneeIsMe ? ( 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 2d8003e128..8fe5f94426 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 @@ -69,9 +69,9 @@ import { SEVERITY_COLOR, SEVERITY_LABEL, SLA_CLOCK_LABEL, - STATE_COLOR, - STATE_LABEL, formatTimeToBreach, + stateColor, + stateLabel, } from "@features/csm-dashboard/utils/abtDashboard"; import RelativeTime from "@components/RelativeTime"; import type { @@ -118,6 +118,7 @@ const LIFECYCLE_TOAST: Record = { close: "Case closed.", close_no_response: "Closed (no response received).", reopen: "Case reopened.", + transition: "Case updated.", }; type FeedbackSeverity = "success" | "info" | "warning" | "error"; @@ -142,6 +143,7 @@ const LIFECYCLE_SEVERITY: Record = { close: "success", close_no_response: "success", reopen: "info", + transition: "info", }; // Lifecycle actions that map to a `PATCH /cases/{id}` state transition. @@ -593,8 +595,8 @@ export default function CsmCaseDetailPage(): JSX.Element { /> {c.caseNumber} · {c.subject} - {c.customer} · {STATE_LABEL[c.state]} + {c.customer} · {stateLabel(c.state)} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/components/SlaAtRiskSection.tsx b/apps/csm-portal/webapp/src/features/csm-dashboard/components/SlaAtRiskSection.tsx index 5079aa2e5d..3815632713 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/components/SlaAtRiskSection.tsx +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/components/SlaAtRiskSection.tsx @@ -21,8 +21,8 @@ import SectionCard from "@features/csm-dashboard/components/SectionCard"; import { SEVERITY_COLOR, SLA_CLOCK_LABEL, - STATE_LABEL, formatTimeToBreach, + stateLabel, } from "@features/csm-dashboard/utils/abtDashboard"; import { casesHref } from "@features/csm-cases/utils/casesFiltersUrl"; import type { CsmSlaAtRiskCase } from "@features/csm-dashboard/types/abtDashboard"; @@ -124,7 +124,7 @@ export default function SlaAtRiskSection({ {c.caseNumber} · {c.subject} - {c.customer} · {STATE_LABEL[c.state]} · Assignee: {c.assignee} + {c.customer} · {stateLabel(c.state)} · Assignee: {c.assignee} diff --git a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts index 935e02079a..edd612ffab 100644 --- a/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts +++ b/apps/csm-portal/webapp/src/features/csm-dashboard/utils/abtDashboard.ts @@ -74,6 +74,40 @@ export const STATE_COLOR: Record< closed: "success", }; +/** + * Title-case an unknown backend state key for display, e.g. + * `pending_review` -> "Pending review". This is the fallback that lets a state + * the frontend has not been taught about still render with a readable label, + * so introducing a new case state on the backend needs no frontend change. + */ +export function humanizeState(state: string): string { + if (!state) return "Unknown"; + const words = state.split("_").filter(Boolean); + return words + .map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w)) + .join(" "); +} + +/** + * Display label for a case state. Uses the curated label for known states and + * gracefully degrades to a humanized key for any state the frontend does not + * recognize (backend/frontend rollout skew, or a newly added state). + */ +export function stateLabel(state: string): string { + return STATE_LABEL[state as CaseState] ?? humanizeState(state); +} + +/** + * Status-chip colour for a case state, defaulting to the neutral `default` + * (grey) for any unrecognized state so a new state renders without styling + * having to be added on the frontend first. + */ +export function stateColor( + state: string, +): "info" | "warning" | "success" | "default" { + return STATE_COLOR[state as CaseState] ?? "default"; +} + export const SLA_CLOCK_LABEL: Record = { ack: "Ack", first_response: "First response",