From d65aa465bcfe7cecf7310e5ce3949a5bade7f4aa Mon Sep 17 00:00:00 2001 From: Glenn Jacobs Date: Fri, 17 Jul 2026 12:25:04 +0100 Subject: [PATCH 1/7] Panel edit drafts: autosaved pending edits with field-level conflict detection (spec 0051) Adds an edit_drafts layer to lunarphp/panel: in-progress edits autosave to a per-staff draft row capturing a base snapshot per touched field, and commits detect conflicts per field against those snapshots, so staff editing disjoint fields never collide. Conflicting commits return a 409 resolved through a keep-mine/take-theirs/manual-merge dialog with a rebase protocol that re-flags fields that move again mid-resolution. Commits are atomic. Wired into the customer details form; other resources opt in by registering a DraftableResource via Section::draftables(). Co-Authored-By: Claude Fable 5 --- TODO.md | 1 + packages/panel/config/panel.php | 13 + ..._07_17_000000_create_edit_drafts_table.php | 27 ++ .../js/components/DraftConflictDialog.test.ts | 116 ++++++ .../js/components/DraftConflictDialog.vue | 138 +++++++ .../js/composables/useEditDraft.test.ts | 231 ++++++++++++ .../resources/js/composables/useEditDraft.ts | 258 ++++++++++++++ packages/panel/resources/js/lib/http.test.ts | 76 ++++ packages/panel/resources/js/lib/http.ts | 79 ++++ .../resources/js/pages/customers/Edit.vue | 109 ++++-- packages/panel/resources/js/ui.ts | 7 + packages/panel/resources/lang/ar/drafts.php | 18 + packages/panel/resources/lang/bg/drafts.php | 18 + packages/panel/resources/lang/de/drafts.php | 18 + packages/panel/resources/lang/en/drafts.php | 18 + packages/panel/resources/lang/es/drafts.php | 18 + packages/panel/resources/lang/fa/drafts.php | 18 + packages/panel/resources/lang/fr/drafts.php | 18 + packages/panel/resources/lang/hr/drafts.php | 18 + packages/panel/resources/lang/hu/drafts.php | 18 + packages/panel/resources/lang/mn/drafts.php | 18 + packages/panel/resources/lang/nl/drafts.php | 18 + packages/panel/resources/lang/pl/drafts.php | 18 + .../panel/resources/lang/pt_BR/drafts.php | 18 + packages/panel/resources/lang/ro/drafts.php | 18 + packages/panel/resources/lang/tr/drafts.php | 18 + packages/panel/resources/lang/vi/drafts.php | 18 + .../panel/resources/panel-package/index.js | 13 + packages/panel/src/Contracts/DraftManager.php | 39 ++ .../panel/src/Contracts/DraftableResource.php | 65 ++++ packages/panel/src/Drafts/CommitResult.php | 27 ++ packages/panel/src/Drafts/DraftManager.php | 219 ++++++++++++ .../panel/src/Drafts/DraftableResource.php | 23 ++ .../Customers/CustomerEditController.php | 13 +- .../Http/Controllers/EditDraftController.php | 100 ++++++ packages/panel/src/Models/EditDraft.php | 55 +++ packages/panel/src/PanelManager.php | 45 +++ packages/panel/src/PanelServiceProvider.php | 14 + .../panel/src/Sections/ProvidesNavigation.php | 9 + .../Sections/Sales/CustomerDraftResource.php | 109 ++++++ .../panel/src/Sections/Sales/SalesSection.php | 14 + packages/panel/src/Sections/Section.php | 12 + .../panel/src/Sections/SectionExtension.php | 12 + specs/0051-panel-edit-drafts.md | 336 ++++++++++++++++++ specs/README.md | 2 + .../Feature/Customers/CustomerDraftTest.php | 219 ++++++++++++ .../panel/Feature/Drafts/DraftManagerTest.php | 282 +++++++++++++++ 47 files changed, 2923 insertions(+), 28 deletions(-) create mode 100644 packages/panel/database/migrations/2026_07_17_000000_create_edit_drafts_table.php create mode 100644 packages/panel/resources/js/components/DraftConflictDialog.test.ts create mode 100644 packages/panel/resources/js/components/DraftConflictDialog.vue create mode 100644 packages/panel/resources/js/composables/useEditDraft.test.ts create mode 100644 packages/panel/resources/js/composables/useEditDraft.ts create mode 100644 packages/panel/resources/js/lib/http.test.ts create mode 100644 packages/panel/resources/js/lib/http.ts create mode 100644 packages/panel/resources/lang/ar/drafts.php create mode 100644 packages/panel/resources/lang/bg/drafts.php create mode 100644 packages/panel/resources/lang/de/drafts.php create mode 100644 packages/panel/resources/lang/en/drafts.php create mode 100644 packages/panel/resources/lang/es/drafts.php create mode 100644 packages/panel/resources/lang/fa/drafts.php create mode 100644 packages/panel/resources/lang/fr/drafts.php create mode 100644 packages/panel/resources/lang/hr/drafts.php create mode 100644 packages/panel/resources/lang/hu/drafts.php create mode 100644 packages/panel/resources/lang/mn/drafts.php create mode 100644 packages/panel/resources/lang/nl/drafts.php create mode 100644 packages/panel/resources/lang/pl/drafts.php create mode 100644 packages/panel/resources/lang/pt_BR/drafts.php create mode 100644 packages/panel/resources/lang/ro/drafts.php create mode 100644 packages/panel/resources/lang/tr/drafts.php create mode 100644 packages/panel/resources/lang/vi/drafts.php create mode 100644 packages/panel/src/Contracts/DraftManager.php create mode 100644 packages/panel/src/Contracts/DraftableResource.php create mode 100644 packages/panel/src/Drafts/CommitResult.php create mode 100644 packages/panel/src/Drafts/DraftManager.php create mode 100644 packages/panel/src/Drafts/DraftableResource.php create mode 100644 packages/panel/src/Http/Controllers/EditDraftController.php create mode 100644 packages/panel/src/Models/EditDraft.php create mode 100644 packages/panel/src/Sections/Sales/CustomerDraftResource.php create mode 100644 specs/0051-panel-edit-drafts.md create mode 100644 tests/panel/Feature/Customers/CustomerDraftTest.php create mode 100644 tests/panel/Feature/Drafts/DraftManagerTest.php diff --git a/TODO.md b/TODO.md index 26171797d4..d3bb2e1c7b 100644 --- a/TODO.md +++ b/TODO.md @@ -10,6 +10,7 @@ Items tagged _(judgement)_ are genuine line-calls worth revisiting. - Inertia admin panel — new `lunarphp/panel` package: auth, extension points (navigation, slots, table columns, row/bulk/page actions with shared ordering), Customers CRUD, Channels settings (spec 0049) - Panel order-value chart on the customer edit page + `TimeSeriesChart` on the add-on surface (spec 0050) +- Panel edit drafts — autosaved pending edits with field-level conflict detection (spec 0051) - Default professional customer notifications for the order lifecycle (spec 0036) _(judgement)_ - Bulk order operations — goal-oriented bulk actions on the orders table (spec 0026) - Line-item refunds — refund specific lines/quantities via a dedicated refund page (spec 0028) diff --git a/packages/panel/config/panel.php b/packages/panel/config/panel.php index 227ba54993..e553801eac 100644 --- a/packages/panel/config/panel.php +++ b/packages/panel/config/panel.php @@ -39,6 +39,19 @@ 'storefront_url' => null, + /* + |-------------------------------------------------------------------------- + | Edit Drafts + |-------------------------------------------------------------------------- + | + | Drafts untouched for ttl_days are pruned by the scheduled model:prune + | run; their base snapshots are too stale for reliable conflict checks. + | + */ + 'drafts' => [ + 'ttl_days' => 7, + ], + 'support_url' => 'https://docs.lunarphp.com/', /* diff --git a/packages/panel/database/migrations/2026_07_17_000000_create_edit_drafts_table.php b/packages/panel/database/migrations/2026_07_17_000000_create_edit_drafts_table.php new file mode 100644 index 0000000000..00092a85e6 --- /dev/null +++ b/packages/panel/database/migrations/2026_07_17_000000_create_edit_drafts_table.php @@ -0,0 +1,27 @@ +prefix.'edit_drafts', function (Blueprint $table) { + $table->id(); + $table->morphs('draftable'); + $table->foreignId('staff_id')->constrained($this->prefix.'staff')->cascadeOnDelete(); + $table->json('data'); + $table->json('base_snapshot'); + $table->timestamps(); + + $table->unique(['draftable_type', 'draftable_id', 'staff_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists($this->prefix.'edit_drafts'); + } +}; diff --git a/packages/panel/resources/js/components/DraftConflictDialog.test.ts b/packages/panel/resources/js/components/DraftConflictDialog.test.ts new file mode 100644 index 0000000000..7a0216cf8d --- /dev/null +++ b/packages/panel/resources/js/components/DraftConflictDialog.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DraftConflictDialog from './DraftConflictDialog.vue'; +import type { DraftConflict } from '../lib/http'; + +// Reka's DialogPortal teleports content to document.body, out of the +// wrapper's reach; interactions query the document instead. +const body = (): HTMLElement => document.body; + +const textConflict: DraftConflict = { + key: 'first_name', + label: 'First name', + mine: 'Mine', + base: 'Base', + theirs: 'Theirs', +}; + +const arrayConflict: DraftConflict = { + key: 'customer_group_ids', + label: 'Customer groups', + mine: [1, 2], + base: [1], + theirs: [1, 3], +}; + +let wrapper: VueWrapper | null = null; + +async function mountDialog(conflicts: DraftConflict[]): Promise { + wrapper = mount(DraftConflictDialog, { + props: { open: true, conflicts }, + attachTo: document.body, + }); + + // The portal teleports content to document.body on the next tick. + await nextTick(); + + return wrapper; +} + +function clickButtonByText(text: string): Promise { + const button = [...body().querySelectorAll('button')].find((el) => el.textContent?.includes(text)); + + expect(button, `button containing "${text}"`).toBeDefined(); + button?.click(); + + return nextTick(); +} + +afterEach(() => { + wrapper?.unmount(); + wrapper = null; + document.body.innerHTML = ''; +}); + +describe('DraftConflictDialog', () => { + it('renders a row per conflict with both values and the base note', async () => { + await mountDialog([textConflict, arrayConflict]); + + const text = body().textContent ?? ''; + + expect(text).toContain('First name'); + expect(text).toContain('Mine'); + expect(text).toContain('Theirs'); + expect(text).toContain('Customer groups'); + expect(text).toContain('1, 2'); + expect(text).toContain('1, 3'); + }); + + it('defaults to keeping mine and emits theirs as the rebase pin', async () => { + const dialog = await mountDialog([textConflict]); + + await clickButtonByText('drafts.apply'); + + expect(dialog.emitted('resolve')).toEqual([[{ first_name: 'Mine' }, { first_name: 'Theirs' }]]); + }); + + it('emits the current value when take-theirs is chosen', async () => { + const dialog = await mountDialog([textConflict]); + + await clickButtonByText('drafts.current_value'); + await clickButtonByText('drafts.apply'); + + expect(dialog.emitted('resolve')).toEqual([[{ first_name: 'Theirs' }, { first_name: 'Theirs' }]]); + }); + + it('lets a manual edit override the chosen scalar value', async () => { + const dialog = await mountDialog([textConflict]); + + const input = body().querySelector('input[type="text"]'); + expect(input).not.toBeNull(); + + input!.value = 'Merged by hand'; + input!.dispatchEvent(new Event('input', { bubbles: true })); + await nextTick(); + + await clickButtonByText('drafts.apply'); + + expect(dialog.emitted('resolve')).toEqual([[{ first_name: 'Merged by hand' }, { first_name: 'Theirs' }]]); + }); + + it('offers no manual edit input for structured values', async () => { + await mountDialog([arrayConflict]); + + expect(body().querySelector('input[type="text"]')).toBeNull(); + }); + + it('closes without resolving on cancel', async () => { + const dialog = await mountDialog([textConflict]); + + await clickButtonByText('common.cancel'); + + expect(dialog.emitted('resolve')).toBeUndefined(); + expect(dialog.emitted('update:open')).toEqual([[false]]); + }); +}); diff --git a/packages/panel/resources/js/components/DraftConflictDialog.vue b/packages/panel/resources/js/components/DraftConflictDialog.vue new file mode 100644 index 0000000000..f4c831b3ea --- /dev/null +++ b/packages/panel/resources/js/components/DraftConflictDialog.vue @@ -0,0 +1,138 @@ + + + diff --git a/packages/panel/resources/js/composables/useEditDraft.test.ts b/packages/panel/resources/js/composables/useEditDraft.test.ts new file mode 100644 index 0000000000..c69d1552e9 --- /dev/null +++ b/packages/panel/resources/js/composables/useEditDraft.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick } from 'vue'; +import { DraftConflictError, ValidationError } from '../lib/http'; +import { useEditDraft } from './useEditDraft'; + +const { httpMock, reloadMock } = vi.hoisted(() => ({ + httpMock: { + patch: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, + reloadMock: vi.fn(), +})); + +vi.mock('../lib/http', async (importOriginal) => ({ + ...(await importOriginal()), + http: httpMock, +})); + +vi.mock('@inertiajs/vue3', () => ({ + router: { reload: reloadMock }, +})); + +const urls = { draft: '/customers/1/draft', commit: '/customers/1/draft/commit' }; + +async function flushAutosave(): Promise { + await nextTick(); + await vi.advanceTimersByTimeAsync(750); + await vi.runOnlyPendingTimersAsync(); +} + +describe('useEditDraft', () => { + beforeEach(() => { + vi.useFakeTimers(); + httpMock.patch.mockResolvedValue({ data: {}, updated_at: '2026-07-17T00:00:00Z' }); + httpMock.post.mockResolvedValue({ committed: true }); + httpMock.delete.mockResolvedValue(null); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('overlays a stored draft onto the initial values', () => { + const form = useEditDraft({ + initial: { first_name: 'Original', company_name: 'Acme' }, + draft: { data: { first_name: 'Drafted' }, updated_at: '2026-07-16T00:00:00Z' }, + urls, + }); + + expect(form.values.first_name).toBe('Drafted'); + expect(form.values.company_name).toBe('Acme'); + expect(form.isDirty.value).toBe(true); + expect(form.hasDraft.value).toBe(true); + expect(form.restoredFrom.value).toBe('2026-07-16T00:00:00Z'); + }); + + it('autosaves only the dirty diff after the debounce', async () => { + const form = useEditDraft({ + initial: { first_name: 'Original', company_name: 'Acme' }, + draft: null, + urls, + }); + + form.values.first_name = 'Changed'; + await flushAutosave(); + + expect(httpMock.patch).toHaveBeenCalledTimes(1); + expect(httpMock.patch).toHaveBeenCalledWith(urls.draft, { data: { first_name: 'Changed' } }); + expect(form.hasDraft.value).toBe(true); + expect(form.savedAt.value).toBe('2026-07-17T00:00:00Z'); + }); + + it('collapses rapid edits into one autosave', async () => { + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + + form.values.first_name = 'C'; + await nextTick(); + await vi.advanceTimersByTimeAsync(300); + form.values.first_name = 'Ch'; + await flushAutosave(); + + expect(httpMock.patch).toHaveBeenCalledTimes(1); + expect(httpMock.patch).toHaveBeenCalledWith(urls.draft, { data: { first_name: 'Ch' } }); + }); + + it('deletes the draft when the form returns to clean', async () => { + const form = useEditDraft({ + initial: { first_name: 'Original' }, + draft: { data: { first_name: 'Drafted' }, updated_at: null }, + urls, + }); + + form.values.first_name = 'Original'; + await flushAutosave(); + + expect(httpMock.delete).toHaveBeenCalledWith(urls.draft); + expect(httpMock.patch).not.toHaveBeenCalled(); + expect(form.hasDraft.value).toBe(false); + }); + + it('treats reordered array values as clean', async () => { + const form = useEditDraft({ + initial: { customer_group_ids: [1, 2] }, + draft: null, + urls, + }); + + form.values.customer_group_ids = [1, 2]; + await flushAutosave(); + + expect(httpMock.patch).not.toHaveBeenCalled(); + expect(form.isDirty.value).toBe(false); + }); + + it('commits the current diff and reloads on success', async () => { + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + + form.values.first_name = 'Changed'; + + await expect(form.commit()).resolves.toBe(true); + + expect(httpMock.post).toHaveBeenCalledWith(urls.commit, { + data: { first_name: 'Changed' }, + rebase: {}, + }); + expect(reloadMock).toHaveBeenCalled(); + expect(form.hasDraft.value).toBe(false); + expect(form.isDirty.value).toBe(false); + }); + + it('cancels a pending autosave when committing', async () => { + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + + form.values.first_name = 'Changed'; + await nextTick(); + + await form.commit(); + await vi.runOnlyPendingTimersAsync(); + + expect(httpMock.patch).not.toHaveBeenCalled(); + }); + + it('captures conflicts from a 409 without reloading', async () => { + const conflicts = [{ key: 'first_name', label: 'First name', mine: 'Mine', base: 'Base', theirs: 'Theirs' }]; + httpMock.post.mockRejectedValue(new DraftConflictError(conflicts)); + + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + form.values.first_name = 'Mine'; + + await expect(form.commit()).resolves.toBe(false); + + expect(form.conflicts.value).toEqual(conflicts); + expect(reloadMock).not.toHaveBeenCalled(); + }); + + it('captures field errors from a 422', async () => { + httpMock.post.mockRejectedValue(new ValidationError({ first_name: ['Required.', 'Second message.'] })); + + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + form.values.first_name = ''; + + await expect(form.commit()).resolves.toBe(false); + + expect(form.errors.value).toEqual({ first_name: 'Required.' }); + }); + + it('applies resolutions and re-commits with the rebase payload', async () => { + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + form.values.first_name = 'Mine'; + + await expect(form.resolve({ first_name: 'Theirs' }, { first_name: 'Theirs' })).resolves.toBe(true); + + expect(form.values.first_name).toBe('Theirs'); + expect(httpMock.post).toHaveBeenCalledWith(urls.commit, { + data: { first_name: 'Theirs' }, + rebase: { first_name: 'Theirs' }, + }); + }); + + it('discard resets values and deletes the server draft', async () => { + const form = useEditDraft({ + initial: { first_name: 'Original' }, + draft: { data: { first_name: 'Drafted' }, updated_at: '2026-07-16T00:00:00Z' }, + urls, + }); + + await form.discard(); + + expect(form.values.first_name).toBe('Original'); + expect(httpMock.delete).toHaveBeenCalledWith(urls.draft); + expect(form.hasDraft.value).toBe(false); + expect(form.restoredFrom.value).toBeNull(); + expect(form.isDirty.value).toBe(false); + }); + + it('serialises in-flight requests so responses apply in order', async () => { + const order: string[] = []; + let releaseFirst!: () => void; + + httpMock.patch + .mockImplementationOnce(async () => { + await new Promise((resolvePromise) => { + releaseFirst = resolvePromise; + }); + order.push('first'); + + return { data: {}, updated_at: 'first' }; + }) + .mockImplementationOnce(async () => { + order.push('second'); + + return { data: {}, updated_at: 'second' }; + }); + + const form = useEditDraft({ initial: { first_name: 'Original' }, draft: null, urls }); + + form.values.first_name = 'One'; + await flushAutosave(); + + form.values.first_name = 'Two'; + await flushAutosave(); + + releaseFirst(); + await vi.runAllTimersAsync(); + + expect(order).toEqual(['first', 'second']); + expect(form.savedAt.value).toBe('second'); + }); +}); diff --git a/packages/panel/resources/js/composables/useEditDraft.ts b/packages/panel/resources/js/composables/useEditDraft.ts new file mode 100644 index 0000000000..8e7f241001 --- /dev/null +++ b/packages/panel/resources/js/composables/useEditDraft.ts @@ -0,0 +1,258 @@ +import { computed, reactive, ref, watch, type ComputedRef, type Ref } from 'vue'; +import { router } from '@inertiajs/vue3'; +import { DraftConflictError, ValidationError, http, type DraftConflict } from '../lib/http'; + +export interface DraftState { + data: Record; + updated_at: string | null; +} + +export interface EditDraftOptions> { + initial: T; + draft: DraftState | null; + urls: { + draft: string; + commit: string; + }; + debounceMs?: number; +} + +export interface EditDraftForm> { + values: T; + errors: Ref>; + conflicts: Ref; + isDirty: ComputedRef; + saving: Ref; + committing: Ref; + savedAt: Ref; + hasDraft: Ref; + restoredFrom: Ref; + commit: () => Promise; + resolve: (resolutions: Record, rebase: Record) => Promise; + discard: () => Promise; +} + +// 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. +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 + * never lands after a newer one), and commit() surfaces per-field conflicts + * for the resolution dialog instead of failing the whole save. + */ +export function useEditDraft>(options: EditDraftOptions): EditDraftForm { + const debounceMs = options.debounceMs ?? 750; + + const pristine: Record = clone(options.initial); + const values = reactive(clone(options.initial)) as T; + + for (const [key, value] of Object.entries(options.draft?.data ?? {})) { + if (key in values) { + (values as Record)[key] = coerceToShape(clone(value), pristine[key]); + } + } + + const errors = ref>({}); + const conflicts = ref([]); + const saving = ref(false); + const committing = ref(false); + const savedAt = ref(options.draft?.updated_at ?? null); + const hasDraft = ref(options.draft !== null); + const restoredFrom = ref(options.draft?.updated_at ?? null); + + const diff = (): Record => { + const changed: Record = {}; + + for (const key of Object.keys(pristine)) { + const value = (values as Record)[key]; + + if (encode(value) !== encode(pristine[key])) { + changed[key] = value; + } + } + + return changed; + }; + + const isDirty = computed(() => Object.keys(diff()).length > 0); + + // In-flight requests chain so responses apply in request order. + let queue: Promise = Promise.resolve(); + const enqueue = (task: () => Promise): Promise => { + const run = queue.then(task, task); + queue = run.catch(() => undefined); + + return run; + }; + + let timer: ReturnType | null = null; + const cancelPendingAutosave = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const autosave = (): Promise => + enqueue(async () => { + const changed = diff(); + + if (Object.keys(changed).length === 0) { + if (!hasDraft.value) { + return; + } + + saving.value = true; + + try { + await http.delete(options.urls.draft); + hasDraft.value = false; + savedAt.value = null; + } finally { + saving.value = false; + } + + return; + } + + saving.value = true; + + try { + const response = await http.patch<{ data: Record; updated_at: string | null }>( + options.urls.draft, + { data: changed }, + ); + hasDraft.value = true; + savedAt.value = response.updated_at; + } finally { + saving.value = false; + } + }); + + watch( + values, + () => { + cancelPendingAutosave(); + timer = setTimeout(() => { + timer = null; + autosave().catch(() => undefined); + }, debounceMs); + }, + { deep: true }, + ); + + const send = async (rebase: Record): Promise => { + cancelPendingAutosave(); + committing.value = true; + errors.value = {}; + + try { + await enqueue(() => http.post(options.urls.commit, { data: diff(), rebase })); + + conflicts.value = []; + hasDraft.value = false; + savedAt.value = null; + restoredFrom.value = null; + Object.assign(pristine, clone(values)); + + // reload() preserves scroll and state; it refreshes props and + // surfaces the session flash the commit endpoint set. + router.reload(); + + return true; + } catch (error) { + if (error instanceof DraftConflictError) { + conflicts.value = error.conflicts; + + return false; + } + + if (error instanceof ValidationError) { + errors.value = Object.fromEntries( + Object.entries(error.errors).map(([key, messages]) => [key, messages[0]]), + ); + + return false; + } + + throw error; + } finally { + committing.value = false; + } + }; + + const commit = (): Promise => send({}); + + const resolve = (resolutions: Record, rebase: Record): Promise => { + for (const [key, value] of Object.entries(resolutions)) { + if (key in values) { + (values as Record)[key] = coerceToShape(clone(value), pristine[key]); + } + } + + return send(rebase); + }; + + const discard = async (): Promise => { + cancelPendingAutosave(); + + for (const key of Object.keys(pristine)) { + (values as Record)[key] = clone(pristine[key]); + } + + await enqueue(() => http.delete(options.urls.draft)); + + hasDraft.value = false; + savedAt.value = null; + restoredFrom.value = null; + errors.value = {}; + conflicts.value = []; + }; + + return { + values, + errors, + conflicts, + isDirty, + saving, + committing, + savedAt, + hasDraft, + restoredFrom, + commit, + resolve, + discard, + }; +} diff --git a/packages/panel/resources/js/lib/http.test.ts b/packages/panel/resources/js/lib/http.test.ts new file mode 100644 index 0000000000..0b64e80261 --- /dev/null +++ b/packages/panel/resources/js/lib/http.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DraftConflictError, HttpError, ValidationError, http } from './http'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('http', () => { + beforeEach(() => { + document.cookie = 'XSRF-TOKEN=token%3Dvalue'; + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends JSON requests with the XSRF token from the cookie', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(200, { ok: true })); + + await http.patch('/draft', { data: { first_name: 'A' } }); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(url).toBe('/draft'); + expect(init?.method).toBe('PATCH'); + expect(init?.body).toBe(JSON.stringify({ data: { first_name: 'A' } })); + expect(init?.headers).toMatchObject({ + Accept: 'application/json', + 'X-XSRF-TOKEN': 'token=value', + }); + }); + + it('returns the decoded payload on success', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(200, { updated_at: 'now' })); + + await expect(http.post('/commit')).resolves.toEqual({ updated_at: 'now' }); + }); + + it('returns null for 204 responses', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 204 })); + + await expect(http.delete('/draft')).resolves.toBeNull(); + }); + + it('throws a typed ValidationError on 422', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(422, { errors: { first_name: ['Required.'] } })); + + const error = await http.post('/commit').catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).errors).toEqual({ first_name: ['Required.'] }); + }); + + it('throws a typed DraftConflictError carrying the conflict set on 409', async () => { + const conflicts = [{ key: 'first_name', label: 'First name', mine: 'A', base: 'B', theirs: 'C' }]; + vi.mocked(fetch).mockResolvedValue(jsonResponse(409, { conflicts })); + + const error = await http.post('/commit').catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(DraftConflictError); + expect((error as DraftConflictError).conflicts).toEqual(conflicts); + }); + + it('throws HttpError for other failure statuses', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(500, {})); + + const error = await http.get('/draft').catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(HttpError); + expect((error as HttpError).status).toBe(500); + }); +}); diff --git a/packages/panel/resources/js/lib/http.ts b/packages/panel/resources/js/lib/http.ts new file mode 100644 index 0000000000..cae6127853 --- /dev/null +++ b/packages/panel/resources/js/lib/http.ts @@ -0,0 +1,79 @@ +// The panel's sanctioned non-Inertia transport, for endpoints whose responses +// have no home in Inertia's page-visit cycle (draft autosave, commit's 409 +// conflict payload). Everything page-shaped stays on @inertiajs/vue3. + +export interface DraftConflict { + key: string; + label: string; + mine: unknown; + base: unknown; + theirs: unknown; +} + +export class ValidationError extends Error { + constructor(public readonly errors: Record) { + super('The request payload failed validation.'); + this.name = 'ValidationError'; + } +} + +export class DraftConflictError extends Error { + constructor(public readonly conflicts: DraftConflict[]) { + super('The commit conflicts with concurrent changes.'); + this.name = 'DraftConflictError'; + } +} + +export class HttpError extends Error { + constructor(public readonly status: number) { + super(`Request failed with status ${status}.`); + this.name = 'HttpError'; + } +} + +function xsrfToken(): string { + const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/); + + return match ? decodeURIComponent(match[1]) : ''; +} + +async function request(method: string, url: string, body?: unknown): Promise { + const response = await fetch(url, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-XSRF-TOKEN': xsrfToken(), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + if (response.status === 204) { + return null as T; + } + + const payload = await response.json().catch(() => null); + + if (response.status === 422) { + throw new ValidationError((payload?.errors ?? {}) as Record); + } + + if (response.status === 409) { + throw new DraftConflictError((payload?.conflicts ?? []) as DraftConflict[]); + } + + if (!response.ok) { + throw new HttpError(response.status); + } + + return payload as T; +} + +export const http = { + get: (url: string): Promise => request('GET', url), + post: (url: string, body?: unknown): Promise => request('POST', url, body), + patch: (url: string, body?: unknown): Promise => request('PATCH', url, body), + delete: (url: string): Promise => request('DELETE', url), +}; diff --git a/packages/panel/resources/js/pages/customers/Edit.vue b/packages/panel/resources/js/pages/customers/Edit.vue index 3dacc939e6..f3920f6ad7 100644 --- a/packages/panel/resources/js/pages/customers/Edit.vue +++ b/packages/panel/resources/js/pages/customers/Edit.vue @@ -10,6 +10,7 @@ import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.v import Combobox from '../../components/Combobox.vue'; import ConfirmDialog from '../../components/ConfirmDialog.vue'; import Dialog from '../../components/Dialog.vue'; +import DraftConflictDialog from '../../components/DraftConflictDialog.vue'; import FieldLabel from '../../components/FieldLabel.vue'; import FilterDropdown, { type FilterOption } from '../../components/FilterDropdown.vue'; import FlashMessage from '../../components/FlashMessage.vue'; @@ -26,6 +27,7 @@ import Textarea from '../../components/Textarea.vue'; import TextInput from '../../components/TextInput.vue'; import TimeSeriesChart, { type ChartPoint } from '../../components/TimeSeriesChart.vue'; import PanelLayout from '../../layouts/PanelLayout.vue'; +import { useEditDraft, type DraftState } from '../../composables/useEditDraft'; interface OptionItem { id: number; @@ -106,6 +108,7 @@ const props = defineProps<{ range: string; buckets: ChartPoint[]; }; + draft: DraftState | null; urls: { index: string; update: string; @@ -113,6 +116,8 @@ const props = defineProps<{ addressesStore: string; usersStore: string; notesUpdate: string; + draft: string; + draftCommit: string; }; }>(); @@ -140,17 +145,47 @@ const headerUsers = computed(() => { return props.users.length > 1 ? t('customers.user_count', props.users.length) : null; }); -// Personal details + customer groups -const detailsForm = useForm({ - title: props.customer.title ?? '', - first_name: props.customer.first_name, - last_name: props.customer.last_name, - company_name: props.customer.company_name ?? '', - tax_identifier: props.customer.tax_identifier ?? '', - account_ref: props.customer.account_ref ?? '', - customer_group_ids: props.customer.customer_groups.map((group) => group.id), +// Personal details + customer groups, driven by an autosaving edit draft: +// dirty fields persist server-side and commit with field-level conflict +// detection instead of a last-write-wins PUT. +const { + values: details, + errors: detailsErrors, + conflicts: draftConflicts, + saving: draftSaving, + committing: draftCommitting, + savedAt: draftSavedAt, + restoredFrom: draftRestoredFrom, + commit: commitDetails, + resolve: resolveDraft, + discard: discardDraft, +} = useEditDraft({ + initial: { + title: props.customer.title ?? '', + first_name: props.customer.first_name, + last_name: props.customer.last_name, + company_name: props.customer.company_name ?? '', + tax_identifier: props.customer.tax_identifier ?? '', + account_ref: props.customer.account_ref ?? '', + customer_group_ids: props.customer.customer_groups.map((group) => group.id), + }, + draft: props.draft, + urls: { draft: props.urls.draft, commit: props.urls.draftCommit }, +}); + +const conflictOpen = computed({ + get: () => draftConflicts.value.length > 0, + set: (value: boolean) => { + if (!value) { + draftConflicts.value = []; + } + }, }); +const onResolveConflicts = (resolutions: Record, rebase: Record): void => { + void resolveDraft(resolutions, rebase); +}; + // Stored values stay canonical; only the visible labels are translated. A stored // title outside the base list (e.g. entered at checkout) is kept as its own option // so it still displays and survives a save untouched. @@ -176,22 +211,22 @@ const titleOptions = computed(() => { const groupName = (id: number): string => props.customerGroups.find((group) => group.id === id)?.name ?? String(id); const addableGroupOptions = computed(() => { - const taken = new Set(detailsForm.customer_group_ids); + const taken = new Set(details.customer_group_ids); return props.customerGroups.filter((group) => !taken.has(group.id)).map((group) => ({ value: group.id, label: group.name })); }); const addGroupSelection = ref(null); const onAddGroup = (value: string | number): void => { - detailsForm.customer_group_ids.push(Number(value)); + details.customer_group_ids.push(Number(value)); addGroupSelection.value = null; }; const removeGroup = (id: number): void => { - detailsForm.customer_group_ids = detailsForm.customer_group_ids.filter((groupId) => groupId !== id); + details.customer_group_ids = details.customer_group_ids.filter((groupId) => groupId !== id); }; const submitDetails = (): void => { - detailsForm.put(props.urls.update, { preserveScroll: true }); + void commitDetails(); }; // Confirmation dialog (shared across customer delete / address delete / user unlink) @@ -540,6 +575,15 @@ const tabDefs = computed(() => [ +
+ + {{ t('drafts.restored', { time: new Date(draftRestoredFrom).toLocaleString() }) }} + +
+
@@ -565,34 +609,34 @@ const tabDefs = computed(() => [
{{ t('customers.field_title') }} -
{{ t('customers.field_first_name') }} - -
{{ detailsForm.errors.first_name }}
+ +
{{ detailsErrors.first_name }}
{{ t('customers.field_last_name') }} - -
{{ detailsForm.errors.last_name }}
+ +
{{ detailsErrors.last_name }}
{{ t('customers.field_company_name') }} - -
{{ detailsForm.errors.company_name }}
+ +
{{ detailsErrors.company_name }}
{{ t('customers.field_tax_identifier') }} - -
{{ detailsForm.errors.tax_identifier }}
+ +
{{ detailsErrors.tax_identifier }}
{{ t('customers.field_account_ref') }} - -
{{ detailsForm.errors.account_ref }}
+ +
{{ detailsErrors.account_ref }}
@@ -601,7 +645,7 @@ const tabDefs = computed(() => [
@@ -615,7 +659,7 @@ const tabDefs = computed(() => [ - {{ t('customers.no_groups') }} + {{ t('customers.no_groups') }}
[
- +
+ + {{ t('drafts.saving') }} + {{ t('drafts.saved') }} +
@@ -869,6 +917,13 @@ const tabDefs = computed(() => [ :confirm-label="confirmLabel" @confirm="confirmDestroy" /> + +
diff --git a/packages/panel/resources/js/ui.ts b/packages/panel/resources/js/ui.ts index 0fe8d0be36..908354e28a 100644 --- a/packages/panel/resources/js/ui.ts +++ b/packages/panel/resources/js/ui.ts @@ -42,6 +42,13 @@ export { default as Tooltip } from './components/Tooltip.vue'; export { default as SideCard } from './components/SideCard.vue'; export { default as Tabs } from './components/Tabs.vue'; +// Edit drafts +export { default as DraftConflictDialog } from './components/DraftConflictDialog.vue'; +export { useEditDraft } from './composables/useEditDraft'; +export type { DraftState, EditDraftForm, EditDraftOptions } from './composables/useEditDraft'; +export { DraftConflictError, HttpError, ValidationError, http } from './lib/http'; +export type { DraftConflict } from './lib/http'; + // Primitives export { default as Button } from './components/Button.vue'; export { default as Icon } from './components/Icon.vue'; diff --git a/packages/panel/resources/lang/ar/drafts.php b/packages/panel/resources/lang/ar/drafts.php new file mode 100644 index 0000000000..db2a383684 --- /dev/null +++ b/packages/panel/resources/lang/ar/drafts.php @@ -0,0 +1,18 @@ + 'تم حفظ التغييرات.', + 'restored' => 'تمت استعادة التغييرات غير المحفوظة من {time}.', + 'discard' => 'تجاهل', + 'saving' => 'جارٍ حفظ المسودة…', + 'saved' => 'تم حفظ المسودة', + 'conflict_title' => 'حل التغييرات المتعارضة', + 'conflict_description' => 'قام شخص آخر بتغيير هذه الحقول أثناء تحريرك. اختر القيمة التي تريد الاحتفاظ بها لكل حقل؛ وسيتم حفظ بقية تغييراتك دون تأثر.', + 'keep_mine' => 'الاحتفاظ بقيمتي', + 'take_theirs' => 'أخذ قيمتهم', + 'your_value' => 'قيمتك', + 'current_value' => 'القيمة الحالية', + 'base_note' => 'كانت {value} عندما بدأت التحرير', + 'empty' => 'فارغ', + 'apply' => 'تطبيق وحفظ', +]; diff --git a/packages/panel/resources/lang/bg/drafts.php b/packages/panel/resources/lang/bg/drafts.php new file mode 100644 index 0000000000..83b3eca882 --- /dev/null +++ b/packages/panel/resources/lang/bg/drafts.php @@ -0,0 +1,18 @@ + 'Промените са запазени.', + 'restored' => 'Възстановени са незапазени промени от {time}.', + 'discard' => 'Отхвърляне', + 'saving' => 'Записване на черновата…', + 'saved' => 'Черновата е запазена', + 'conflict_title' => 'Разрешаване на конфликтни промени', + 'conflict_description' => 'Някой друг промени тези полета, докато редактирахте. Изберете коя стойност да запазите за всяко поле; останалите ви промени ще бъдат запазени без промяна.', + 'keep_mine' => 'Запази моята', + 'take_theirs' => 'Вземи тяхната', + 'your_value' => 'Вашата стойност', + 'current_value' => 'Текуща стойност', + 'base_note' => 'Беше {value}, когато започнахте да редактирате', + 'empty' => 'Празно', + 'apply' => 'Приложи и запази', +]; diff --git a/packages/panel/resources/lang/de/drafts.php b/packages/panel/resources/lang/de/drafts.php new file mode 100644 index 0000000000..5c214a38c5 --- /dev/null +++ b/packages/panel/resources/lang/de/drafts.php @@ -0,0 +1,18 @@ + 'Änderungen gespeichert.', + 'restored' => 'Ungespeicherte Änderungen von {time} wiederhergestellt.', + 'discard' => 'Verwerfen', + 'saving' => 'Entwurf wird gespeichert…', + 'saved' => 'Entwurf gespeichert', + 'conflict_title' => 'Konflikte auflösen', + 'conflict_description' => 'Jemand anderes hat diese Felder während Ihrer Bearbeitung geändert. Wählen Sie für jedes Feld den zu behaltenden Wert; Ihre übrigen Änderungen werden unverändert gespeichert.', + 'keep_mine' => 'Meinen behalten', + 'take_theirs' => 'Deren übernehmen', + 'your_value' => 'Ihr Wert', + 'current_value' => 'Aktueller Wert', + 'base_note' => 'War {value}, als Sie mit der Bearbeitung begannen', + 'empty' => 'Leer', + 'apply' => 'Übernehmen und speichern', +]; diff --git a/packages/panel/resources/lang/en/drafts.php b/packages/panel/resources/lang/en/drafts.php new file mode 100644 index 0000000000..9a25c9e2a1 --- /dev/null +++ b/packages/panel/resources/lang/en/drafts.php @@ -0,0 +1,18 @@ + 'Changes saved.', + 'restored' => 'Unsaved changes from {time} restored.', + 'discard' => 'Discard', + 'saving' => 'Saving draft…', + 'saved' => 'Draft saved', + 'conflict_title' => 'Resolve conflicting changes', + 'conflict_description' => 'Someone else changed these fields while you were editing. Choose which value to keep for each field; your other changes will save unaffected.', + 'keep_mine' => 'Keep mine', + 'take_theirs' => 'Take theirs', + 'your_value' => 'Your value', + 'current_value' => 'Current value', + 'base_note' => 'Was {value} when you started editing', + 'empty' => 'Empty', + 'apply' => 'Apply and save', +]; diff --git a/packages/panel/resources/lang/es/drafts.php b/packages/panel/resources/lang/es/drafts.php new file mode 100644 index 0000000000..01736cfd0c --- /dev/null +++ b/packages/panel/resources/lang/es/drafts.php @@ -0,0 +1,18 @@ + 'Cambios guardados.', + 'restored' => 'Cambios sin guardar de {time} restaurados.', + 'discard' => 'Descartar', + 'saving' => 'Guardando borrador…', + 'saved' => 'Borrador guardado', + 'conflict_title' => 'Resolver cambios en conflicto', + 'conflict_description' => 'Otra persona modificó estos campos mientras editabas. Elige qué valor conservar en cada campo; el resto de tus cambios se guardarán sin cambios.', + 'keep_mine' => 'Mantener el mío', + 'take_theirs' => 'Tomar el suyo', + 'your_value' => 'Tu valor', + 'current_value' => 'Valor actual', + 'base_note' => 'Era {value} cuando empezaste a editar', + 'empty' => 'Vacío', + 'apply' => 'Aplicar y guardar', +]; diff --git a/packages/panel/resources/lang/fa/drafts.php b/packages/panel/resources/lang/fa/drafts.php new file mode 100644 index 0000000000..8e0c1267c4 --- /dev/null +++ b/packages/panel/resources/lang/fa/drafts.php @@ -0,0 +1,18 @@ + 'تغییرات ذخیره شد.', + 'restored' => 'تغییرات ذخیره‌نشده از {time} بازیابی شد.', + 'discard' => 'صرف‌نظر', + 'saving' => 'در حال ذخیره پیش‌نویس…', + 'saved' => 'پیش‌نویس ذخیره شد', + 'conflict_title' => 'حل تغییرات متعارض', + 'conflict_description' => 'شخص دیگری این فیلدها را هنگام ویرایش شما تغییر داده است. برای هر فیلد مقدار موردنظر را انتخاب کنید؛ سایر تغییرات شما بدون تغییر ذخیره می‌شوند.', + 'keep_mine' => 'حفظ مقدار من', + 'take_theirs' => 'پذیرش مقدار آن‌ها', + 'your_value' => 'مقدار شما', + 'current_value' => 'مقدار فعلی', + 'base_note' => 'هنگام شروع ویرایش {value} بود', + 'empty' => 'خالی', + 'apply' => 'اعمال و ذخیره', +]; diff --git a/packages/panel/resources/lang/fr/drafts.php b/packages/panel/resources/lang/fr/drafts.php new file mode 100644 index 0000000000..d5d67c2579 --- /dev/null +++ b/packages/panel/resources/lang/fr/drafts.php @@ -0,0 +1,18 @@ + 'Modifications enregistrées.', + 'restored' => 'Modifications non enregistrées du {time} restaurées.', + 'discard' => 'Abandonner', + 'saving' => 'Enregistrement du brouillon…', + 'saved' => 'Brouillon enregistré', + 'conflict_title' => 'Résoudre les modifications en conflit', + 'conflict_description' => "Quelqu'un d'autre a modifié ces champs pendant votre édition. Choisissez la valeur à conserver pour chaque champ ; vos autres modifications seront enregistrées sans changement.", + 'keep_mine' => 'Garder la mienne', + 'take_theirs' => 'Prendre la leur', + 'your_value' => 'Votre valeur', + 'current_value' => 'Valeur actuelle', + 'base_note' => 'Était {value} au début de votre édition', + 'empty' => 'Vide', + 'apply' => 'Appliquer et enregistrer', +]; diff --git a/packages/panel/resources/lang/hr/drafts.php b/packages/panel/resources/lang/hr/drafts.php new file mode 100644 index 0000000000..d87b8033e6 --- /dev/null +++ b/packages/panel/resources/lang/hr/drafts.php @@ -0,0 +1,18 @@ + 'Promjene su spremljene.', + 'restored' => 'Vraćene su nespremljene promjene od {time}.', + 'discard' => 'Odbaci', + 'saving' => 'Spremanje skice…', + 'saved' => 'Skica spremljena', + 'conflict_title' => 'Riješite sukobljene promjene', + 'conflict_description' => 'Netko drugi je promijenio ova polja dok ste uređivali. Odaberite koju vrijednost zadržati za svako polje; ostale promjene bit će spremljene netaknute.', + 'keep_mine' => 'Zadrži moju', + 'take_theirs' => 'Preuzmi njihovu', + 'your_value' => 'Vaša vrijednost', + 'current_value' => 'Trenutna vrijednost', + 'base_note' => 'Bila je {value} kad ste počeli uređivati', + 'empty' => 'Prazno', + 'apply' => 'Primijeni i spremi', +]; diff --git a/packages/panel/resources/lang/hu/drafts.php b/packages/panel/resources/lang/hu/drafts.php new file mode 100644 index 0000000000..e2b6b1a489 --- /dev/null +++ b/packages/panel/resources/lang/hu/drafts.php @@ -0,0 +1,18 @@ + 'A módosítások mentve.', + 'restored' => 'A(z) {time} időpontból származó nem mentett módosítások visszaállítva.', + 'discard' => 'Elvetés', + 'saving' => 'Piszkozat mentése…', + 'saved' => 'Piszkozat mentve', + 'conflict_title' => 'Ütköző módosítások feloldása', + 'conflict_description' => 'Valaki más módosította ezeket a mezőket a szerkesztés közben. Válassza ki, melyik értéket tartja meg mezőnként; a többi módosítása változatlanul mentésre kerül.', + 'keep_mine' => 'Az enyém megtartása', + 'take_theirs' => 'Az övék átvétele', + 'your_value' => 'Az Ön értéke', + 'current_value' => 'Jelenlegi érték', + 'base_note' => '{value} volt, amikor a szerkesztést elkezdte', + 'empty' => 'Üres', + 'apply' => 'Alkalmazás és mentés', +]; diff --git a/packages/panel/resources/lang/mn/drafts.php b/packages/panel/resources/lang/mn/drafts.php new file mode 100644 index 0000000000..a7d5a3cfbd --- /dev/null +++ b/packages/panel/resources/lang/mn/drafts.php @@ -0,0 +1,18 @@ + 'Өөрчлөлтүүд хадгалагдлаа.', + 'restored' => '{time}-ийн хадгалагдаагүй өөрчлөлтүүдийг сэргээлээ.', + 'discard' => 'Цуцлах', + 'saving' => 'Ноорог хадгалж байна…', + 'saved' => 'Ноорог хадгалагдлаа', + 'conflict_title' => 'Зөрчилтэй өөрчлөлтүүдийг шийдвэрлэх', + 'conflict_description' => 'Таныг засварлаж байх үед өөр хэн нэгэн эдгээр талбарыг өөрчилсөн байна. Талбар бүрд хадгалах утгаа сонгоно уу; бусад өөрчлөлтүүд тань хэвээр хадгалагдана.', + 'keep_mine' => 'Минийхийг үлдээх', + 'take_theirs' => 'Тэднийхийг авах', + 'your_value' => 'Таны утга', + 'current_value' => 'Одоогийн утга', + 'base_note' => 'Таныг засварлаж эхлэхэд {value} байсан', + 'empty' => 'Хоосон', + 'apply' => 'Хэрэглэж хадгалах', +]; diff --git a/packages/panel/resources/lang/nl/drafts.php b/packages/panel/resources/lang/nl/drafts.php new file mode 100644 index 0000000000..cda6719ce8 --- /dev/null +++ b/packages/panel/resources/lang/nl/drafts.php @@ -0,0 +1,18 @@ + 'Wijzigingen opgeslagen.', + 'restored' => 'Niet-opgeslagen wijzigingen van {time} hersteld.', + 'discard' => 'Verwerpen', + 'saving' => 'Concept opslaan…', + 'saved' => 'Concept opgeslagen', + 'conflict_title' => 'Conflicterende wijzigingen oplossen', + 'conflict_description' => 'Iemand anders heeft deze velden gewijzigd terwijl u aan het bewerken was. Kies per veld welke waarde u wilt behouden; uw overige wijzigingen worden ongewijzigd opgeslagen.', + 'keep_mine' => 'Mijn waarde behouden', + 'take_theirs' => 'Hun waarde overnemen', + 'your_value' => 'Uw waarde', + 'current_value' => 'Huidige waarde', + 'base_note' => 'Was {value} toen u begon met bewerken', + 'empty' => 'Leeg', + 'apply' => 'Toepassen en opslaan', +]; diff --git a/packages/panel/resources/lang/pl/drafts.php b/packages/panel/resources/lang/pl/drafts.php new file mode 100644 index 0000000000..402cfdd417 --- /dev/null +++ b/packages/panel/resources/lang/pl/drafts.php @@ -0,0 +1,18 @@ + 'Zmiany zapisane.', + 'restored' => 'Przywrócono niezapisane zmiany z {time}.', + 'discard' => 'Odrzuć', + 'saving' => 'Zapisywanie wersji roboczej…', + 'saved' => 'Wersja robocza zapisana', + 'conflict_title' => 'Rozwiąż konfliktowe zmiany', + 'conflict_description' => 'Ktoś inny zmienił te pola podczas Twojej edycji. Wybierz, którą wartość zachować dla każdego pola; pozostałe zmiany zostaną zapisane bez zmian.', + 'keep_mine' => 'Zachowaj moją', + 'take_theirs' => 'Przyjmij ich', + 'your_value' => 'Twoja wartość', + 'current_value' => 'Aktualna wartość', + 'base_note' => 'Było {value}, gdy rozpoczynano edycję', + 'empty' => 'Puste', + 'apply' => 'Zastosuj i zapisz', +]; diff --git a/packages/panel/resources/lang/pt_BR/drafts.php b/packages/panel/resources/lang/pt_BR/drafts.php new file mode 100644 index 0000000000..0e6220ad8a --- /dev/null +++ b/packages/panel/resources/lang/pt_BR/drafts.php @@ -0,0 +1,18 @@ + 'Alterações salvas.', + 'restored' => 'Alterações não salvas de {time} restauradas.', + 'discard' => 'Descartar', + 'saving' => 'Salvando rascunho…', + 'saved' => 'Rascunho salvo', + 'conflict_title' => 'Resolver alterações conflitantes', + 'conflict_description' => 'Outra pessoa alterou estes campos enquanto você editava. Escolha qual valor manter em cada campo; suas demais alterações serão salvas sem alteração.', + 'keep_mine' => 'Manter o meu', + 'take_theirs' => 'Usar o deles', + 'your_value' => 'Seu valor', + 'current_value' => 'Valor atual', + 'base_note' => 'Era {value} quando você começou a editar', + 'empty' => 'Vazio', + 'apply' => 'Aplicar e salvar', +]; diff --git a/packages/panel/resources/lang/ro/drafts.php b/packages/panel/resources/lang/ro/drafts.php new file mode 100644 index 0000000000..b6a70cb355 --- /dev/null +++ b/packages/panel/resources/lang/ro/drafts.php @@ -0,0 +1,18 @@ + 'Modificările au fost salvate.', + 'restored' => 'Modificările nesalvate din {time} au fost restaurate.', + 'discard' => 'Renunță', + 'saving' => 'Se salvează ciorna…', + 'saved' => 'Ciornă salvată', + 'conflict_title' => 'Rezolvați modificările în conflict', + 'conflict_description' => 'Altcineva a modificat aceste câmpuri în timp ce editați. Alegeți ce valoare păstrați pentru fiecare câmp; celelalte modificări vor fi salvate neatinse.', + 'keep_mine' => 'Păstrează-l pe al meu', + 'take_theirs' => 'Preia-l pe al lor', + 'your_value' => 'Valoarea dvs.', + 'current_value' => 'Valoarea curentă', + 'base_note' => 'Era {value} când ați început editarea', + 'empty' => 'Gol', + 'apply' => 'Aplică și salvează', +]; diff --git a/packages/panel/resources/lang/tr/drafts.php b/packages/panel/resources/lang/tr/drafts.php new file mode 100644 index 0000000000..5cab3b328f --- /dev/null +++ b/packages/panel/resources/lang/tr/drafts.php @@ -0,0 +1,18 @@ + 'Değişiklikler kaydedildi.', + 'restored' => '{time} tarihli kaydedilmemiş değişiklikler geri yüklendi.', + 'discard' => 'Vazgeç', + 'saving' => 'Taslak kaydediliyor…', + 'saved' => 'Taslak kaydedildi', + 'conflict_title' => 'Çakışan değişiklikleri çözün', + 'conflict_description' => 'Siz düzenlerken başka biri bu alanları değiştirdi. Her alan için hangi değerin korunacağını seçin; diğer değişiklikleriniz olduğu gibi kaydedilecek.', + 'keep_mine' => 'Benimkini koru', + 'take_theirs' => 'Onlarınkini al', + 'your_value' => 'Sizin değeriniz', + 'current_value' => 'Geçerli değer', + 'base_note' => 'Düzenlemeye başladığınızda {value} idi', + 'empty' => 'Boş', + 'apply' => 'Uygula ve kaydet', +]; diff --git a/packages/panel/resources/lang/vi/drafts.php b/packages/panel/resources/lang/vi/drafts.php new file mode 100644 index 0000000000..20e6703094 --- /dev/null +++ b/packages/panel/resources/lang/vi/drafts.php @@ -0,0 +1,18 @@ + 'Đã lưu các thay đổi.', + 'restored' => 'Đã khôi phục các thay đổi chưa lưu từ {time}.', + 'discard' => 'Hủy bỏ', + 'saving' => 'Đang lưu bản nháp…', + 'saved' => 'Đã lưu bản nháp', + 'conflict_title' => 'Giải quyết các thay đổi xung đột', + 'conflict_description' => 'Người khác đã thay đổi các trường này trong khi bạn chỉnh sửa. Chọn giá trị cần giữ cho từng trường; các thay đổi khác của bạn sẽ được lưu nguyên vẹn.', + 'keep_mine' => 'Giữ của tôi', + 'take_theirs' => 'Lấy của họ', + 'your_value' => 'Giá trị của bạn', + 'current_value' => 'Giá trị hiện tại', + 'base_note' => 'Là {value} khi bạn bắt đầu chỉnh sửa', + 'empty' => 'Trống', + 'apply' => 'Áp dụng và lưu', +]; diff --git a/packages/panel/resources/panel-package/index.js b/packages/panel/resources/panel-package/index.js index f22ba2d8d4..11cca7d0c7 100644 --- a/packages/panel/resources/panel-package/index.js +++ b/packages/panel/resources/panel-package/index.js @@ -42,6 +42,19 @@ export const Tooltip = ui().Tooltip; export const SideCard = ui().SideCard; export const Tabs = ui().Tabs; +// Edit drafts +export const DraftConflictDialog = ui().DraftConflictDialog; +export const useEditDraft = (...args) => ui().useEditDraft(...args); +export const http = { + get: (...args) => ui().http.get(...args), + post: (...args) => ui().http.post(...args), + patch: (...args) => ui().http.patch(...args), + delete: (...args) => ui().http.delete(...args), +}; +export const DraftConflictError = ui().DraftConflictError; +export const HttpError = ui().HttpError; +export const ValidationError = ui().ValidationError; + // Primitives export const Button = ui().Button; export const Icon = ui().Icon; diff --git a/packages/panel/src/Contracts/DraftManager.php b/packages/panel/src/Contracts/DraftManager.php new file mode 100644 index 0000000000..cd3a5661fc --- /dev/null +++ b/packages/panel/src/Contracts/DraftManager.php @@ -0,0 +1,39 @@ + $data + */ + public function merge(DraftableResource $resource, Model $draftable, Authenticatable $staff, array $data): ?EditDraft; + + public function discard(Model $draftable, Authenticatable $staff): void; + + /** + * Attempt to commit: overlay the request's final diff onto the stored + * draft, apply any snapshot rebases from conflict resolution, validate + * the full payload, then detect per-field conflicts. Applies everything + * or nothing — any conflict means no field is written. + * + * @param array $data + * @param array $rebase field key to the current-DB value the client resolved against + * + * @throws ValidationException + */ + public function commit(DraftableResource $resource, Model $draftable, Authenticatable $staff, array $data, array $rebase = []): CommitResult; +} diff --git a/packages/panel/src/Contracts/DraftableResource.php b/packages/panel/src/Contracts/DraftableResource.php new file mode 100644 index 0000000000..ef542373b3 --- /dev/null +++ b/packages/panel/src/Contracts/DraftableResource.php @@ -0,0 +1,65 @@ + */ + public function model(): string; + + /** + * The field keys this resource drafts; autosave and commit reject any + * key outside this set. + * + * @return array + */ + public function fields(): array; + + /** + * The current, normalised database value of every draftable field key. + * + * @return array + */ + public function currentValues(Model $record): array; + + /** + * Normalise incoming draft values into the same shape currentValues() + * reports, so equality comparison is meaningful (e.g. empty string to + * null for nullable text columns, sorted unique ids for relation keys). + * + * @param array $data + * @return array + */ + public function normalize(array $data): array; + + /** + * Validation rules for a full commit payload (every field key present). + * + * @return array + */ + public function rules(Model $record): array; + + /** + * Apply a validated, conflict-free full value set to the record. Always + * delegates to the core action contracts; the panel never writes model + * fields directly. + * + * @param array $values + */ + public function commit(Model $record, array $values): void; + + /** + * Field key to lang key, for conflict-dialog field labels. Keys without + * an entry fall back to the raw field key. + * + * @return array + */ + public function labels(): array; +} diff --git a/packages/panel/src/Drafts/CommitResult.php b/packages/panel/src/Drafts/CommitResult.php new file mode 100644 index 0000000000..910ce536b7 --- /dev/null +++ b/packages/panel/src/Drafts/CommitResult.php @@ -0,0 +1,27 @@ + $conflicts + */ + private function __construct( + public readonly bool $committed, + public readonly array $conflicts, + ) {} + + public static function committed(): self + { + return new self(true, []); + } + + /** + * @param array $conflicts + */ + public static function conflicted(array $conflicts): self + { + return new self(false, $conflicts); + } +} diff --git a/packages/panel/src/Drafts/DraftManager.php b/packages/panel/src/Drafts/DraftManager.php new file mode 100644 index 0000000000..eeeab46c6f --- /dev/null +++ b/packages/panel/src/Drafts/DraftManager.php @@ -0,0 +1,219 @@ +where('draftable_type', $draftable->getMorphClass()) + ->where('draftable_id', $draftable->getKey()) + ->where('staff_id', $staff->getAuthIdentifier()) + ->first(); + } + + public function merge(DraftableResource $resource, Model $draftable, Authenticatable $staff, array $data): ?EditDraft + { + $data = $resource->normalize($data); + + $this->assertKnownFields($resource, $data); + + if ($data === []) { + $this->discard($draftable, $staff); + + return null; + } + + $draft = $this->find($draftable, $staff) ?? $this->newDraft($draftable, $staff); + + $snapshot = $this->snapshotFor($draft, $data, $resource->currentValues($draftable)); + + return $this->persist($draft, $data, $snapshot); + } + + public function discard(Model $draftable, Authenticatable $staff): void + { + $this->find($draftable, $staff)?->delete(); + } + + public function commit(DraftableResource $resource, Model $draftable, Authenticatable $staff, array $data, array $rebase = []): CommitResult + { + $data = $resource->normalize($data); + $rebase = $resource->normalize($rebase); + + $this->assertKnownFields($resource, $data); + $this->assertKnownFields($resource, $rebase); + + $draft = $this->find($draftable, $staff); + + // Overlay the request's final diff onto the stored draft — unlike + // autosave's wholesale replace, commit must not drop fields another + // tab may have drafted since this client last loaded. + $merged = [...($draft?->data ?? []), ...$data]; + + if ($merged === []) { + return CommitResult::committed(); + } + + $current = $resource->currentValues($draftable); + + $snapshot = $this->snapshotFor($draft, $merged, $current); + + // A rebase pins a resolved field's snapshot to the current-DB value + // the user was shown; if the DB moves again before this commit lands, + // the field conflicts again rather than silently overwriting. + foreach ($rebase as $key => $value) { + if (array_key_exists($key, $merged)) { + $snapshot[$key] = $value; + } + } + + $draft = $this->persist($draft ?? $this->newDraft($draftable, $staff), $merged, $snapshot); + + $this->validator + ->make([...$current, ...$merged], $resource->rules($draftable)) + ->validate(); + + if ($conflicts = $this->detectConflicts($resource, $merged, $snapshot, $current)) { + return CommitResult::conflicted($conflicts); + } + + $this->db->connection()->transaction(function () use ($resource, $draftable, $current, $merged, $draft): void { + $resource->commit($draftable, [...$current, ...$merged]); + $draft->delete(); + }); + + return CommitResult::committed(); + } + + /** + * @param array $data + * @param array $snapshot + * @param array $current + * @return array + */ + protected function detectConflicts(DraftableResource $resource, array $data, array $snapshot, array $current): array + { + $conflicts = []; + + foreach ($data as $key => $mine) { + $base = $snapshot[$key] ?? null; + $theirs = $current[$key] ?? null; + + if (! $this->valuesMatch($base, $theirs)) { + $conflicts[] = [ + 'key' => $key, + 'label' => __($resource->labels()[$key] ?? $key), + 'mine' => $mine, + 'base' => $base, + 'theirs' => $theirs, + ]; + } + } + + return $conflicts; + } + + /** + * The base snapshot for a draft about to hold $data: existing snapshot + * entries are preserved (a field's base stays fixed for the life of the + * draft), newly-drafted keys capture the record's current value, and keys + * no longer drafted are dropped. + * + * @param array $data + * @param array $current + * @return array + */ + protected function snapshotFor(?EditDraft $draft, array $data, array $current): array + { + $existing = $draft?->base_snapshot ?? []; + + $snapshot = []; + + foreach (array_keys($data) as $key) { + $snapshot[$key] = array_key_exists($key, $existing) + ? $existing[$key] + : ($current[$key] ?? null); + } + + return $snapshot; + } + + /** + * @param array $data + * @param array $snapshot + */ + protected function persist(EditDraft $draft, array $data, array $snapshot): EditDraft + { + $draft->fill(['data' => $data, 'base_snapshot' => $snapshot])->save(); + + return $draft; + } + + protected function newDraft(Model $draftable, Authenticatable $staff): EditDraft + { + return new EditDraft([ + 'draftable_type' => $draftable->getMorphClass(), + 'draftable_id' => $draftable->getKey(), + 'staff_id' => $staff->getAuthIdentifier(), + ]); + } + + /** + * Values match when their normalised JSON encodings are identical — + * associative arrays compare order-insensitively, lists order-sensitively. + */ + protected function valuesMatch(mixed $a, mixed $b): bool + { + return $this->encode($a) === $this->encode($b); + } + + protected function encode(mixed $value): string + { + return (string) json_encode($this->sortKeys($value)); + } + + protected function sortKeys(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + $value = array_map(fn (mixed $item): mixed => $this->sortKeys($item), $value); + + if (! array_is_list($value)) { + ksort($value); + } + + return $value; + } + + /** + * @param array $data + * + * @throws ValidationException + */ + protected function assertKnownFields(DraftableResource $resource, array $data): void + { + if ($unknown = array_diff(array_keys($data), $resource->fields())) { + throw ValidationException::withMessages([ + 'data' => 'Unknown draft field ['.implode(', ', $unknown).'].', + ]); + } + } +} diff --git a/packages/panel/src/Drafts/DraftableResource.php b/packages/panel/src/Drafts/DraftableResource.php new file mode 100644 index 0000000000..bfab5c9506 --- /dev/null +++ b/packages/panel/src/Drafts/DraftableResource.php @@ -0,0 +1,23 @@ + $data + * @return array + */ + public function normalize(array $data): array + { + return $data; + } + + /** @return array */ + public function labels(): array + { + return []; + } +} diff --git a/packages/panel/src/Http/Controllers/Customers/CustomerEditController.php b/packages/panel/src/Http/Controllers/Customers/CustomerEditController.php index 13ea5d881a..829e2436e6 100644 --- a/packages/panel/src/Http/Controllers/Customers/CustomerEditController.php +++ b/packages/panel/src/Http/Controllers/Customers/CustomerEditController.php @@ -16,7 +16,9 @@ use Lunar\Core\Models\Customer; use Lunar\Core\Models\CustomerGroup; use Lunar\Core\Models\Order; +use Lunar\Panel\Contracts\DraftManager; use Lunar\Panel\Http\Requests\Customers\CustomerRequest; +use Lunar\Panel\PanelManager; use Spatie\Activitylog\Models\Activity; class CustomerEditController @@ -33,10 +35,13 @@ class CustomerEditController '10y' => ['months' => 120, 'bucket' => 'year'], ]; - public function edit(Request $request, Customer $customer): Response + public function edit(Request $request, Customer $customer, PanelManager $panel, DraftManager $drafts): Response { $customer->load('customerGroups:id,name'); + $staff = $panel->user(); + $draft = $staff ? $drafts->find($customer, $staff) : null; + $addresses = $customer->addresses()->latest()->get()->map(fn (Address $address) => [ 'id' => $address->id, 'title' => $address->title, @@ -141,6 +146,10 @@ public function edit(Request $request, Customer $customer): Response 'orders' => $orders, 'stats' => $stats, 'orderChart' => $this->orderChart($request, $customer), + 'draft' => $draft ? [ + 'data' => $draft->data, + 'updated_at' => $draft->updated_at?->toJSON(), + ] : null, 'urls' => [ 'index' => route('panel.customers.index'), 'update' => route('panel.customers.update', $customer), @@ -148,6 +157,8 @@ public function edit(Request $request, Customer $customer): Response 'addressesStore' => route('panel.customers.addresses.store', $customer), 'usersStore' => route('panel.customers.users.store', $customer), 'notesUpdate' => route('panel.customers.notes.update', $customer), + 'draft' => route('panel.customers.draft.update', $customer), + 'draftCommit' => route('panel.customers.draft.commit', $customer), ], ]); } diff --git a/packages/panel/src/Http/Controllers/EditDraftController.php b/packages/panel/src/Http/Controllers/EditDraftController.php new file mode 100644 index 0000000000..7e889be8d7 --- /dev/null +++ b/packages/panel/src/Http/Controllers/EditDraftController.php @@ -0,0 +1,100 @@ +draftable($request, $panel); + + $data = $request->validate(['data' => ['present', 'array']])['data']; + + $draft = $drafts->merge($resource, $record, $this->staff($panel), $data); + + return response()->json([ + 'data' => $draft?->data ?? (object) [], + 'updated_at' => $draft?->updated_at?->toJSON(), + ]); + } + + public function destroy(Request $request, PanelManager $panel, DraftManager $drafts): Response + { + [$record] = $this->draftable($request, $panel); + + $drafts->discard($record, $this->staff($panel)); + + return response()->noContent(); + } + + public function commit(Request $request, PanelManager $panel, DraftManager $drafts): JsonResponse + { + [$record, $resource] = $this->draftable($request, $panel); + + $payload = $request->validate([ + 'data' => ['sometimes', 'array'], + 'rebase' => ['sometimes', 'array'], + ]); + + $result = $drafts->commit( + $resource, + $record, + $this->staff($panel), + $payload['data'] ?? [], + $payload['rebase'] ?? [], + ); + + if (! $result->committed) { + return response()->json(['conflicts' => $result->conflicts], 409); + } + + $request->session()->flash('success', __('panel::drafts.flash_committed')); + + return response()->json(['committed' => true]); + } + + /** + * The bound draftable record and its registered resource definition: the + * first route-bound Eloquent model, 404 when nothing is bound or no + * definition covers it. + * + * @return array{0: Model, 1: DraftableResource} + */ + protected function draftable(Request $request, PanelManager $panel): array + { + $record = collect($request->route()?->parameters() ?? []) + ->first(fn (mixed $parameter): bool => $parameter instanceof Model); + + abort_unless($record instanceof Model, 404); + + $resource = $panel->draftableFor($record); + + abort_unless((bool) $resource, 404); + + return [$record, $resource]; + } + + protected function staff(PanelManager $panel): Authenticatable + { + $staff = $panel->user(); + + abort_unless((bool) $staff, 401); + + return $staff; + } +} diff --git a/packages/panel/src/Models/EditDraft.php b/packages/panel/src/Models/EditDraft.php new file mode 100644 index 0000000000..e64c21ba02 --- /dev/null +++ b/packages/panel/src/Models/EditDraft.php @@ -0,0 +1,55 @@ + */ + protected $guarded = []; + + /** @var array */ + protected $casts = [ + 'data' => 'array', + 'base_snapshot' => 'array', + ]; + + public function draftable(): MorphTo + { + return $this->morphTo(); + } + + public function staff(): BelongsTo + { + return $this->belongsTo(Staff::class); + } + + /** + * Drafts untouched beyond the configured TTL: their base snapshots are too + * stale for trustworthy conflict comparison, so they prune rather than + * resume. + * + * @return Builder + */ + public function prunable(): Builder + { + return static::query()->where( + 'updated_at', + '<', + now()->subDays((int) config('lunar.panel.drafts.ttl_days', 7)), + ); + } +} diff --git a/packages/panel/src/PanelManager.php b/packages/panel/src/PanelManager.php index 531f330fba..29c3cf85e6 100644 --- a/packages/panel/src/PanelManager.php +++ b/packages/panel/src/PanelManager.php @@ -4,9 +4,12 @@ use Closure; use Illuminate\Contracts\Auth\Authenticatable; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Lunar\Panel\Actions\PageActionResolver; +use Lunar\Panel\Contracts\DraftableResource; +use Lunar\Panel\Models\EditDraft; use Lunar\Panel\Navigation\NavigationRegistry; use Lunar\Panel\Sections\ProvidesNavigation; use Lunar\Panel\Sections\Section; @@ -33,6 +36,9 @@ class PanelManager /** @var array */ protected array $pageActions = []; + /** @var array, DraftableResource> */ + protected array $draftables = []; + /** @var Closure[] */ protected array $routeRegistrars = []; @@ -133,6 +139,10 @@ private function processEntity(string $sectionKey, ProvidesNavigation $entity): } } + foreach ($entity->draftables() as $definitionClass) { + $this->draftable($definitionClass); + } + if ($viteConfig = $entity->vite()) { $this->vite($this->viteKeyFor($sectionKey, $entity), $viteConfig); } @@ -209,6 +219,41 @@ public function resolvePageActions(string $pageId): PageActionResolver return new PageActionResolver($this->getPageActions($pageId), $this->user()); } + /** + * Register a draftable-resource definition, keyed by its model class. + * Registration also removes a record's drafts when the record is deleted, + * so drafts never point at gone records. + * + * @param class-string $definitionClass + */ + public function draftable(string $definitionClass): static + { + /** @var DraftableResource $definition */ + $definition = app($definitionClass); + + $model = $definition->model(); + + if (isset($this->draftables[$model])) { + Log::warning("Lunar Panel: draftable resource for [{$model}] is already registered and will be overwritten."); + } else { + $model::deleted(function (Model $record): void { + EditDraft::query() + ->where('draftable_type', $record->getMorphClass()) + ->where('draftable_id', $record->getKey()) + ->delete(); + }); + } + + $this->draftables[$model] = $definition; + + return $this; + } + + public function draftableFor(Model $model): ?DraftableResource + { + return $this->draftables[$model::class] ?? null; + } + /** * Expose translator namespaces to the panel frontend: every lang group * under each namespace is served by the translations endpoint as diff --git a/packages/panel/src/PanelServiceProvider.php b/packages/panel/src/PanelServiceProvider.php index 33f61fe9d3..cf51304ca5 100644 --- a/packages/panel/src/PanelServiceProvider.php +++ b/packages/panel/src/PanelServiceProvider.php @@ -2,6 +2,7 @@ namespace Lunar\Panel; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -9,9 +10,12 @@ use Lunar\Panel\Auth\EmailTwoFactor; use Lunar\Panel\Console\Commands\InstallPanelCommand; use Lunar\Panel\Console\Commands\LinkPanelAssetsCommand; +use Lunar\Panel\Contracts\DraftManager as DraftManagerContract; +use Lunar\Panel\Drafts\DraftManager; use Lunar\Panel\Facades\Panel; use Lunar\Panel\Http\Middleware\Authenticate; use Lunar\Panel\Http\Middleware\HandlePanelInertiaRequests; +use Lunar\Panel\Models\EditDraft; use Lunar\Panel\Navigation\NavigationItem; use Lunar\Panel\Sections\Sales\SalesSection; use Lunar\Panel\Sections\Settings\ChannelsSection; @@ -35,6 +39,8 @@ public function register(): void $this->app->singleton(AppAuthentication::class); $this->app->singleton(EmailTwoFactor::class); + + $this->app->singleton(DraftManagerContract::class, DraftManager::class); } public function boot(): void @@ -42,6 +48,10 @@ public function boot(): void $this->loadViewsFrom("{$this->root}/resources/views", 'panel'); $this->loadTranslationsFrom("{$this->root}/resources/lang", 'panel'); + if (! config('lunar.database.disable_migrations', false)) { + $this->loadMigrationsFrom("{$this->root}/database/migrations"); + } + if ($this->app->runningInConsole()) { collect($this->configFiles)->each(function ($config) { $this->publishes([ @@ -58,6 +68,10 @@ public function boot(): void InstallPanelCommand::class, LinkPanelAssetsCommand::class, ]); + + $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void { + $schedule->command('model:prune', ['--model' => [EditDraft::class]])->daily(); + }); } $this->registerPermissionGate(); diff --git a/packages/panel/src/Sections/ProvidesNavigation.php b/packages/panel/src/Sections/ProvidesNavigation.php index fc0bde15ae..cb34914d6e 100644 --- a/packages/panel/src/Sections/ProvidesNavigation.php +++ b/packages/panel/src/Sections/ProvidesNavigation.php @@ -3,6 +3,7 @@ namespace Lunar\Panel\Sections; use Closure; +use Lunar\Panel\Contracts\DraftableResource; use Lunar\Panel\Navigation\NavigationRegistry; use Lunar\Panel\Slots\SlotRegistry; @@ -28,6 +29,14 @@ public function tableExtensions(): array; */ public function pageActions(): array; + /** + * Draftable-resource definitions for edit forms, e.g. + * [CustomerDraftResource::class]. + * + * @return array> + */ + public function draftables(): array; + /** * @return array{input?: string|string[], hotFile?: string|null, buildDirectory?: string, __buildSourcePath?: string}|string|null */ diff --git a/packages/panel/src/Sections/Sales/CustomerDraftResource.php b/packages/panel/src/Sections/Sales/CustomerDraftResource.php new file mode 100644 index 0000000000..07443538ea --- /dev/null +++ b/packages/panel/src/Sections/Sales/CustomerDraftResource.php @@ -0,0 +1,109 @@ + $record->title, + 'first_name' => $record->first_name, + 'last_name' => $record->last_name, + 'company_name' => $record->company_name, + 'tax_identifier' => $record->tax_identifier, + 'account_ref' => $record->account_ref, + 'customer_group_ids' => $this->sortedIds($record->customerGroups()->allRelatedIds()->all()), + ]; + } + + public function normalize(array $data): array + { + foreach (self::NULLABLE_TEXT_FIELDS as $field) { + if (array_key_exists($field, $data) && $data[$field] === '') { + $data[$field] = null; + } + } + + if (array_key_exists('customer_group_ids', $data)) { + $data['customer_group_ids'] = $this->sortedIds((array) $data['customer_group_ids']); + } + + return $data; + } + + public function rules(Model $record): array + { + return (new CustomerRequest)->rules(); + } + + public function commit(Model $record, array $values): void + { + /** @var Customer $record */ + $this->updatesCustomer->execute( + $record, + collect($values)->except('customer_group_ids')->all(), + $values['customer_group_ids'] ?? [], + ); + } + + public function labels(): array + { + return [ + 'title' => 'panel::customers.field_title', + 'first_name' => 'panel::customers.field_first_name', + 'last_name' => 'panel::customers.field_last_name', + 'company_name' => 'panel::customers.field_company_name', + 'tax_identifier' => 'panel::customers.field_tax_identifier', + 'account_ref' => 'panel::customers.field_account_ref', + 'customer_group_ids' => 'panel::customers.customer_groups', + ]; + } + + /** + * @param array $ids + * @return array + */ + protected function sortedIds(array $ids): array + { + $ids = array_values(array_unique(array_map('intval', $ids))); + + sort($ids); + + return $ids; + } +} diff --git a/packages/panel/src/Sections/Sales/SalesSection.php b/packages/panel/src/Sections/Sales/SalesSection.php index 749b62cae5..5712ba0200 100644 --- a/packages/panel/src/Sections/Sales/SalesSection.php +++ b/packages/panel/src/Sections/Sales/SalesSection.php @@ -4,12 +4,14 @@ use Closure; use Illuminate\Support\Facades\Route; +use Lunar\Panel\Contracts\DraftableResource; use Lunar\Panel\Http\Controllers\Customers\CustomerAddressController; use Lunar\Panel\Http\Controllers\Customers\CustomerCreateController; use Lunar\Panel\Http\Controllers\Customers\CustomerEditController; use Lunar\Panel\Http\Controllers\Customers\CustomerIndexController; use Lunar\Panel\Http\Controllers\Customers\CustomerNotesController; use Lunar\Panel\Http\Controllers\Customers\CustomerUserController; +use Lunar\Panel\Http\Controllers\EditDraftController; use Lunar\Panel\Navigation\NavigationItem; use Lunar\Panel\Navigation\NavigationRegistry; use Lunar\Panel\Sections\Sales\Tables\CustomersTableExtension; @@ -54,6 +56,14 @@ public function tableExtensions(): array ]; } + /** @return array> */ + public function draftables(): array + { + return [ + CustomerDraftResource::class, + ]; + } + public function routes(): ?Closure { return function (): void { @@ -66,6 +76,10 @@ public function routes(): ?Closure Route::delete('/{customer}', [CustomerEditController::class, 'destroy'])->name('destroy'); Route::put('/{customer}/notes', [CustomerNotesController::class, 'update'])->name('notes.update'); + Route::patch('/{customer}/draft', [EditDraftController::class, 'update'])->name('draft.update'); + Route::delete('/{customer}/draft', [EditDraftController::class, 'destroy'])->name('draft.destroy'); + Route::post('/{customer}/draft/commit', [EditDraftController::class, 'commit'])->name('draft.commit'); + Route::scopeBindings()->group(function (): void { Route::post('/{customer}/addresses', [CustomerAddressController::class, 'store'])->name('addresses.store'); Route::put('/{customer}/addresses/{address}', [CustomerAddressController::class, 'update'])->name('addresses.update'); diff --git a/packages/panel/src/Sections/Section.php b/packages/panel/src/Sections/Section.php index 9d01e3302a..f1397d0a27 100644 --- a/packages/panel/src/Sections/Section.php +++ b/packages/panel/src/Sections/Section.php @@ -3,6 +3,7 @@ namespace Lunar\Panel\Sections; use Closure; +use Lunar\Panel\Contracts\DraftableResource; use Lunar\Panel\Navigation\NavigationRegistry; use Lunar\Panel\Slots\SlotRegistry; @@ -48,6 +49,17 @@ public function pageActions(): array return []; } + /** + * Return draftable-resource definitions for edit forms in this section, + * e.g. [CustomerDraftResource::class]. + * + * @return array> + */ + public function draftables(): array + { + return []; + } + /** * Return a Vite config to register for this section, or null to skip. * diff --git a/packages/panel/src/Sections/SectionExtension.php b/packages/panel/src/Sections/SectionExtension.php index 8dfa35d668..fff8143686 100644 --- a/packages/panel/src/Sections/SectionExtension.php +++ b/packages/panel/src/Sections/SectionExtension.php @@ -3,6 +3,7 @@ namespace Lunar\Panel\Sections; use Closure; +use Lunar\Panel\Contracts\DraftableResource; use Lunar\Panel\Navigation\NavigationRegistry; use Lunar\Panel\Slots\SlotRegistry; @@ -40,6 +41,17 @@ public function pageActions(): array return []; } + /** + * Return draftable-resource definitions for edit forms this extension + * contributes, e.g. [CustomerDraftResource::class]. + * + * @return array> + */ + public function draftables(): array + { + return []; + } + /** * Return a Vite config to register for this extension, or null to skip. * Registered under a key derived from the target section and this class diff --git a/specs/0051-panel-edit-drafts.md b/specs/0051-panel-edit-drafts.md new file mode 100644 index 0000000000..e78cef60f0 --- /dev/null +++ b/specs/0051-panel-edit-drafts.md @@ -0,0 +1,336 @@ +# 0051 — Panel edit drafts and field-level conflict detection + +- Status: implemented +- Author: Glenn Jacobs +- Created: 2026-07-17 +- TODO item: Panel edit drafts — autosaved pending edits with field-level conflict detection (spec 0051) + +## Problem + +Panel edit pages lose work and overwrite work. + +- **Lost work**: an edit form (`customers/Edit.vue`, `settings/channels/Edit.vue`) holds + state only in an Inertia `useForm`. Navigating away, a session timeout, or a browser + crash discards everything typed since the last save. There is no dirty-state guard and + no persistence of in-progress edits. +- **Silent overwrites**: saves are last-write-wins. Two staff members editing the same + customer both submit the full field set; whoever saves second reverts the other's + changes without either noticing. Nothing in core or the panel detects concurrent + edits — no version column, no `updated_at` comparison, no lock (the activity log + records what happened, after the fact). + +Row-level optimistic locking (reject the save if `updated_at` moved) would fix the +overwrite but at the wrong granularity: two staff editing *different* fields on the same +record would block each other for no reason. + +## Proposal + +A drafts layer in `lunarphp/panel`: in-progress edits autosave to an `edit_drafts` row +(one per staff member per record), and commits detect conflicts **per field** by +comparing each drafted field's base snapshot against the current database value. Staff +editing disjoint fields never collide; staff editing the same field get a scoped +resolution UI instead of a silent overwrite. + +The feature lives entirely in the panel package. Core stays headless — drafts exist only +because of admin editing, so core gains no awareness of them; the `edit_drafts` table +references core models purely through its polymorphic relation. The Filament admin is +untouched (a Livewire-native equivalent can be built later if wanted; see Alternatives). + +First wiring target: the customer details form. Its field set (scalar columns plus the +relation-backed `customer_group_ids` array) exercises both value shapes the design must +handle, without the multi-form complexity of the addresses/users tabs. Channels and +future sections follow by registering a definition — no new infrastructure. + +### Data model + +New table `edit_drafts` (standard `lunar.database.table_prefix`), the panel package's +first migration (`packages/panel/database/migrations/`, extending +`Lunar\Core\Database\Migration` for the prefix, loaded via `loadMigrationsFrom` as the +stripe and opayo packages do): + +| Column | Type | Notes | +|---|---|---| +| `id` | bigint | PK | +| `draftable_type` | string | morph alias via `Base::morphName()` (e.g. `customer`) | +| `draftable_id` | unsignedBigInteger | all core models use bigint PKs | +| `staff_id` | foreignId | constrained to the prefixed `staff` table, `cascadeOnDelete` | +| `data` | json | changed fields only, keyed by field key: `{"first_name": "Glenn"}` | +| `base_snapshot` | json | DB value of each drafted field at the moment it was first touched | +| `created_at` / `updated_at` | timestamps | `updated_at` doubles as the staleness clock | + +Unique index on `(draftable_type, draftable_id, staff_id)` — one draft per staff member +per record. Model: `Lunar\Panel\Models\EditDraft` with a `draftable()` morphTo and a +`staff()` belongsTo. The owner column is `staff_id`, not `user_id`: the panel +authenticates `Lunar\Core\Models\Staff` via the staff guard, and storefront users never +touch drafts. + +#### The base snapshot + +When a field first enters `data`, its current database value is captured into +`base_snapshot` under the same key. Subsequent edits to that field update `data` only — +the snapshot stays fixed until commit or discard. Commit-time conflict detection is then +a per-key comparison: `base_snapshot[key]` versus the current DB value. Match means no +one else changed the field since the draft started tracking it; mismatch means someone +did. No audit trail, version columns, or core changes required. + +#### Field keys and value normalisation + +A draft's `data`/`base_snapshot` keys are **field keys** — stable, dot-notation +identifiers for independently-conflictable units of a form: + +- Scalar columns: the column name (`first_name`, `account_ref`). +- Translated JSON columns (products later): one key per locale — `name.en`, `name.fr` — + so two staff translating into different languages never conflict. +- Attribute data (products later): `attribute_data.{handle}`, comparing the raw + persisted value (`FieldType::jsonSerialize()`), since FieldType instances are not + meaningfully `==`-comparable but their raw values are. +- Relation-backed form fields: a resource-defined key resolving to a normalised value — + `customer_group_ids` resolves to a sorted integer array of the current pivot ids. + +Values are normalised before both storage and comparison: JSON encoding with recursively +sorted object keys, resource-defined per-field normalisation (empty string to null for +nullable text columns, sorted unique ids for relation arrays). Comparison is equality of +the normalised encodings. Related *rows* (prices, addresses) are not fields — they are +separate editing surfaces out of scope here (see Out of scope). + +### Service layer + +New `Lunar\Panel\Drafts\` namespace, following the monorepo service-layer rules +(constructor-promoted DI, interfaces in `Contracts/`, no facades-for-forwarding): + +- **`Contracts\DraftableResource`** — a per-resource definition, the seam that makes + drafts generic: + - `model(): string` — the draftable model class. + - `fields(): array` — the allowed field keys; autosave rejects keys outside this set. + - `currentValues(Model $record): array` — current normalised DB value per field key + (this is where `customer_group_ids` reads the pivot). + - `rules(Model $record): array` — validation rules for a full commit payload (the + customer definition reuses `CustomerRequest::rules()`). + - `commit(Model $record, array $values): void` — applies resolved values through the + existing core action contracts (`UpdatesCustomer` for customers); the panel never + writes model fields directly. + - `labels(): array` — field key to lang key, for the conflict dialog. +- **`Contracts\DraftManager`** / **`Drafts\DraftManager`** — the operations, each taking + the record, the staff member, and the registered definition: + - `merge($record, $staff, array $data): EditDraft` — upsert the draft; replace `data` + wholesale with the incoming diff, capture `base_snapshot` entries for newly-present + keys via `currentValues()`, drop snapshot entries for keys no longer present. An + empty diff deletes the row (the form returned to clean). + - `discard($record, $staff): void` — delete the row. + - `commit($record, $staff, array $data, array $rebase = []): CommitResult` — merge + `$data` first (the client's final un-debounced diff), apply any `$rebase` snapshot + updates (see the resolution protocol), validate the merged full payload against + `rules()`, detect conflicts, and either apply everything through + `DraftableResource::commit()` and delete the draft, or return the conflict set + having applied **nothing**. +- **Registry**: sections declare their draftable resources through a new + `Section::draftables(): array` hook (same optional-hook pattern as `tableExtensions()` + and `pageActions()`); `PanelManager` indexes them by morph alias. Registering a + definition also attaches a `deleted` listener to the model class that removes its + drafts, so drafts never point at gone records. + +#### Commit and conflict detection + +1. Merge the request's `data` diff into the draft (capturing snapshots for new keys). +2. Validate the full payload — the record's `currentValues()` overlaid with the draft's + `data` — against `rules()`. Failures return 422 before any conflict work. +3. For each key in `data`, compare `base_snapshot[key]` to the current DB value. +4. **No conflicts**: apply the full value set via `DraftableResource::commit()` inside a + transaction, delete the draft, flash success into the session, return 200. +5. **Any conflict**: apply nothing. Return 409 with, per conflicting field: + `{key, label, mine, base, theirs}` — the drafted value, the value the draft started + from, and the current DB value. + +Commits are **atomic**: a commit with conflicts applies no fields, including +non-conflicting ones. The requirements sketch proposed applying clean fields +immediately and 409-ing only the rest; that leaves the record in a half-intended state +(change `first_name` + `last_name`, conflict on `last_name`, and the record briefly +carries a mismatched name), splits one logical save into two writes with two activity +entries, and means validation ran against a payload that is then only partially applied. +Resolution is an immediate, in-dialog step — holding the clean fields in the draft for +the seconds it takes costs nothing. Flagged in Open questions since it deviates from the +initial requirements. + +The disjoint-fields case still works exactly as intended: staff A drafting `first_name` +and staff B drafting `customer_group_ids` both commit cleanly, in either order, with no +conflict shown to anyone. Only same-field edits collide. + +#### Resolution protocol + +On 409 the client shows the conflict dialog; the user resolves each field (keep mine / +take theirs / manual merge). Re-commit sends the resolved values in `data` plus +`rebase: {key: theirs}` — for each resolved key, the **current-DB value the user was +shown**. The server sets `base_snapshot[key]` to the rebase value, then runs the normal +commit flow. If the DB moved *again* between showing the dialog and re-committing, the +rebase value no longer matches and the field conflicts again — the user never silently +overwrites a version they did not see. (Rebasing to "current value at re-commit time" +instead would be a time-of-check race; the client must pin the version it resolved +against.) + +### HTTP layer + +Three routes per resource, registered inside the owning section's route closure next to +the existing CRUD (inheriting auth middleware and the section's `can:` permission — for +customers, `sales:manage-customers`), all handled by one shared +`Http\Controllers\EditDraftController` that resolves the `DraftableResource` from the +bound model's morph alias: + +| Method | Route | Purpose | +|---|---|---| +| `PATCH` | `customers/{customer}/draft` | `DraftManager::merge()`; returns `{data, updated_at}` | +| `DELETE` | `customers/{customer}/draft` | discard | +| `POST` | `customers/{customer}/draft/commit` | commit; 200 / 409 conflicts / 422 validation | + +There is no `GET /draft`: the edit page is an Inertia visit, so the edit controller +includes a `draft` prop (`{data, updated_at}` or `null`) resolved for the current staff +member. Only the draft owner is ever addressed — the routes operate on "the current +user's draft for this record", so no cross-staff access exists to authorise. + +Commit is `POST` rather than the sketch's `PUT`: it is not idempotent (it mutates the +record, deletes the draft, and writes activity/search side effects). + +Drafts are invisible to other staff until committed: no "someone has unsaved changes" +indicator, no table badge, no locking. Conflicts surface exactly once, at commit, scoped +to the fields that actually collided. + +### Inertia panel UI + +The panel currently performs every request through Inertia — deliberately unsuitable +here, since Inertia's cycle is page-visit-shaped and its error contract is validation +errors, not a 409 conflict payload. Autosave and commit therefore go through a new +panel-owned fetch helper; everything else on the page stays Inertia. + +- **`resources/js/lib/http.ts`** — a thin wrapper over native `fetch` (no new npm + dependency): same-origin JSON requests, `X-XSRF-TOKEN` from the cookie, throws typed + `DraftConflictError` (carrying the 409 conflict set) and `ValidationError` (422 + errors bag). This becomes the panel's sanctioned non-Inertia transport, exported on + `ui.ts` for add-ons with the same need. +- **`resources/js/composables/useEditDraft.ts`** — the reusable form driver: + - Input: pristine server values, the `draft` prop, and the three endpoint URLs. + - Exposes reactive `values` (pristine overlaid with the draft), `isDirty`, autosave + state (`saving` / `savedAt`), `errors`, `conflicts`, and `commit()` / `discard()` / + `resolve()`. + - Autosave: a debounced (~750 ms) watcher diffs `values` against pristine and + `PATCH`es the diff; a diff shrinking to empty issues the `DELETE`. In-flight + autosaves are serialised so a stale response never clobbers newer state. + - `commit()` sends the current diff (not waiting out the debounce), on success clears + local draft state and `router.reload({preserveScroll: true})` — the endpoint + flashed success into the session, so the existing server-flash `FlashMessage` + displays it with no new toast mechanism. On `DraftConflictError` it populates + `conflicts`; on `ValidationError`, `errors` (rendered inline exactly as + `form.errors` is today). +- **`resources/js/components/DraftConflictDialog.vue`** — a `Dialog`-based component + following the panel's `v-model:open` overlay convention. One row per conflicted + field: the translated label, "your value" and "current value" side by side (the base + value shown subtly for context), a keep-mine / take-theirs choice, and an editable + value input for manual merges. Confirming emits the resolutions; the composable + re-commits with the rebase payload. Cancelling leaves the draft intact. +- **`customers/Edit.vue`** — the details form moves from `useForm` to `useEditDraft`. + When the page hydrates from an existing draft, a banner shows "restored unsaved + changes from {time}" with a discard action. Save commits. The notes, addresses, and + users forms keep their existing direct Inertia flows. +- **Exports**: `useEditDraft`, `DraftConflictDialog`, and the http helper join `ui.ts` + (and the mirrored `@lunarphp/panel` index), so add-on edit pages get drafts by + registering a `DraftableResource` and consuming the same composable. +- **Translations**: new `drafts.php` lang group (banner, autosave states, dialog copy, + flash messages, conflict labels) in `en` first, mirrored across all 16 locales. + +### Expiry + +Drafts untouched for 7 days (configurable, `lunar.panel.drafts.ttl_days`) are pruned: +`EditDraft` implements `MassPrunable` with a `prunable()` scope on `updated_at`, and +`PanelServiceProvider` schedules the prune daily via `callAfterResolving(Schedule::class)` +— the same pattern core uses for `lunar:prune:carts`. A week-old base snapshot is stale +enough that its conflict comparisons stop being trustworthy; pruning turns "resume a +dead draft and fight phantom conflicts" into "start fresh". + +## Alternatives considered + +- **Row-level optimistic locking** (version column or `updated_at` check) — rejected: + blocks disjoint-field edits, the common concurrent case in a back office, and offers + no resolution path beyond "reload and retype". +- **Partial apply on conflict** (the requirements sketch: apply clean fields, 409 the + rest) — rejected for atomic commit; reasoning inline above. Revisit if real usage + shows long-lived conflict-resolution sessions where holding clean fields hurts. +- **Drafts in core** — rejected: core must stay headless; drafts exist only because of + admin editing. The polymorphic relation gives the panel everything it needs without + core awareness. If Filament later wants equivalent behaviour, build a Livewire-native + version or extract a shared service once this pattern is proven. +- **axios for the non-Inertia calls** — rejected: the panel has no HTTP client today + and the need (JSON, CSRF header, typed 409/422) is a few dozen lines over native + `fetch`. Adding a runtime dependency to the panel bundle — and implicitly blessing it + for every add-on — is not warranted. +- **Reusing Inertia partial visits for autosave** — rejected per the requirements: + autosave must not enter the page-visit cycle (scroll/focus/history side effects), and + commit's 409 payload has no home in Inertia's error contract. +- **A "someone is editing" presence indicator** — deferred: it needs its own freshness + mechanism (polling or sockets) and reintroduces soft-lock social pressure the + field-level design exists to avoid. The conflict dialog already covers the case where + it matters. +- **Full-snapshot drafts** (store every field, diff at commit) — rejected: diffs keep + payloads small and, more importantly, scope conflict detection to fields the user + actually touched — a full snapshot would flag conflicts on fields they never edited. + +## Migration impact + +- **Database**: one new table, `edit_drafts`, as the panel package's first migration + (panel-owned directory, not the core baseline — the table is a panel feature, and + non-core packages shipping their own migrations has precedent in stripe/opayo). No + changes to core tables. +- **Breaking changes**: none. New surface only. +- **Upgrade path**: none required; v2 is unreleased and v1 has no equivalent feature. +- **Translations**: new `drafts.php` group across all 16 panel locales. +- **Filament / admin impact**: none. Both panels continue to share the staff guard; a + Filament user and a panel drafter editing the same record are exactly the + concurrent-edit case the commit check catches. +- **Public contract surface** (treated as contract from first release): the + `DraftableResource` and `DraftManager` contracts, `Section::draftables()`, the + `EditDraft` model and table, the three endpoint shapes and their 409/422 payloads, + the rebase protocol, the `draft` Inertia prop shape, and the `ui.ts` exports + (`useEditDraft`, `DraftConflictDialog`, the http helper). Config: + `lunar.panel.drafts.ttl_days`. + +## Open questions + +- ~~Atomic vs partial-apply commit~~ — resolved: implemented atomic (apply nothing when + any field conflicts), deviating from the initial requirements' partial apply for the + reasons argued inline. +- ~~Should the customer notes sidebar form join the draft scope~~ — resolved: stays a + direct save; it is a separate endpoint and low-contention. +- ~~Host-app models outside the core morph map~~ — resolved: FQCN morph aliases in + `draftable_type` are acceptable; registration does not require an alias. +- ~~Multi-tab behaviour for the same staff member~~ — resolved: last-write-wins on the + single draft row (the unique index makes it safe); commit overlay-merges rather than + replacing, so a commit never drops fields another tab drafted. A same-user cross-tab + merge UI is deliberately out of scope. + +## References + +- Initial requirements: "Lunar v2: Pending Edit Drafts & Field-Level Conflict Detection" + (this spec supersedes it; deviations — staff ownership, atomic commit, fetch over + axios, POST commit — are called out inline). +- [[0049-inertia-panel]] — panel architecture, section hooks, `ui.ts` add-on surface. +- [[0019-attribute-system-redesign]] / [[0018-dedicated-name-description-fields]] — the + value shapes behind the field-key scheme. +- [[0016-service-layer-di]] — action/contract conventions the commit path follows. +- Prior art: Shopify admin's unsaved-changes bar (draft persistence UX); git's + three-way merge (base/mine/theirs resolution model). + +## Implementation plan + +- [x] Slice 1 — Persistence + service layer: `edit_drafts` migration, `EditDraft` model + (`MassPrunable` + scheduled prune), `DraftableResource` / `DraftManager` contracts and + implementation (merge/discard/commit, snapshot capture, normalisation, conflict + detection, rebase), `Section::draftables()` registry + deleted-record cleanup; unit + tests for the conflict matrix. +- [x] Slice 2 — HTTP layer: shared `EditDraftController`, the three customer routes, + `CustomerDraftResource` (fields, group-ids resolver, `CustomerRequest` rules, + `UpdatesCustomer` commit), `draft` prop on the customer edit response, session flash + on commit; feature tests covering autosave lifecycle, 409/422/200 paths, rebase + re-commit, ownership, and pruning. +- [x] Slice 3 — Frontend driver: `lib/http.ts` fetch wrapper with typed errors, + `useEditDraft` composable (debounced diff autosave, serialised in-flight requests, + commit/discard/resolve); vitest coverage. +- [x] Slice 4 — UI wiring: `DraftConflictDialog`, customer `Edit.vue` on `useEditDraft` + with the restored-draft banner, `drafts.php` lang group across 16 locales, `ui.ts` / + `@lunarphp/panel` exports; Inertia page tests and a vitest pass over the dialog. diff --git a/specs/README.md b/specs/README.md index ba17a7ef2b..24fd8f19dd 100644 --- a/specs/README.md +++ b/specs/README.md @@ -75,3 +75,5 @@ Each spec carries a `Status:` line in its frontmatter / header: | 0047 | Promotions: a campaign layer over discounts | declined | | 0048 | Rename `product_variants.purchasable` to `selling_policy` | implemented | | 0049 | Inertia admin panel (`lunarphp/panel`) | implemented | +| 0050 | Panel order-value chart and public charting component | accepted | +| 0051 | Panel edit drafts and field-level conflict detection | implemented | diff --git a/tests/panel/Feature/Customers/CustomerDraftTest.php b/tests/panel/Feature/Customers/CustomerDraftTest.php new file mode 100644 index 0000000000..0fef40c318 --- /dev/null +++ b/tests/panel/Feature/Customers/CustomerDraftTest.php @@ -0,0 +1,219 @@ +create(['admin' => true]); + + test()->actingAs($staff, 'staff'); + + return $staff; +} + +it('autosaves a draft and returns its state', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ]) + ->assertOk() + ->assertJsonPath('data.first_name', 'Changed') + ->assertJsonPath('updated_at', fn (?string $value) => $value !== null); + + expect(EditDraft::count())->toBe(1); +}); + +it('deletes the draft when an empty diff is autosaved', function () { + actingStaff(); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + $this->patchJson(route('panel.customers.draft.update', $customer), ['data' => []]) + ->assertOk() + ->assertJsonPath('updated_at', null); + + expect(EditDraft::count())->toBe(0); +}); + +it('rejects autosaves with unknown field keys', function () { + actingStaff(); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['admin_notes' => 'not draftable'], + ])->assertUnprocessable(); +}); + +it('hydrates the edit page with the current staff member\'s draft', function () { + actingStaff(); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + $this->get(route('panel.customers.edit', $customer)) + ->assertInertia(fn (Assert $page) => $page + ->where('draft.data.first_name', 'Changed') + ->whereNot('draft.updated_at', null) + ->where('urls.draft', route('panel.customers.draft.update', $customer)) + ->where('urls.draftCommit', route('panel.customers.draft.commit', $customer)) + ); +}); + +it('keeps drafts invisible to other staff', function () { + actingStaff(); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + $this->actingAs(Staff::factory()->create(['admin' => true]), 'staff'); + + $this->get(route('panel.customers.edit', $customer)) + ->assertInertia(fn (Assert $page) => $page->where('draft', null)); +}); + +it('discards a draft', function () { + actingStaff(); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + $this->deleteJson(route('panel.customers.draft.destroy', $customer)) + ->assertNoContent(); + + expect(EditDraft::count())->toBe(0); +}); + +it('commits a draft, applies the changes and flashes success', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + $this->postJson(route('panel.customers.draft.commit', $customer)) + ->assertOk() + ->assertJsonPath('committed', true) + ->assertSessionHas('success'); + + expect($customer->refresh()->first_name)->toBe('Changed') + ->and(EditDraft::count())->toBe(0); +}); + +it('merges the request diff into the draft at commit time', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original', 'company_name' => 'Acme']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertOk(); + + // The un-debounced final keystrokes ride along with the commit. + $this->postJson(route('panel.customers.draft.commit', $customer), [ + 'data' => ['company_name' => 'Globex'], + ])->assertOk(); + + expect($customer->refresh()->first_name)->toBe('Changed') + ->and($customer->company_name)->toBe('Globex'); +}); + +it('returns 409 with per-field detail when a drafted field conflicts', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original', 'company_name' => 'Acme']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Mine', 'company_name' => 'Globex'], + ])->assertOk(); + + $customer->update(['first_name' => 'Theirs']); + + $this->postJson(route('panel.customers.draft.commit', $customer)) + ->assertStatus(409) + ->assertJsonCount(1, 'conflicts') + ->assertJsonPath('conflicts.0.key', 'first_name') + ->assertJsonPath('conflicts.0.label', __('panel::customers.field_first_name')) + ->assertJsonPath('conflicts.0.mine', 'Mine') + ->assertJsonPath('conflicts.0.base', 'Original') + ->assertJsonPath('conflicts.0.theirs', 'Theirs'); + + // Atomic: nothing applied, the draft survives for resolution. + expect($customer->refresh()->company_name)->toBe('Acme') + ->and(EditDraft::count())->toBe(1); +}); + +it('commits resolved conflicts sent with a rebase payload', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Mine'], + ])->assertOk(); + + $customer->update(['first_name' => 'Theirs']); + + $this->postJson(route('panel.customers.draft.commit', $customer))->assertStatus(409); + + $this->postJson(route('panel.customers.draft.commit', $customer), [ + 'data' => ['first_name' => 'Mine'], + 'rebase' => ['first_name' => 'Theirs'], + ])->assertOk(); + + expect($customer->refresh()->first_name)->toBe('Mine'); +}); + +it('returns 422 when the merged payload fails validation', function () { + actingStaff(); + + $customer = Customer::factory()->create(['first_name' => 'Original']); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => ''], + ])->assertOk(); + + $this->postJson(route('panel.customers.draft.commit', $customer)) + ->assertUnprocessable() + ->assertJsonValidationErrors('first_name'); + + expect($customer->refresh()->first_name)->toBe('Original'); +}); + +it('requires the manage-customers permission', function () { + $this->actingAs(Staff::factory()->create(['admin' => false]), 'staff'); + + $customer = Customer::factory()->create(); + + $this->patchJson(route('panel.customers.draft.update', $customer), [ + 'data' => ['first_name' => 'Changed'], + ])->assertForbidden(); + + $this->postJson(route('panel.customers.draft.commit', $customer))->assertForbidden(); + + $this->deleteJson(route('panel.customers.draft.destroy', $customer))->assertForbidden(); +}); diff --git a/tests/panel/Feature/Drafts/DraftManagerTest.php b/tests/panel/Feature/Drafts/DraftManagerTest.php new file mode 100644 index 0000000000..06252bd334 --- /dev/null +++ b/tests/panel/Feature/Drafts/DraftManagerTest.php @@ -0,0 +1,282 @@ +create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Changed']); + + expect($draft->data)->toBe(['first_name' => 'Changed']) + ->and($draft->base_snapshot)->toBe(['first_name' => 'Original']); +}); + +it('keeps the base snapshot fixed across further edits to the same field', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'First edit']); + + // The record moves on underneath; the snapshot must not follow it. + $customer->update(['first_name' => 'Moved']); + + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Second edit']); + + expect($draft->data)->toBe(['first_name' => 'Second edit']) + ->and($draft->base_snapshot)->toBe(['first_name' => 'Original']); +}); + +it('replaces data wholesale on merge, dropping snapshots for reverted fields', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original', 'company_name' => 'Acme']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, [ + 'first_name' => 'Changed', + 'company_name' => 'Globex', + ]); + + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, ['company_name' => 'Globex']); + + expect($draft->data)->toBe(['company_name' => 'Globex']) + ->and($draft->base_snapshot)->toBe(['company_name' => 'Acme']); +}); + +it('deletes the draft when the diff shrinks to empty', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Changed']); + + expect(EditDraft::count())->toBe(1); + + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, []); + + expect($draft)->toBeNull() + ->and(EditDraft::count())->toBe(0); +}); + +it('rejects field keys outside the resource definition', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['admin_notes' => 'sneaky']); +})->throws(ValidationException::class); + +it('normalises empty strings to null for nullable text fields', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['company_name' => null]); + + // '' from the form equals the stored null, so nothing is dirty. + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, ['company_name' => '']); + + expect($draft->data)->toBe(['company_name' => null]); + + $result = draftManager()->commit(customerDraftResource(), $customer, $staff, []); + + expect($result->committed)->toBeTrue(); +}); + +it('normalises customer group ids to a sorted unique set', function () { + $staff = Staff::factory()->create(); + $groups = CustomerGroup::factory()->count(2)->create(); + [$low, $high] = $groups->sortBy('id')->values(); + + $customer = Customer::factory()->create(); + $customer->customerGroups()->sync([$low->id, $high->id]); + + // Same membership in a different order is not a change. + $draft = draftManager()->merge(customerDraftResource(), $customer, $staff, [ + 'customer_group_ids' => [(string) $high->id, $low->id, $low->id], + ]); + + expect($draft->data['customer_group_ids'])->toBe([$low->id, $high->id]); + + $result = draftManager()->commit(customerDraftResource(), $customer, $staff, []); + + expect($result->committed)->toBeTrue() + ->and($customer->refresh()->customerGroups)->toHaveCount(2); +}); + +it('commits cleanly when no other write touched the drafted fields', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Changed']); + + $result = draftManager()->commit(customerDraftResource(), $customer, $staff, []); + + expect($result->committed)->toBeTrue() + ->and($result->conflicts)->toBe([]) + ->and($customer->refresh()->first_name)->toBe('Changed') + ->and(EditDraft::count())->toBe(0); +}); + +it('lets two staff editing disjoint fields both commit without conflict', function () { + $staffA = Staff::factory()->create(); + $staffB = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original', 'company_name' => 'Acme']); + + draftManager()->merge(customerDraftResource(), $customer, $staffA, ['first_name' => 'Alice']); + draftManager()->merge(customerDraftResource(), $customer, $staffB, ['company_name' => 'Globex']); + + $resultA = draftManager()->commit(customerDraftResource(), $customer, $staffA, []); + $resultB = draftManager()->commit(customerDraftResource(), $customer->refresh(), $staffB, []); + + expect($resultA->committed)->toBeTrue() + ->and($resultB->committed)->toBeTrue() + ->and($customer->refresh()->first_name)->toBe('Alice') + ->and($customer->company_name)->toBe('Globex'); +}); + +it('flags a conflict when another write changed a drafted field, applying nothing', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original', 'company_name' => 'Acme']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, [ + 'first_name' => 'Mine', + 'company_name' => 'Globex', + ]); + + // A concurrent editor wins the race on first_name. + $customer->update(['first_name' => 'Theirs']); + + $result = draftManager()->commit(customerDraftResource(), $customer, $staff, []); + + expect($result->committed)->toBeFalse() + ->and($result->conflicts)->toHaveCount(1) + ->and($result->conflicts[0])->toMatchArray([ + 'key' => 'first_name', + 'mine' => 'Mine', + 'base' => 'Original', + 'theirs' => 'Theirs', + ]) + // Atomic: the clean company_name change must not have applied either. + ->and($customer->refresh()->company_name)->toBe('Acme') + ->and(EditDraft::count())->toBe(1); +}); + +it('commits after a rebase pinning the resolved field to the value the user saw', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Mine']); + $customer->update(['first_name' => 'Theirs']); + + expect(draftManager()->commit(customerDraftResource(), $customer, $staff, [])->committed)->toBeFalse(); + + $result = draftManager()->commit( + customerDraftResource(), + $customer, + $staff, + ['first_name' => 'Mine'], + rebase: ['first_name' => 'Theirs'], + ); + + expect($result->committed)->toBeTrue() + ->and($customer->refresh()->first_name)->toBe('Mine'); +}); + +it('re-conflicts when the record moves again after the user resolved', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Mine']); + $customer->update(['first_name' => 'Theirs']); + + expect(draftManager()->commit(customerDraftResource(), $customer, $staff, [])->committed)->toBeFalse(); + + // The user resolved against 'Theirs', but a third write lands first. + $customer->update(['first_name' => 'Newer still']); + + $result = draftManager()->commit( + customerDraftResource(), + $customer, + $staff, + ['first_name' => 'Mine'], + rebase: ['first_name' => 'Theirs'], + ); + + expect($result->committed)->toBeFalse() + ->and($result->conflicts[0]['theirs'])->toBe('Newer still') + ->and($customer->refresh()->first_name)->toBe('Newer still'); +}); + +it('validates the full payload before conflict detection', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(['first_name' => 'Original']); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => '']); + + draftManager()->commit(customerDraftResource(), $customer, $staff, []); +})->throws(ValidationException::class); + +it('treats a commit with no draft and no diff as a no-op success', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(); + + $result = draftManager()->commit(customerDraftResource(), $customer, $staff, []); + + expect($result->committed)->toBeTrue(); +}); + +it('keeps one draft per staff member per record', function () { + $staffA = Staff::factory()->create(); + $staffB = Staff::factory()->create(); + $customer = Customer::factory()->create(); + + draftManager()->merge(customerDraftResource(), $customer, $staffA, ['first_name' => 'A']); + draftManager()->merge(customerDraftResource(), $customer, $staffA, ['first_name' => 'A2']); + draftManager()->merge(customerDraftResource(), $customer, $staffB, ['first_name' => 'B']); + + expect(EditDraft::count())->toBe(2) + ->and(draftManager()->find($customer, $staffA)->data['first_name'])->toBe('A2') + ->and(draftManager()->find($customer, $staffB)->data['first_name'])->toBe('B'); +}); + +it('removes drafts when their record is deleted', function () { + $staff = Staff::factory()->create(); + $customer = Customer::factory()->create(); + + draftManager()->merge(customerDraftResource(), $customer, $staff, ['first_name' => 'Changed']); + + $customer->delete(); + + expect(EditDraft::count())->toBe(0); +}); + +it('prunes drafts untouched beyond the configured ttl', function () { + $staff = Staff::factory()->create(); + $stale = Customer::factory()->create(); + $fresh = Customer::factory()->create(); + + draftManager()->merge(customerDraftResource(), $stale, $staff, ['first_name' => 'Old']); + + $this->travel(8)->days(); + + draftManager()->merge(customerDraftResource(), $fresh, $staff, ['first_name' => 'New']); + + $this->artisan('model:prune', ['--model' => [EditDraft::class]]); + + expect(EditDraft::count())->toBe(1) + ->and(EditDraft::first()->draftable_id)->toBe($fresh->id); +}); From 8e6102493c21330a26a1fc2559a99b8842185b94 Mon Sep 17 00:00:00 2001 From: Glenn Jacobs Date: Fri, 17 Jul 2026 13:02:58 +0100 Subject: [PATCH 2/7] Move the draft save cluster into the sticky breadcrumb bar Adds a standardised DraftActions component (autosave status, Discard, Save) that renders in the Breadcrumbs #actions slot only while there is something to save, Shopify-style. The customer edit page swaps its Docs button, in-form save row, and restored-draft banner for the cluster; a hidden submit keeps Enter-to-save working. Exported on ui.ts and the @lunarphp/panel fallback for add-ons. Co-Authored-By: Claude Fable 5 --- .../js/components/DraftActions.test.ts | 84 +++++++++++++++++++ .../resources/js/components/DraftActions.vue | 42 ++++++++++ .../resources/js/pages/customers/Edit.vue | 49 ++++------- packages/panel/resources/js/ui.ts | 1 + .../panel/resources/panel-package/index.js | 1 + 5 files changed, 145 insertions(+), 32 deletions(-) create mode 100644 packages/panel/resources/js/components/DraftActions.test.ts create mode 100644 packages/panel/resources/js/components/DraftActions.vue diff --git a/packages/panel/resources/js/components/DraftActions.test.ts b/packages/panel/resources/js/components/DraftActions.test.ts new file mode 100644 index 0000000000..ed56ee219d --- /dev/null +++ b/packages/panel/resources/js/components/DraftActions.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { computed, reactive, ref } from 'vue'; +import DraftActions from './DraftActions.vue'; +import type { EditDraftForm } from '../composables/useEditDraft'; + +function fakeForm(overrides: Partial> = {}): EditDraftForm> { + return { + values: reactive({}), + errors: ref({}), + conflicts: ref([]), + isDirty: computed(() => false), + saving: ref(false), + committing: ref(false), + savedAt: ref(null), + hasDraft: ref(false), + restoredFrom: ref(null), + commit: vi.fn().mockResolvedValue(true), + resolve: vi.fn().mockResolvedValue(true), + discard: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as EditDraftForm>; +} + +const buttons = (wrapper: ReturnType) => wrapper.findAll('button'); + +describe('DraftActions', () => { + it('renders nothing while the form is clean', () => { + const wrapper = mount(DraftActions, { props: { form: fakeForm() } }); + + expect(wrapper.find('button').exists()).toBe(false); + }); + + it('shows discard and save once the form is dirty', () => { + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true) }) }, + }); + + const labels = buttons(wrapper).map((button) => button.text()); + + expect(labels).toEqual(['drafts.discard', 'common.save_changes']); + }); + + it('stays visible for a stored draft even when local values are clean', () => { + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ hasDraft: ref(true) }) }, + }); + + expect(buttons(wrapper)).toHaveLength(2); + }); + + it('shows the autosave status text', () => { + const saving = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true), saving: ref(true) }) }, + }); + const saved = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true), savedAt: ref('2026-07-17T00:00:00Z') }) }, + }); + + expect(saving.find('[role="status"]').text()).toBe('drafts.saving'); + expect(saved.find('[role="status"]').text()).toBe('drafts.saved'); + }); + + it('commits on save and discards on discard', async () => { + const form = fakeForm({ isDirty: computed(() => true) }); + const wrapper = mount(DraftActions, { props: { form } }); + + await buttons(wrapper)[1].trigger('click'); + expect(form.commit).toHaveBeenCalledTimes(1); + + await buttons(wrapper)[0].trigger('click'); + expect(form.discard).toHaveBeenCalledTimes(1); + }); + + it('disables both buttons while committing', () => { + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true), committing: ref(true) }) }, + }); + + for (const button of buttons(wrapper)) { + expect(button.attributes('disabled')).toBeDefined(); + } + }); +}); diff --git a/packages/panel/resources/js/components/DraftActions.vue b/packages/panel/resources/js/components/DraftActions.vue new file mode 100644 index 0000000000..f4f05fca5f --- /dev/null +++ b/packages/panel/resources/js/components/DraftActions.vue @@ -0,0 +1,42 @@ + + + diff --git a/packages/panel/resources/js/pages/customers/Edit.vue b/packages/panel/resources/js/pages/customers/Edit.vue index f3920f6ad7..4fc0e74cde 100644 --- a/packages/panel/resources/js/pages/customers/Edit.vue +++ b/packages/panel/resources/js/pages/customers/Edit.vue @@ -10,6 +10,7 @@ import Breadcrumbs, { type BreadcrumbItem } from '../../components/Breadcrumbs.v import Combobox from '../../components/Combobox.vue'; import ConfirmDialog from '../../components/ConfirmDialog.vue'; import Dialog from '../../components/Dialog.vue'; +import DraftActions from '../../components/DraftActions.vue'; import DraftConflictDialog from '../../components/DraftConflictDialog.vue'; import FieldLabel from '../../components/FieldLabel.vue'; import FilterDropdown, { type FilterOption } from '../../components/FilterDropdown.vue'; @@ -147,19 +148,9 @@ const headerUsers = computed(() => { // Personal details + customer groups, driven by an autosaving edit draft: // dirty fields persist server-side and commit with field-level conflict -// detection instead of a last-write-wins PUT. -const { - values: details, - errors: detailsErrors, - conflicts: draftConflicts, - saving: draftSaving, - committing: draftCommitting, - savedAt: draftSavedAt, - restoredFrom: draftRestoredFrom, - commit: commitDetails, - resolve: resolveDraft, - discard: discardDraft, -} = useEditDraft({ +// detection instead of a last-write-wins PUT. The save cluster lives in the +// sticky breadcrumb bar via . +const draftForm = useEditDraft({ initial: { title: props.customer.title ?? '', first_name: props.customer.first_name, @@ -173,6 +164,15 @@ const { urls: { draft: props.urls.draft, commit: props.urls.draftCommit }, }); +const { + values: details, + errors: detailsErrors, + conflicts: draftConflicts, + committing: draftCommitting, + commit: commitDetails, + resolve: resolveDraft, +} = draftForm; + const conflictOpen = computed({ get: () => draftConflicts.value.length > 0, set: (value: boolean) => { @@ -540,9 +540,7 @@ const tabDefs = computed(() => [
@@ -575,15 +573,6 @@ const tabDefs = computed(() => [ -
- - {{ t('drafts.restored', { time: new Date(draftRestoredFrom).toLocaleString() }) }} - -
-
@@ -672,13 +661,9 @@ const tabDefs = computed(() => [
-
-
- - {{ t('drafts.saving') }} - {{ t('drafts.saved') }} -
-
+ +
+ + diff --git a/packages/panel/resources/js/layouts/SettingsShell.vue b/packages/panel/resources/js/layouts/SettingsShell.vue index 89f749b732..d3b7c438aa 100644 --- a/packages/panel/resources/js/layouts/SettingsShell.vue +++ b/packages/panel/resources/js/layouts/SettingsShell.vue @@ -9,7 +9,7 @@ import Icon from '../components/Icon.vue'; import PageActions, { type PageAction } from '../components/PageActions.vue'; import PageHeader from '../components/PageHeader.vue'; import PageZone from '../components/PageZone.vue'; -import FlashMessage from '../components/FlashMessage.vue'; +import Toaster from '../components/Toaster.vue'; import { useNavState } from '../composables/useNavState'; // Settings pages get the same scaffold as top-level pages: a breadcrumb bar @@ -42,8 +42,6 @@ const crumbs = computed(() => { const panelName = computed(() => (usePage().props.panel as { name: string }).name); const pageActions = computed(() => (usePage().props.pageActions as PageAction[] | undefined) ?? []); -const flashSuccess = computed(() => (usePage().props.flash as { success?: string })?.success); -const flashError = computed(() => (usePage().props.flash as { error?: string })?.error); const isDesktop = ref(typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 1024px)').matches : true); @@ -192,14 +190,13 @@ onUnmounted(() => {
- - -
+ + diff --git a/packages/panel/resources/js/pages/account/Security.vue b/packages/panel/resources/js/pages/account/Security.vue index 0719d0b9be..a6e6b65c28 100644 --- a/packages/panel/resources/js/pages/account/Security.vue +++ b/packages/panel/resources/js/pages/account/Security.vue @@ -1,11 +1,10 @@