fix: recover safely from settings load and save failures
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 17:44:53 -04:00
parent 9a790ce294
commit d321d8c679
4 changed files with 129 additions and 11 deletions
@@ -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: '<div style="background: var(--color-background); color: var(--color-text); padding: 16px"><story /></div>' })],
};
+21 -8
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, reactive, ref } from 'vue';
import { nextTick, onMounted, reactive, ref } from 'vue';
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
@@ -11,6 +11,9 @@ const settings = reactive({
});
const isLoading = ref(true);
const isSaving = ref(false);
const hasLoaded = ref(false);
const descriptionInput = ref(null);
const retryButton = ref(null);
const message = ref('');
const error = ref('');
@@ -18,17 +21,25 @@ function csrfToken() {
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
}
async function load() {
async function load({ restoreFocus = false } = {}) {
isLoading.value = true;
error.value = '';
try {
Object.assign(settings, await getAdminSettings());
hasLoaded.value = true;
} catch (requestError) {
error.value = requestError.message;
} finally {
isLoading.value = false;
if (restoreFocus) {
await nextTick();
(hasLoaded.value ? descriptionInput.value : retryButton.value)?.focus();
}
}
}
async function save() {
if (!hasLoaded.value || isSaving.value) return;
isSaving.value = true;
message.value = '';
error.value = '';
@@ -60,24 +71,26 @@ onMounted(load);
<p v-if="isLoading" class="state state--loading" role="status">Loading settings...</p>
<p v-if="message" class="state" role="status">{{ message }}</p>
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
<fieldset class="showcase-panel">
<button v-if="error && !hasLoaded" ref="retryButton" class="button button--primary" type="button" @click="load({ restoreFocus: true })">Retry loading settings</button>
<p v-if="isSaving" class="state" role="status">Saving settings...</p>
<form v-if="hasLoaded" class="admin-workspace-grid" @submit.prevent="save">
<fieldset class="showcase-panel" :disabled="isSaving">
<legend>General</legend>
<label class="fixture-label" for="settings-description">Public seller description (optional)</label>
<p id="settings-description-help">Shown on public listing and product pages. Up to 2,000 characters. Clear to remove.</p>
<textarea id="settings-description" v-model="settings.general.publicDescription" class="fixture-input" rows="5" maxlength="2000" aria-describedby="settings-description-help" />
<textarea ref="descriptionInput" id="settings-description" v-model="settings.general.publicDescription" class="fixture-input" rows="5" maxlength="2000" aria-describedby="settings-description-help" />
<label class="fixture-label" for="settings-email">Public contact email (optional)</label>
<input id="settings-email" v-model="settings.general.publicContactEmail" class="fixture-input" type="email" aria-describedby="settings-contact-help" />
<label class="fixture-label" for="settings-phone">Public contact phone (optional)</label>
<input id="settings-phone" v-model="settings.general.publicContactPhone" class="fixture-input" type="tel" maxlength="50" aria-describedby="settings-contact-help" />
<p id="settings-contact-help">Shown on public listing and product pages. Clear a field and save to remove that contact method.</p>
</fieldset>
<fieldset class="showcase-panel">
<fieldset class="showcase-panel" :disabled="isSaving">
<legend>Inventory</legend>
<label class="fixture-label" for="settings-images">Maximum images per item</label>
<input id="settings-images" v-model.number="settings.inventory.maxImagesPerItem" class="fixture-input" type="number" min="1" max="100" />
</fieldset>
<fieldset class="showcase-panel">
<fieldset class="showcase-panel" :disabled="isSaving">
<legend>Pricing</legend>
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
<select id="settings-strategy" v-model="settings.pricing.defaultStrategy" class="fixture-input">
@@ -86,7 +99,7 @@ onMounted(load);
<option value="weighted_average">Weighted average</option>
</select>
</fieldset>
<fieldset class="showcase-panel">
<fieldset class="showcase-panel" :disabled="isSaving">
<legend>Security</legend>
<label class="fixture-label" for="settings-idle">Session idle minutes</label>
<input id="settings-idle" v-model.number="settings.security.sessionIdleMinutes" class="fixture-input" type="number" min="5" max="43200" />
+10 -2
View File
@@ -146,7 +146,7 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- Validation: 166 API tests, 11 web tests, 3 public Playwright tests, production web build, Storybook build, and 32 Storybook suites / 93 tests. No API or persistence changes.
- Screen-reader speech and native browser zoom remain manual review items; this slice verifies browser focus and title behavior.
### Latest Vertical Slice: Clear Public Contact Methods
### Clear Public Contact Methods
- Settings now sends explicit empty strings when a seller clears email or phone; help text explains removal. Omitted API fields still retain existing values.
- Validation accepts empty/whitespace-only email as removal while rejecting invalid non-empty addresses. Both public detail repositories expose absent/cleared contacts as null.
@@ -154,7 +154,15 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- Storybook covers clearing contacts while retaining the description and rendering seller information without contact links.
- Validation: 167 API tests, 23 isolated integration tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 95 tests. No migration required.
Next bounded slice: Settings loading/error recovery. A failed settings read currently leaves editable default values visible. Hide the form until settings load successfully, provide a retry action, and preserve entered values across failed saves, with loading/error/retry Storybook coverage.
### Latest Vertical Slice: Settings Error Recovery
- Settings only exposes its form after a successful load. Failed reads offer Retry instead of editable defaults.
- Keyboard retry restores focus to the description field on success or Retry on repeated failure.
- Saving announces progress and disables fields/submission while in flight; failed saves retain edits and allow retry without reloading.
- Storybook covers loading, load errors, successful/repeated retry, save failure/retry, saving/disabled states, mobile retry and dark-theme save recovery.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 102 tests. Frontend-only change; no API or persistence changes.
Next bounded slice: Saved Views error recovery. Failed reads currently also display “No saved views yet” and provide no retry. Separate loading/error/empty states, add retry and reliable create/delete feedback, and cover recovery with Storybook interactions.
## Next Milestone
+9
View File
@@ -76,3 +76,12 @@ Dynamic actions must provide accessible status feedback, including:
- background job progress
Avoid relying on color alone.
## Settings recovery
Do not show editable defaults until Settings has loaded successfully. A failed
load offers a keyboard-accessible retry and announces the error. After retry,
focus moves to the first form field on success or back to Retry on failure.
Saving announces progress and temporarily disables the form to prevent duplicate
submissions and edits being overwritten by the save response. A failed save
keeps entered values available for correction or retry.