fix: preserve image manager state during action failures
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 17:57:28 -04:00
parent c5a07b2b5a
commit 72e9a79b67
9 changed files with 240 additions and 39 deletions
@@ -16,6 +16,7 @@
--color-success-500: #12a150;
--color-warning-500: #d88a00;
--color-danger-500: #e5484d;
--color-danger-solid: #b4232b;
--color-info-500: #1683c4;
--color-success-text: #08783a;
--color-warning-text: #8a5700;
+1 -1
View File
@@ -79,7 +79,7 @@ h1 { max-width: 760px; margin: var(--space-3) 0 var(--space-4); font-size: var(-
.button { min-height: 44px; border: var(--border-thin) solid transparent; border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); font-weight: var(--weight-bold); }
.button--primary { background: var(--color-interactive); color: var(--color-on-interactive); }
.button--secondary { border-color: var(--color-border-strong); background: var(--color-surface); color: var(--color-text); }
.button--danger { background: var(--color-danger-text); color: var(--color-white); }
.button--danger { background: var(--color-danger-solid); color: var(--color-white); }
.button:disabled { cursor: not-allowed; opacity: 0.45; }
.swatch { display: grid; gap: var(--space-2); color: var(--color-text-muted); font-size: var(--text-xs); }
.swatch__chip { width: 72px; height: 44px; border-radius: var(--radius-md); }
@@ -44,3 +44,14 @@ export const RetryFailedImage = {
await expect(canvas.getByRole('button', { name: 'Retry processing' })).toBeInTheDocument();
},
};
export const DisabledAfterRefreshFailure = {
args: { images, disabled: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
for (const button of canvas.getAllByRole('button', { name: /Edit alt text|Make primary|Move up|Move down|Delete image/ })) {
await expect(button).toBeDisabled();
}
},
};
@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, reactive, ref, watch } from 'vue';
import ConditionBadge from '@/components/atoms/ConditionBadge.vue';
import ImageGallery from '@/components/molecules/ImageGallery.vue';
@@ -7,6 +7,7 @@ import StatusBadge from '@/components/atoms/StatusBadge.vue';
const props = defineProps({
images: { type: Array, required: true },
disabled: { type: Boolean, default: false },
isSaving: { type: Boolean, default: false },
});
const emit = defineEmits(['upload', 'move', 'primary', 'alt-text', 'retry', 'delete']);
@@ -17,16 +18,21 @@ const altTextDrafts = reactive({});
const isDragging = ref(false);
function beginEditAltText(image) {
altTextDrafts[image.id] = image.altText ?? '';
altTextDrafts[image.id] ??= image.altText ?? '';
editingImageId.value = image.id;
}
function saveAltText(imageId) {
emit('alt-text', { id: imageId, altText: altTextDrafts[imageId] ?? '' });
editingImageId.value = null;
}
watch(() => props.images, (images) => {
const edited = images.find((image) => image.id === editingImageId.value);
if (edited && edited.altText === altTextDrafts[edited.id]) editingImageId.value = null;
});
function queueUpload(fileList) {
if (props.isSaving || props.disabled) return;
const files = Array.from(fileList ?? []);
if (files.length > 0) emit('upload', files);
}
@@ -61,15 +67,15 @@ function handleDrop(event) {
>
<p>Drag and drop images here, or choose files below.</p>
<label class="fixture-label" for="admin-image-upload">Add images</label>
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" multiple :disabled="isSaving" @change="handleFileChange" />
<input id="admin-image-upload" class="fixture-input" type="file" accept="image/jpeg,image/png,image/webp" multiple :disabled="isSaving || disabled" @change="handleFileChange" />
</div>
<ImageGallery :images="orderedImages" />
<p v-if="isSaving" class="state state--loading" role="status">Saving image changes...</p>
<p v-else-if="orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p>
<p v-if="!isSaving && orderedImages.length === 0" class="state" role="status">No images uploaded yet.</p>
<ul v-else class="admin-image-manager__list">
<ul v-if="orderedImages.length" class="admin-image-manager__list">
<li v-for="(image, index) in orderedImages" :key="image.id" class="admin-image-manager__item">
<div class="admin-image-manager__row">
<ConditionBadge :label="image.altText || `Image ${index + 1}`" :selected="image.isPrimary" />
@@ -79,17 +85,17 @@ function handleDrop(event) {
<div class="admin-image-manager__row">
<label class="fixture-label" :for="`admin-alt-text-${image.id}`">Alt text</label>
<input :id="`admin-alt-text-${image.id}`" v-model="altTextDrafts[image.id]" class="fixture-input" :disabled="editingImageId !== image.id" />
<button v-if="editingImageId !== image.id" class="button button--secondary" type="button" @click="beginEditAltText(image)">Edit alt text</button>
<button v-else class="button button--primary" type="button" @click="saveAltText(image.id)">Save alt text</button>
<input :id="`admin-alt-text-${image.id}`" v-model="altTextDrafts[image.id]" class="fixture-input" :disabled="isSaving || disabled || editingImageId !== image.id" />
<button v-if="editingImageId !== image.id" class="button button--secondary" type="button" :disabled="isSaving || disabled" @click="beginEditAltText(image)">Edit alt text</button>
<button v-else class="button button--primary" type="button" :disabled="isSaving || disabled" @click="saveAltText(image.id)">Save alt text</button>
</div>
<div class="admin-image-manager__row">
<button class="button button--secondary" type="button" :disabled="image.isPrimary" @click="emit('primary', image.id)">Make primary</button>
<button class="button button--secondary" type="button" :disabled="index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button>
<button class="button button--secondary" type="button" :disabled="index === orderedImages.length - 1" @click="emit('move', { id: image.id, direction: 'down' })">Move down</button>
<button v-if="image.processingStatus === 'FAILED'" class="button button--secondary" type="button" @click="emit('retry', image.id)">Retry processing</button>
<button class="button button--danger" type="button" @click="emit('delete', image.id)">Delete image</button>
<button class="button button--secondary" type="button" :disabled="isSaving || disabled || image.isPrimary" @click="emit('primary', image.id)">Make primary</button>
<button class="button button--secondary" type="button" :disabled="isSaving || disabled || index === 0" @click="emit('move', { id: image.id, direction: 'up' })">Move up</button>
<button class="button button--secondary" type="button" :disabled="isSaving || disabled || index === orderedImages.length - 1" @click="emit('move', { id: image.id, direction: 'down' })">Move down</button>
<button v-if="image.processingStatus === 'FAILED'" class="button button--secondary" type="button" :disabled="isSaving || disabled" @click="emit('retry', image.id)">Retry processing</button>
<button class="button button--danger" type="button" :disabled="isSaving || disabled" @click="emit('delete', image.id)">Delete image</button>
</div>
</li>
</ul>
+18 -5
View File
@@ -19,21 +19,32 @@ export function useAdminImages(inventoryItemId) {
const isLoading = ref(false);
const isSaving = ref(false);
const error = ref(null);
const loadError = ref(null);
const hasLoaded = ref(false);
async function load() {
isLoading.value = true;
error.value = null;
try { images.value = await listAdminImages(inventoryItemId); }
catch (requestError) { error.value = requestError; }
loadError.value = null;
try {
images.value = await listAdminImages(inventoryItemId);
hasLoaded.value = true;
}
catch (requestError) { loadError.value = requestError; }
finally { isLoading.value = false; }
}
async function apply(action) {
if (!hasLoaded.value || isSaving.value || isLoading.value || loadError.value) return;
isSaving.value = true;
error.value = null;
try { await action(csrfToken()); await load(); }
try { await action(csrfToken()); }
catch (requestError) { error.value = requestError; }
finally { isSaving.value = false; }
finally {
// Multi-file uploads and reordering can partially succeed. Reconcile even
// on failure, without replaying any mutation.
await load();
isSaving.value = false;
}
}
async function uploadOne(file, isPrimary, token) {
@@ -83,6 +94,8 @@ export function useAdminImages(inventoryItemId) {
isLoading,
isSaving,
error,
loadError,
hasLoaded,
load,
upload,
move: moveImage,
+136 -2
View File
@@ -1,5 +1,139 @@
import { expect, userEvent, within, waitFor } from '@storybook/test';
import AdminImagePage from './AdminImagePage.vue';
export default { title: 'Pages/AdminImagePage', component: AdminImagePage };
export default {
title: 'Pages/AdminImagePage', component: AdminImagePage,
decorators: [() => ({ template: '<div style="background: var(--color-background); color: var(--color-text)"><story /></div>' })],
beforeEach: (context) => {
const originalFetch = globalThis.fetch;
const scenario = context.parameters.imageScenario;
let reads = 0;
let writes = 0;
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
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/')) return originalFetch(url, options);
if (options.method === 'GET') {
reads += 1;
if (scenario === 'loading') return new Promise(() => {});
if (scenario === 'load-error' || (scenario === 'retry-load' && reads === 1) || (scenario === 'refresh-error' && reads === 2)) return fail('Image list unavailable.');
return reply(images);
}
writes += 1;
if (scenario === 'saving') return new Promise(() => {});
if (scenario === 'partial-upload' && writes === 2) return fail('Second upload failed.');
if (options.body instanceof FormData) images = [...images, { id: `uploaded-${writes}`, altText: options.body.get('altText'), sortOrder: images.length, isPrimary: false, processingStatus: 'COMPLETED' }];
if ((scenario === 'edit-error' || scenario === 'delete-error') && writes === 1) return fail('Image action failed. Try again.');
if (options.method === 'DELETE') images = [];
if (String(url).endsWith('/alt-text')) images = images.map((image) => ({ ...image, altText: JSON.parse(options.body).altText }));
return reply(null);
};
return () => { globalThis.fetch = originalFetch; };
},
};
export const Overview = {};
export const Loading = {
parameters: { imageScenario: 'loading' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('status')).toHaveTextContent('Loading images...');
await expect(canvas.queryByLabelText('Add images')).toBeNull();
},
};
export const LoadError = {
parameters: { imageScenario: 'load-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByRole('alert');
await expect(canvas.queryByLabelText('Add images')).toBeNull();
const reload = canvas.getByRole('button', { name: 'Reload images' });
reload.focus();
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(canvas.getByRole('button', { name: 'Reload images' })).toHaveFocus());
},
};
export const RetryLoad = {
parameters: { imageScenario: 'retry-load' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Reload images' }));
await expect(await canvas.findByLabelText('Add images')).toBeEnabled();
await expect(canvas.getByRole('region', { name: 'Image manager workspace' })).toHaveFocus();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const AltTextFailureAndRetry = {
parameters: { imageScenario: 'edit-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Edit alt text' }));
const field = canvas.getByLabelText('Alt text');
await userEvent.clear(field);
await userEvent.type(field, 'Updated card front');
await userEvent.click(canvas.getByRole('button', { name: 'Save alt text' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Image action failed. Try again.');
await waitFor(() => expect(field).toBeEnabled());
await expect(field).toHaveValue('Updated card front');
await expect(canvas.getByLabelText('Add images')).toBeEnabled();
await userEvent.click(canvas.getByRole('button', { name: 'Save alt text' }));
await waitFor(() => expect(canvas.getByRole('button', { name: 'Edit alt text' })).toBeEnabled());
await expect(canvas.queryByRole('alert')).toBeNull();
await expect(field).toHaveValue('Updated card front');
},
};
export const DeleteFailureAndRetry = {
parameters: { imageScenario: 'delete-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await canvas.findByRole('alert');
await waitFor(() => expect(canvas.getByRole('button', { name: 'Delete image' })).toBeEnabled());
await userEvent.click(canvas.getByRole('button', { name: 'Delete image' }));
await expect(await canvas.findByText('No images uploaded yet.')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const RefreshFailureAfterDelete = {
parameters: { imageScenario: 'refresh-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await expect(await canvas.findByRole('alert')).toHaveTextContent('Could not refresh images.');
await expect(canvas.getByRole('button', { name: 'Delete image' })).toBeDisabled();
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
await userEvent.click(canvas.getByRole('button', { name: 'Reload images' }));
await expect(await canvas.findByText('No images uploaded yet.')).toBeVisible();
await expect(canvas.queryByRole('alert')).toBeNull();
},
};
export const SavingKeepsGallery = {
parameters: { imageScenario: 'saving' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Delete image' }));
await expect(canvas.getByRole('status')).toHaveTextContent('Saving image changes...');
await expect(canvas.getByRole('button', { name: 'Delete image' })).toBeDisabled();
await expect(canvas.getByRole('button', { name: 'Edit alt text' })).toBeDisabled();
await expect(canvas.getByLabelText('Add images')).toBeDisabled();
},
};
export const MobileRetryLoad = { ...RetryLoad };
export const DarkAltTextRecovery = {
...AltTextFailureAndRetry,
globals: { theme: 'dark' },
};
export const PartialUploadFailure = {
parameters: { imageScenario: 'partial-upload' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const bytes = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), (char) => char.charCodeAt(0));
const input = await canvas.findByLabelText('Add images');
await userEvent.upload(input, [new File([bytes], 'front.png', { type: 'image/png' }), new File([bytes], 'back.png', { type: 'image/png' })]);
await expect(await canvas.findByRole('alert')).toHaveTextContent('Second upload failed.');
await waitFor(() => expect(input).toBeEnabled());
await expect(canvas.getAllByRole('button', { name: 'Delete image' })).toHaveLength(2);
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
},
};
+32 -15
View File
@@ -1,5 +1,5 @@
<script setup>
import { onMounted } from 'vue';
import { nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute } from 'vue-router';
import AdminImageManager from '@/components/organisms/AdminImageManager.vue';
@@ -7,8 +7,15 @@ import { useAdminImages } from '@/composables/useAdminImages.js';
const route = useRoute();
const inventoryItemId = route.params.id;
const { images, isLoading, isSaving, error, load, upload, move, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
const { images, isLoading, isSaving, error, loadError, hasLoaded, load, upload, move, setPrimary, updateAltText, retry, remove } = useAdminImages(inventoryItemId);
const retryButton = ref(null);
const managerRegion = ref(null);
async function reloadImages() {
await load();
await nextTick();
(loadError.value ? retryButton.value : managerRegion.value)?.focus();
}
onMounted(load);
</script>
@@ -17,18 +24,28 @@ onMounted(load);
<RouterLink class="detail-back" :to="{ name: 'catalog' }">Back to catalog</RouterLink>
<p class="catalog-kicker">Admin inventory</p>
<h1>Manage images</h1>
<p v-if="isLoading" class="catalog-state" role="status">Loading images...</p>
<p v-else-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<AdminImageManager
v-else
:images="images"
:is-saving="isSaving"
@upload="upload"
@move="({ id, direction }) => move(id, direction)"
@primary="setPrimary"
@alt-text="({ id, altText }) => updateAltText(id, altText)"
@retry="retry"
@delete="remove"
/>
<p v-if="isLoading" class="catalog-state" role="status">{{ hasLoaded ? 'Refreshing images...' : 'Loading images...' }}</p>
<p v-if="error" class="catalog-state catalog-state--error" role="alert">{{ error.message }}</p>
<div v-if="loadError" role="alert" class="catalog-state catalog-state--error">
<p>{{ hasLoaded ? 'Could not refresh images. Reload before making more changes.' : 'Could not load images.' }} {{ loadError.message }}</p>
<button ref="retryButton" class="button button--primary" type="button" :disabled="isLoading || isSaving" @click="reloadImages">Reload images</button>
</div>
<div v-if="hasLoaded" ref="managerRegion" tabindex="-1" aria-label="Image manager workspace" role="region" class="image-manager-region">
<AdminImageManager
:images="images"
:is-saving="isSaving"
:disabled="isLoading || Boolean(loadError)"
@upload="upload"
@move="({ id, direction }) => move(id, direction)"
@primary="setPrimary"
@alt-text="({ id, altText }) => updateAltText(id, altText)"
@retry="retry"
@delete="remove"
/>
</div>
</main>
</template>
<style scoped>
.image-manager-region:focus-visible { outline: var(--border-strong) solid var(--color-focus); outline-offset: 3px; }
</style>