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>