Skip to content

fix(frontend): keep the bulk modal open and retry only failed buckets - #1390

Merged
IAmJSD merged 9 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-757
Oct 25, 2026
Merged

IAmJSD merged 9 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-757

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

If some buckets of a multi-bucket (fan-out) bulk purchase failed, the dialog closed and the selection was cleared anyway, and the only record was a toast that cut the reasons off after 3 and the labels after 5. Retrying meant finding and reselecting the failed rows in a large table, at the risk of resubmitting buckets that had already gone through. This PR keeps the modal open with per-bucket status and the full reason, and turns the submit button into "Retry N failed", which resends only the failed buckets.

Closes #757

What changed

  • New frontend/src/fanout-retry.ts:
    • showFanOutRetry() marks each bucket section "Submitted: awaiting approval [from X]", "Submitted: joined existing request ; " or "Failed: "; dims submitted buckets and locks their payment select; adds a role="alert" banner ("N buckets submitted, M failed. Submitted buckets will not be sent again, and closing this dialog does not cancel them..."); and narrows the fan-out state to the failed buckets.
    • It does this only while #purchase-modal is visible and still holds the submitted bucket ids. If the user closed the dialog (Escape or Cancel) while the POSTs were in flight, the result is reported by toast exactly as on main. purchaseModalReplaced() detects that the user has already opened a newer purchase modal, so late results leave it alone.
    • fanOutRetryLabel() turns the submit button into "Retry N failed".
  • frontend/src/app.ts: submitFanOutBuckets builds per-bucket outcomes with the same success rule as the toast. On any failure, if the submitting modal is still open, it keeps the modal open (it used to call closePurchaseModal/clearFanOutBuckets first) and skips the sticky failure toast. The #1503 payment-adjustment toast uses the default timeout instead of sticking in that case, so it doesn't sit on the Retry button. The Archera offer is held back while the modal is in retry mode and fires when a retry with no failures closes it. If a newer purchase modal is open when the results arrive, it is not closed or cleared, and the offer is skipped. handleFanOutExecute restores the retry label. A fully successful retry closes the modal as before.
  • frontend/src/recommendations.ts: FanOutBucket gets an optional id, bucket-<open token>-<index>, which is unique per modal open (since getFanOutBuckets() returns copies, and so results from an earlier open can't match a later one). Sections get data-bucket-id, and a new retainFanOutBuckets(ids) narrows state and refreshes the modal header.
  • frontend/src/styles/modals.css: status, lock and banner styles.
  • Tests: new __tests__/app-fanout-retry.test.ts and tests-e2e/fanout-partial-failure.spec.ts, plus a case in __tests__/purchase-modal-submit.test.ts.

Why this approach

Narrowing the existing module-level fan-out state reuses the whole submit path (confirm dialog, double-submit guard, payment handling, same POST body), so a retry can't drift from a normal submit and structurally can't resend a bucket that went through. Matching on a bucket id rather than an index is needed because getFanOutBuckets() hands out copies and a retry submits a subset. The id includes the existing purchase-modal open token, so POSTs that settle after the dialog was closed and reopened can't mark or narrow the new one. The id is optional: callers without ids fall back to the old close-and-toast path, so existing flows and tests are unchanged. The sticky failure toast is dropped in retry mode because the e2e run showed it covering the Retry button, and the modal already shows the untruncated detail. Retry mode needs the dialog to be visible, so a user who closed it never loses the toast. The dialog also stays open when every bucket failed, since the same retry is needed there.

Screenshots

Before is main at a14dc8f2. Main has since added account labels to the failure toast (#1352) and combined totals to the confirm (#1362), but it still closes the dialog and leaves only that toast. After is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API, with 3 buckets (AWS/Azure/GCP) where the Azure POST returns 502.

Before After
partial-failure before partial-failure after
(new behavior, no before) retry-button after
  • After Azure fails (1 of 3 buckets): on main the dialog has closed and only a truncated toast remains, with the Archera offer stacked on top. After, the dialog stays open with a banner (2 submitted, 1 failed); AWS and GCP show Submitted and are locked, and Azure shows Failed with the full reason.
  • Scrolled to the footer: the submit button reads "Retry 1 failed" and resends only the Azure bucket.

Verification

Run on this branch's head e2858b94, which includes main 10f2c8af:

  • Lint: 0 errors (141 pre-existing warnings). Typecheck and build pass.

  • Jest (--maxWorkers=2): 166 suites, 4385 passed, 1 skipped, 0 failed. New app-fanout-retry.test.ts has 9 cases:

    • the dialog stays open with status, locks, banner and label, and no failure toast;
    • a retry posts only the failed buckets, then closes and opens Archera;
    • a second partial failure keeps the earlier successes counted;
    • when every bucket fails, the dialog stays open;
    • id-less buckets keep the old behavior;
    • failure reason text can't inject markup;
    • closing the dialog mid-submit, then one POST failing, shows the account-labelled "2 of 3 submitted · 1 failed: ..." toast and puts no banner in the hidden dialog;
    • closing and opening a new bulk dialog before the old POSTs settle leaves the new one untouched (not marked, narrowed, closed or cleared);
    • the payment-adjustment toast isn't sticky in retry mode.

    purchase-modal-submit.test.ts adds a case for the real modal: two opens get disjoint bucket ids, and the rendered sections carry the current ones. The last three app cases and the id case fail on the previous head and pass now.

  • Playwright via e2e-local.sh: 260 passed. The new fanout-partial-failure.spec.ts runs the real UI: 3 providers, Azure fails once, and "Retry 1 failed" posts only Azure and closes the dialog.

  • git-secrets scan clean (planted key flagged); test-git-secrets-allowlist.sh: 137 pass, 0 fail.

Notes for review

Behavior changes to confirm:

  • On any bucket failure the dialog stays open, including when every bucket failed.
  • The sticky "N of M submitted · K failed: ..." toast isn't shown in that case, because the dialog has the full per-bucket detail and the toast covered the Retry button. It still appears, as on main, when the dialog can't show retry: the user closed it while the POSTs were in flight, or the buckets have no ids.
  • After a partial failure, the Archera insurance offer appears only when a retry with no failures closes the dialog. If the user closes it with Cancel instead, or every retry fails, the offer never appears, even though some buckets were submitted. It is also skipped when a newer purchase dialog is open by the time the results arrive.

Copy to approve: the banner text, the "Submitted: ..." / "Failed: ..." lines and the "Retry N failed" button.

Known rough edges:

  • After narrowing, the modal header reads Bulk purchase — 1 bucket (it describes what a retry will send) while all sections stay visible.
  • The Cancel button keeps its label; the banner says closing doesn't cancel submitted buckets.
  • Changing the payment on a failed bucket re-renders that section without its Failed line or class; it is still retryable.
  • Buckets the modal had already marked "will be skipped" are not part of the retry set, so fixing their payment in retry mode does nothing.

Overlaps

The overlapping work has all landed on main and is merged into this branch: #711 (#1364, monthly and term totals), #707 (#1362, combined-totals confirm label), #434 (#1352, account-labelled failure toast), #741 (#1378, modals.css) and #215 (#1340, an import next to closeModal). To match them, ea0a590f updates the e2e confirm label for #1362, and fa3d6bc2 switches the bucket status weight to var(--cudly-fw-semibold) for #1378.

Note for the UI owner

This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 24, 2026 00:27
…ure and retry only failed buckets

When some buckets of a multi-bucket ("fan-out") purchase failed, the modal
closed and the selection was cleared before the outcome was classified, and
the toast truncated the reasons (3) and labels (5). Users had to find and
reselect the failed rows in a large table, at the risk of resubmitting the
buckets that had already gone through.

On any bucket failure the modal now stays open:
- each bucket section shows "Submitted: awaiting approval [from X]" /
  "Submitted: joined existing request ..." or "Failed: <full reason>";
- submitted buckets are dimmed and their payment selects are locked;
- a banner (role=alert) states how many were submitted and failed, that
  submitted buckets will not be sent again, and that closing the dialog
  does not cancel them;
- the fan-out state is narrowed to the failed buckets, so the submit
  button becomes "Retry N failed" and resubmits only those. A failed
  bucket's payment can still be changed before retrying.
The sticky failure toast is skipped in this mode (the modal has the full
detail, and the toast covered the Retry button), and the Archera offer
waits until the purchase modal closes. A fully successful retry closes
the modal as before.

Buckets get a stable id (copies from getFanOutBuckets() lose identity)
and sections carry data-bucket-id so results map back to their section.
Callers without ids keep the previous close-and-toast behavior.

Refs LeanerCloud#757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jest: the modal stays open with per-bucket status, submitted buckets are
locked, the button reads "Retry N failed", a retry posts only the failed
buckets and closes the modal (and opens the Archera offer) once all
succeed, a second partial failure keeps earlier successes counted, an
all-failed run also stays open, buckets without ids keep the old
behavior, and failure reasons cannot inject markup.
Playwright: three providers, Azure fails once, the dialog shows the
failure, Retry 1 failed posts only the Azure bucket and closes.

Refs LeanerCloud#757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/bug Defect labels Oct 24, 2026
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

IAmJSD and others added 6 commits October 24, 2026 17:50
Conflicts:
- frontend/src/app.ts: both sides added an import after the closeModal
  import (this branch's ./fanout-retry, main's ./init-error from LeanerCloud#1338).
  Kept both.

The submitFanOutBuckets/handleFanOutExecute retry changes merged cleanly
with main's LeanerCloud#1352 account-labelled bucket names and LeanerCloud#1364 commitment
lines; the toast those feed is still used when retry mode does not apply.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/app.ts: import block; kept this branch's ./fanout-retry
  import and main's ./purchasing-nav import (LeanerCloud#1336).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts:
- frontend/src/app.ts: import block. Kept this branch's fanout-retry
  import (showFanOutRetry, fanOutRetryLabel, FanOutBucketOutcome) and
  main's setupMobileNav import from nav/mobile-nav (LeanerCloud#1340).

handleFanOutExecute merged cleanly: main's combined bulk-approval
totals and upfront-naming confirm label (LeanerCloud#1362) now sit alongside this
branch's retry-label restore in the finally block. A retry passes only
the narrowed failed buckets, so the confirm totals cover just the
requests being resent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…etry e2e

Main's LeanerCloud#1362 renamed the fan-out confirm button from "Send all for
approval" to "Send for Approval ($X upfront on approval)", naming the
combined upfront of the buckets being sent. The partial-failure spec
still clicked the old label and timed out. It now matches the new
label with the amount as a pattern, since the first send and the retry
carry different totals.

Refs LeanerCloud#757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts resolved:
- frontend/src/app.ts: import block only. Kept this branch's
  fanout-retry import and main's wireTablistKeyboard (LeanerCloud#730/LeanerCloud#1371) and
  setOpenAccountsHandler (LeanerCloud#1389) imports; the code using each merged
  cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tatus

Main's typography foundation (LeanerCloud#1378) forbids literal font weights in
modals.css that duplicate a token. The fan-out status line used a
literal 600; use var(--cudly-fw-semibold) instead, same weight.

Refs LeanerCloud#757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): REQUEST_CHANGES fa3d6bc2dff64d4c7e30e9365f8cdc101c51fd87

Blocking:

  • frontend/src/fanout-retry.ts:57 (+ app.ts:742/802, recommendations.ts:4587): showFanOutRetry only checks that #purchase-details still contains section.fanout-bucket. It does not check that the purchase modal is open or that these are still the same buckets. closeModal only adds .hidden and leaves #purchase-details populated, and nothing blocks Cancel/Escape while POSTs are in flight. Failure: the user clicks Send, then presses Escape (or Cancel) during "Sending 0/3…", and one POST fails. showFanOutRetry returns true, writes the banner into the hidden modal, and retainFanOutBuckets is a no-op because onClose already cleared the state. retrying=true therefore suppresses the failure toast, and failed>0 suppresses the success toast. The user gets no notification at all that a purchase bucket failed; main shows "2 of 3 submitted · 1 failed: ...". I reproduced this in a scratch jest test: showToast was never called and the banner text sat in the detached modal. A related case: ids are per-index ("bucket-0".."bucket-N") and reused on every open. If the user closes and opens a new bulk modal before the old POSTs settle, the stale results mark the new modal's sections Submitted/locked and narrow its buckets to the old failed ids. Fix: return false unless #purchase-modal is open (not .hidden) and getFanOutBuckets() is non-null with the submitted ids. Make ids unique per open, e.g. prefix with purchaseModalOpenToken. Add a test for close-mid-submit.
    Non-blocking:
  • app.ts submitFanOutBuckets: the #1503 payment-adjustment notice is still a sticky (timeout:null) toast in retry mode. That is the "sticky toast covering the Retry button" problem the PR cites for dropping the failure toast.
  • Archera offer: after a partial failure, if the user closes via Cancel instead of retrying (or the retry fails entirely), the offer never appears, even though buckets were submitted.
  • Changing payment on a failed bucket re-renders the section without the Failed line and class. Skipped buckets drop out of currentFanOutBuckets, so fixing their payment in retry mode silently does nothing. Both are disclosed as rough edges.
    Attacked: Read origin/main...HEAD in full and traced handleFanOutExecute/submitFanOutBuckets against main's fix(frontend): name the account in the fan-out awaiting-approval note #1352 account-labelled toast, fix(frontend): show totals in the bulk approval confirm #1362 combined-totals confirm label, fix(frontend): show the monthly charge and term total before purchase #1364 monthly/term totals and fix(frontend): Escape leaves stale purchase buckets for the next modal submission #331 onClose clearing. The latest merge 315d1a0 checked against both parents is import-only (fanout-retry + main's wireTablistKeyboard/setOpenAccountsHandler), and nothing from main is dropped. Follow-ups: ea0a590 correctly matches the new "Send for Approval ($X upfront on approval)" label, and fa3d6bc swaps 600 for var(--cudly-fw-semibold). All CSS tokens exist in base.css. Bucket reasons are set with textContent, so there is no XSS. Scratch jest (app-fanout-retry + app tests): 55 passed. The close-mid-submit repro test fails as described.

…al is open

showFanOutRetry only checked that #purchase-details still held fan-out
sections. Closing the modal (Escape or Cancel) while the POSTs were in
flight leaves those sections in the hidden modal, so a failed bucket put
its banner there and suppressed the failure toast: the user got no
notice at all. Bucket ids were also per-index and reused on every open,
so results settling after the user opened a new bulk modal marked its
sections submitted and narrowed its buckets to the old failed ids.

Bucket ids now carry the purchase-modal open token, and retry mode is
entered only when #purchase-modal is visible and still holds the
submitted bucket ids. Otherwise the toasts fire as on main. If a newer
purchase modal is open, it is not closed or cleared. The #1503
payment-adjustment toast also uses the default timeout in retry mode
instead of staying on top of the Retry button.

Refs LeanerCloud#757

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

On the blocking re-review finding above: fixed in e2858b94.

  • When retry mode applies: only while #purchase-modal is visible and still holds the submitted bucket ids (modalHoldsBuckets()). Otherwise results are reported exactly as on main. Closing mid-submit now shows the account-labelled "2 of 3 submitted · 1 failed: ..." toast, and no banner goes into the hidden modal.
  • Stale results: bucket ids now include the purchase-modal open token (bucket-<token>-<index>). Results that settle after the user closed and reopened a bulk modal can no longer mark, narrow, close or clear the new one. The Archera offer is also skipped while a newer modal is open.
  • Non-blocking item, also fixed: the #1503 payment-adjustment toast is non-sticky in retry mode, so it no longer covers the Retry button.
  • Tests: 4 new jest cases cover close-mid-submit, reopen before the old POSTs settle, the payment-adjustment timeout, and unique ids across two real opens. All 4 fail on fa3d6bc2 and pass now.
  • Checks at e2858b94: jest 4385 passed, Playwright 260 passed, and lint, typecheck, build and git-secrets all pass.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE e2858b94d29482e1fa8612a2bab2756c9ccadc61

Blocking:

  • none (the previous blocker is fixed. showFanOutRetry now goes through modalHoldsBuckets(): #purchase-modal must not be .hidden, and getFanOutBuckets() must still hold every submitted id. Ids are now bucket--. If the user closes the dialog mid-submit, the "N of M submitted · K failed" toast and the Archera offer come back. If they reopen before the old POSTs settle, purchaseModalReplaced() leaves the new modal open and keeps its state.)
    Non-blocking:
  • frontend/src/app.ts:663 (handleFanOutExecute confirm-cancel branch): in retry mode, clicking "Retry 1 failed" and then cancelling the confirm dialog resets the button to executeButtonLabel(false) ("Send for Approval"). The narrowed retry state and banner stay, so clicking it again still sends only the failed buckets, but the label now suggests a full resend. Reproduced in a scratch jest case: expected "Retry 1 failed", received "Send for Approval". Fix: use fanOutRetryLabel() ?? executeButtonLabel(false) there too, as the finally block does.
  • Interaction with main fix(frontend): fix stale copy, pause toasts and name History confirms #1369 (b42245c, not yet merged into this branch, merges cleanly): toasts now pause their countdown while hovered or focused. If the non-sticky payment-adjustment toast covers the Retry button, a pointer moving toward Retry over it keeps it alive. This is minor because the fix(frontend): ladder config asks for a raw account UUID and hides auto-approve impact #742 toast layout already moves the stack aside from open dialogs.
  • Rough edges already disclosed and still present: the Archera offer is lost on Cancel or when every retry fails; a payment change re-renders a failed section without its Failed line; skipped buckets drop out of the retry set.
    Attacked: Read origin/main...HEAD in full (7 files) and traced submitFanOutBuckets/handleFanOutExecute through every case: close mid-submit, reopen as fan-out or single-row during the old POSTs (replaced=true, new state not cleared), a retry's second partial failure, all buckets failing, id-less buckets, and payment changes being blocked while submitting is true (ids stay, since b is mutated in place). Reasons are set with textContent, so no XSS; there are no inline styles and every CSS token exists. All four merges (30b9621, 6ac3080, bc23447, 315d1a0) are import-only in app.ts per git show --cc. HEAD differs from each merge's main parent only in the branch's own 6 files, so nothing from main was dropped. Follow-ups ea0a590, fa3d6bc and e2858b9 were reviewed. Scratch jest at HEAD: app-fanout-retry, purchase-modal-submit, app, typography and css all pass (173). Scratch merge with current main b42245c: tsc clean, and app-fanout-retry, purchase-modal-submit, app, toast and recommendations all pass (528).

@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at e2858b94 merged onto current main (b42245cc; merge-tree 9e2dbe30; Linux, Node, headless Chromium):

  • This branch's own checks passed at this head: lint, typecheck, build, the full jest suite, the full Playwright e2e suite, and the git-secrets checks (see Verification in the PR body).
  • The combined tree of every UI PR in this merge wave (tree 9e2dbe30, main plus 1 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4411 passed, 4412 total), and the full Playwright suite (265 passed).
  • CI is green on e2858b94 (CI Success, Run pre-commit hooks).
  • The independent review above approves this exact head.
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 25, 2026 00:02
@IAmJSD
IAmJSD merged commit 23b0cb0 into LeanerCloud:main Oct 25, 2026
29 checks passed
@IAmJSD

IAmJSD commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at e2858b94.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(frontend): partial bulk purchase failure closes the modal, no retry of failed buckets

1 participant