Panel draft slices: namespaced contributions to first-party edit drafts (spec 0086) - #2741
glennjacobs wants to merge 5 commits into
Conversation
…dit drafts (spec 0086)
Introduces the DraftSlice contract and ComposedDraftResource so a namespaced
surface (first-party or add-on) declares its fields, rules, labels and commit in
its own terms and the panel composes it into the resource's draft under
`{namespace}:{field}`. Sections register first-party slices via draftSlices();
add-ons via draftExtensions(), which lands them under the reserved `addon:{key}`
namespace. The product's attribute, availability and sole-variant surfaces move
onto the contract with no key changes.
- Server: DraftSlice contract/abstract, ComposedDraftResource, PanelManager
registration with duplicate/reserved/malformed namespace rejection, lazy
composition in draftableFor(), discard fan-out through the EditDraft deleting
event (EditDraft is now Prunable), stale stored keys dropped at commit.
- Seeding: shared `draftSliceValues` Inertia prop resolved from the deepest
route-bound record; the product page's hand-built value props go away.
- Client: useEditDraft merges the shared prop (opt-out via `slices: false`),
exposes dirtyKeys and provides itself; new useDraftSlice / useAddonDraftSlice
composable exported to add-ons as `useDraftSlice`.
- Example add-on: LoyaltyTierSlice + LoyaltyCard in customers.edit:main:after,
README guide section, end-to-end tests against the real customer routes.
- Spec 0087 (product editing through the draft) included for review; its
implementation follows separately.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| * register through Section::draftSlices() and own a bare namespace; add-ons | ||
| * register through Section::draftExtensions() and land under `addon:{key}`. | ||
| */ | ||
| interface DraftSlice |
There was a problem hiding this comment.
what I understand is DraftSlice also handle persisting/save, it feels odd. and this assumes any resource from lunar will always be draftable. when not draftable but to allow save hook, would need to introduce a new interface that duplicate rules/commit/model etc
There was a problem hiding this comment.
Thanks. Two things here.
On persistence: a slice commits its own values by design, the same way a DraftableResource commits through core actions. The panel cannot know where an add-on's data lives, and the alternative is a post-save hook that hands the add-on a payload outside the transaction with ordering left to chance, which the spec rejects for that reason. commit() is that hook, typed, ordered after the record's own commit, and inside the same transaction. The docblock on the contract now says so.
On forms without a draft: you are right that a second contract would be the wrong outcome, so the contract is now split rather than duplicated. FormSlice carries everything a form needs (model, key, fields, currentValues, normalize, rules, commit, labels); DraftSlice extends FormSlice adds only the draft-specific discard(). The hooks and composable are renamed to match (Section::formSlices() / formExtensions(), useFormSlice). This PR wires the drafted edit pages; a stacked follow-up (spec 0088) composes the same slices into the create pages and settings forms, so one slice class serves both. The stance in one line: an add-on adds fields under a namespace it owns, on any first-party form's save, and never touches the form's own fields.
…urface after forms Records the panel's stance on extending first-party forms: an add-on adds fields under a namespace it owns, on any form's save, and never touches the form's own fields. The form kind decides the plumbing, not the contract, so the contract is now FormSlice (model, key, fields, currentValues, normalize, rules, commit, labels) with DraftSlice extending it for the draft-only discard() hook. Nothing behavioural changes; a plain FormSlice composes into a draft unchanged. Renames the form-agnostic parts so the follow-up for plain forms (spec 0088) adds capability without renaming anything add-on authors have used: Section::formSlices() / formExtensions(), PanelManager::formSlice(), the formSliceValues shared prop, and useFormSlice on the client, which now injects a SliceForm shape any page form can provide. The commit() docblock explains why a slice persists its own values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
useFormSlice and the FormSlice type are new public surface on the package, so the drift check asks for a version the registry has not seen. The example add-on's range follows so the workspace keeps linking the local package. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| use Lunar\Panel\Forms\FormSlice; | ||
| use Lunar\Panel\Models\EditDraft; | ||
|
|
||
| abstract class DraftSlice extends FormSlice implements DraftSliceContract |
There was a problem hiding this comment.
what do you think about not having DraftSlice abstract and a different name for the contract?
my thoughts are for example we create a model Bundle (not in direct relation with the bundle PR you have)
then we would have a form thats BundleForm
when we want a simple persists only form it would read nicely class BundleForm extends FormSlice
but when we also want it to be draftable class BundleForm extends DraftSlice feels less clear
would prefer something along class BundleForm extends FormSlice implements Draftable
Draftable (or maybe HasDraft) is same as the current Contracts\DraftSlice, which already have the discard interface/contract
while in current shape class BundleForm extends FormSlice implements Contracts\DraftSlice can provide the same context
it might gives 2 ways to implement same thing on userland, and if lunar to do reflections on class, it would be 2 path as well
Summary
Implements spec 0086 and lands spec 0087 for review (its implementation follows separately once this merges).
The problem. Add-ons can put a component on a first-party edit page, but it cannot take part in the save: nothing it collects autosaves, restores, conflict-checks, or commits with the record. The pressure this creates is lunar#2736's ask for a field-level hook into first-party forms, which is the wrong shape for the panel: once an add-on can reach into a form it does not own, two add-ons installed together can break each other. Separately,
ProductDraftResourcealready hand-rolled three namespaced surfaces (attribute:,channel:/customer_group:,variant:) with prefix checks in every method.The change. A draft slice is a registered, namespaced contribution to a resource's edit draft. It declares fields, current values, rules, labels and commit in its own unprefixed terms;
ComposedDraftResourcecomposes it under{namespace}:{field}keys the slice never sees. First-party sections register slices under a bare namespace throughSection::formSlices(); add-ons register throughSection::formExtensions()and land under the reservedaddon:{key}namespace. Add-ons add; only the host subtracts.Lunar\Panel\Contracts\FormSliceandDraftSlice extends FormSlice(the draft-onlydiscard()), with abstractsForms\FormSliceandDrafts\DraftSlice;Drafts\ComposedDraftResource;PanelManager::formSlice()with duplicate, reserved and malformed namespace rejection; lazy composition indraftableFor(); discard fan-out through theEditDraftdeletingevent (EditDraftmoves fromMassPrunabletoPrunable, record-deleted cleanup deletes per model); stored keys the resource no longer declares are dropped at commit instead of blocking it.formSliceValuesInertia prop, resolved from the deepest route-bound record (the variant on a variant page, not the product). The product page'sattributeValues/availabilityValues/variantValuesprops go away.useEditDraftmerges the shared prop (slices: falseopts out), exposesdirtyKeys, and provides itself; newuseFormSlice(namespace)/useAddonDraftSlice(key), the latter exported to add-ons asuseFormSliceon@lunarphp/panel.ProductAttributeSlice,ProductChannelSlice,ProductCustomerGroupSlice,SoleVariantSlicereplace the hand-rolled surfaces. No key changes; the existing product draft tests pass unchanged.AvailabilitySchemagains an optional per-side selector andpivotRows().LoyaltyTierSliceonCustomer(stored inmeta, so the example stays schema-free) andLoyaltyCard.vueincustomers.edit:main:after, a README guide section,en/frstrings, and end-to-end tests through the real customer draft routes (autosave, conflict with the add-on's label, rebased commit in one transaction, validation under the prefixed key).Related
useFormSlice, and the worked example for add-on authors. Merge it after this stack lands.FormSlice/DraftSliceand the surface renamed in response to wychoong's comment; details in the reply there.Deliberate deviations from the spec text (spec amended in this PR)
DraftManager::commit()rather than the composer, since only the manager holds the stored draft. Amended spec section: "Server: stale slice keys".EditDraft::deleting) so the discard endpoint, pruning, and record deletion all reach it; commit deletes quietly. Amended: "Server: registration and composition".attribute:{handle}), validated only on the namespace.Public surface
FormSliceandDraftSlicecontracts and abstracts,Section::formSlices()/formExtensions(), the key schemes,formSliceValues,useFormSliceandDraftSlicetype, theslicesoption anddirtyKeysonuseEditDraft.ProvidesNavigationgains two methods.SectionandSectionExtensionsupply defaults; a consumer implementing the interface directly would need to add them (pre-release).en/frgroups gain the loyalty strings.Verification
vendor/bin/pest --testsuite panel --parallel: 957 passedvendor/bin/phpstan analyse: no errors;vendor/bin/pint: cleannpm test(350 passed),npm run type-check,npm run buildinpackages/panel; example add-on builds through the workspace2.x.Follow-ups
🤖 Generated with Claude Code