From f44ab589c14f78101a35a7bf5d10e9802cc11acc Mon Sep 17 00:00:00 2001 From: Glenn Jacobs Date: Wed, 16 Sep 2026 21:43:17 +0100 Subject: [PATCH 1/2] feat(panel): form slices on the create pages and settings forms (spec 0088) Composes a model's registered form slices into the panel's plain forms, so the same FormSlice class an add-on wrote for a drafted edit page also serves the create pages and the settings edit forms. A plain form carries only what it posts, so a slice takes part only when a component bound it: its keys join the form on bind, its rules apply when its keys are present, and its commit runs for the namespaces the input holds, after the form's action inside one transaction. - SliceSet extracted from ComposedDraftResource and shared by both composers. - FormSlices service: rules() under `sometimes`, values() for seeding, commit(), and save() wrapping an action and the commit in a transaction. - ValidatesFormSlices request trait merges slice rules into the six create and thirteen settings requests, keeps slice keys out of validated(), and hands them over through sliceInput(). - Create controllers pass formSliceValues for a fresh record; the shared prop now resolves slices for any model, not only draftable ones. - usePanelForm wraps Inertia's useForm and provides a SliceForm with claim-on-bind; every create page and settings edit page swaps to it. - ProductAttributeSlice tolerates an unsaved product. - The example add-on's loyalty card also sits on the customer create page, with an end-to-end test through the plain store route. Co-Authored-By: Claude Fable 5.1 --- TODO.md | 1 + packages/panel-addon-example/README.md | 8 + .../resources/js/components/LoyaltyCard.vue | 8 +- .../src/ExampleSection.php | 7 + .../resources/js/composables/formValues.ts | 28 ++++ .../resources/js/composables/sliceForm.ts | 6 + .../resources/js/composables/useEditDraft.ts | 28 +--- .../resources/js/composables/useFormSlice.ts | 2 + .../js/composables/usePanelForm.test.ts | 64 ++++++++ .../resources/js/composables/usePanelForm.ts | 69 ++++++++ .../resources/js/pages/brands/Create.vue | 5 +- .../resources/js/pages/collections/Create.vue | 5 +- .../resources/js/pages/customers/Create.vue | 5 +- .../resources/js/pages/discounts/Create.vue | 5 +- .../js/pages/product-types/Create.vue | 5 +- .../resources/js/pages/products/Create.vue | 5 +- .../pages/settings/attribute-groups/Edit.vue | 5 +- .../js/pages/settings/attributes/Edit.vue | 5 +- .../js/pages/settings/channels/Edit.vue | 5 +- .../js/pages/settings/countries/Edit.vue | 5 +- .../js/pages/settings/currencies/Edit.vue | 5 +- .../pages/settings/customer-groups/Edit.vue | 5 +- .../js/pages/settings/languages/Edit.vue | 5 +- .../js/pages/settings/locations/Edit.vue | 5 +- .../pages/settings/product-options/Edit.vue | 5 +- .../js/pages/settings/regions/Edit.vue | 5 +- .../js/pages/settings/staff/Edit.vue | 5 +- .../js/pages/settings/tax-classes/Edit.vue | 5 +- .../js/pages/settings/tax-zones/Edit.vue | 5 +- packages/panel/resources/js/ui.ts | 1 + .../panel/resources/panel-package/index.js | 1 + .../src/Drafts/ComposedDraftResource.php | 119 +++----------- packages/panel/src/Forms/FormSlices.php | 104 ++++++++++++ packages/panel/src/Forms/SliceSet.php | 154 ++++++++++++++++++ .../Brands/BrandCreateController.php | 9 +- .../CollectionCreateController.php | 9 +- .../Customers/CustomerCreateController.php | 11 +- .../Discounts/DiscountCreateController.php | 13 +- .../ProductTypeCreateController.php | 11 +- .../Products/ProductCreateController.php | 11 +- .../Settings/AttributeEditController.php | 5 +- .../Settings/AttributeGroupEditController.php | 5 +- .../Settings/ChannelEditController.php | 5 +- .../Settings/CountryEditController.php | 5 +- .../Settings/CurrencyEditController.php | 7 +- .../Settings/CustomerGroupEditController.php | 5 +- .../Settings/LanguageEditController.php | 5 +- .../Settings/LocationEditController.php | 5 +- .../Settings/ProductOptionEditController.php | 5 +- .../Settings/RegionEditController.php | 5 +- .../Settings/StaffEditController.php | 5 +- .../Settings/TaxClassEditController.php | 5 +- .../Settings/TaxZoneEditController.php | 5 +- .../Middleware/HandlePanelInertiaRequests.php | 24 ++- .../src/Http/Requests/Brands/BrandRequest.php | 6 + .../Collections/CollectionStoreRequest.php | 6 + .../Requests/Concerns/ValidatesFormSlices.php | 68 ++++++++ .../Requests/Customers/CustomerRequest.php | 7 + .../ProductTypes/ProductTypeRequest.php | 6 + .../Requests/Products/ProductStoreRequest.php | 7 + .../Settings/AttributeGroupRequest.php | 6 + .../Requests/Settings/AttributeRequest.php | 6 + .../Http/Requests/Settings/ChannelRequest.php | 6 + .../Http/Requests/Settings/CountryRequest.php | 6 + .../Requests/Settings/CurrencyRequest.php | 6 + .../Settings/CustomerGroupRequest.php | 6 + .../Requests/Settings/LanguageRequest.php | 6 + .../Requests/Settings/LocationRequest.php | 6 + .../Settings/ProductOptionRequest.php | 6 + .../Http/Requests/Settings/RegionRequest.php | 6 + .../Http/Requests/Settings/StaffRequest.php | 6 + .../Requests/Settings/TaxClassRequest.php | 7 + .../Http/Requests/Settings/TaxZoneRequest.php | 7 + .../Catalog/Slices/ProductAttributeSlice.php | 11 ++ specs/0088-panel-form-slices.md | 146 +++++++++++++++++ specs/README.md | 1 + tests/panel/Feature/ExampleAddonTest.php | 26 +++ tests/panel/Feature/Forms/FormSlicesTest.php | 120 ++++++++++++++ .../panel/Fixtures/Forms/ChannelMemoSlice.php | 62 +++++++ 79 files changed, 1174 insertions(+), 222 deletions(-) create mode 100644 packages/panel/resources/js/composables/formValues.ts create mode 100644 packages/panel/resources/js/composables/usePanelForm.test.ts create mode 100644 packages/panel/resources/js/composables/usePanelForm.ts create mode 100644 packages/panel/src/Forms/FormSlices.php create mode 100644 packages/panel/src/Forms/SliceSet.php create mode 100644 packages/panel/src/Http/Requests/Concerns/ValidatesFormSlices.php create mode 100644 specs/0088-panel-form-slices.md create mode 100644 tests/panel/Feature/Forms/FormSlicesTest.php create mode 100644 tests/panel/Fixtures/Forms/ChannelMemoSlice.php diff --git a/TODO.md b/TODO.md index ec134f3554..bad22d29fc 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting. - 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 form slices on plain forms — the create pages and settings forms compose the same `FormSlice` contract the drafted edit pages do (spec 0088) - 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) diff --git a/packages/panel-addon-example/README.md b/packages/panel-addon-example/README.md index 8d31b33fea..f77c013036 100644 --- a/packages/panel-addon-example/README.md +++ b/packages/panel-addon-example/README.md @@ -479,6 +479,14 @@ 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. +The same slice and card serve the customer **create** page too, through a second +slot in `customers.create:main:after`. A create page posts a plain form rather +than a draft, so the rules differ slightly: a slice's keys join the form only +when a component binds to it (so a page nobody extends posts exactly what it +did before), its rules apply only when its keys are present, and its `commit()` +runs after the store action, inside the same transaction, with the record just +created. The component does not change between the two pages. + 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 diff --git a/packages/panel-addon-example/resources/js/components/LoyaltyCard.vue b/packages/panel-addon-example/resources/js/components/LoyaltyCard.vue index 7fd94d68a0..3b85df7522 100644 --- a/packages/panel-addon-example/resources/js/components/LoyaltyCard.vue +++ b/packages/panel-addon-example/resources/js/components/LoyaltyCard.vue @@ -4,9 +4,11 @@ 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. +// `addon:example-addon:` namespace on the page's form. On the edit page that +// is the customer's draft, so `tier` autosaves, restores, conflicts and +// commits alongside the customer's own fields; on the create page it posts +// with the form and commits once the customer exists. Either way this +// component never touches the customer's own fields. const { t } = useI18n(); const slice = useFormSlice<{ tier: string | null }>('example-addon'); diff --git a/packages/panel-addon-example/src/ExampleSection.php b/packages/panel-addon-example/src/ExampleSection.php index 079df173e0..4848fd9e95 100644 --- a/packages/panel-addon-example/src/ExampleSection.php +++ b/packages/panel-addon-example/src/ExampleSection.php @@ -168,6 +168,13 @@ public function slots(SlotRegistry $registry): void component: 'example-addon::LoyaltyCard', )); + // The same card and slice on the create page: there the field posts + // with the plain form and commits after the customer is created. + $registry->add(new Slot( + zone: 'customers.create: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 diff --git a/packages/panel/resources/js/composables/formValues.ts b/packages/panel/resources/js/composables/formValues.ts new file mode 100644 index 0000000000..044b42fd1a --- /dev/null +++ b/packages/panel/resources/js/composables/formValues.ts @@ -0,0 +1,28 @@ +// Value helpers shared by the page forms that host form slices. + +// JSON round-trip rather than structuredClone: form values are JSON-shaped +// by construction, and this also unwraps Vue reactive proxies safely. +export function clone(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +// Mirrors the server's comparison: object keys sort, list order matters. +export function normalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalize); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, entry]) => [key, normalize(entry)]), + ); + } + + return value; +} + +export function encode(value: unknown): string { + return JSON.stringify(normalize(value)) ?? 'undefined'; +} diff --git a/packages/panel/resources/js/composables/sliceForm.ts b/packages/panel/resources/js/composables/sliceForm.ts index 4c1493fc79..4e45e2f595 100644 --- a/packages/panel/resources/js/composables/sliceForm.ts +++ b/packages/panel/resources/js/composables/sliceForm.ts @@ -11,6 +11,12 @@ export interface SliceForm { dirtyKeys: ComputedRef; saving: Ref; committing: Ref; + /** + * Offered by plain page forms: seed a namespace's keys into the form on + * first bind, so a page posts only the slices a component actually uses. + * Drafted pages seed every slice up front and omit it. + */ + claim?: (namespace: string) => void; } export const sliceFormKey: InjectionKey = Symbol('lunar-panel:slice-form'); diff --git a/packages/panel/resources/js/composables/useEditDraft.ts b/packages/panel/resources/js/composables/useEditDraft.ts index 3545332732..bae268fef5 100644 --- a/packages/panel/resources/js/composables/useEditDraft.ts +++ b/packages/panel/resources/js/composables/useEditDraft.ts @@ -11,6 +11,7 @@ import { type Ref, } from 'vue'; import { router, usePage } from '@inertiajs/vue3'; +import { clone, encode } from './formValues'; import { sliceFormKey } from './sliceForm'; import { useI18n } from 'vue-i18n'; import { DraftConflictError, ValidationError, http, type DraftConflict } from '../lib/http'; @@ -62,12 +63,6 @@ function sharedSliceValues(): Record { } } -// JSON round-trip rather than structuredClone: draft values are JSON-shaped -// by construction, and this also unwraps Vue reactive proxies safely. -function clone(value: T): T { - return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); -} - // The server stores empty nullable text fields as null; string-bound inputs // need '' back, or overlaying a draft would fake dirtiness against a // ''-shaped pristine value. @@ -75,27 +70,6 @@ function coerceToShape(value: unknown, reference: unknown): unknown { return value === null && typeof reference === 'string' ? '' : value; } -// Mirrors the server's comparison: object keys sort, list order matters. -function normalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(normalize); - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([key, entry]) => [key, normalize(entry)]), - ); - } - - return value; -} - -function encode(value: unknown): string { - return JSON.stringify(normalize(value)) ?? 'undefined'; -} - /** * Drives a draft-backed edit form: values overlay the staff member's stored * draft, dirty fields autosave (debounced, serialised so a stale response diff --git a/packages/panel/resources/js/composables/useFormSlice.ts b/packages/panel/resources/js/composables/useFormSlice.ts index f2bdc949e7..c3226f3156 100644 --- a/packages/panel/resources/js/composables/useFormSlice.ts +++ b/packages/panel/resources/js/composables/useFormSlice.ts @@ -25,6 +25,8 @@ export interface FormSlice = Record>(form: SliceForm, namespace: string): FormSlice { + form.claim?.(namespace); + const prefix = `${namespace}:`; const target = form.values; diff --git a/packages/panel/resources/js/composables/usePanelForm.test.ts b/packages/panel/resources/js/composables/usePanelForm.test.ts new file mode 100644 index 0000000000..a2754116f7 --- /dev/null +++ b/packages/panel/resources/js/composables/usePanelForm.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; +import { usePanelForm } from './usePanelForm'; +import { useAddonFormSlice, type FormSlice } from './useFormSlice'; + +const { pageProps } = vi.hoisted(() => ({ pageProps: {} as Record })); + +vi.mock('@inertiajs/vue3', async (importOriginal) => ({ + ...(await importOriginal()), + usePage: () => ({ props: pageProps }), +})); + +describe('usePanelForm', () => { + afterEach(() => { + delete pageProps.formSliceValues; + }); + + it('posts only the page fields until a slice is bound', () => { + pageProps.formSliceValues = { 'addon:loyalty:tier': 'bronze', 'channel:1': { enabled: true } }; + + const form = usePanelForm({ first_name: 'Ada' }); + + expect(form.data()).toEqual({ first_name: 'Ada' }); + }); + + it('seeds a claimed namespace into the data and defaults, and tracks its dirt', async () => { + pageProps.formSliceValues = { 'addon:loyalty:tier': 'bronze', 'channel:1': { enabled: true } }; + + let slice: FormSlice<{ tier: string }> | undefined; + let form: ReturnType> | undefined; + + const Child = defineComponent({ + setup() { + slice = useAddonFormSlice<{ tier: string }>('loyalty'); + + return () => null; + }, + }); + + const Page = defineComponent({ + setup() { + form = usePanelForm({ first_name: 'Ada' }); + + return () => h('div', [h(Child)]); + }, + }); + + mount(Page); + + expect(form?.data()).toEqual({ first_name: 'Ada', 'addon:loyalty:tier': 'bronze' }); + expect(slice?.values.tier).toBe('bronze'); + expect(slice?.isDirty.value).toBe(false); + + slice!.values.tier = 'gold'; + + expect(form?.data()).toEqual({ first_name: 'Ada', 'addon:loyalty:tier': 'gold' }); + expect(slice?.isDirty.value).toBe(true); + + // Inertia recomputes its own isDirty in a watcher. + await nextTick(); + expect(form?.isDirty).toBe(true); + }); +}); diff --git a/packages/panel/resources/js/composables/usePanelForm.ts b/packages/panel/resources/js/composables/usePanelForm.ts new file mode 100644 index 0000000000..53b1bd5b0c --- /dev/null +++ b/packages/panel/resources/js/composables/usePanelForm.ts @@ -0,0 +1,69 @@ +import { computed, getCurrentInstance, provide, ref, type Ref } from 'vue'; +import { useForm, usePage, type InertiaForm } from '@inertiajs/vue3'; +import type { FormDataType } from '@inertiajs/core'; +import { clone, encode } from './formValues'; +import { sliceFormKey, type SliceForm } from './sliceForm'; + +// The shared prop is absent outside an Inertia page (unit tests, tooling). +function sharedSliceValues(): Record { + try { + return (usePage().props.formSliceValues as Record | undefined) ?? {}; + } catch { + return {}; + } +} + +/** + * Inertia's useForm for a plain (non-drafted) panel page, with the page + * form offered to form slices. A slot component that binds a slice with + * useFormSlice() claims its namespace: the namespace's keys are seeded from + * the shared formSliceValues prop into the form's data and defaults, so they + * post with the form and nothing else changes for a page nobody extends. + * Returns the Inertia form unchanged, so page code reads as before. + */ +export function usePanelForm>(initial: T): InertiaForm { + // useForm's precognition constraint is a private type; the data shape is + // the same one every page already passed to useForm. + const form = useForm(initial as never) as unknown as InertiaForm; + const seeds = sharedSliceValues(); + const pristine: Record = {}; + const claimed = new Set(); + const data = form as unknown as Record; + + const claim = (namespace: string): void => { + if (claimed.has(namespace)) { + return; + } + + claimed.add(namespace); + + const prefix = `${namespace}:`; + + for (const [key, value] of Object.entries(seeds)) { + if (!key.startsWith(prefix) || key in data) { + continue; + } + + // defaults() is what data() enumerates on submit; the property + // itself is what the slice proxy reads and writes. + form.defaults(key as never, clone(value) as never); + data[key] = clone(value); + pristine[key] = clone(value); + } + }; + + const host: SliceForm = { + values: data, + errors: computed(() => ({ ...(form.errors as Record) })) as unknown as Ref>, + dirtyKeys: computed(() => Object.keys(pristine).filter((key) => encode(data[key]) !== encode(pristine[key]))), + saving: ref(false), + committing: computed(() => form.processing) as unknown as Ref, + claim, + }; + + if (getCurrentInstance()) { + provide(sliceFormKey, host); + } + + return form; +} diff --git a/packages/panel/resources/js/pages/brands/Create.vue b/packages/panel/resources/js/pages/brands/Create.vue index ae15863506..89fa496718 100644 --- a/packages/panel/resources/js/pages/brands/Create.vue +++ b/packages/panel/resources/js/pages/brands/Create.vue @@ -1,6 +1,7 @@