This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,12 +24,17 @@ 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>
|
||||
<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
|
||||
v-else
|
||||
:images="images"
|
||||
:is-saving="isSaving"
|
||||
:disabled="isLoading || Boolean(loadError)"
|
||||
@upload="upload"
|
||||
@move="({ id, direction }) => move(id, direction)"
|
||||
@primary="setPrimary"
|
||||
@@ -30,5 +42,10 @@ onMounted(load);
|
||||
@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>
|
||||
|
||||
@@ -108,3 +108,14 @@ Public galleries must support:
|
||||
- screen-reader-friendly state
|
||||
|
||||
Primary images power cards, search results, homepage previews, and social/share metadata.
|
||||
|
||||
## Admin error recovery
|
||||
|
||||
Keep the loaded gallery and alt-text drafts mounted during image actions and
|
||||
refreshes. Show action failures separately from list-read failures. After every
|
||||
action, including a failed multi-file upload or reorder, reload image metadata to
|
||||
reconcile any partial success without replaying writes. If that refresh fails,
|
||||
retain the last loaded gallery but disable mutations until an explicit reload
|
||||
succeeds. Failed initial loads show a reload action instead of an empty manager.
|
||||
Reload restores keyboard focus to the manager or the retry control. Pending
|
||||
operations block conflicting writes and uploads, including drag-and-drop.
|
||||
|
||||
@@ -162,14 +162,22 @@ 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.
|
||||
|
||||
### Latest Vertical Slice: Saved Views Error Recovery
|
||||
### 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.
|
||||
### Latest Vertical Slice: Image Manager Error Recovery
|
||||
|
||||
- Loaded images and alt-text drafts survive action failures and refreshes. Initial-load errors offer Reload; refresh errors retain the gallery with writes disabled until reload succeeds.
|
||||
- Every action reconciles metadata even on failure, exposing partial multi-upload/reorder success without replaying mutations. Pending actions block overlapping writes and file drops.
|
||||
- Added page/component stories covering load/retry, edit/delete failure and retry, successful deletion followed by refresh failure, pending/disabled states, partial upload failure, mobile and dark-theme recovery.
|
||||
- Dark-theme coverage also fixed destructive-button contrast by separating its solid background token from error-text color.
|
||||
- Validation: 167 API tests, 11 web tests, production web build, Storybook build, and 32 Storybook suites / 124 tests. No API or persistence changes.
|
||||
|
||||
Next bounded slice: atomic image reordering. Move up/down currently sends two separate order updates; failure between them can leave a partial reorder. Add a seller-scoped API operation that updates both positions transactionally, connect the controls to it, and verify rollback and ownership with integration tests.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
|
||||
Reference in New Issue
Block a user