Skip to content

Panel draft slices: namespaced contributions to first-party edit drafts (spec 0086) - #2741

Open
glennjacobs wants to merge 5 commits into
2.xfrom
spec/0086-panel-draft-slices
Open

glennjacobs wants to merge 5 commits into
2.xfrom
spec/0086-panel-draft-slices

Conversation

@glennjacobs

@glennjacobs glennjacobs commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

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, ProductDraftResource already 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; ComposedDraftResource composes it under {namespace}:{field} keys the slice never sees. First-party sections register slices under a bare namespace through Section::formSlices(); add-ons register through Section::formExtensions() and land under the reserved addon:{key} namespace. Add-ons add; only the host subtracts.

  • Server: Lunar\Panel\Contracts\FormSlice and DraftSlice extends FormSlice (the draft-only discard()), with abstracts Forms\FormSlice and Drafts\DraftSlice; Drafts\ComposedDraftResource; PanelManager::formSlice() with duplicate, reserved and malformed namespace rejection; lazy composition in draftableFor(); discard fan-out through the EditDraft deleting event (EditDraft moves from MassPrunable to Prunable, record-deleted cleanup deletes per model); stored keys the resource no longer declares are dropped at commit instead of blocking it.
  • Seeding: shared formSliceValues Inertia prop, resolved from the deepest route-bound record (the variant on a variant page, not the product). The product page's attributeValues/availabilityValues/variantValues props go away.
  • Client: useEditDraft merges the shared prop (slices: false opts out), exposes dirtyKeys, and provides itself; new useFormSlice(namespace) / useAddonDraftSlice(key), the latter exported to add-ons as useFormSlice on @lunarphp/panel.
  • Product migration: ProductAttributeSlice, ProductChannelSlice, ProductCustomerGroupSlice, SoleVariantSlice replace the hand-rolled surfaces. No key changes; the existing product draft tests pass unchanged. AvailabilitySchema gains an optional per-side selector and pivotRows().
  • Example add-on: LoyaltyTierSlice on Customer (stored in meta, so the example stays schema-free) and LoyaltyCard.vue in customers.edit:main:after, a README guide section, en/fr strings, 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

  • Documentation: lunarphp/docs#57 documents the contract, the hooks, useFormSlice, and the worked example for add-on authors. Merge it after this stack lands.
  • Stack: bottom of stack #2743. #2742 (spec 0088) sits on top and composes the same slices into the create pages and settings forms.
  • Review thread: the contract was split into FormSlice / DraftSlice and 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)

  • Stale-key pruning lives in DraftManager::commit() rather than the composer, since only the manager holds the stored draft. Amended spec section: "Server: stale slice keys".
  • Discard fan-out is event-driven (EditDraft::deleting) so the discard endpoint, pruning, and record deletion all reach it; commit deletes quietly. Amended: "Server: registration and composition".
  • Slice field names are free-form (the sole-variant slice nests attribute:{handle}), validated only on the namespace.

Public surface

  • New: FormSlice and DraftSlice contracts and abstracts, Section::formSlices()/formExtensions(), the key schemes, formSliceValues, useFormSlice and DraftSlice type, the slices option and dirtyKeys on useEditDraft.
  • ProvidesNavigation gains two methods. Section and SectionExtension supply defaults; a consumer implementing the interface directly would need to add them (pre-release).
  • No database changes. No new panel lang keys; the example add-on's en/fr groups gain the loyalty strings.

Verification

  • vendor/bin/pest --testsuite panel --parallel: 957 passed
  • vendor/bin/phpstan analyse: no errors; vendor/bin/pint: clean
  • npm test (350 passed), npm run type-check, npm run build in packages/panel; example add-on builds through the workspace
  • Not verified in a browser: the host app on this machine is wired to the bundles branch and does not boot on 2.x.

Follow-ups

  • Spec 0087 implementation (associations, slugs, prices, media as slices; "applies immediately" marker), one PR per slice after this merges.

🤖 Generated with Claude Code

glennjacobs and others added 2 commits September 16, 2026 10:31
…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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

glennjacobs and others added 2 commits September 16, 2026 21:31
…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>
@glennjacobs
glennjacobs added this pull request to stack #2743 September 16, 2026 21:28
use Lunar\Panel\Forms\FormSlice;
use Lunar\Panel\Models\EditDraft;

abstract class DraftSlice extends FormSlice implements DraftSliceContract

@wychoong wychoong Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants