Skip to content

fix(clusters): a saved edit draft reopens its cluster's edit form, not a new-cluster form - #1870

Open
dawsontoth wants to merge 1 commit into
claude/1326-new-cluster-back-buttonfrom
claude/1852-edit-draft-reopens-edit
Open

dawsontoth wants to merge 1 commit into
claude/1326-new-cluster-back-buttonfrom
claude/1852-edit-draft-reopens-edit

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

A fox reopening her own treehouse sketch, not a blank plan

⊙ Problem

A cluster draft saved in local storage makes the clusters list redirect to the cluster form, and the list sent every draft to /<org>/new-cluster. A draft saved while editing a cluster carries clusterId, so landing on the list with one reopened the edit as a create form, and the billing step offered Create New Cluster for what was an edit of an existing cluster (#1852). The billing return page (ProcessSetupIntent) already sent an edit draft back to ../../<clusterId>/edit. Tracing it further, the form itself applied whatever draft was saved, on any cluster form: the new-cluster page, another cluster's edit page, or the create form an empty organization's list renders inline. #1780 changes how a draft's regions are read (it migrates name-keyed entries to region ids) but not which form reads the draft, so all of this still holds on top of it.

Stack: #1780 ← #1842 ← #1849 ← #1851 ← this PR. It is based on #1851's branch (claude/1326-new-cluster-back-button), which made this redirect replace its history entry and deliberately left the destination alone. Retarget it as the stack lands.

❓ Your call: the issue asks for the list redirect; I also changed which draft the form reads and clears, because the list was only one of four ways to reach a form holding someone else's draft. The alternative is the list fix alone, which leaves the new-cluster page and an empty organization's inline create form still taking an edit draft as a create. Dropping the form half is a revert of one hunk.

💡 Solution

A draft now belongs to the form it was saved from. An edit draft (one with clusterId) sends the list to that cluster's edit form; a new-cluster draft still goes to new-cluster. The form reads a draft only when its clusterId matches the route's, and lets its own clears through only for such a draft, so visiting another form neither fills it from the draft nor discards the draft. The list, the billing return page and the form all decide this through one helper.

🔧 Changes

❓ Your call: an edit draft for a cluster this organization doesn't list (another organization's, or one that is gone) now leaves the list where it is and keeps the draft, so the organization it came from still resumes it. The alternatives were to clear it here, which loses the other organization's draft, or to redirect anyway, which opens a cluster under the wrong organization's path. Both are a one-line change.

⚠️ Look hardest: the wrapper decides from the draft stored when the clear runs (a functional state update), not from the render's copy. The billing step saves a draft and clears it again after await stripe.confirmSetup(…), using the callback from the render before the save. A render-time check would see no draft there and skip the clear, leaving a stale draft that redirects the list on the next visit.

❓ Your call: because storage holds one draft, a draft kept for another form now survives until that form opens or a new draft replaces it. Before, any cluster form consumed it, wrongly. Submitting a different cluster's form no longer clears it either.

On 2026-10-13 the stack was rebased again with #1780 onto stage, and this PR's commit replayed unchanged. At b1721c1, npx tsc -b exit 0, npx vitest run src/features/clusters src/features/organization/billing 236 passed, and the full suite passed (5,552 tests, exit 0).

The rebase onto #1780 was mechanical for the source: the commit replayed without conflicts, and the branch no longer carries the merge commits it picked up from #1851. Only the new index.test.tsx harness needed adapting to #1780's UpsertCluster, which also loads the organization's custom regions, asks useStaffPermission, and refuses to open an edit whose region it can't resolve. The test now mocks the first two and gives the edit route's cluster a region in the mocked catalog.

✅ Verification

