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
2 changes: 2 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting.

## Outstanding

- Panel draft slices — one contract for namespaced draft contributions, used by first-party surfaces and by add-on slot components alike (spec 0086)
- Product editing through the draft — associations, slugs, prices, and media join the product draft as slices; the few operations that stay immediate say so (spec 0087)
- Panel order screen improvements — stock visibility, activity pagination, timeline money events, address polish (spec 0069)
- Default professional customer notifications for the order lifecycle (spec 0036) _(judgement)_
- Bulk order operations — goal-oriented bulk actions on the orders table (spec 0026)
Expand Down
117 changes: 117 additions & 0 deletions packages/panel-addon-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,120 @@ error, because `SlotRegistry::forPage()` just won't find a match. If your
slot isn't appearing, this is the first thing to check (see
[Troubleshooting](#troubleshooting)).

## Adding fields to a first-party form

A slot component can take part in the save of the page it sits on. An add-on
does that with a **form slice**: a server-side class declaring its fields,
rules and commit, plus a component that binds to them. The panel places every
add-on slice under a namespace of its own, `addon:{key}:`, so it can add fields
to the form but can never read, hide or alter the form's own fields, or another
add-on's. That is the panel's whole stance on extending first-party forms:
add-ons add, only the host subtracts.

The form kind decides the plumbing, not the contract. First-party edit pages
(customers, products, brands, collections, product types, variants) are driven
by an autosaving **edit draft**, so a slice there also autosaves as staff type,
restores when they come back, and commits with field-level conflict detection.
This example targets the customer edit page.

`src/Drafts/LoyaltyTierSlice.php`:

```php
class LoyaltyTierSlice extends FormSlice
{
public function __construct(protected UpdatesCustomer $updatesCustomer) {}

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

// Bare key; the panel places the slice under addon:example-addon:
public function key(): string { return 'example-addon'; }

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

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

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

// Runs after the customer's own commit, inside the same transaction, with
// every field present (current values overlaid with the draft).
public function commit(Model $record, array $values): void
{
$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'];
}
}
```

Register it from the section, and register the slot component that edits it:

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

public function slots(SlotRegistry $registry): void
{
$registry->add(new Slot(
zone: 'customers.edit:main:after',
component: 'example-addon::LoyaltyCard',
));
}
```

`resources/js/components/LoyaltyCard.vue` binds to the slice with
`useFormSlice`, passing the slice's key. The composable finds the page's form
above it in the component tree (every slot zone on an edit page is inside it)
and returns a view scoped to the add-on's namespace:

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

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

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

`slice.values` is a reactive object holding only this slice's fields.
`slice.errors` carries commit-time validation errors keyed by bare field name,
`slice.field('tier')` gives a writable ref for one field, and `slice.isDirty`,
`slice.saving` and `slice.committing` mirror the page form. Writing to a field
the slice did not declare throws.

What the add-on gets for free: autosave and restore, the dirty-navigation
guard, per-field conflict detection (a conflicting tier shows in the page's
conflict dialog under the label from `labels()`), 422 mapping, and an atomic
commit with the customer's own fields. If the slice's `commit()` throws, the
customer's changes roll back too.

Optional: extend `Lunar\Panel\Drafts\DraftSlice` instead and implement
`discard(Model $record, EditDraft $draft)` when a slice holds state outside the
draft's JSON columns (staged uploads, for instance). It is called when a draft
holding the slice's keys is discarded or pruned. A plain `FormSlice` needs no
such hook and works on drafted and plain forms alike.

## Registering a table extension

A `TableExtension` bundles one or more `TableColumn`s (plus optional filters
Expand Down Expand Up @@ -931,6 +1045,9 @@ pages, not only in an isolated fixture.
actions injected into the first-party customers table.
- `src/Actions/ImportPageAction.php` / `AuditPageAction.php` — the listing-
and record-page header actions.
- `src/Drafts/LoyaltyTierSlice.php` / `resources/js/components/LoyaltyCard.vue`
— the form slice added to the customer form and the slot component that
edits it.
- `resources/js/addon.ts` — the IIFE entry point.
- `resources/js/pages/Widgets/Index.vue`, `resources/js/components/InfoBanner.vue`
— the example page and slot component.
Expand Down
2 changes: 1 addition & 1 deletion packages/panel-addon-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"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
2 changes: 2 additions & 0 deletions packages/panel-addon-example/resources/js/addon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import WidgetsIndexPage from './pages/Widgets/Index.vue';
import SettingsIndexPage from './pages/Settings/Index.vue';
import CustomerCountWidgetComponent from './components/CustomerCountWidget.vue';
import InfoBannerComponent from './components/InfoBanner.vue';
import LoyaltyCardComponent from './components/LoyaltyCard.vue';
import SeoCardComponent from './components/SeoCard.vue';

// Register eagerly. The panel's app.ts publishes window.LunarPanel and is emitted
Expand All @@ -18,5 +19,6 @@ window.LunarPanel.registerPages({
window.LunarPanel.registerComponents('example-addon', {
CustomerCountWidget: CustomerCountWidgetComponent,
InfoBanner: InfoBannerComponent,
LoyaltyCard: LoyaltyCardComponent,
SeoCard: SeoCardComponent,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { FieldLabel, Select, useFormSlice } from '@lunarphp/panel';
import { useI18n } from 'vue-i18n';

// Binds to the LoyaltyTierSlice registered in ExampleSection::formExtensions().
// The key given here is the slice's key(); the panel resolves it to the
// `addon:example-addon:` namespace on the customer page's draft, so `tier`
// autosaves, restores, conflicts and commits alongside the customer's own
// fields without this component ever touching them.
const { t } = useI18n();

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

const tiers = ['bronze', 'silver', 'gold'] as const;
</script>

<template>
<div class="rounded-lg border border-line bg-surface p-4 mt-6">
<h2 class="text-sm font-semibold text-ink-900">{{ t('example-addon::example.loyalty_title') }}</h2>
<p class="text-xs text-ink-500 mt-1 mb-3">{{ t('example-addon::example.loyalty_description') }}</p>

<FieldLabel for="example-addon-loyalty-tier">{{ t('example-addon::example.loyalty_tier') }}</FieldLabel>
<Select id="example-addon-loyalty-tier" v-model="slice.values.tier" :invalid="!!slice.errors.value.tier">
<option :value="null">{{ t('example-addon::example.loyalty_tier_none') }}</option>
<option v-for="tier in tiers" :key="tier" :value="tier">
{{ t(`example-addon::example.loyalty_tier_${tier}`) }}
</option>
</Select>
<p v-if="slice.errors.value.tier" class="text-xs text-danger mt-1">{{ slice.errors.value.tier }}</p>
<p v-else-if="slice.isDirty.value" class="text-xs text-ink-400 mt-1">{{ t('example-addon::example.loyalty_unsaved') }}</p>
</div>
</template>
8 changes: 8 additions & 0 deletions packages/panel-addon-example/resources/lang/en/example.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@
'widget_description' => 'Total customers, and how many joined in the selected range.',
'widget_total' => 'Total customers',
'widget_recent' => 'New in range',
'loyalty_title' => 'Loyalty',
'loyalty_description' => 'A field contributed by the example add-on. It saves with the rest of the customer through the edit draft.',
'loyalty_tier' => 'Loyalty tier',
'loyalty_tier_none' => 'No tier',
'loyalty_tier_bronze' => 'Bronze',
'loyalty_tier_silver' => 'Silver',
'loyalty_tier_gold' => 'Gold',
'loyalty_unsaved' => 'Changes save with the customer.',
];
8 changes: 8 additions & 0 deletions packages/panel-addon-example/resources/lang/fr/example.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@
'widget_description' => 'Le total des clients, et combien ont rejoint sur la période sélectionnée.',
'widget_total' => 'Clients au total',
'widget_recent' => 'Nouveaux sur la période',
'loyalty_title' => 'Fidélité',
'loyalty_description' => 'Un champ fourni par l\'add-on d\'exemple. Il s\'enregistre avec le reste du client via le brouillon de modification.',
'loyalty_tier' => 'Niveau de fidélité',
'loyalty_tier_none' => 'Aucun niveau',
'loyalty_tier_bronze' => 'Bronze',
'loyalty_tier_silver' => 'Argent',
'loyalty_tier_gold' => 'Or',
'loyalty_unsaved' => 'Les modifications s\'enregistrent avec le client.',
];
78 changes: 78 additions & 0 deletions packages/panel-addon-example/src/Drafts/LoyaltyTierSlice.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?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;

/**
* Adds a loyalty tier to the customer form. Registered through
* ExampleSection::formExtensions(), so the panel places it under
* `addon:example-addon:` and the LoyaltyCard component binds to it with
* useFormSlice('example-addon'). On the customer edit page the value
* autosaves, restores, conflicts and commits with the customer's own fields
* through the edit draft; this class only ever sees its bare `tier` field.
*
* 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.
*/
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'];
}
}
20 changes: 20 additions & 0 deletions packages/panel-addon-example/src/ExampleSection.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use LunarPanelExample\Actions\AuditPageAction;
use LunarPanelExample\Actions\ImportPageAction;
use LunarPanelExample\Dashboard\CustomerCountWidget;
use LunarPanelExample\Drafts\LoyaltyTierSlice;
use LunarPanelExample\Search\CustomerEmailSearchSource;
use LunarPanelExample\Search\PingWidgetsCommand;
use LunarPanelExample\Tables\ExampleTableExtension;
Expand Down Expand Up @@ -159,6 +160,14 @@ public function slots(SlotRegistry $registry): void
props: ['message' => 'This banner was injected by the example add-on via a slot.'],
));

// A slot component that takes part in the page's save: LoyaltyCard binds
// to the LoyaltyTierSlice below through useFormSlice('example-addon'),
// so its field autosaves and commits with the customer's own.
$registry->add(new Slot(
zone: 'customers.edit:main:after',
component: 'example-addon::LoyaltyCard',
));

// The canonical slot example (spec 0049/0057): the product edit page
// deliberately ships no SEO section — an add-on injects one into the
// content-adjacent zone between the content cluster and the variants
Expand All @@ -174,6 +183,17 @@ public function tableExtensions(): array
return ['customers.index' => ExampleTableExtension::class];
}

/**
* Contribute fields to a first-party form. The panel places each slice
* under `addon:{key}` (here `addon:example-addon:`), so it can add to the
* customer form but never reach the customer's own fields or another
* add-on's.
*/
public function formExtensions(): array
{
return [LoyaltyTierSlice::class];
}

/**
* Contribute a dashboard widget. Staff reorder, hide, and re-add it like
* any first-party widget; its data ships as a deferred Inertia prop.
Expand Down
272 changes: 0 additions & 272 deletions packages/panel/public/build/assets/app-C3gs_Or0.js

This file was deleted.

277 changes: 277 additions & 0 deletions packages/panel/public/build/assets/app-Cj1yh0PK.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/panel/public/build/assets/app-D1QRsHyg.css

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion packages/panel/public/build/assets/app-DrcNYi_n.css

This file was deleted.

6 changes: 3 additions & 3 deletions packages/panel/public/build/manifest.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function fakeForm(overrides: Partial<Record<string, unknown>> = {}): EditDraftFo
errors: ref({}),
conflicts: ref([]),
isDirty: computed(() => false),
dirtyKeys: computed(() => []),
saving: ref(false),
committing: ref(false),
savedAt: ref<string | null>(null),
Expand Down
16 changes: 16 additions & 0 deletions packages/panel/resources/js/composables/sliceForm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ComputedRef, InjectionKey, Ref } from 'vue';

/**
* What a form must expose for form slices to bind to it. useEditDraft
* provides its form under sliceFormKey; a plain page form can provide the
* same shape.
*/
export interface SliceForm {
values: Record<string, unknown>;
errors: Ref<Record<string, string>>;
dirtyKeys: ComputedRef<string[]>;
saving: Ref<boolean>;
committing: Ref<boolean>;
}

export const sliceFormKey: InjectionKey<SliceForm> = Symbol('lunar-panel:slice-form');
Loading
Loading