This commit is contained in:
@@ -1,5 +1,158 @@
|
|||||||
|
import { expect, userEvent, within, waitFor } from '@storybook/test';
|
||||||
import AdminSavedViewsPage from './AdminSavedViewsPage.vue';
|
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 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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { createAdminSavedView, deleteAdminSavedView, listAdminSavedViews } from '@/services/admin-saved-view-service.js';
|
import { createAdminSavedView, deleteAdminSavedView, listAdminSavedViews } from '@/services/admin-saved-view-service.js';
|
||||||
|
|
||||||
@@ -8,6 +8,11 @@ const name = ref('');
|
|||||||
const filters = ref('{}');
|
const filters = ref('{}');
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const isSaving = ref(false);
|
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 error = ref('');
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
|
||||||
@@ -15,17 +20,26 @@ 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 {
|
||||||
views.value = await listAdminSavedViews();
|
views.value = await listAdminSavedViews();
|
||||||
|
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 ? nameInput.value : retryButton.value)?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createView() {
|
async function createView() {
|
||||||
|
if (!hasLoaded.value || isSaving.value) return;
|
||||||
|
pendingMessage.value = 'Saving view...';
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
message.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;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
await deleteAdminSavedView(id, csrfToken());
|
await deleteAdminSavedView(id, csrfToken());
|
||||||
views.value = views.value.filter((view) => view.id !== id);
|
views.value = views.value.filter((view) => view.id !== id);
|
||||||
|
removed = true;
|
||||||
|
message.value = 'Saved view deleted.';
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
error.value = requestError.message;
|
error.value = requestError.message;
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false;
|
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="isLoading" class="state state--loading" role="status">Loading saved views...</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="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>
|
<h2>New saved view</h2>
|
||||||
<label class="fixture-label" for="saved-view-name">Name</label>
|
<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>
|
<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>
|
<button class="button button--primary" type="submit" :disabled="isSaving">Save view</button>
|
||||||
</form>
|
</form>
|
||||||
<section class="admin-list" aria-labelledby="saved-views-title">
|
<section v-if="hasLoaded" class="admin-list" aria-labelledby="saved-views-title">
|
||||||
<h2 id="saved-views-title">Your saved views</h2>
|
<h2 ref="listHeading" id="saved-views-title" tabindex="-1">Your saved views</h2>
|
||||||
<p v-if="!views.length && !isLoading" class="state state--empty">No saved views yet.</p>
|
<p v-if="!views.length" class="state state--empty">No saved views yet.</p>
|
||||||
<ul v-else>
|
<ul v-else>
|
||||||
<li v-for="view in views" :key="view.id">
|
<li v-for="view in views" :key="view.id">
|
||||||
<strong>{{ view.name }}</strong> · {{ JSON.stringify(view.filters) }}
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
#saved-views-title:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
||||||
|
|
||||||
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Next Milestone
|
||||||
|
|
||||||
|
|||||||
+10
@@ -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
|
Saving announces progress and temporarily disables the form to prevent duplicate
|
||||||
submissions and edits being overwritten by the save response. A failed save
|
submissions and edits being overwritten by the save response. A failed save
|
||||||
keeps entered values available for correction or retry.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user