End-to-end route: component tests through the real ClustersList and UpsertCluster, with routing, queries, storage and the form mocked at their boundaries. Not browser-verified: reproducing it needs a Stripe payment method whose confirmation redirects to an external page, and port 5173 is shared by parallel sessions here.

  • src/features/clusters/ClustersList.test.tsx: an edit draft for a listed cluster renders Replace /org-a/Staging/edit; an edit draft for an unlisted cluster renders the list and no redirect. The existing new-cluster-draft test is unchanged.
  • src/features/clusters/upsert/index.test.tsx (new): the form mock clears on mount like ClusterForm and replays the billing save-then-clear from its first setter. Matching edit draft: applied, opens on billing, cleared. Edit draft on the new-cluster route or another cluster's edit route: not applied and still stored. New-cluster draft and a failed cluster tried again: applied and cleared. Billing save-then-clear from a stale render: cleared.
  • src/features/clusters/upsert/draftClusterIdOf.test.ts (new): an edit draft names its cluster; no draft, a new-cluster draft, a tried-again Cluster, a createCluster string, an empty id and a non-string id name none.
  • Mutation checks, re-run on the rebased code: restoring the base ClustersList fails both list tests; dropping only the organization check fails the unlisted-cluster test; reading every draft in UpsertCluster fails both not-applied tests; letting every clear through fails both still-stored tests; deciding the clear from the render's copy fails the billing test.
  • Full gate on this branch, the top of the stack: npx vitest run 403 files, 3731 passed, 11 skipped; npx tsc -b, npx oxlint --format stylish . and npx dprint check all exit 0.
  • Cross-model review, two full rounds before the rebase: codex (graded) and gemini ran both. The Cursor legs failed both rounds because the 1Password SSH agent refused their git fetch. The Harper domain adjudicator failed both rounds on an expired Claude OAuth session, so findings were triaged by hand. Round 1 found a real defect, adopted above: a draft the form ignored was still deleted by ClusterForm's mount clear, including another organization's draft through an empty organization's inline create form. Round 2 confirmed that fixed. Rejected on evidence: round 2's "the clear can erase a newer draft another tab wrote" is how useLocalStorage already works. Each hook instance keeps its own copy and writes it back, by design (src/hooks/useLocalStorage.ts:8, "does NOT pub-sub"). This change only ever skips a write, so it can't widen that race. No new round after the rebase: this PR's source replayed unchanged, and only test mocks moved.
  • gemini-code-assist left no comments on this PR.

🤖 Generated by Anthropic Claude Code (Claude Opus 5.5); posted via @dawsontoth.

🤖 Generated with Claude Code

Related PRs: #1851 overlaps (this PR's base; it made the same redirect replace its history entry), #1780 overlaps (base of the stack; it changes how UpsertCluster reads a draft's regions)
Complexity: medium

Review-Coverage: authored=claude; ran=gemini,codex; blocked=cursor-composer(no-receipt),domain(auth),cursor-muse(no-receipt); declined=cursor-grok,cursor-kimi; rounds=2; full=1 @ 01b94c5

Review-Attention: study ~9m (raised: degraded review, open major) @ 01b94c5

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a mechanism to ensure that saved cluster drafts are correctly associated with and reopened in the specific form they were saved from (either a new cluster form or a specific cluster's edit form). It introduces a helper function draftClusterIdOf to extract the cluster ID from the saved state, which is then used in ClustersList, UpsertCluster, and the billing confirmation flow (ProcessSetupIntent) to handle routing and draft clearance correctly. Extensive unit and integration tests have been added to verify these behaviors. There are no review comments to address, and I have no additional feedback to provide.

@dawsontoth
dawsontoth added this pull request to stack #1907 October 11, 2026 21:26
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 79.88% 14852 / 18592
🔵 Statements 79.8% 15760 / 19747
🔵 Functions 75.15% 3957 / 5265
🔵 Branches 74.68% 11698 / 15663
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/clusters/ClustersList.tsx 97.29% 90.24% 94.11% 97.14% 152
src/features/clusters/upsert/draftClusterIdOf.ts 100% 100% 100% 100%
src/features/clusters/upsert/index.tsx 79.78% 60.13% 80% 80.68% 108-111, 126, 133-143, 177, 229-238, 255, 291-294, 302-323, 328-340, 345-357
src/features/organization/billing/confirm/ProcessSetupIntent.tsx 69.23% 68.42% 100% 69.23% 38, 49-50, 60-72
Generated in workflow #2354 for commit b1721c1 by the Vitest Coverage Report Action

…t a new-cluster form

A draft saved while editing carries clusterId, but the clusters list sent every draft to new-cluster, where the form took it as a create and the billing step offered Create New Cluster for an edit. The list now sends an edit draft to that cluster's edit form, as the billing return page already did, and leaves an edit draft for a cluster this organization doesn't list alone. All three read the draft's cluster through one helper.

The form now reads a draft only when its clusterId matches the route's, and clears only such a draft, so an edit draft never fills a create form and is never discarded by a visit to another form, including the create form an empty organization's list shows. The clear checks the stored draft at the moment it runs, because the billing step saves a draft and clears it again across an await.

Closes #1852

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/1852-edit-draft-reopens-edit branch from 01b94c5 to b1721c1 Compare October 14, 2026 01:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants