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
213 changes: 213 additions & 0 deletions web/src/routes/SalesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,216 @@ describe("SalesPage cross-window display while loading (#469)", () => {
expect(screen.queryByText("SO-OLD")).not.toBeInTheDocument();
});
});

// #474 — the screen renders the error paragraph three times: once per dialog
// and once for the page. All three carried the PAGE's guard (`!creatingOrder &&
// !paying`), which is false exactly when the dialog holding that copy is open —
// so a mutation that failed under a dialog cleared its spinner and said
// nothing. The dialog copies are guarded by the Dialog itself (it renders
// nothing while closed), and the page copy keeps the suppression so the message
// is never duplicated.
describe("SalesPage in-dialog errors (#474)", () => {
it("shows a failed create-order inside the new-order dialog", async () => {
await renderReady();
mockCreateOrder.mockRejectedValueOnce(
new ApiError(422, "Validation failed", "Order date cannot be in the future."));

fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => {
fireEvent.click(within(dialog()).getByRole("button", { name: "New draft order" }));
});

// The dialog stays up (a throw keeps it open) and now says why.
expect(within(dialog()).getByText("Order date cannot be in the future.")).toBeInTheDocument();
// Exactly one copy: the page-level paragraph stays suppressed behind the
// open dialog, so a fix that simply dropped the page guard fails here.
expect(screen.getAllByText("Order date cannot be in the future.")).toHaveLength(1);
});

it("shows a failed payment inside the payment dialog", async () => {
mockListOrderPayments.mockResolvedValue({
items: [], paidMinorUnits: 0, outstandingMinorUnits: 12000, totalMinorUnits: 12000,
currencyCode: "BHD", currencyMinorUnit: 3,
});
await openOrder(
{ ...draftEmpty(3, "BHD", "o9"), referenceNumber: "SO-9", status: "Confirmed", totalMinorUnits: 12000, items: [ITEM_A] },
/Grade A Dozen/);
fireEvent.click(await screen.findByRole("button", { name: "Record payment" }));

mockRecordPayment.mockRejectedValueOnce(
new ApiError(422, "Validation failed", "Payment exceeds the outstanding balance."));
fireEvent.change(within(dialog()).getByLabelText(/Amount/), { target: { value: "99" } });
await act(async () => {
fireEvent.click(within(dialog()).getByRole("button", { name: "Record payment" }));
});

expect(within(dialog()).getByText("Payment exceeds the outstanding balance.")).toBeInTheDocument();
expect(screen.getAllByText("Payment exceeds the outstanding balance.")).toHaveLength(1);
});

// Codex review of #476: `error` is ONE state shared by every action on the
// screen, and neither dialog trigger is disabled while another request is in
// flight. So an unconditional in-dialog render presents someone else's
// failure — a payments read, a write started before the dialog opened — as
// the dialog's own. The error carries the scope that raised it, and each
// dialog shows only its own.
const CONFIRMED_9: SalesOrder = {
...draftEmpty(2, "USD", "o9"), referenceNumber: "SO-9", status: "Confirmed",
totalMinorUnits: 2900, items: [ITEM_A],
};

it("keeps an unrelated failure out of the new-order dialog", async () => {
let rejectPayments!: (e: unknown) => void;
mockListOrderPayments.mockReturnValueOnce(
new Promise((_, rej) => { rejectPayments = rej; }) as never);
await openOrder(CONFIRMED_9, /Grade A Dozen/);

// The trigger is live while that read is still out.
fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => { rejectPayments(new Error("boom")); });

const message = "Could not load this order's payments.";
expect(within(dialog()).queryByText(message)).not.toBeInTheDocument();
// Not swallowed either — it belongs to the page, and says so there.
expect(screen.getByText(message)).toBeInTheDocument();
});

it("keeps a panel write's failure out of the new-order dialog", async () => {
// The other source: not a background read but another WRITE, started
// before the dialog was opened. Its scope is the one run() was called
// with, so tagging every failure alike would land it here.
await openOrder(DRAFT_TWO, /Grade A Dozen/);
let rejectAdd!: (e: unknown) => void;
vi.mocked(addOrderItem).mockReturnValue(
new Promise((_, rej) => { rejectAdd = rej; }) as never);
fireEvent.click(screen.getByRole("button", { name: "Add line" }));

fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => { rejectAdd(new ApiError(422, "Validation failed", "That product is no longer sellable.")); });

expect(within(dialog()).queryByText("That product is no longer sellable.")).not.toBeInTheDocument();
expect(screen.getByText("That product is no longer sellable.")).toBeInTheDocument();
});

