Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 4 additions & 3 deletions apps/csm-portal/webapp/src/api/backend/mappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -61,6 +61,7 @@ describe("uiStateFromBe / beStateFromUi", () => {
"work_in_progress",
"waiting_on_wso2",
"awaiting_info",
"reopened",
"solution_proposed",
"closed",
] as const) {
Expand Down
33 changes: 17 additions & 16 deletions apps/csm-portal/webapp/src/api/backend/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// ---------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<CaseState, CaseState[]> = {
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 {
Expand All @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<CaseActionBar
caseDetail={caseInState("work_in_progress", undefined)}
onAction={() => {}}
/>,
);
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)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ const TARGET_CONFIG: Record<CaseState, TargetConfig> = {
confirmColor: "primary",
},
},
reopen: { action: "reopen", color: "primary", icon: <RotateCcw size={16} /> },
reopened: { action: "reopen", color: "primary", icon: <RotateCcw size={16} /> },
closed: {
action: "close",
color: "warning",
Expand All @@ -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<CaseState, CaseState[]> = {
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
Expand All @@ -171,7 +149,7 @@ const DISPLAY_ORDER: CaseState[] = [
"work_in_progress",
"awaiting_info",
"waiting_on_wso2",
"reopen",
"reopened",
"open",
"closed",
];
Expand All @@ -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: <RotateCcw size={16} />,
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const MATRIX_STATES: CaseState[] = [
"waiting_on_wso2",
"awaiting_info",
"solution_proposed",
"reopen",
"reopened",
"closed",
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function MyQueueSection({
<StatPill
label="Action required"
value={isLoading ? "—" : (queue?.actionRequiredCount ?? 0)}
href={casesHref({ assignees: [ASSIGNEE_ME_TOKEN], states: ["open", "reopen"] })}
href={casesHref({ assignees: [ASSIGNEE_ME_TOKEN], states: ["open", "reopened"] })}
onNavigate={go}
/>
<StatPill
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type CaseState =
| "solution_proposed"
| "awaiting_info"
| "waiting_on_wso2"
| "reopen"
| "reopened"
| "closed";

export type SlaClockType = "ack" | "first_response" | "resolution";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const STATE_LABEL: Record<CaseState, string> = {
solution_proposed: "Solution proposed",
awaiting_info: "Awaiting info",
waiting_on_wso2: "Waiting on WSO2",
reopen: "Reopened",
reopened: "Reopened",
closed: "Closed",
};

Expand All @@ -55,7 +55,7 @@ export const STATE_LABEL: Record<CaseState, string> = {
// 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
Expand All @@ -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",
Expand Down