Skip to content

Panel edit drafts: autosaved pending edits with field-level conflict detection (spec 0051)#2566

Merged
glennjacobs merged 7 commits into
2.xfrom
feature/0051-panel-edit-drafts
Jul 17, 2026
Merged

Panel edit drafts: autosaved pending edits with field-level conflict detection (spec 0051)#2566
glennjacobs merged 7 commits into
2.xfrom
feature/0051-panel-edit-drafts

Conversation

@glennjacobs

Copy link
Copy Markdown
Contributor

Summary

Implements spec 0051 (included in this PR): panel edit forms autosave in-progress changes to a per-staff edit_drafts row, and commits detect conflicts per field against a base snapshot captured when each field first entered the draft. Two staff editing different fields on the same record never collide; same-field collisions return a scoped 409 resolved through a keep-mine / take-theirs / manual-merge dialog. Commits are atomic (a conflict applies nothing), and a rebase protocol re-flags any field that moves again mid-resolution.

Wired into the customer details form; any resource opts in by registering a DraftableResource via the new Section::draftables() hook.

Backend

  • edit_drafts table (first panel-owned migration; precedent: stripe/opayo), EditDraft model — MassPrunable with a configurable 7-day TTL (lunar.panel.drafts.ttl_days), pruned daily, plus a factory.
  • DraftManager service behind a contract: wholesale-replace merge on autosave with snapshot capture, overlay-merge on commit, normalised JSON comparison, validation before conflict detection, atomic apply through core's UpdatesCustomer action.
  • Shared EditDraftController behind three per-resource routes (PATCH/DELETE …/draft, POST …/draft/commit), inheriting section can: middleware; the edit page ships a draft Inertia prop.

Frontend

  • useEditDraft composable: debounced diff autosave (serialised in-flight requests), commit/discard/resolve, 422/409 handling via a new native-fetch helper (lib/http.ts) with typed errors — the panel's sanctioned non-Inertia transport.
  • DraftActions: Shopify-style contextual save cluster (status text + Discard + Save) in the sticky breadcrumb bar, shown only while there is something to save; the "Draft saved" note self-clears after 10s.
  • DraftConflictDialog: per-field resolution with manual-merge input for scalar values.
  • Toaster + useToasts replace the inline FlashMessage bar: fixed bottom-centre stack handling success/error/info (errors sticky), bridging server flash props automatically. flash.info added to the shared middleware props.
  • New drafts.php lang group across all 16 locales; ui.ts / @lunarphp/panel export the new surface for add-ons.

Notes for review

  • New contract surface: DraftableResource/DraftManager contracts, Section::draftables() (also added to ProvidesNavigation — breaking only for direct interface implementors, none exist pre-release), the three endpoint shapes and 409/422 payloads, and the ui.ts exports. FlashMessage is removed from the add-on surface, replaced by Toaster/useToasts.
  • Soft-deleted staff: cascadeOnDelete on staff_id only fires on hard deletes; a soft-deleted staff member's drafts linger until the 7-day prune sweeps them — deliberate.
  • Deviations from the original requirements doc are argued inline in the spec: atomic commit rather than partial apply, staff_id ownership, native fetch over axios, POST commit.

Test plan

  • vendor/bin/pest --testsuite panel --parallel — 283 passed (29 new: conflict matrix, rebase re-commit, atomicity, normalisation, ownership, pruning, permissions)
  • npm test — 88 passed (31 new across http/composable/dialog/save-cluster/toaster)
  • npm run type-check && npm run build (+ example add-on workspace build)
  • vendor/bin/phpstan analyse / vendor/bin/pint
  • /lunar:pr-review run; the should-fix (missing factory) addressed in the final commit

🤖 Generated with Claude Code

glennjacobs and others added 7 commits July 17, 2026 12:25
…detection (spec 0051)

Adds an edit_drafts layer to lunarphp/panel: in-progress edits autosave to
a per-staff draft row capturing a base snapshot per touched field, and
commits detect conflicts per field against those snapshots, so staff
editing disjoint fields never collide. Conflicting commits return a 409
resolved through a keep-mine/take-theirs/manual-merge dialog with a rebase
protocol that re-flags fields that move again mid-resolution. Commits are
atomic. Wired into the customer details form; other resources opt in by
registering a DraftableResource via Section::draftables().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a standardised DraftActions component (autosave status, Discard,
Save) that renders in the Breadcrumbs #actions slot only while there is
something to save, Shopify-style. The customer edit page swaps its Docs
button, in-form save row, and restored-draft banner for the cluster; a
hidden submit keeps Enter-to-save working. Exported on ui.ts and the
@lunarphp/panel fallback for add-ons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The note is a confirmation, not a permanent label: it appears when a save
lands, clears after 10 seconds, and no longer shows stale on page load for
a restored draft. "Saving draft" still shows for the duration of a save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FlashMessage rendered where the page put it, so a scrolled viewport never
saw it. A new Toaster component renders a fixed bottom-centre stack
(teleported to body) fed by a useToasts() store handling success, error
and info tones - success/info auto-dismiss after 6s with pause-on-hover,
errors stay until closed. Toaster mounts once per shell (PanelLayout and
SettingsShell) and bridges the server flash props into the stack, deduped
by flash-object identity so layout swaps never double-toast; the
middleware now shares flash.info alongside success/error. Pages drop
their inline FlashMessage usage; the forgot-password confirmation stays
as a persistent inline note since it must remain readable. FlashMessage
is removed from the component set and the add-on surface, replaced by
Toaster and useToasts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pristine was a plain object, so re-baselining it after commit never
invalidated the cached isDirty computed - Discard/Save stayed visible
until the next keystroke. Make pristine reactive; test now reads isDirty
before committing so the stale-cache regression is covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The animation remount keyed off the points prop reference, so the full
prop refresh after a draft commit replayed it with identical data.
Compare by value: only actual data changes (e.g. a range switch) restart
the draw-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixture-style tests (pruning, cleanup) get a factory; behavioural tests
keep creating drafts through DraftManager so snapshot capture stays
exercised. First factory in the panel package, autoloaded from both the
monorepo root and the split package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-project-automation github-project-automation Bot moved this to Todo in Roadmap Jul 17, 2026
@glennjacobs
glennjacobs merged commit 39e2ca3 into 2.x Jul 17, 2026
23 checks passed
@glennjacobs
glennjacobs deleted the feature/0051-panel-edit-drafts branch July 17, 2026 13:56
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant