This commit is contained in:
@@ -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>' })],
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user