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
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
Typography,
} from "@wso2/oxygen-ui";
import { Phone, Plus, RefreshCw } from "@wso2/oxygen-ui-icons-react";
import { useState, type JSX } from "react";
import { useEffect, useState, type JSX } from "react";
import type { BeCallRequestView, BeCallRequestStateKey } from "@api/backend/types";
import type { Severity } from "@features/csm-dashboard/types/abtDashboard";
import {
Expand Down Expand Up @@ -57,6 +57,13 @@ interface CallRequestsWidgetProps {
caseId: string;
/** Case severity (S0-S4) — passed to the create dialog to enforce the lead-time rule. */
severity?: Severity;
/** True to pop the "Create call request" dialog from outside the widget
* (e.g. the case action bar's "Request a call" item). One-shot: the
* widget calls `onAutoOpenCreateHandled` once it has acted on it, so the
* caller can drop it back to false — otherwise every remount of this
* widget (e.g. just clicking back onto the tab) would reopen the dialog. */
autoOpenCreate?: boolean;
onAutoOpenCreateHandled?: () => void;
}

// ---------------------------------------------------------------------------
Expand All @@ -66,6 +73,8 @@ interface CallRequestsWidgetProps {
export function CallRequestsWidget({
caseId,
severity,
autoOpenCreate,
onAutoOpenCreateHandled,
}: CallRequestsWidgetProps): JSX.Element {
// State filter — empty string means "all". Filtering happens server-side
// via `filters.states` on the search request.
Expand All @@ -82,6 +91,14 @@ export function CallRequestsWidget({
const [createOpen, setCreateOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);

useEffect(() => {
if (autoOpenCreate) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncs the dialog open to an external one-shot trigger from the case action bar
setCreateOpen(true);
onAutoOpenCreateHandled?.();
}
}, [autoOpenCreate, onAutoOpenCreateHandled]);

// Dialog targets — only one dialog is ever open at a time, driven by which
// action was clicked on a row.
const [scheduleTarget, setScheduleTarget] = useState<BeCallRequestView | null>(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^closed$/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /^close$/i })).toBeInTheDocument();
});

it("shows exactly the transitions the backend permits, nothing more", () => {
Expand All @@ -106,10 +106,10 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /solution proposed/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /awaiting info/i })).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: /propose solution/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /request information/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^close$/i })).not.toBeInTheDocument();
});

it("labels a target the same regardless of source state (no UI-invented verbs)", () => {
Expand All @@ -120,19 +120,19 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
unmount();

// ...and from a paused state, the SAME target reads the same — "Waiting on
// ...and from a paused state, the SAME target reads the same — "Wait on
// WSO2", not a fabricated "Resume work".
render(
<CaseActionBar
caseDetail={caseInState("awaiting_info", ["waiting_on_wso2"])}
onAction={() => {}}
/>,
);
expect(screen.getByRole("button", { name: /waiting on wso2/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /wait on wso2/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
});

Expand All @@ -144,9 +144,9 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={onAction}
/>,
);
// "Waiting on WSO2" has no confirm dialog, so it dispatches immediately, and
// "Wait on WSO2" has no confirm dialog, so it dispatches immediately, and
// the target must be the real backend nextState.
fireEvent.click(screen.getByRole("button", { name: /waiting on wso2/i }));
fireEvent.click(screen.getByRole("button", { name: /wait on wso2/i }));
expect(onAction).toHaveBeenCalledWith("wait_on_wso2", "waiting_on_wso2");
});

Expand All @@ -158,8 +158,8 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={onAction}
/>,
);
// Clicking "Closed" must NOT dispatch yet — it opens a confirm dialog.
fireEvent.click(screen.getByRole("button", { name: /^closed$/i }));
// Clicking "Close" must NOT dispatch yet — it opens a confirm dialog.
fireEvent.click(screen.getByRole("button", { name: /^close$/i }));
expect(onAction).not.toHaveBeenCalled();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Confirming dispatches with the close action + closed target.
Expand All @@ -177,10 +177,10 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={() => {}}
/>,
);
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.queryByRole("button", { name: /propose solution/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /request information/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /^close$/i })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument();
});

