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/composer.json b/composer.json index d09b4ffeef..1e8ccbbd61 100644 --- a/composer.json +++ b/composer.json @@ -72,6 +72,7 @@ "Lunar\\Meilisearch\\": "packages/meilisearch/src/", "Lunar\\Opayo\\": "packages/opayo/src/", "Lunar\\Panel\\": "packages/panel/src/", + "Lunar\\Panel\\Database\\Factories\\": "packages/panel/database/factories", "Lunar\\Paypal\\": "packages/paypal/src/", "Lunar\\Search\\": "packages/search/src/", "Lunar\\Shipping\\": "packages/table-rate-shipping/src", diff --git a/packages/panel/composer.json b/packages/panel/composer.json index 80ce452ea5..87898e0958 100644 --- a/packages/panel/composer.json +++ b/packages/panel/composer.json @@ -4,7 +4,8 @@ "license": "MIT", "autoload": { "psr-4": { - "Lunar\\Panel\\": "src/" + "Lunar\\Panel\\": "src/", + "Lunar\\Panel\\Database\\Factories\\": "database/factories" } }, "minimum-stability": "dev", 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/factories/EditDraftFactory.php b/packages/panel/database/factories/EditDraftFactory.php new file mode 100644 index 0000000000..1dea5a37f1 --- /dev/null +++ b/packages/panel/database/factories/EditDraftFactory.php @@ -0,0 +1,35 @@ + Customer::morphName(), + 'draftable_id' => Customer::factory(), + 'staff_id' => Staff::factory(), + 'data' => ['first_name' => $this->faker->firstName()], + 'base_snapshot' => ['first_name' => $this->faker->firstName()], + ]; + } +} 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/DraftActions.test.ts b/packages/panel/resources/js/components/DraftActions.test.ts new file mode 100644 index 0000000000..b5cef05ddd --- /dev/null +++ b/packages/panel/resources/js/components/DraftActions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { computed, nextTick, 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 saving status text while an autosave is in flight', () => { + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true), saving: ref(true) }) }, + }); + + expect(wrapper.find('[role="status"]').text()).toBe('drafts.saving'); + }); + + it('shows the saved note after a save lands and clears it after 10 seconds', async () => { + vi.useFakeTimers(); + + try { + const saving = ref(true); + const savedAt = ref(null); + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ isDirty: computed(() => true), saving, savedAt }) }, + }); + + saving.value = false; + savedAt.value = '2026-07-17T00:00:00Z'; + await nextTick(); + + expect(wrapper.find('[role="status"]').text()).toBe('drafts.saved'); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(wrapper.find('[role="status"]').exists()).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('does not show a stale saved note for a restored draft on load', () => { + const wrapper = mount(DraftActions, { + props: { form: fakeForm({ hasDraft: ref(true), savedAt: ref('2026-07-16T00:00:00Z') }) }, + }); + + expect(wrapper.find('[role="status"]').exists()).toBe(false); + expect(buttons(wrapper)).toHaveLength(2); + }); + + 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..be5dc32ebc --- /dev/null +++ b/packages/panel/resources/js/components/DraftActions.vue @@ -0,0 +1,74 @@ + + + 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/components/FlashMessage.test.ts b/packages/panel/resources/js/components/FlashMessage.test.ts deleted file mode 100644 index 66ea581209..0000000000 --- a/packages/panel/resources/js/components/FlashMessage.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import FlashMessage from './FlashMessage.vue'; - -vi.mock('@inertiajs/vue3', () => ({ - usePage: () => ({ props: { flash: {} } }), -})); - -const mountFlash = (props = {}) => mount(FlashMessage, { props: { message: 'Saved.', ...props } }); - -describe('FlashMessage', () => { - beforeEach(() => vi.useFakeTimers()); - afterEach(() => vi.useRealTimers()); - - it('shows the message with a status role', () => { - const wrapper = mountFlash(); - - const status = wrapper.find('[role="status"]'); - expect(status.exists()).toBe(true); - expect(status.text()).toContain('Saved.'); - }); - - it('renders nothing without a message', () => { - const wrapper = mountFlash({ message: null }); - - expect(wrapper.find('[role="status"]').exists()).toBe(false); - }); - - it('dismisses via the close button', async () => { - const wrapper = mountFlash(); - - await wrapper.find('button').trigger('click'); - - expect(wrapper.find('[role="status"]').exists()).toBe(false); - }); - - it('auto-dismisses after the timeout', async () => { - const wrapper = mountFlash({ timeout: 5000 }); - - expect(wrapper.find('[role="status"]').exists()).toBe(true); - - vi.advanceTimersByTime(5001); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[role="status"]').exists()).toBe(false); - }); - - it('persists when the timeout is zero', async () => { - const wrapper = mountFlash({ timeout: 0 }); - - vi.advanceTimersByTime(60_000); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[role="status"]').exists()).toBe(true); - }); - - it('pauses the auto-dismiss while hovered', async () => { - const wrapper = mountFlash({ timeout: 5000 }); - - await wrapper.find('[role="status"]').trigger('mouseenter'); - vi.advanceTimersByTime(10_000); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[role="status"]').exists()).toBe(true); - - await wrapper.find('[role="status"]').trigger('mouseleave'); - vi.advanceTimersByTime(5001); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[role="status"]').exists()).toBe(false); - }); -}); diff --git a/packages/panel/resources/js/components/FlashMessage.vue b/packages/panel/resources/js/components/FlashMessage.vue deleted file mode 100644 index 157409abd2..0000000000 --- a/packages/panel/resources/js/components/FlashMessage.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - diff --git a/packages/panel/resources/js/components/Icon.vue b/packages/panel/resources/js/components/Icon.vue index 14990ff821..e25d30c2d2 100644 --- a/packages/panel/resources/js/components/Icon.vue +++ b/packages/panel/resources/js/components/Icon.vue @@ -36,6 +36,7 @@ const ICONS: Record = { tag: '', folder: '', globe: '', + info: '', dashboard: '', 'layout-dashboard': '', help: '', diff --git a/packages/panel/resources/js/components/TimeSeriesChart.test.ts b/packages/panel/resources/js/components/TimeSeriesChart.test.ts index 1a3d5c8cde..75823f0268 100644 --- a/packages/panel/resources/js/components/TimeSeriesChart.test.ts +++ b/packages/panel/resources/js/components/TimeSeriesChart.test.ts @@ -69,6 +69,21 @@ describe('TimeSeriesChart', () => { expect(wrapper.find('path.tsc-area').exists()).toBe(true); }); + it('replays the draw-in only when point values actually change', async () => { + const wrapper = mountChart(); + + const lineBefore = wrapper.findAll('path')[1].element; + + // A fresh but value-identical array (a partial reload after a draft + // commit) must not remount the series paths. + await wrapper.setProps({ points: points.map((point) => ({ ...point })) }); + expect(wrapper.findAll('path')[1].element).toBe(lineBefore); + + // Different values (a range switch) restart the animation via remount. + await wrapper.setProps({ points: [...points, { label: 'Apr', value: 25 }] }); + expect(wrapper.findAll('path')[1].element).not.toBe(lineBefore); + }); + it('scales a flat zero series without dividing by zero', () => { const wrapper = mountChart({ points: [{ label: 'Jan', value: 0 }, { label: 'Feb', value: 0 }] }); diff --git a/packages/panel/resources/js/components/TimeSeriesChart.vue b/packages/panel/resources/js/components/TimeSeriesChart.vue index 0cabf35e40..0f6fb441de 100644 --- a/packages/panel/resources/js/components/TimeSeriesChart.vue +++ b/packages/panel/resources/js/components/TimeSeriesChart.vue @@ -107,9 +107,13 @@ const showLabel = (index: number): boolean => { // Remounting the series paths (via :key) restarts their draw-in animation // whenever the data changes, e.g. when a range switcher swaps the buckets. +// Compared by value: partial reloads hand over a fresh but identical array +// (e.g. after a draft commit), which must not replay the animation. const animationKey = ref(0); -watch(() => props.points, () => { - animationKey.value++; +watch(() => props.points, (points, previous) => { + if (JSON.stringify(points) !== JSON.stringify(previous)) { + animationKey.value++; + } }); // Hover/focus: a crosshair snaps to the nearest bucket; the same readout is diff --git a/packages/panel/resources/js/components/Toaster.test.ts b/packages/panel/resources/js/components/Toaster.test.ts new file mode 100644 index 0000000000..fd6dda5dc2 --- /dev/null +++ b/packages/panel/resources/js/components/Toaster.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { nextTick, reactive } from 'vue'; +import Toaster from './Toaster.vue'; +import { useToasts } from '../composables/useToasts'; + +const pageProps = reactive<{ flash?: Record }>({}); + +vi.mock('@inertiajs/vue3', () => ({ + usePage: () => ({ props: pageProps }), +})); + +let wrapper: VueWrapper | null = null; + +// The stack teleports to document.body, out of the wrapper's subtree. +const stack = (): HTMLElement => document.body; + +function mountToaster(): VueWrapper { + wrapper = mount(Toaster, { attachTo: document.body }); + + return wrapper; +} + +describe('Toaster', () => { + beforeEach(() => { + vi.useFakeTimers(); + useToasts().clear(); + delete pageProps.flash; + }); + + afterEach(() => { + wrapper?.unmount(); + wrapper = null; + useToasts().clear(); + vi.useRealTimers(); + document.body.innerHTML = ''; + }); + + it('renders pushed toasts with their tone roles', async () => { + mountToaster(); + + const toast = useToasts(); + toast.success('Saved.'); + toast.error('Broke.'); + toast.info('FYI.'); + await nextTick(); + + const statuses = [...stack().querySelectorAll('[role="status"]')].map((el) => el.textContent?.trim()); + const alerts = [...stack().querySelectorAll('[role="alert"]')].map((el) => el.textContent?.trim()); + + expect(statuses.some((text) => text?.includes('Saved.'))).toBe(true); + expect(statuses.some((text) => text?.includes('FYI.'))).toBe(true); + expect(alerts.some((text) => text?.includes('Broke.'))).toBe(true); + }); + + it('auto-dismisses success toasts and keeps error toasts until closed', async () => { + mountToaster(); + + const toast = useToasts(); + toast.success('Saved.'); + toast.error('Broke.'); + await nextTick(); + + await vi.advanceTimersByTimeAsync(6000); + + expect(stack().textContent).not.toContain('Saved.'); + expect(stack().textContent).toContain('Broke.'); + }); + + it('dismisses a toast via its close button', async () => { + mountToaster(); + + useToasts().error('Broke.'); + await nextTick(); + + stack().querySelector('[role="alert"] button')?.click(); + await nextTick(); + + expect(stack().textContent).not.toContain('Broke.'); + }); + + it('pauses the auto-dismiss while hovered and re-arms on leave', async () => { + mountToaster(); + + useToasts().success('Saved.'); + await nextTick(); + + const el = stack().querySelector('[role="status"]'); + el?.dispatchEvent(new Event('mouseenter')); + await vi.advanceTimersByTimeAsync(10_000); + + expect(stack().textContent).toContain('Saved.'); + + el?.dispatchEvent(new Event('mouseleave')); + await vi.advanceTimersByTimeAsync(6000); + + expect(stack().textContent).not.toContain('Saved.'); + }); + + it('bridges server flash props into toasts, deduping repeat passes over one flash object', async () => { + mountToaster(); + + pageProps.flash = { success: 'Changes saved.', info: 'Heads up.' }; + await nextTick(); + + expect(stack().textContent).toContain('Changes saved.'); + expect(stack().textContent).toContain('Heads up.'); + expect(useToasts().toasts.value).toHaveLength(2); + + // A second instance (as during a layout swap) sees the same flash + // object and must not duplicate it. + const second = mount(Toaster, { attachTo: document.body }); + await nextTick(); + + expect(useToasts().toasts.value).toHaveLength(2); + second.unmount(); + + // A fresh flash object from the next response toasts again, even with + // identical text (a repeat save). + pageProps.flash = { ...pageProps.flash }; + await nextTick(); + + expect(useToasts().toasts.value).toHaveLength(4); + }); +}); diff --git a/packages/panel/resources/js/components/Toaster.vue b/packages/panel/resources/js/components/Toaster.vue new file mode 100644 index 0000000000..41f594fc2a --- /dev/null +++ b/packages/panel/resources/js/components/Toaster.vue @@ -0,0 +1,125 @@ + + + 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..38dce73986 --- /dev/null +++ b/packages/panel/resources/js/composables/useEditDraft.test.ts @@ -0,0 +1,235 @@ +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'; + + // Read (and cache) the computed before committing: the commit must + // invalidate it when it re-baselines pristine, not leave it stale. + expect(form.isDirty.value).toBe(true); + + 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..ffac0c0b29 --- /dev/null +++ b/packages/panel/resources/js/composables/useEditDraft.ts @@ -0,0 +1,261 @@ +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; + + // Reactive so isDirty recomputes when a successful commit re-baselines + // pristine to the committed values — a plain object would leave the stale + // dirty state cached until the next keystroke. + const pristine = reactive>(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/composables/useToasts.ts b/packages/panel/resources/js/composables/useToasts.ts new file mode 100644 index 0000000000..75047e7632 --- /dev/null +++ b/packages/panel/resources/js/composables/useToasts.ts @@ -0,0 +1,88 @@ +import { ref, type Ref } from 'vue'; + +export type ToastTone = 'success' | 'error' | 'info'; + +export interface Toast { + id: number; + tone: ToastTone; + message: string; + /** Milliseconds before auto-dismiss; 0 keeps the toast until closed. */ + duration: number; +} + +export interface ToastApi { + toasts: Ref; + success: (message: string, duration?: number) => number; + error: (message: string, duration?: number) => number; + info: (message: string, duration?: number) => number; + dismiss: (id: number) => void; + clear: () => void; +} + +// Errors stay until dismissed; confirmations and notes clear themselves. +const DEFAULT_DURATIONS: Record = { + success: 6000, + info: 6000, + error: 0, +}; + +// Module-level store so any page, component, or add-on bundle pushes into the +// same stack, and toasts survive layout swaps between visits. +const toasts = ref([]); +let nextId = 0; + +function push(tone: ToastTone, message: string, duration?: number): number { + const id = ++nextId; + + toasts.value = [...toasts.value, { id, tone, message, duration: duration ?? DEFAULT_DURATIONS[tone] }]; + + return id; +} + +function dismiss(id: number): void { + toasts.value = toasts.value.filter((toast) => toast.id !== id); +} + +export function useToasts(): ToastApi { + return { + toasts, + success: (message, duration?) => push('success', message, duration), + error: (message, duration?) => push('error', message, duration), + info: (message, duration?) => push('info', message, duration), + dismiss, + clear: () => { + toasts.value = []; + }, + }; +} + +export interface ServerFlash { + success?: string | null; + error?: string | null; + info?: string | null; +} + +// The flash prop object is a fresh reference per server response, so identity +// tracking both dedupes double-fires (two Toaster instances during a layout +// swap) and still re-toasts an identical message on a repeat save. +let handledFlash: ServerFlash | null = null; + +export function pushServerFlash(flash: ServerFlash | null | undefined): void { + if (!flash || flash === handledFlash) { + return; + } + + handledFlash = flash; + + if (flash.success) { + push('success', flash.success); + } + + if (flash.error) { + push('error', flash.error); + } + + if (flash.info) { + push('info', flash.info); + } +} diff --git a/packages/panel/resources/js/layouts/PanelLayout.vue b/packages/panel/resources/js/layouts/PanelLayout.vue index 517f64f4aa..67ec2bf4a6 100644 --- a/packages/panel/resources/js/layouts/PanelLayout.vue +++ b/packages/panel/resources/js/layouts/PanelLayout.vue @@ -5,6 +5,7 @@ import { usePage } from '@inertiajs/vue3'; import { useI18n } from 'vue-i18n'; import NavBody from '../components/NavBody.vue'; import Icon from '../components/Icon.vue'; +import Toaster from '../components/Toaster.vue'; import { useNavState } from '../composables/useNavState'; const { state, toggleCollapsed, openDrawer } = useNavState(); @@ -144,5 +145,7 @@ onUnmounted(() => { + + 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/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/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 @@