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..69f17c8253 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) { @@ -69,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 d1e3b58452..22cf18950a 100644 --- a/apps/csm-portal/webapp/src/api/backend/mappers.ts +++ b/apps/csm-portal/webapp/src/api/backend/mappers.ts @@ -81,27 +81,21 @@ 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. 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`. */ -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"; - } +export function uiStateFromBe(state: string | undefined): CaseState { + return (state ?? "open") as CaseState; } 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..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 @@ -124,17 +124,42 @@ 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("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)", () => { 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..7121ae24c2 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 @@ -28,6 +28,7 @@ import { } from "@wso2/oxygen-ui"; import { AlertTriangle, + ArrowRight, CheckCircle, ChevronDown, Clock, @@ -52,7 +53,7 @@ import type { CsmCaseDetail, } from "@features/csm-cases/types/csmCases"; import type { CaseState } from "@features/csm-dashboard/types/abtDashboard"; -import { STATE_LABEL } from "@features/csm-dashboard/utils/abtDashboard"; +import { stateLabel } from "@features/csm-dashboard/utils/abtDashboard"; type ActionConfirm = { title: string; @@ -63,7 +64,7 @@ type ActionConfirm = { /** * Presentation for a transition *into* a given state. The button LABEL is never - * stored here — it always comes from `STATE_LABEL[targetState]`, so the bar + * stored here — it always comes from `stateLabel(targetState)`, so the bar * honours the backend transition graph verbatim and never invents UI-specific * verbs. This only carries the icon/colour, the lifecycle action used for the * post-transition toast, and an optional confirm gate. @@ -124,7 +125,7 @@ const TARGET_CONFIG: Record = { confirmColor: "primary", }, }, - reopen: { action: "reopen", color: "primary", icon: }, + reopened: { action: "reopen", color: "primary", icon: }, closed: { action: "close", color: "warning", @@ -133,33 +134,28 @@ const TARGET_CONFIG: Record = { }, }; -/** 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] }; -} - /** - * 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. + * 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 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: [], +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: stateLabel(target), + ...(TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG), + }; +} + /** * 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 +167,7 @@ const DISPLAY_ORDER: CaseState[] = [ "work_in_progress", "awaiting_info", "waiting_on_wso2", - "reopen", + "reopened", "open", "closed", ]; @@ -186,11 +182,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: stateLabel("reopened"), action: "reopen", color: "warning", icon: , @@ -276,11 +272,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/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 { /> { 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..0b6219cf50 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 @@ -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 { ASSIGNEE_ME_TOKEN } from "@features/csm-cases/components/CasesFilterBar"; @@ -104,7 +104,7 @@ export default function MyQueueSection({ {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/types/abtDashboard.ts b/apps/csm-portal/webapp/src/features/csm-dashboard/types/abtDashboard.ts index a02472d2db..768911ff6e 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 @@ -22,7 +22,7 @@ export type CaseState = | "solution_proposed" | "awaiting_info" | "waiting_on_wso2" - | "reopen" + | "reopened" | "closed"; export type SlaClockType = "ack" | "first_response" | "resolution"; 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 bd5da8e3be..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 @@ -46,7 +46,7 @@ export const STATE_LABEL: Record = { 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,12 +68,46 @@ 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", }; +/** + * 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",