Expand Down Expand Up @@ -212,7 +212,7 @@ describe("CaseActionBar — nextStates-driven buttons", () => {
onAction={() => {}}
/>,
);
expect(screen.queryByRole("button", { name: /waiting on wso2/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /wait on wso2/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /reopened/i })).not.toBeInTheDocument();
// The state-independent "More" overflow is unaffected.
expect(screen.getByRole("button", { name: /more/i })).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ import {
Phone,
Play,
Send,
ShieldAlert,
TriangleAlert,
User,
} from "@wso2/oxygen-ui-icons-react";
import { useState, type JSX } from "react";
Expand Down Expand Up @@ -141,12 +139,37 @@ const DEFAULT_TARGET_CONFIG: TargetConfig = {
icon: <ArrowRight size={16} />,
};

// Friendlier verbs for the transition buttons than the raw state name reads
// as. `work_in_progress` is special-cased in `buttonFor` since its wording
// (and underlying action) depends on who the case is assigned to.
const TRANSITION_LABEL: Partial<Record<CaseState, string>> = {
solution_proposed: "Propose solution",
awaiting_info: "Request information",
waiting_on_wso2: "Wait on WSO2",
closed: "Close",
};

/** Build the button for a transition into `target`, labelled by the BE state. */
function buttonFor(target: CaseState): PrimaryButton {
function buttonFor(target: CaseState, caseDetail: CsmCaseDetail): PrimaryButton {
const config = TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG;
if (target === "work_in_progress") {
// Moving into Work in progress reads differently depending on whether the
// case is already the current engineer's: unassigned/someone-else's case
// needs claiming first ("Assign to me"), while an already-own case just
// needs its work started ("Start progress"). `onAction` uses the `action`
// value (not just the target state) to decide whether to PATCH the
// assignee before moving the state.
return {
targetState: target,
...config,
label: caseDetail.assigneeIsMe ? "Start progress" : "Assign to me",
action: caseDetail.assigneeIsMe ? "start_work" : "assign_to_me",
};
}
return {
targetState: target,
label: stateLabel(target),
...(TARGET_CONFIG[target] ?? DEFAULT_TARGET_CONFIG),
label: TRANSITION_LABEL[target] ?? stateLabel(target),
...config,
};
}

Expand Down Expand Up @@ -186,18 +209,18 @@ interface SecondaryItem {
* The "More" overflow lists state-independent actions on a case. Items here
* map to documented use cases — see `UseCases.md`:
* - Reassign engineer → ISSU-002 (self-assign generalised)
* - Escalate / Severity change → ISSU-006, ISSU-007
* - Hold auto-closure → ISSU-027
* - Hold auto-closure → ISSU-027 (only while awaiting info / solution proposed)
* - Create incident / link incident → ISSU-021
* - Raise Git issue → ISSU-020
* - Create task → ISSU-025
* - Request a call → ISSU-008
* - Request a call → ISSU-008 (opens the Call requests tab's create dialog)
* - Log time → ISSU-017
* - Copy case link → ISSU-010 (per-comment + per-case permalinks)
*
* Intentionally NOT here:
* - Watch / unwatch → managed via the Watchers widget in Details (ISSU-018)
* - Watch / unwatch → withdrawn along with the Watchers widget (ISSU-018), no backend flow planned yet
* - Open in ServiceNow → this platform replaces ServiceNow; no back-link
* - Escalate to lead / Request severity change → withdrawn, no backend flow planned yet
*/
function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
const items: SecondaryItem[] = [];
Expand Down Expand Up @@ -226,9 +249,17 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
const reassignBlocked =
caseDetail.state === "work_in_progress" && caseDetail.workState === "ongoing";

// Only "Copy case link" is wired up for now. The rest are disabled until
// their backend flows land, so the menu advertises the roadmap without
// exposing dead actions that would no-op or toast a mock message.
// Hold auto-closure only makes sense while the case is sitting in a state
// that's subject to auto-closure (awaiting the customer's response) —
// showing it at any other time would offer to hold a closure that isn't
// pending.
const canHoldAutoClose =
caseDetail.state === "awaiting_info" || caseDetail.state === "solution_proposed";

// Only "Copy case link", "Request a call", and "Log time" are wired up.
// The rest are disabled until their backend flows land, so the menu
// advertises the roadmap without exposing dead actions that would no-op or
// toast a mock message.
items.push(
{ key: "raise_git_issue", label: "Raise internal Git issue…", icon: <GitBranch size={16} />, divider: true },
{
Expand All @@ -241,13 +272,13 @@ function buildSecondaryItems(caseDetail: CsmCaseDetail): SecondaryItem[] {
? "Can't reassign while the case is in progress and ongoing. Pause the work first, or ask the current assignee or a lead to reassign."
: undefined,
},
{ key: "escalate", label: "Escalate to lead…", icon: <TriangleAlert size={16} />, disabled: true },
{ key: "change_severity", label: "Request severity change…", icon: <ShieldAlert size={16} />, disabled: true },
{ key: "hold_auto_close", label: "Hold auto-closure…", icon: <PauseCircle size={16} />, divider: true, disabled: true },
...(canHoldAutoClose
? [{ key: "hold_auto_close", label: "Hold auto-closure…", icon: <PauseCircle size={16} />, divider: true }]
: []),
{ key: "create_incident", label: "Create incident from case…", icon: <AlertTriangle size={16} />, disabled: true },
{ key: "link_incident", label: "Link to incident…", icon: <LinkIcon size={16} />, divider: true, disabled: true },
{ key: "create_task", label: "Create task…", icon: <ListChecks size={16} />, divider: true, disabled: true },
{ key: "request_call", label: "Request a call…", icon: <Phone size={16} />, disabled: true },
{ key: "request_call", label: "Request a call…", icon: <Phone size={16} /> },
{ key: "log_time", label: "Log time…", icon: <Clock size={16} />, divider: true },
{ key: "copy_link", label: "Copy case link", icon: <Copy size={16} /> },
);
Expand Down Expand Up @@ -287,7 +318,7 @@ export default function CaseActionBar({
const targets = caseDetail.nextStates ?? [];
const lifecycle = [...new Set(targets)]
.sort((a, b) => orderRank(a) - orderRank(b))
.map(buttonFor);
.map((target) => buttonFor(target, caseDetail));
const primary = lifecycle;
const secondary = buildSecondaryItems(caseDetail);

Expand All @@ -309,7 +340,20 @@ export default function CaseActionBar({
justifyContent: { xs: "flex-start", md: "flex-end" },
}}
>
{primary.length > 0 && (
{primary.length === 1 && (
// A single reachable state needs no menu — show the transition
// itself as one click rather than "Change state" → pick the only item.
<Button
size="small"
variant="contained"
color={primary[0].color}
startIcon={primary[0].icon}
onClick={() => runPrimary(primary[0])}
>
{primary[0].label}
</Button>
)}
{primary.length > 1 && (
<>
<Button
size="small"
Expand Down
Loading