Repository navigation
fix(builder): extend the unsaved-changes guard to the layout tab - #58
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #55, which guarded the schema builder's structure tab. The layout tab was still unguarded.
Problem
The route-level
useBlockercompared the field draft againstsavedSnapshot, which only describes the structure tab. The layout tab rendersCardLayoutBuilder, 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
CardLayoutBuildergains an optionalonDirtyChangeprop; the route ORs that into the existing blocker, so both tabs share one guard and oneUnsavedChangesModal. No new i18n keys — the existingunsaved.*set covers it.snapshotLayoutserializes exactly whathandleSavepersists (columnCount,columns,hiddenLabels) and normalizeshiddenLabelsto the ids genuinely hidden, so toggling a label on and back off — which leaves an explicitfalse— 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:
columnCountdo not register as user edits;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
columnsOfreplaces 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.
CardLayoutBuilderunmounts 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 staletrue. 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.tsxcover 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 inbuilder-unsaved-guard.test.tsxcovers the wiring end to end — switch to the layout tab, edit, and assertshouldBlockFn()andenableBeforeUnload()both flip totrue. That last one was confirmed to fail when theonDirtyChangewiring is removed.Full client suite passes (69 tests, 17 files);
tsc -b, oxlint, and Prettier clean.🤖 Generated with Claude Code