Skip to content

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

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

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

⊙ 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.

Stack: stage ← #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 once 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.

  • src/features/clusters/upsert/index.tsx: UpsertCluster reads the stored draft only when its clusterId matches the route's, and wraps the setter it hands ClusterForm so a clear goes through only for such a draft. ClusterForm clears the draft when it mounts and when it saves, so without that wrapper, reading a draft as absent would still have deleted it.

⚠️ 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.

✅ 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.
  • npx vitest run on those three files plus ProcessSetupIntent.test.tsx: 23 passed, exit 0.
  • Mutation checks, run against the new tests: 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.
  • Pre-commit hook: full vitest suite 400 files, 3697 passed, 11 skipped. npx tsc -b, npx oxlint --format stylish ., npx dprint check all exit 0.
  • Cross-model review, two full rounds: 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. Kept the two inline comments both legs called narration: one says why an unlisted cluster's draft falls through on purpose, the other why the clear must read the stored draft, which a test pins.

🤖 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)
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 @ 7f8e4c2

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

…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>

@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

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 69.66% 10440 / 14987
🔵 Statements 69.77% 11134 / 15957
🔵 Functions 62.94% 2698 / 4286
🔵 Branches 64.91% 8007 / 12335
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/clusters/ClustersList.tsx 97.14% 94.59% 93.75% 96.77% 139
src/features/clusters/upsert/draftClusterIdOf.ts 100% 100% 100% 100%
src/features/clusters/upsert/index.tsx 74.07% 61.36% 72.22% 76.31% 89-92, 107, 114-124, 153, 190, 192-195, 201-210, 214, 226, 256-259, 264-276, 281-293
src/features/organization/billing/confirm/ProcessSetupIntent.tsx 69.23% 68.42% 100% 69.23% 38, 49-50, 60-72
Generated in workflow #2121 for commit 7f8e4c2 by the Vitest Coverage Report Action

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.

1 participant