it("keeps an unrelated failure out of the payment dialog", async () => {
mockListOrderPayments.mockResolvedValue({
items: [{
id: "pay1", salesOrderId: "o9", customerId: "c1", amountMinorUnits: 500,
currencyCode: "USD", currencyMinorUnit: 2, method: "Cash", paymentDate: "2026-07-20",
referenceNumber: "R1", note: null, voided: false, voidReason: null, version: 1,
}],
paidMinorUnits: 500, outstandingMinorUnits: 2400, totalMinorUnits: 2900,
currencyCode: "USD", currencyMinorUnit: 2,
});
let rejectVoid!: (e: unknown) => void;
vi.mocked(voidPayment).mockReturnValue(
new Promise((_, rej) => { rejectVoid = rej; }) as never);
await openOrder(CONFIRMED_9, /Grade A Dozen/);

// Start voiding a payment, leave it in flight…
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "void" }));
});
fireEvent.change(within(dialog()).getByLabelText("Reason *"), { target: { value: "wrong order" } });
await act(async () => {
fireEvent.click(within(dialog()).getByRole("button", { name: "Void payment" }));
});
// …then open the payment dialog and let the void fail underneath it.
fireEvent.click(screen.getByRole("button", { name: "Record payment" }));
await act(async () => { rejectVoid(new ApiError(409, "Conflict", "That payment was already voided.")); });

expect(within(dialog()).queryByText("That payment was already voided.")).not.toBeInTheDocument();
expect(screen.getByText("That payment was already voided.")).toBeInTheDocument();
});

it("drops the dialog's own error when the dialog is dismissed", async () => {
// #474's own complaint, the other way round: a message about an abandoned
// attempt, left on the page after its dialog is gone, "reads as a
// page-level error with no context". The attempt is over — so is the
// message.
await renderReady();
mockCreateOrder.mockRejectedValueOnce(
new ApiError(422, "Validation failed", "Order date cannot be in the future."));
fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => {
fireEvent.click(within(dialog()).getByRole("button", { name: "New draft order" }));
});
expect(within(dialog()).getByText("Order date cannot be in the future.")).toBeInTheDocument();

fireEvent.click(within(dialog()).getByRole("button", { name: "Cancel" }));

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Order date cannot be in the future.")).not.toBeInTheDocument();
});

it("says nothing when the dialog is dismissed before its write fails", async () => {
// Codex P2 + pi, same hole: dismissing only cleared an error that had
// ALREADY landed. A slow request the user gave up on still reported at
// page level afterwards — the context-free message again, now with the
// form that explains it gone. Cancel is live during `busy`, and Escape and
// the backdrop close the dialog too, so this is the ordinary case.
await renderReady();
let rejectCreate!: (e: unknown) => void;
mockCreateOrder.mockReturnValueOnce(new Promise((_, rej) => { rejectCreate = rej; }) as never);
fireEvent.click(screen.getByRole("button", { name: "New order" }));
fireEvent.click(within(dialog()).getByRole("button", { name: "New draft order" }));

fireEvent.click(within(dialog()).getByRole("button", { name: "Cancel" }));
await act(async () => {
rejectCreate(new ApiError(422, "Validation failed", "Order date cannot be in the future."));
});

expect(screen.queryByText("Order date cannot be in the future.")).not.toBeInTheDocument();
});

it("reports the next attempt after an abandoned one", async () => {
// The abandonment is per-attempt, not permanent: reopening and failing
// again must still say so, or the first Cancel would mute the dialog for
// the rest of the session.
await renderReady();
let rejectCreate!: (e: unknown) => void;
mockCreateOrder.mockReturnValueOnce(new Promise((_, rej) => { rejectCreate = rej; }) as never);
fireEvent.click(screen.getByRole("button", { name: "New order" }));
fireEvent.click(within(dialog()).getByRole("button", { name: "New draft order" }));
fireEvent.click(within(dialog()).getByRole("button", { name: "Cancel" }));
await act(async () => { rejectCreate(new ApiError(500, "Server error", "abandoned")); });

mockCreateOrder.mockRejectedValueOnce(
new ApiError(422, "Validation failed", "Order date cannot be in the future."));
fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => {
fireEvent.click(within(dialog()).getByRole("button", { name: "New draft order" }));
});

expect(within(dialog()).getByText("Order date cannot be in the future.")).toBeInTheDocument();
});

