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" />