fix(uve): lock layout canvas during save to prevent container data loss (#36197) - #37244
fix(uve): lock layout canvas during save to prevent container data loss (#36197)#37244gortiz-dotcms wants to merge 10 commits into
Conversation
|
Claude finished @gortiz-dotcms's task in 2m 20s —— View job PR Review — canvas lock during layout saveReviewed the current branch state (10 commits) against Resolved
New Issues
Notes
Verdict: All four blocking findings from the prior review are resolved with real behavioral fixes and matching tests. The two remaining items are documented, accepted trade-offs with narrow impact. No new blocking issues. Note: this is a static review — I did not run the two Jest targets in the test plan. |
…ss (#36197) The GridStack canvas in the UVE Layout tab was never disabled while a save was in flight, allowing edits during the debounce window, the in-flight POST, and the post-save pageReload() re-hydration to be silently dropped when the server response overwrote local state. Changes: - Add `disabled` @input to TemplateBuilderComponent. When true, ngOnChanges calls grid.disable() + subgrid.disable() synchronously (before any render cycle) and dispatches keydown Escape to cancel any in-progress drag or close any open PrimeNG dropdown. - Add a visible overlay (semi-transparent white + backdrop-blur + PrimeNG ProgressSpinner) rendered after the grid content so it sits on top in DOM order, giving users clear saving feedback. - Add $isSaving computed signal in EditEmaLayoutComponent that reads uveStatus === LOADING and binds it to [disabled] on the template builder. - Move setUveStatus(LOADING) from the tap() before debounceTime into switchMap, so the canvas only locks when the POST is actually sent — not on every keystroke during the 5-second debounce window. - Guard nextTemplateUpdate() to discard templateChange events that arrive while uveStatus === LOADING, covering the edge case where an in-progress action (held drag, open dropdown) completes after the freeze activates. The lock spans the full lifecycle: POST fired → pageReload() re-fetch → updateOldRows() re-hydration → uveStatus reset to LOADED → canvas unlocked. Refs: #36197 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cover the disabled input, overlay rendering, grid API interactions, nextTemplateUpdate LOADING guard, and debounce-before-lock behaviour. Refs: #36197 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
MockDotRouterJestService is a singleton across the test suite; without clearing forbidRouteDeactivation before the guard assertion, accumulated calls from prior tests cause a false failure. Refs: #36197 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
a4f41f5 to
7dbb9b8
Compare
rjvelazco
left a comment
There was a problem hiding this comment.
Review — canvas lock during layout save
Thanks for the clear write-up in the description; the root-cause analysis is correct and made this reviewable. Full-file review of both components plus the UVE store features, cross-checked against the pinned gridstack@8.4.0 and primeng@21.1.3 sources in node_modules.
Verdict: needs changes before merge. Requirement 2 (don't lock during the debounce) is precisely implemented, but requirement 3 (cancel in-progress interactions) is unmet in a way that reproduces the original bug, and coupling the lock to uveStatus leaves it unlockable by unrelated store writers.
What's well done
- The diagnosis is right. The debounce → POST → re-hydration window is exactly where the data loss lives.
- Requirement 2 is met precisely. Moving
setUveStatus(LOADING)out of the pre-debounceTimetapand into theswitchMapis the minimal correct change. ngOnChangesordering is correct.updateOldRows(layout branch) runs before the grid is re-enabled, so re-hydration happens under the lock.- The store cooperates.
withPageApi.ts:340-348patchesuveStatus: LOADEDandsetPageAssetin one synchronous tap, solayoutanddisabledreachngOnChangesin a single CD pass — no interim "unlocked with stale layout" frame. - Defence in depth is sound.
updateOldRowsalready setsshouldEmit: false(template-builder.store.ts:434-439); the newnextTemplateUpdateguard is a correct second barrier. - Overlay placement is correct. It sits outside the
@if (vm$ | async)block, and:host { @apply relative }gives it the right containing block — so it covers the toolbar (the external drag-in source), not just the canvas. - Backward compatible.
disabled = falseleaves the other consumer (apps/dotcms-ui/.../dot-template-builder.component.html) untouched. - Test hygiene is generally good —
byTestId,spectator.setInput, the realUVEStorerather than a stub,fakeAsync/tickwith an explicit flush comment.
Blocking (details inline)
| # | Where | Issue |
|---|---|---|
| 1 | template-builder.component.ts:296-303 |
🔴 The Escape dispatch is a no-op — GridStack 8.4.0 has no keyboard handling (added in v10+). What actually ends the drag is grid.disable(), and it commits the move instead of cancelling it → the row moves locally, the emit is dropped by the new guard, and updateOldRows merges over it. Same divergence class the PR fixes. |
| 2 | edit-ema-layout.component.ts:57 |
🟠 uveStatus is a shared flag — withWorkflow.ts:179 (page lock/unlock, reachable from the shell banner on this route) can patch LOADED mid-POST and unlock the canvas early. |
| 3 | edit-ema-layout.component.ts (removed tap) |
🟠 The deleted line was gating the Page Properties nav item (dot-ema-shell.component.ts:188). That gate is now gone for the whole 5s pending-save window. |
| 4 | edit-ema-layout.component.ts:148-150 |
🟠 No catchError in the switchMap — one HTTP error kills updateTemplate$ for the component's lifetime, so requirement 4 advertises a retry that silently no-ops. |
Issues 2 and 3 both dissolve if the lock gets its own component-local signal instead of borrowing uveStatus — one flag serving two purposes is the pattern that produced the original bug.
Also worth addressing
- Keyboard bypasses the overlay — no
inert/aria-busy; tab order still reaches the toolbar and box controls. - Escape closes PrimeNG dialogs — the dispatch's one real effect, and unintended: it discards an in-progress "Add style classes" edit.
- Test interdependence in the new layout specs (shared router mock, worked around with an inline
mockClear()) — a Critical 🔴 perdocs/frontend/TESTING_REVIEW_RULES.md. - Missing coverage for unlock-on-ERROR and for the lock surviving
pageReload()— the requirement most central to the fix. - Minor: init race when
disabledis alreadytruebefore the grid exists; hardcodedaria-label(needs| dm);bg-white/50is light-theme-only.
Note on @Input() over input()
docs/frontend/ANGULAR_STANDARDS.md:51 prefers signal inputs, but this is a justified deviation — the JSDoc correctly explains the state must apply synchronously before render, which a post-CD effect() can't do, and the component's four existing inputs are all decorators. Consistent locally, no change requested. (An @Input() set disabled(v: boolean) setter would express the intent more directly than the ngOnChanges branch, if you want it.)
Follow-up (not this PR)
apps/dotcms-ui/.../dot-template-builder is the other consumer of this component and has the same debounce-save shape without a lock. It won't regress from this PR, but it carries the same bug — worth a ticket.
Note: review is static — the two Jest targets in the test plan were not run.
Suppresses GridStack store writes (suppressStoreUpdates flag) during the synchronous cancel window triggered by applyGridDisabled(true), so that the forced mouseup commit and the subsequent grid.load() restore do not corrupt the Angular store. Adds preDragGrid tracking so column drags restore the correct subgrid instead of the main grid. Removes orphaned placeholder widgets from subgrids (toolbar Box drop) and the main grid (toolbar Row drop) when cancelled mid-flight. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The canvas was unlocking the moment the POST returned 200, before pageReload() finished its re-fetch. During that gap the store guard was open and user edits would be merged over by updateOldRows(). Moves the unlock to a #handleReloadComplete effect that fires only when uveStatus reaches LOADED — i.e. after the full reload cycle. Also: - aria-label on the saving spinner now goes through | dm - applyGridDisabled() is called at the end of setUpGridStack() so a grid that initialises while disabled=true cannot boot enabled behind the overlay - Documents why the manual subgrid querySelectorAll loop is needed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pageReload() sets uveStatus=ERROR (not LOADED) when the re-fetch fails. #handleReloadComplete now clears the in-flight flag on either LOADED or ERROR so a failed reload can never leave the canvas permanently locked. Documents the known workflow-lock LOADED race and the Escape/DynamicDialog limitation so future reviewers understand the trade-offs. Documents why lastTemplate is intentionally not updated when an edit is dropped mid-flight. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…te field template-builder: z-[9999] → z-9999 (better-tailwindcss/enforce-canonical-classes) edit-ema-layout: #handleReloadComplete → $handleReloadComplete to match the $handleCanEditLayout convention and avoid no-unused-private-class-members Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ation and wrong injector in spec Two test failures fixed: 1. portlets-edit-ema-portlet: jest.clearAllMocks() does not reset mockReturnValue/mockImplementation overrides, so the error test's throwError() persisted into the next tests, triggering the error path and clearing #layoutSaveInFlight prematurely. Fix: explicitly reset dotPageLayoutService.save to the default implementation in beforeEach after injecting the service. 2. template-builder: setSubGridEvent describe block injected DotTemplateBuilderStore without `true`, getting the TestBed root instance instead of the component-scoped instance (the component declares providers: [DotTemplateBuilderStore]). Spying on the wrong instance made subGridOnDropped appear uncalled. Fix: use spectator.inject(DotTemplateBuilderStore, true). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Fixes #36197 — UVE Layout tab data loss caused by the GridStack canvas never being locked while a layout save is in flight.
Root cause
Every layout change emits
templateChange→updateTemplate$→ 5-second debounce →POST /api/v1/page/{id}/layout. The canvas remained fully interactive during the entire debounce window, the in-flight POST, and thepageReload()+updateOldRows()re-hydration cycle. Any edit made in that window was silently discarded when the server response overwrote local state. Because the backend performs a destructive diff (deletes any container absent from the incoming payload), mid-flight edits could permanently remove content.Changes
TemplateBuilderComponent(template-builder.component.ts/.html)@Input() disabled = false— new input that locks the canvas.applyGridDisabled(disabled)— called synchronously fromngOnChanges(before any render cycle):grid.disable()/grid.enable()on the main grid and every subgrid — blocks drag, resize, and external drop at the GridStack event level.keydown Escapeondocument— closes any open PrimeNG dropdowns (container pickers, etc.) that are appended to<body>outside the[inert]subtree.document mouseupto terminate it.suppressStoreUpdatesis set for the synchronous cancel window so that GridStack's forced commit events (and the subsequentgrid.load()restore) do not write to the store. Placeholder widgets committed by the cancelled drag are removed viaremoveWidgetbefore the lock completes.preDragGridtracking — records the specific GridStack instance (main grid or subgrid) that owns the dragged widget. Row drags restore the main grid; column/box drags restore only the affected subgrid via its ownsave(false)snapshot.suppressStoreUpdatesflag — silencesmoveRow,addRow,updateColumnGridStackData, andsubGridOnDroppedcalls inside GridStack event handlers during the cancel window, keeping the store consistent with pre-drag state.@if (disabled)block rendered after the grid content withbg-white/50 backdrop-blur-[2px]and a centeredp-progress-spinner.[attr.inert]on both the toolbar and the grid container blocks all keyboard/pointer interaction.EditEmaLayoutComponent(edit-ema-layout.component.ts/.html)#layoutSaveInFlight = signal(false)— replaces thecomputed(() => uveStore.uveStatus() === LOADING)approach. Decouples the canvas-disabled state from the global UVE status so that unrelated LOADING events (workflow lock toggles, page-api re-fetches viawithWorkflow.ts) cannot unlock the canvas mid-POST or trigger a spurious spinner.nextTemplateUpdateguard — returns early when#layoutSaveInFlightis true, dropping anytemplateChangeevents that fire after the canvas is frozen (in-progress drags, open dropdown selections).tapbeforedebounceTime— gates the Page Properties nav item immediately on any edit (setUveStatus(LOADING)) so users cannot navigate to page settings while layout changes are pending, while the actual canvas lock (#layoutSaveInFlight) only engages when the POST is actually sent.catchError→EMPTY— keeps theswitchMapsubscription alive on HTTP error so a failed save doesn't permanently disable autosave.Lock lifecycle
Test plan
uveStatusset toERROR, user can retrypnpm nx test portlets-edit-ema-portlet --testPathPattern=edit-ema-layoutpnpm nx test template-builder --testPathPattern=template-builder.componentGrabacion.de.pantalla.2026-08-24.a.la.s.5.41.44.p.m.mov
🤖 Generated with Claude Code