Skip to content

fix(builder): extend the unsaved-changes guard to the layout tab - #58

Merged
Morialkar merged 1 commit into
mainfrom
claude/vigilant-cray-bfffa8
Aug 1, 2026
Merged

Morialkar merged 1 commit into
mainfrom
claude/vigilant-cray-bfffa8

Conversation

@Morialkar

Copy link
Copy Markdown
Owner

Follow-up to #55, which guarded the schema builder's structure tab. The layout tab was still unguarded.

Problem

The route-level useBlocker compared the field draft against savedSnapshot, which only describes the structure tab. The layout tab renders CardLayoutBuilder, which keeps its own working layout and persists it through an explicit Save (PUT /views/{id}). Unsaved layout edits — reordered fields, moved columns, changed column count, toggled labels — were still discarded silently on navigation.

Approach

CardLayoutBuilder gains an optional onDirtyChange prop; the route ORs that into the existing blocker, so both tabs share one guard and one UnsavedChangesModal. No new i18n keys — the existing unsaved.* set covers it.

snapshotLayout serializes exactly what handleSave persists (columnCount, columns, hiddenLabels) and normalizes hiddenLabels to the ids genuinely hidden, so toggling a label on and back off — which leaves an explicit false — still reads as clean. Unassigned fields are excluded, since their order is not part of the saved config.

The baseline is rebased wherever the saved layout changes:

  • on load, from the same normalized data the working copy is built from, so fields dropped for no longer existing and columns padded/truncated to columnCount do not register as user edits;
  • on save, from the mutation payload rather than waiting for the refetch, so the guard releases the moment the save lands.

The signal resets on unmount: the working layout lives in the component, so once it is gone there is nothing left to warn about.

Two incidental cleanups fall out of this: the sync effect's three duplicated fallback branches collapse into one while computing the baseline, and columnsOf replaces the column-flattening logic that was repeated across the snapshot, handleSave, and the render path.

Known gap

This does not cover switching between the Structure and Layout tabs, which still discards layout edits silently. CardLayoutBuilder unmounts on tab switch and its state goes with it, so by the time any navigation happens there is nothing left to save and no blocker can intervene — which is also why the dirty signal resets on unmount rather than persisting a stale true. Closing it properly means lifting the layout state into the route (or keeping the component mounted and hidden); that felt like a larger change than this fix warranted, but it is the remaining hole in "no silent data loss" on this page.

Tests

Five cases in CardLayoutBuilder.test.tsx cover the dirty state directly: clean on load, dirty on field removal, dirty on column-count change, clean again after save, and clean on unmount. One case in builder-unsaved-guard.test.tsx covers the wiring end to end — switch to the layout tab, edit, and assert shouldBlockFn() and enableBeforeUnload() both flip to true. That last one was confirmed to fail when the onDirtyChange wiring is removed.

Full client suite passes (69 tests, 17 files); tsc -b, oxlint, and Prettier clean.

🤖 Generated with Claude Code

The route-level blocker only compared the field draft against its snapshot, so
it covered the structure tab alone. The layout tab renders CardLayoutBuilder,
which holds its own working layout and persists it through an explicit Save
(PUT /views/{id}); unsaved layout edits were still discarded silently on
navigation.

Lift a dirty signal out of CardLayoutBuilder through an optional onDirtyChange
prop and OR it into the same blocker, so both tabs share one guard and one
UnsavedChangesModal. snapshotLayout serializes exactly what handleSave persists
and normalizes hiddenLabels to the ids genuinely hidden, so toggling a label on
and back off — which leaves an explicit false — still reads as clean.

The baseline is rebased wherever the saved layout changes: on load, from the
same normalized data the working copy is built from, so fields dropped for no
longer existing and columns padded to columnCount do not register as edits; and
on save, from the mutation payload rather than the refetch, so the guard
releases as soon as the save lands. The signal resets on unmount, since the
working layout lives in the component and is already gone by then.

The sync effect's three duplicated fallback branches collapse into one while
computing that baseline, and columnsOf replaces the column-flattening repeated
across the snapshot, handleSave and the render path.

Note this does not cover switching between the two tabs, which still discards
layout edits: CardLayoutBuilder unmounts and takes its state with it, so no
blocker can intervene. Closing that needs the layout state lifted into the
route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Morialkar
Morialkar merged commit 467ff9f into main Aug 1, 2026
5 checks passed
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