This commit is contained in:
@@ -3,12 +3,27 @@ import AdminSettingsPage from './AdminSettingsPage.vue';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Pages/AdminSettingsPage', component: AdminSettingsPage,
|
title: 'Pages/AdminSettingsPage', component: AdminSettingsPage,
|
||||||
beforeEach: () => {
|
beforeEach: (context) => {
|
||||||
const originalFetch = globalThis.fetch;
|
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' } };
|
let stored = { general: { publicDescription: 'Independent collector.', publicContactEmail: 'collector@example.test', publicContactPhone: '555-0100' } };
|
||||||
globalThis.fetch = async (url, options) => {
|
globalThis.fetch = async (url, options) => {
|
||||||
if (!String(url).endsWith('/admin/settings')) return originalFetch(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') {
|
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);
|
const update = JSON.parse(options.body);
|
||||||
stored = { ...stored, ...update, general: { ...stored.general, ...update.general } };
|
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.');
|
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>' })],
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, reactive, ref } from 'vue';
|
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||||
|
|
||||||
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
|
import { getAdminSettings, updateAdminSettings } from '@/services/admin-settings-service.js';
|
||||||
|
|
||||||
@@ -11,6 +11,9 @@ const settings = reactive({
|
|||||||
});
|
});
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
|
const hasLoaded = ref(false);
|
||||||
|
const descriptionInput = ref(null);
|
||||||
|
const retryButton = ref(null);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
|
||||||
@@ -18,17 +21,25 @@ function csrfToken() {
|
|||||||
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
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 {
|
try {
|
||||||
Object.assign(settings, await getAdminSettings());
|
Object.assign(settings, await getAdminSettings());
|
||||||
|
hasLoaded.value = true;
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
error.value = requestError.message;
|
error.value = requestError.message;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
if (restoreFocus) {
|
||||||
|
await nextTick();
|
||||||
|
(hasLoaded.value ? descriptionInput.value : retryButton.value)?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (!hasLoaded.value || isSaving.value) return;
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
error.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="isLoading" class="state state--loading" role="status">Loading settings...</p>
|
||||||
<p v-if="message" class="state" role="status">{{ message }}</p>
|
<p v-if="message" class="state" role="status">{{ message }}</p>
|
||||||
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
||||||
<form v-if="!isLoading" class="admin-workspace-grid" @submit.prevent="save">
|
<button v-if="error && !hasLoaded" ref="retryButton" class="button button--primary" type="button" @click="load({ restoreFocus: true })">Retry loading settings</button>
|
||||||
<fieldset class="showcase-panel">
|
<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>
|
<legend>General</legend>
|
||||||
<label class="fixture-label" for="settings-description">Public seller description (optional)</label>
|
<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>
|
<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>
|
<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" />
|
<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>
|
<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" />
|
<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>
|
<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>
|
||||||
<fieldset class="showcase-panel">
|
<fieldset class="showcase-panel" :disabled="isSaving">
|
||||||
<legend>Inventory</legend>
|
<legend>Inventory</legend>
|
||||||
<label class="fixture-label" for="settings-images">Maximum images per item</label>
|
<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" />
|
<input id="settings-images" v-model.number="settings.inventory.maxImagesPerItem" class="fixture-input" type="number" min="1" max="100" />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="showcase-panel">
|
<fieldset class="showcase-panel" :disabled="isSaving">
|
||||||
<legend>Pricing</legend>
|
<legend>Pricing</legend>
|
||||||
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
|
<label class="fixture-label" for="settings-strategy">Default valuation strategy</label>
|
||||||
<select id="settings-strategy" v-model="settings.pricing.defaultStrategy" class="fixture-input">
|
<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>
|
<option value="weighted_average">Weighted average</option>
|
||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="showcase-panel">
|
<fieldset class="showcase-panel" :disabled="isSaving">
|
||||||
<legend>Security</legend>
|
<legend>Security</legend>
|
||||||
<label class="fixture-label" for="settings-idle">Session idle minutes</label>
|
<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" />
|
<input id="settings-idle" v-model.number="settings.security.sessionIdleMinutes" class="fixture-input" type="number" min="5" max="43200" />
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Next Milestone
|
||||||
|
|
||||||
|
|||||||
@@ -76,3 +76,12 @@ Dynamic actions must provide accessible status feedback, including:
|
|||||||
- background job progress
|
- background job progress
|
||||||
|
|
||||||
Avoid relying on color alone.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user