Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion 2.x/admin/extending/addons.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Two packages are published to npm with each tagged Lunar release, and an add-on
},
"devDependencies": {
"@inertiajs/vue3": "^2.0.0",
"@lunarphp/panel": "^0.1.0",
"@lunarphp/panel": "^0.2.0",
"@lunarphp/panel-vite-plugin": "^0.1.0",
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0",
Expand Down Expand Up @@ -123,6 +123,7 @@ export default defineConfig({
```ts
import WidgetsIndexPage from './pages/Widgets/Index.vue';
import InfoBannerComponent from './components/InfoBanner.vue';
import LoyaltyCardComponent from './components/LoyaltyCard.vue';

// Register eagerly. The panel's frontend entry publishes window.LunarPanel and
// is emitted before any add-on script, so it is always present here.
Expand All @@ -132,6 +133,7 @@ window.LunarPanel.registerPages({

window.LunarPanel.registerComponents('example-addon', {
InfoBanner: InfoBannerComponent,
LoyaltyCard: LoyaltyCardComponent,
});
```

Expand Down
8 changes: 7 additions & 1 deletion 2.x/admin/extending/edit-drafts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ On a draft-backed edit page (Customers, Discounts, and other first-party record

Two staff members editing *different* fields on the same record never see a conflict, in either save order. Only the same field, edited by both, triggers resolution.

## Adding to a draft the add-on does not own

Registering a `DraftableResource` is for a record the add-on owns outright. To add fields to a first-party form, such as a loyalty tier on the customer pages, register a [form slice](/2.x/admin/extending/form-slices) instead. On a drafted edit page a slice joins the record's draft under a namespace of its own and gets the same autosave, conflict detection, and atomic commit; on a create or settings form it posts with the form. Either way it cannot touch the record's own fields.

## Opting a resource in

Drafting is not automatic per model — a `Section` registers a `DraftableResource` definition describing exactly which fields it drafts and how they read and write. Return definitions from `Section::draftables()`:
Expand Down Expand Up @@ -201,13 +205,15 @@ const form = useEditDraft({
`useEditDraft` exposes:

- `values` — reactive form state, the pristine record overlaid with any restored draft.
- `isDirty`, `saving`, `committing`, `savedAt`, `hasDraft`, `restoredFrom` — status for the autosave indicator and the restored-draft banner.
- `isDirty`, `dirtyKeys`, `saving`, `committing`, `savedAt`, `hasDraft`, `restoredFrom` — status for the autosave indicator and the restored-draft banner.
- `errors` — validation errors from a failed commit.
- `conflicts` — the per-field conflict set from a 409 response.
- `commit()` — sends the current diff immediately (not waiting out the autosave debounce) and, on success, reloads the page so the server's session flash message shows.
- `resolve(resolutions, rebase)` — re-commits after the staff member resolves conflicts, pinning each resolved field's `rebase` value to the current database value they were shown (so a further change to the same field between resolving and re-committing conflicts again, rather than being silently overwritten).
- `discard()` — reverts local values to pristine and deletes the draft.

`initial` is merged with the page's shared `formSliceValues` prop, so any [form slices](/2.x/admin/extending/form-slices) registered on the record seed the form automatically. Pass `slices: false` on a page that drafts some record other than the route's deepest binding.

Autosave watches `values` with a debounce (roughly 750ms by default), sends only the changed fields, and serialises requests so a slower, older response can never clobber a newer one. A diff that empties back out (the user undid their own change) triggers a `DELETE` instead of a `PATCH`.

Two ready-made components pair with the composable:
Expand Down
260 changes: 260 additions & 0 deletions 2.x/admin/extending/form-slices.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
---
title: "Form Slices"
sidebarTitle: "Form Slices"
description: "How an add-on adds its own fields to a first-party form's save, on a drafted edit page or a plain create or settings form."
---

A form slice is a namespaced contribution to a first-party form's save: an add-on declares fields, rules, and a commit for a record it does not own, and the panel composes them into that form under a namespace of the add-on's own.

## The stance on extending first-party forms

A [slot](/2.x/admin/extending/slots) can put an add-on's component on the customer or product page, but on its own that component cannot take part in the page's save. Anything staff type into it is lost on navigation, cannot be committed alongside the record, and gets none of the validation or conflict detection the form gives first-party fields.

A slice fixes that without opening first-party forms to modification. The panel's stance is one sentence: an add-on can add fields to any first-party form's save, under a namespace it owns, and can never read, hide, disable, relax, or replace a field the panel owns. The kind of form decides the plumbing, not the contract. On a drafted edit page the add-on's fields also autosave, restore, and conflict-check; on a plain create or settings form they post with it. Either way, add-ons add and only the host subtracts, so two add-ons installed together cannot break each other.

## The key scheme

Every form field has a key. A slice's fields live under `{namespace}:{field}`:

- First-party slices, registered by the panel's own sections, own a bare namespace such as `attribute` or `channel`.
- Slices registered by an add-on through `Section::formExtensions()` are placed under `addon:{key}`, where `{key}` is the slice's `key()`. Their full keys are `addon:{key}:{field}`.

The slice never sees the prefix. It declares `tier`, the panel stores `addon:example-addon:tier`, and the slice's `commit()` receives `tier` again. Because prefixing happens in the composer rather than in the slice, there is no API through which a slice could name a key outside its namespace. The `addon` namespace is reserved and cannot be claimed as a bare namespace.

## Registering a slice

Return slice classes from a `Section` or `SectionExtension`'s `formExtensions()` hook:

```php
use LunarPanelExample\Drafts\LoyaltyTierSlice;

public function formExtensions(): array
{
return [LoyaltyTierSlice::class];
}
```

`Lunar\Panel\PanelManager` resolves each class from the container and indexes it by the model it declares, then by namespace. Registration rejects a malformed key (it must match `[a-z0-9_-]+`), a first-party slice claiming `addon`, and a second class claiming a namespace another class already holds on the same model. Each throws an `InvalidArgumentException` at boot naming the classes involved, so a clash between two add-ons surfaces immediately rather than as a silent overwrite.

<Info>
`formSlices()` is the sibling hook the panel's own sections use to register slices under a bare namespace. It exists on `Section` and `SectionExtension` too, but an add-on should use `formExtensions()`. Nothing stops a service provider calling the first-party hook, since it runs with the same privileges as the panel's own; the public hook simply does not offer a bare namespace.
</Info>

## The `FormSlice` contract

```php
namespace Lunar\Panel\Contracts;

interface FormSlice
{
/** @return class-string<Model> */
public function model(): string;

public function key(): string;

/** @return array<int, string> */
public function fields(Model $record): array;

/** @return array<string, mixed> */
public function currentValues(Model $record): array;

/** @return array<string, mixed> */
public function normalize(array $data): array;

/** @return array<string, mixed> */
public function rules(Model $record): array;

public function commit(Model $record, array $values): void;

/** @return array<string, string> */
public function labels(): array;
}
```

Extend the abstract `Lunar\Panel\Forms\FormSlice` rather than implementing the interface directly. It supplies a passthrough `normalize()` and an empty `labels()`.

| Method | Purpose |
|:-------|:--------|
| `model()` | The Eloquent model whose forms this slice joins. |
| `key()` | The namespace, `[a-z0-9_-]+`, unique per model. An add-on's key is placed under `addon:`. |
| `fields(Model $record)` | The bare field names for this record. Takes the record because a row-shaped slice derives its field set from data (which channels exist, which currencies are enabled). On a create form the record is a fresh, unsaved instance. |
| `currentValues(Model $record)` | The current stored value of every field, keyed by bare name and normalized the same way `normalize()` shapes incoming data. Seeds the form, and on a drafted page is the baseline conflict detection compares against. |
| `normalize(array $data)` | Shapes incoming values so equality against `currentValues()` holds. Receives only this slice's keys. |
| `rules(Model $record)` | Validation rules keyed by bare field, `.*` entries included. The composer prefixes the keys; rule parameters pass through verbatim. |
| `commit(Model $record, array $values)` | Persists the slice's values: every field present, current values overlaid with the submitted ones, unprefixed. Runs after the form's own action inside the same transaction, so a slice that throws rolls the record's changes back too. Delegate to a core action. |
| `labels()` | Bare field to lang key, for the conflict dialog and validation messages. |

The slice persists its own values because it is the only thing that knows where its data lives. The panel never writes an add-on's storage, and a post-save hook outside the transaction would leave ordering and atomicity to chance. `commit()` is that hook, typed, ordered after the record's own commit, and inside the same transaction.

<Tip>
A rule parameter that names one of the slice's own fields needs the full key, because the validator sees the composed payload. The abstract class provides `$this->field('other')` for exactly that, returning `addon:{key}:other` for an add-on slice.
</Tip>

### `DraftSlice` for draft-only state

`Lunar\Panel\Contracts\DraftSlice extends FormSlice` adds one method, `discard(Model $record, EditDraft $draft)`, called when a draft holding the slice's keys is discarded, pruned, or orphaned by its record's deletion. Only a slice that keeps state outside the draft's JSON columns (staged uploads, for instance) needs it. Extend the abstract `Lunar\Panel\Drafts\DraftSlice` to get a no-op default. A plain `FormSlice` composes into a draft unchanged, so most slices never need this.

### Worked example

`LoyaltyTierSlice` in `lunarphp/panel-addon-example` adds a loyalty tier to the customer form. The tier is kept in the customer's `meta` column so the example stays schema-free; a real add-on would persist to its own table through its own action.

```php
namespace LunarPanelExample\Drafts;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
use Lunar\Core\Contracts\Actions\Customers\UpdatesCustomer;
use Lunar\Core\Models\Customer;
use Lunar\Panel\Forms\FormSlice;

class LoyaltyTierSlice extends FormSlice
{
public const TIERS = ['bronze', 'silver', 'gold'];

public function __construct(protected UpdatesCustomer $updatesCustomer) {}

public function model(): string
{
return Customer::class;
}

public function key(): string
{
return 'example-addon';
}

public function fields(Model $record): array
{
return ['tier'];
}

public function currentValues(Model $record): array
{
/** @var Customer $record */
return ['tier' => $record->meta['loyalty_tier'] ?? null];
}

public function normalize(array $data): array
{
// The select submits '' for "no tier"; the stored value is null.
if (array_key_exists('tier', $data) && $data['tier'] === '') {
$data['tier'] = null;
}

return $data;
}

public function rules(Model $record): array
{
return ['tier' => ['nullable', Rule::in(self::TIERS)]];
}

public function commit(Model $record, array $values): void
{
/** @var Customer $record */
$meta = $record->meta?->getArrayCopy() ?? [];
$meta['loyalty_tier'] = $values['tier'] ?? null;

$this->updatesCustomer->execute($record, ['meta' => $meta]);
}

public function labels(): array
{
return ['tier' => 'example-addon::example.loyalty_tier'];
}
}
```

The customer form now carries `addon:example-addon:tier`, on the edit page and the create page alike. Nothing in the slice knows that.

## The JS side

The add-on edits its slice from a component placed on the page through a [slot](/2.x/admin/extending/slots). `useFormSlice` and `usePanelForm` ship in `@lunarphp/panel` 0.2.0 and later, so an add-on that uses them depends on `"@lunarphp/panel": "^0.2.0"`. Every zone on a form page sits inside the page's form, so the component binds with `useFormSlice`, exported from `@lunarphp/panel`, passing the slice's `key()`:

```vue
<script setup lang="ts">
import { FieldLabel, Select, useFormSlice } from '@lunarphp/panel';

const slice = useFormSlice<{ tier: string | null }>('example-addon');
</script>

<template>
<FieldLabel for="loyalty-tier">Loyalty tier</FieldLabel>
<Select id="loyalty-tier" v-model="slice.values.tier" :invalid="!!slice.errors.value.tier">
<option :value="null">No tier</option>
<option value="bronze">Bronze</option>
<option value="silver">Silver</option>
<option value="gold">Gold</option>
</Select>
<p v-if="slice.errors.value.tier">{{ slice.errors.value.tier }}</p>
</template>
```

The composable resolves the key to the `addon:example-addon:` namespace on the page's form, finds that form above it in the component tree, and returns a view scoped to the namespace. It throws a descriptive error when called outside a page that hosts slices, such as a listing page.

`useFormSlice` returns:

| Property | Purpose |
|:---------|:--------|
| `values` | A reactive object holding only this slice's fields, by bare name. `v-model="slice.values.tier"` reads and writes `addon:example-addon:tier` on the page's form. Writing to a field the slice did not declare throws. |
| `errors` | Validation errors for this namespace, keyed by bare field. |
| `field(name)` | A writable ref for one field, for components that prefer a ref to a property path. |
| `isDirty` | Whether any of the namespace's fields differ from their pristine value. Changes elsewhere on the page leave it `false`. |
| `saving` / `committing` | The page form's status refs, passed through for a local indicator. |

The composable never exposes the underlying form, so a component has no path to another namespace's keys or to the record's own fields. The server enforces the same boundary independently: a request carrying `example-addon:tier` without the `addon:` prefix is rejected as an unknown field on a drafted page and ignored by the slice composer on a plain one.

## Drafted edit pages

On the first-party edit pages (customers, products, brands, collections, product types, variants) the form is an [edit draft](/2.x/admin/extending/edit-drafts). Every registered slice is seeded up front from the shared `formSliceValues` prop, and its fields behave exactly like the record's own:

- **Autosave and restore.** Values persist as staff type and come back when they return to the record, with the same restored-draft banner and **Discard** action.
- **Dirty guard.** Leaving the page with an unsaved tier prompts first.
- **Conflict detection.** If another staff member changes the tier before this draft commits, the page's conflict dialog lists it under the label from `labels()`, with keep-mine and take-theirs choices, and nothing commits until it is resolved.
- **Validation.** A failed rule maps to `errors.tier` on the bound component, and the whole commit is refused.
- **Atomic commit.** Every slice's `commit()` runs after the record's own inside one transaction. If one throws, the record's changes roll back.

## Plain create and settings forms

The create pages and the settings edit forms post a plain Inertia form and redirect. They compose the same slices, with three differences that follow from a plain form carrying only what it posts:

- **Binding claims the namespace.** A slice's keys join the form only when a component binds to it with `useFormSlice`. A page nobody extends posts exactly what it did before.
- **Only submitted namespaces commit.** The slice's `commit()` runs when the request holds at least one of its keys, with the slice's current values overlaid by the submitted ones. Absent namespaces are untouched.
- **Slice rules apply when present.** Each rule is composed under `sometimes`, so an unbound namespace cannot fail validation. Within a bound namespace the slice's rules apply in full, `required` included, and errors surface in the same round as the form's own.

On a create page, `fields()`, `currentValues()`, and `rules()` see a fresh, unsaved instance of the model, and `commit()` receives the record the store action created, inside its transaction. Nothing autosaves or conflict-checks, and `discard()` never runs.

The settings index pages' inline create dialogs expose no slot zone, so there is nothing to bind there; the roles form, the auth and account forms, and the order view are not composed.

<Info>
Inertia's form data uses dot paths on plain forms, so a slice field named `a.b` would nest. Keep field names free of dots; first-party fields have none.
</Info>

## How values reach the page

`Lunar\Panel\Http\Middleware\HandlePanelInertiaRequests` shares a `formSliceValues` prop on every panel response: the prefixed current values of every slice on the page's record, or an empty object when the page has no record or the model has no slices. The record is the deepest model bound to the route, so a product variant's edit page seeds the variant's slices rather than the product's. Create pages bind no record, so their controllers pass the prop for a fresh instance.

`useEditDraft` merges the prop into its `initial` values automatically (opt out with `slices: false` on a page that drafts some record other than the route's deepest binding). `usePanelForm`, which the plain-form pages use in place of Inertia's `useForm`, seeds a namespace from it on first bind. Both are exported from `@lunarphp/panel`, so an add-on's own pages can host slices the same way.

## First-party slices

The panel uses the same contract for its own sub-surfaces on the product edit page, registered through `formSlices()` under bare namespaces:

| Slice | Namespace | Fields |
|:------|:----------|:-------|
| `ProductAttributeSlice` | `attribute` | One per mapped attribute handle. |
| `ProductChannelSlice` | `channel` | One per channel id, each an availability row. |
| `ProductCustomerGroupSlice` | `customer_group` | One per customer group id, each an availability row with the purchasable flag. |
| `SoleVariantSlice` | `variant` | The sole variant's fields on a simple-shape product, refused outright once the product has several variants. |

These are the proof that the add-on surface is complete: a first-party card and an add-on's slot component take the same path.

## Stale keys

A stored draft can carry keys whose slice is no longer registered, for example after an add-on is uninstalled or a channel is deleted. Such keys are dropped when the draft commits rather than blocking it, so a record never becomes unsavable because of a package that is no longer there.

## See also

- [Edit Drafts](/2.x/admin/extending/edit-drafts) — the draft layer a slice composes into on the edit pages, and how to draft a resource the add-on owns outright.
- [Slots](/2.x/admin/extending/slots) — placing the component that edits the slice.
- [Testing an add-on](/2.x/admin/extending/testing) — `tests/panel/Feature/ExampleAddonTest.php` in the monorepo exercises the loyalty tier through the real customer routes: autosave, conflict, and commit on the edit page, and the plain store on the create page.
Loading