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
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions packages/panel-addon-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
7 changes: 7 additions & 0 deletions packages/panel-addon-example/src/ExampleSection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/panel/public/build/manifest.json

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

28 changes: 28 additions & 0 deletions packages/panel/resources/js/composables/formValues.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<string, unknown>)
.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';
}
6 changes: 6 additions & 0 deletions packages/panel/resources/js/composables/sliceForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ export interface SliceForm {
dirtyKeys: ComputedRef<string[]>;
saving: Ref<boolean>;
committing: Ref<boolean>;
/**
* 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<SliceForm> = Symbol('lunar-panel:slice-form');
28 changes: 1 addition & 27 deletions packages/panel/resources/js/composables/useEditDraft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,40 +63,13 @@ function sharedSliceValues(): Record<string, unknown> {
}
}

// JSON round-trip rather than structuredClone: draft values are JSON-shaped
// by construction, and this also unwraps Vue reactive proxies safely.
function clone<T>(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.
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<string, unknown>)
.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
Expand Down
2 changes: 2 additions & 0 deletions packages/panel/resources/js/composables/useFormSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export interface FormSlice<T extends Record<string, unknown> = Record<string, un
* code goes through useFormSlice() / useAddonFormSlice().
*/
export function bindFormSlice<T extends Record<string, unknown>>(form: SliceForm, namespace: string): FormSlice<T> {
form.claim?.(namespace);

const prefix = `${namespace}:`;
const target = form.values;

Expand Down
64 changes: 64 additions & 0 deletions packages/panel/resources/js/composables/usePanelForm.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }));

vi.mock('@inertiajs/vue3', async (importOriginal) => ({
...(await importOriginal<typeof import('@inertiajs/vue3')>()),
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<typeof usePanelForm<{ first_name: string }>> | 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);
});
});
69 changes: 69 additions & 0 deletions packages/panel/resources/js/composables/usePanelForm.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
try {
return (usePage().props.formSliceValues as Record<string, unknown> | 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<T extends FormDataType<T>>(initial: T): InertiaForm<T> {
// 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<T>;
const seeds = sharedSliceValues();
const pristine: Record<string, unknown> = {};
const claimed = new Set<string>();
const data = form as unknown as Record<string, unknown>;

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<string, string>) })) as unknown as Ref<Record<string, string>>,
dirtyKeys: computed(() => Object.keys(pristine).filter((key) => encode(data[key]) !== encode(pristine[key]))),
saving: ref(false),
committing: computed(() => form.processing) as unknown as Ref<boolean>,
claim,
};

if (getCurrentInstance()) {
provide(sliceFormKey, host);
}

return form;
}
5 changes: 3 additions & 2 deletions packages/panel/resources/js/pages/brands/Create.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { usePanelForm } from '../../composables/usePanelForm';
import { useI18n } from 'vue-i18n';
import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.vue';
import Button from '../../components/Button.vue';
Expand All @@ -16,7 +17,7 @@ const props = defineProps<{
urls: { store: string; index: string };
}>();

const form = useForm({
const form = usePanelForm({
name: '',
status: 'active',
});
Expand Down
5 changes: 3 additions & 2 deletions packages/panel/resources/js/pages/collections/Create.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { usePanelForm } from '../../composables/usePanelForm';
import { useI18n } from 'vue-i18n';
import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.vue';
import Button from '../../components/Button.vue';
Expand All @@ -24,7 +25,7 @@ const { t } = useI18n();

const parent = ref<ParentOption | null>(props.preselected.parent);

const form = useForm<{
const form = usePanelForm<{
name: string;
collection_group_id: number | null;
parent_id: number | null;
Expand Down
5 changes: 3 additions & 2 deletions packages/panel/resources/js/pages/customers/Create.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { usePanelForm } from '../../composables/usePanelForm';
import { useI18n } from 'vue-i18n';
import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.vue';
import Button from '../../components/Button.vue';
Expand All @@ -23,7 +24,7 @@ const props = defineProps<{
urls: { store: string; index: string };
}>();

const form = useForm({
const form = usePanelForm({
title: '',
first_name: '',
last_name: '',
Expand Down
5 changes: 3 additions & 2 deletions packages/panel/resources/js/pages/discounts/Create.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { usePanelForm } from '../../composables/usePanelForm';
import { useI18n } from 'vue-i18n';
import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.vue';
import Button from '../../components/Button.vue';
Expand Down Expand Up @@ -32,7 +33,7 @@ const toLocalInput = (date: Date): string => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

const form = useForm({
const form = usePanelForm({
name: '',
handle: '',
type: props.types[0]?.class ?? '',
Expand Down
5 changes: 3 additions & 2 deletions packages/panel/resources/js/pages/product-types/Create.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { usePanelForm } from '../../composables/usePanelForm';
import { useI18n } from 'vue-i18n';
import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.vue';
import Button from '../../components/Button.vue';
Expand All @@ -16,7 +17,7 @@ const props = defineProps<{
urls: { store: string; index: string };
}>();

const form = useForm({
const form = usePanelForm({
name: '',
status: 'active',
});
Expand Down
Loading
Loading