it("keeps someone else's error when a dialog is dismissed", async () => {
// Only the dialog's OWN message goes with it. A failure that was never
// this dialog's is still the page's to report.
let rejectPayments!: (e: unknown) => void;
mockListOrderPayments.mockReturnValueOnce(
new Promise((_, rej) => { rejectPayments = rej; }) as never);
await openOrder(CONFIRMED_9, /Grade A Dozen/);
fireEvent.click(screen.getByRole("button", { name: "New order" }));
await act(async () => { rejectPayments(new Error("boom")); });

fireEvent.click(within(dialog()).getByRole("button", { name: "Cancel" }));

expect(screen.getByText("Could not load this order's payments.")).toBeInTheDocument();
});

it("still renders a page-level error with no dialog open", async () => {
// The panel's own writes are not behind a dialog — their errors must keep
// landing on the page, which is what the page copy's guard exists for.
await openOrder(DRAFT_TWO, /Grade A Dozen/);
vi.mocked(addOrderItem).mockRejectedValueOnce(
new ApiError(422, "Validation failed", "That product is no longer sellable."));

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add line" }));
});

expect(screen.getByText("That product is no longer sellable.")).toBeInTheDocument();
});
});
78 changes: 65 additions & 13 deletions web/src/routes/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,19 @@ export function SalesPage() {
};
const clearKey = (scope: string) => keys.current.delete(scope);

const [error, setError] = useState<string | null>(null);
// #474 — the error carries the SCOPE that raised it, not just its text. One
// flat string was enough while every copy of it rendered in the same place;
// it is not enough now that the dialogs render their own, because neither
// dialog trigger is disabled while another request is in flight. A payments
// read (or a panel write started before the dialog was opened) that rejects
// underneath an open dialog would otherwise be presented as that dialog's
// own failure (codex review of #476). Scopes are the same strings run()
// already takes.
const [error, setError] = useState<{ scope: string; text: string } | null>(null);
// Scopes whose dialog was dismissed while their write was still in flight.
// A ref, not state: it is read in the settle path of a request that is
// already running, and must never be a render behind (#474).
const abandoned = useRef<Set<string>>(new Set());
const [message, setMessage] = useState<string | null>(null);

// Payments (#89, admin-only money data) — settlement state of the open
Expand Down Expand Up @@ -253,7 +265,9 @@ export function SalesPage() {
let cancelled = false;
listOrderPayments(activeId)
.then((p) => { if (!cancelled) setPayments(p); })
.catch(() => { if (!cancelled) setError(i18n.t("sales:loadPaymentsFailed")); });
.catch(() => {
if (!cancelled) setError({ scope: "payments", text: i18n.t("sales:loadPaymentsFailed") });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dialog errors when payment loading fails

When a confirmed order's listOrderPayments call remains pending, the user can open New order and submit because this read does not set the shared busy guard. If createOrder rejects first, its actionable 422 appears in the dialog, but a later payment-read rejection executes this assignment and replaces the sole error state with the payments scope, making the create-order explanation disappear from the still-open modal. Keep errors per scope, or avoid overwriting an active dialog's own error with this background-read failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and you are right that I under-rated it: I had written this off in the PR body as "pre-existing, accepted, needs a per-scope map". The sharp version is not a message is lost, it is the message the user is acting on is replaced while they read it.

#476 merged before this round closed, so the fix is in #478 (issue #477): the state splits into error (the page's, untagged) and dialogError (whichever dialog is open — they are modal, so at most one ever is, and each clears it on open). run() routes by a DIALOG_SCOPES list, and each attempt clears only its own slot, so neither can overwrite the other and trying something new never drops a failure the user has not dealt with. It removes more than it adds: shownInDialog, the page copy's guard, the scope tag on the page error, and the clear-on-dismiss all go.

Four more tests, thirteen mutants all killed on a green baseline of 59 — including one that renders the dialog slot on the page, which is what keeps the dismissal tests honest now that dismissal clears nothing.

An internal reviewer also found a second, still-open half in the same family: an abandoned attempt's success (not failure) force-closes and re-navigates a session the user reopened. That is tracked in #477 and deliberately not in #478 — it needs a per-session generation.

});
return () => { cancelled = true; };
}, [activeId, activeStatus, canSettle]);

Expand All @@ -279,15 +293,36 @@ export function SalesPage() {
// guard but deliberately get no BusyButton treatment (#236 is writes).
const run = (scope: string, fn: () => Promise<void>) =>
runPending(scope, async () => {
// Cleared per attempt: abandoning one attempt must not mute the next.
abandoned.current.delete(scope);
setError(null);
setMessage(null);
try {
await fn();
} catch (err) {
setError(errText(err));
// #474 — a dialog dismissed while its own write was still out has
// abandoned that attempt, and the failure has nowhere honest to land:
// reporting it on the page is the context-free message the issue was
// filed about, now without even the form that would explain it. Cancel
// stays live during `busy`, and Escape and the backdrop dismiss too, so
// this is ordinary, not a corner (codex + pi review of #476).
if (abandoned.current.has(scope)) return;
setError({ scope, text: errText(err) });
}
});

// #474 — dismissing a dialog abandons its attempt, so its failure goes with
// it. Leaving the message behind produces exactly what the issue was filed
// about: a page-level error with no context. Its OWN message only — a
// failure that was never this dialog's is still the page's to report.
const dismiss = (scope: string, setOpen: (open: boolean) => void) => {
setOpen(false);
abandoned.current.add(scope);
setError((current) => (current?.scope === scope ? null : current));
Comment on lines +318 to +321

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore dialog failures that settle after dismissal

If the user submits either dialog and then dismisses it before the request rejects, dismiss sees no current error to clear; the later run catch then stores the dialog-scoped error after the dialog has closed, so the page-level copy renders the same context-free message this change is meant to remove. This is reachable because Cancel remains enabled during busy, and Dialog also invokes onClose for Escape and backdrop clicks. Prevent dismissal while that scope is pending, or invalidate/ignore the request's eventual error when its dialog has been dismissed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real, and fixed in bab7f66. pi found the identical hole independently, with the same reasoning — the previous commit only cleared an error that had already landed, and the realistic ordering is the opposite one: the user gives up on a slow request and cancels, then it rejects.

dismiss now records the scope in an abandoned ref, and run's catch drops a failure whose attempt was abandoned. A ref rather than state because it is read in the settle path of a request already in flight, where a render-behind value would be exactly wrong. The marker is cleared at the start of each run, so abandoning one attempt cannot mute the next — that was the obvious way to get this wrong.

I took your first suggestion (invalidate the eventual error) rather than the second (block dismissal while pending): Dialog handles Escape and backdrop clicks itself, so "prevent dismissal while that scope is pending" would mean either a new prop on the shared component or a dialog the user cannot escape from while a request hangs. Abandoning the result is the smaller change and the better behaviour.

Two tests: dismiss-then-reject says nothing anywhere; reopen-and-fail still reports. Three more mutants — the late-failure guard removed, the dismissal never recorded, the marker never cleared — bringing the set to thirteen, all killed on a green baseline of 55 SalesPage tests.

One related thing I am not changing, so it is on the record rather than silently skipped: error is a single slot, so a dialog failure that overwrites an earlier page-scoped one (e.g. a failed payments read) loses it, and dismissing the dialog does not bring it back. That is the pre-existing shape of the state, unchanged by this PR, and fixing it properly means a per-scope error map — a bigger change than a bug fix should carry. Happy to file it as a follow-up if you think it earns one.

1523 web tests green, typecheck clean.

};
const closeNewOrder = () => dismiss("create-order", setCreatingOrder);
const closePayment = () => dismiss("record-payment", setPaying);

const onCreateOrder = () => run("create-order", async () => {
// runWrite claims the list ticket before the POST, so a filter change
// made while it is in flight keeps the view (#469).
Expand Down Expand Up @@ -490,6 +525,12 @@ export function SalesPage() {
// is part-way through editing (#469). Only the setup reads — customers and
// products, without which no form on this screen can function — still gate
// the page.
// Exactly the two cases a dialog renders itself (#474) — the page copy skips
// those and shows everything else.
const shownInDialog =
(creatingOrder && error?.scope === "create-order")
|| (paying && error?.scope === "record-payment");

if (setupError) return <section><h2>{t("title")}</h2><p className="error">{setupError}</p></section>;
if (orders.rows === null) return <section><h2>{t("title")}</h2><p className="muted">{t("loading")}</p></section>;

Expand Down Expand Up @@ -517,7 +558,7 @@ export function SalesPage() {
{/* Deliberately NOT a <form>: these controls were button-driven, so
wrapping them in one would newly enforce min/step and swallow the
screen's own money messages (codex review of #132). */}
<Dialog open={creatingOrder} title={t("newOrder")} onClose={() => setCreatingOrder(false)}>
<Dialog open={creatingOrder} title={t("newOrder")} onClose={closeNewOrder}>
<div className="form-grid">
<label>{t("customer")}
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)}>
Expand All @@ -528,10 +569,16 @@ export function SalesPage() {
<input type="date" value={orderDate} max={today}
onChange={(e) => setOrderDate(e.target.value)} />
</label>
{/* An open dialog renders its own copy of the error. */}
{error && !creatingOrder && !paying && <p className="error">{error}</p>}
{/* #474 — this copy lives INSIDE the dialog, which renders nothing
while closed, so the page's `!creatingOrder` condition hid the
message exactly when the dialog it belongs to was up. The scope
test replaces it: the dialog reports its OWN write, never
whatever else happened to fail underneath it. role="alert"
because focus is trapped in the panel and nothing else announces
the failure. */}
{error?.scope === "create-order" && <p className="error" role="alert">{error.text}</p>}
<div className="dialog-foot">
<button type="button" className="link" onClick={() => setCreatingOrder(false)}>{tc("cancel")}</button>
<button type="button" className="link" onClick={closeNewOrder}>{tc("cancel")}</button>
<BusyButton disabled={busy || !customerId} busy={isPending("create-order")}
onClick={onCreateOrder}>{t("newDraftOrder")}</BusyButton>
</div>
Expand Down Expand Up @@ -733,7 +780,7 @@ export function SalesPage() {
</div>
)}

<Dialog open={paying} title={t("recordPayment")} onClose={() => setPaying(false)}>
<Dialog open={paying} title={t("recordPayment")} onClose={closePayment}>
<div className="form-grid">
<label>{t("date")}
<input type="date" value={payDate} max={today}
Expand All @@ -760,10 +807,12 @@ export function SalesPage() {
<input value={payNote} maxLength={500}
onChange={(e) => setPayNote(e.target.value)} />
</label>
{/* An open dialog renders its own copy of the error. */}
{error && !creatingOrder && !paying && <p className="error">{error}</p>}
{/* #474 — this dialog's own write only: see the new-order
dialog above. A void raised from the payments table can
land while this is open, and is not this form's failure. */}
{error?.scope === "record-payment" && <p className="error" role="alert">{error.text}</p>}
<div className="dialog-foot">
<button type="button" className="link" onClick={() => setPaying(false)}>{tc("cancel")}</button>
<button type="button" className="link" onClick={closePayment}>{tc("cancel")}</button>
<BusyButton disabled={busy || !payAmount} busy={isPending("record-payment")}
onClick={onRecordPayment}>
{t("recordPayment")}
Expand Down Expand Up @@ -793,8 +842,11 @@ export function SalesPage() {
</div>
)}

{/* An open dialog renders its own copy of the error. */}
{error && !creatingOrder && !paying && <p className="error">{error}</p>}
{/* The page's own copy, for everything not behind a dialog — and for a
failure that is nobody's dialog even while one is open, rather than
swallowing it. Suppressed only for the message the open dialog is
already showing, so it is never rendered twice (#474). */}
{error && !shownInDialog && <p className="error">{error.text}</p>}
{message && <p className="success">{message}</p>}

<h3>{t("ordersHeading")}</h3>
Expand Down
Loading