fix: recover from saved view load and mutation failures
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 17:49:39 -04:00
parent d321d8c679
commit c5a07b2b5a
4 changed files with 213 additions and 13 deletions
@@ -1,5 +1,158 @@
import { expect, userEvent, within, waitFor } from '@storybook/test';
import AdminSavedViewsPage from './AdminSavedViewsPage.vue';
export default { title: 'Pages/AdminSavedViewsPage', component: AdminSavedViewsPage };
export default {
title: 'Pages/AdminSavedViewsPage', component: AdminSavedViewsPage,
beforeEach: (context) => {
const originalFetch = globalThis.fetch;
const scenario = context.parameters.savedViewsScenario;
let reads = 0;
let creates = 0;
let deletes = 0;
let views = scenario === 'empty' ? [] : [{ id: 'view-1', name: 'Needs images', filters: { status: 'ACTIVE' } }];
const reply = (data) => new Response(JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } });
const fail = (message) => new Response(JSON.stringify({ error: { message } }), { status: 503 });
globalThis.fetch = async (url, options = {}) => {
if (!String(url).includes('/admin/saved-views')) return originalFetch(url, options);
if (options.method === 'POST') {
creates += 1;
if (scenario === 'saving') return new Promise(() => {});
if (scenario === 'create-error' && creates === 1) return fail('Could not create view. Try again.');
const view = { id: 'view-2', ...JSON.parse(options.body) };
views = [...views, view];
return reply(view);
}
if (options.method === 'DELETE') {
deletes += 1;
if (scenario === 'deleting') return new Promise(() => {});
if (scenario === 'delete-error' && deletes === 1) return fail('Could not delete view. Try again.');
views = views.filter((view) => !String(url).endsWith(`/${view.id}`));
return reply(null);
}
reads += 1;
if (scenario === 'loading') return new Promise(() => {});
if (scenario === 'load-error' || (scenario === 'retry-load' && reads === 1)) return fail('Could not load saved views.');
return reply(views);
};
return () => { globalThis.fetch = originalFetch; };
},
};
export const SavedViews = {};
export const Empty = {
parameters: { savedViewsScenario: 'empty' },
play: async ({ canvasElement }) => {
await expect(await within(canvasElement).findByText('No saved views yet.')).toBeVisible();
},
};
export const Loading = {
parameters: { savedViewsScenario: 'loading' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('status')).toHaveTextContent('Loading saved views...');
await expect(canvas.queryByText('No saved views yet.')).toBeNull();
await expect(canvas.queryByRole('button', { name: 'Save view' })).toBeNull();
},
};
export const LoadError = {
parameters: { savedViewsScenario: 'load-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole('alert');
await expect(canvas.queryByText('No saved views yet.')).toBeNull();
await expect(canvas.queryByRole('button', { name: 'Save view' })).toBeNull();
const retry = canvas.getByRole('button', { name: 'Retry loading saved views' });
retry.focus();
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(canvas.getByRole('button', { name: 'Retry loading saved views' })).toHaveFocus());
},
};
export const RetryLoad = {
parameters: { savedViewsScenario: 'retry-load' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const retry = await canvas.findByRole('button', { name: 'Retry loading saved views' });
retry.focus();
await userEvent.keyboard('{Enter}');
await expect(await canvas.findByLabelText('Name')).toHaveFocus();
await expect(canvas.getByText('Needs images')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const CreateFailureAndRetry = {
parameters: { savedViewsScenario: 'create-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const name = await canvas.findByLabelText('Name');
await userEvent.type(name, 'Available inventory');
await userEvent.click(canvas.getByRole('button', { name: 'Save view' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Could not create view. Try again.');
await expect(name).toHaveValue('Available inventory');
await expect(canvas.getByLabelText('Filters (JSON)')).toHaveValue('{}');
await userEvent.click(canvas.getByRole('button', { name: 'Save view' }));
await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Saved view created.'));
await expect(name).toHaveValue('');
await expect(canvas.getByText('Available inventory')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const DeleteFailureAndRetry = {
parameters: { savedViewsScenario: 'delete-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const remove = await canvas.findByRole('button', { name: 'Delete' });
remove.focus();
await userEvent.keyboard('{Enter}');
await expect(await canvas.findByRole('alert')).toHaveTextContent('Could not delete view. Try again.');
await expect(canvas.getByText('Needs images')).toBeVisible();
await expect(remove).toHaveFocus();
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(canvas.getByRole('status')).toHaveTextContent('Saved view deleted.'));
await expect(canvas.queryByRole('alert')).toBeNull();
await expect(canvas.getByText('No saved views yet.')).toBeVisible();
await expect(canvas.getByRole('heading', { name: 'Your saved views' })).toHaveFocus();
},
};
export const Deleting = {
parameters: { savedViewsScenario: 'deleting' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete' }));
await expect(canvas.getByRole('status')).toHaveTextContent('Deleting view...');
await expect(canvas.getByRole('button', { name: 'Delete' })).toBeDisabled();
await expect(canvas.getByRole('button', { name: 'Save view' })).toBeDisabled();
await expect(canvas.getByLabelText('Name')).toBeDisabled();
},
};
export const Saving = {
parameters: { savedViewsScenario: 'saving' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(await canvas.findByLabelText('Name'), 'Inventory');
await userEvent.click(canvas.getByRole('button', { name: 'Save view' }));
await expect(canvas.getByRole('status')).toHaveTextContent('Saving view...');
await expect(canvas.getByRole('button', { name: 'Save view' })).toBeDisabled();
await expect(canvas.getByLabelText('Filters (JSON)')).toBeDisabled();
},
};
export const MobileRetryLoad = { ...RetryLoad };
export const DarkDeleteRecovery = {
...DeleteFailureAndRetry,
globals: { theme: 'dark' },
decorators: [() => ({ template: '<div style="background: var(--color-background); color: var(--color-text); padding: 16px"><story /></div>' })],
};
export const InvalidFilters = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(await canvas.findByLabelText('Name'), 'Draft view');
const filters = canvas.getByLabelText('Filters (JSON)');
await userEvent.clear(filters);
await userEvent.type(filters, 'not JSON');
await userEvent.click(canvas.getByRole('button', { name: 'Save view' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Filters must be valid JSON.');
await expect(filters).toHaveValue('not JSON');
await expect(filters).toBeEnabled();
await expect(canvas.getByLabelText('Name')).toHaveValue('Draft view');
},
};
+40 -10
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted, ref } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { createAdminSavedView, deleteAdminSavedView, listAdminSavedViews } from '@/services/admin-saved-view-service.js';
@@ -8,6 +8,11 @@ const name = ref('');
const filters = ref('{}');
const isLoading = ref(true);
const isSaving = ref(false);
const hasLoaded = ref(false);
const pendingMessage = ref('');
const nameInput = ref(null);
const retryButton = ref(null);
const listHeading = ref(null);
const error = ref('');
const message = ref('');
@@ -15,17 +20,26 @@ 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 {
views.value = await listAdminSavedViews();
hasLoaded.value = true;
} catch (requestError) {
error.value = requestError.message;
} finally {
isLoading.value = false;
if (restoreFocus) {
await nextTick();
(hasLoaded.value ? nameInput.value : retryButton.value)?.focus();
}
}
}
async function createView() {
if (!hasLoaded.value || isSaving.value) return;
pendingMessage.value = 'Saving view...';
isSaving.value = true;
error.value = '';
message.value = '';
@@ -42,15 +56,25 @@ async function createView() {
}
}
async function removeView(id) {
async function removeView(id, event) {
if (!hasLoaded.value || isSaving.value) return;
const trigger = event.currentTarget;
let removed = false;
error.value = '';
message.value = '';
pendingMessage.value = 'Deleting view...';
isSaving.value = true;
try {
await deleteAdminSavedView(id, csrfToken());
views.value = views.value.filter((view) => view.id !== id);
removed = true;
message.value = 'Saved view deleted.';
} catch (requestError) {
error.value = requestError.message;
} finally {
isSaving.value = false;
await nextTick();
(removed ? listHeading.value : trigger)?.focus();
}
}
@@ -64,23 +88,29 @@ onMounted(load);
<p v-if="isLoading" class="state state--loading" role="status">Loading saved views...</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="showcase-panel" @submit.prevent="createView">
<button v-if="error && !hasLoaded" ref="retryButton" class="button button--primary" type="button" @click="load({ restoreFocus: true })">Retry loading saved views</button>
<p v-if="isSaving" class="state" role="status">{{ pendingMessage }}</p>
<form v-if="hasLoaded" class="showcase-panel" @submit.prevent="createView">
<h2>New saved view</h2>
<label class="fixture-label" for="saved-view-name">Name</label>
<input id="saved-view-name" v-model="name" class="fixture-input" required maxlength="100" />
<input ref="nameInput" :disabled="isSaving" id="saved-view-name" v-model="name" class="fixture-input" required maxlength="100" />
<label class="fixture-label" for="saved-view-filters">Filters (JSON)</label>
<textarea id="saved-view-filters" v-model="filters" class="fixture-input" rows="4" required />
<textarea :disabled="isSaving" id="saved-view-filters" v-model="filters" class="fixture-input" rows="4" required />
<button class="button button--primary" type="submit" :disabled="isSaving">Save view</button>
</form>
<section class="admin-list" aria-labelledby="saved-views-title">
<h2 id="saved-views-title">Your saved views</h2>
<p v-if="!views.length && !isLoading" class="state state--empty">No saved views yet.</p>
<section v-if="hasLoaded" class="admin-list" aria-labelledby="saved-views-title">
<h2 ref="listHeading" id="saved-views-title" tabindex="-1">Your saved views</h2>
<p v-if="!views.length" class="state state--empty">No saved views yet.</p>
<ul v-else>
<li v-for="view in views" :key="view.id">
<strong>{{ view.name }}</strong> · {{ JSON.stringify(view.filters) }}
<button class="button button--secondary" type="button" :disabled="isSaving" @click="removeView(view.id)">Delete</button>
<button class="button button--secondary" type="button" :disabled="isSaving" @click="removeView(view.id, $event)">Delete</button>
</li>
</ul>
</section>
</section>
</template>
<style scoped>
#saved-views-title:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
</style>
+9 -2
View File
@@ -154,7 +154,7 @@ 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.
### Latest Vertical Slice: Settings Error Recovery
### 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.
@@ -162,7 +162,14 @@ Verified locally: `npm test` passes 167 tests with no failures (23 domain, 79 ap
- 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.
### Latest Vertical Slice: Saved Views Error Recovery
- Failed list reads no longer show an empty-list message or create form; Retry restores keyboard focus on success or repeated failure.
- Create/delete actions clear stale feedback, announce progress, and block conflicting actions. Create failures preserve drafts; delete failures preserve the view and focus; successful deletion announces completion and focuses the list heading.
- Storybook covers populated/empty/loading/error states, load retries, create/delete recovery, invalid JSON, pending disabled states, mobile retry and dark-theme recovery.
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 113 tests. Frontend-only change; API and persistence unchanged.
Next bounded slice: admin image-manager error recovery. A mutation error currently replaces the whole gallery with an error message. Keep loaded images and controls available after failed edits/uploads, distinguish initial-load failure, provide reload retry, and cover retry/failure states with Storybook interactions.
## Next Milestone
+10
View File
@@ -85,3 +85,13 @@ 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.
## Saved Views recovery
Show the create form and saved-view list only after a successful load. Failed
reads show an error and Retry, not an empty-list message. Keyboard retry returns
focus to Name on success or Retry on repeated failure. Failed creation retains
the draft; failed deletion retains the view and returns focus to its Delete
button. Successful deletion announces completion and focuses the list heading.
Mutations announce progress, disable conflicting actions while pending, and clear
stale errors/success messages before a new attempt.