From d321d8c679f3a36007544f3f651ec6ada8d28f23 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 10 Sep 2026 17:44:53 -0400 Subject: [PATCH] fix: recover safely from settings load and save failures --- .../src/pages/AdminSettingsPage.stories.js | 90 ++++++++++++++++++- apps/web/src/pages/AdminSettingsPage.vue | 29 ++++-- docs/IMPLEMENTATION-STATUS.md | 12 ++- docs/UX.md | 9 ++ 4 files changed, 129 insertions(+), 11 deletions(-) diff --git a/apps/web/src/pages/AdminSettingsPage.stories.js b/apps/web/src/pages/AdminSettingsPage.stories.js index d9e4848..c9627f5 100644 --- a/apps/web/src/pages/AdminSettingsPage.stories.js +++ b/apps/web/src/pages/AdminSettingsPage.stories.js @@ -3,12 +3,27 @@ import AdminSettingsPage from './AdminSettingsPage.vue'; export default { title: 'Pages/AdminSettingsPage', component: AdminSettingsPage, - beforeEach: () => { + beforeEach: (context) => { const originalFetch = globalThis.fetch; + const scenario = context.parameters.settingsScenario; + let reads = 0; + let saves = 0; let stored = { general: { publicDescription: 'Independent collector.', publicContactEmail: 'collector@example.test', publicContactPhone: '555-0100' } }; globalThis.fetch = async (url, options) => { if (!String(url).endsWith('/admin/settings')) return originalFetch(url, options); + if (options?.method !== 'PATCH') { + reads += 1; + if (scenario === 'loading') return new Promise(() => {}); + if (scenario === 'load-error' || (scenario === 'retry-load' && reads === 1)) { + return new Response(JSON.stringify({ error: { message: 'Settings could not be loaded.' } }), { status: 503 }); + } + } if (options?.method === 'PATCH') { + saves += 1; + if (scenario === 'saving') return new Promise(() => {}); + if (scenario === 'save-error' && saves === 1) { + return new Response(JSON.stringify({ error: { message: 'Settings could not be saved. Try again.' } }), { status: 503 }); + } const update = JSON.parse(options.body); stored = { ...stored, ...update, general: { ...stored.general, ...update.general } }; } @@ -56,3 +71,76 @@ export const ClearPublicContacts = { await expect(canvas.getByLabelText('Public seller description (optional)')).toHaveValue('Independent collector.'); }, }; + +export const Loading = { + parameters: { settingsScenario: 'loading' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('status')).toHaveTextContent('Loading settings...'); + await expect(canvas.queryByRole('button', { name: 'Save settings' })).toBeNull(); + await expect(canvas.queryByLabelText('Public contact email (optional)')).toBeNull(); + }, +}; +export const LoadError = { + parameters: { settingsScenario: 'load-error' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole('alert')).toHaveTextContent('Settings could not be loaded.'); + await expect(canvas.queryByRole('button', { name: 'Save settings' })).toBeNull(); + const retry = canvas.getByRole('button', { name: 'Retry loading settings' }); + retry.focus(); + await userEvent.keyboard('{Enter}'); + await waitFor(() => expect(canvas.getByRole('button', { name: 'Retry loading settings' })).toHaveFocus()); + }, +}; +export const RetryLoad = { + parameters: { settingsScenario: 'retry-load' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const retry = await canvas.findByRole('button', { name: 'Retry loading settings' }); + retry.focus(); + await userEvent.keyboard('{Enter}'); + const description = await canvas.findByLabelText('Public seller description (optional)'); + await expect(description).toHaveFocus(); + await expect(description).toHaveValue('Independent collector.'); + await expect(canvas.queryByRole('alert')).toBeNull(); + }, +}; +export const SaveFailureAndRetry = { + parameters: { settingsScenario: 'save-error' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const description = await canvas.findByLabelText('Public seller description (optional)'); + await userEvent.clear(description); + await userEvent.type(description, 'My unsaved changes.'); + await userEvent.click(canvas.getByRole('button', { name: 'Save settings' })); + await expect(await canvas.findByRole('alert')).toHaveTextContent('Settings could not be saved. Try again.'); + await expect(description).toHaveValue('My unsaved changes.'); + await expect(description).toBeEnabled(); + await expect(canvas.queryByRole('button', { name: 'Retry loading settings' })).toBeNull(); + await userEvent.click(canvas.getByRole('button', { name: 'Save settings' })); + await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Settings saved.')); + await expect(description).toHaveValue('My unsaved changes.'); + await expect(canvas.queryByRole('alert')).toBeNull(); + }, +}; +export const Saving = { + parameters: { settingsScenario: 'saving' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const save = await canvas.findByRole('button', { name: 'Save settings' }); + await userEvent.click(save); + await expect(canvas.getByRole('status')).toHaveTextContent('Saving settings...'); + await expect(save).toBeDisabled(); + for (const field of ['Public seller description (optional)', 'Public contact email (optional)', 'Maximum images per item', 'Default valuation strategy', 'Session idle minutes']) { + await expect(canvas.getByLabelText(field)).toBeDisabled(); + } + }, +}; + +export const MobileRetryLoad = { ...RetryLoad }; +export const DarkSaveFailureAndRetry = { + ...SaveFailureAndRetry, + globals: { theme: 'dark' }, + decorators: [() => ({ template: '
' })], +}; diff --git a/apps/web/src/pages/AdminSettingsPage.vue b/apps/web/src/pages/AdminSettingsPage.vue index fc72838..36f25f9 100644 --- a/apps/web/src/pages/AdminSettingsPage.vue +++ b/apps/web/src/pages/AdminSettingsPage.vue @@ -1,5 +1,5 @@