feat: reorder inventory images atomically
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-11 00:13:44 -04:00
parent 72e9a79b67
commit 0e81fc4022
15 changed files with 275 additions and 22 deletions
+2 -14
View File
@@ -3,7 +3,7 @@ import { ref } from 'vue';
import {
deleteImage,
listAdminImages,
reorderImage,
moveImage,
retryImageProcessing,
setPrimaryImage,
updateImageAltText,
@@ -76,18 +76,6 @@ export function useAdminImages(inventoryItemId) {
});
}
async function moveImage(imageId, direction) {
const ordered = [...images.value].sort((left, right) => left.sortOrder - right.sortOrder);
const index = ordered.findIndex((image) => image.id === imageId);
const neighborIndex = direction === 'up' ? index - 1 : index + 1;
if (index === -1 || neighborIndex < 0 || neighborIndex >= ordered.length) return;
const current = ordered[index];
const neighbor = ordered[neighborIndex];
await apply(async (token) => {
await reorderImage(current.id, neighbor.sortOrder, token);
await reorderImage(neighbor.id, current.sortOrder, token);
});
}
return {
images,
@@ -98,7 +86,7 @@ export function useAdminImages(inventoryItemId) {
hasLoaded,
load,
upload,
move: moveImage,
move: (imageId, direction) => apply((token) => moveImage(imageId, direction, token)),
setPrimary: (imageId) => apply((token) => setPrimaryImage(imageId, token)),
updateAltText: (imageId, altText) => apply((token) => updateImageAltText(imageId, altText, token)),
retry: (imageId) => apply((token) => retryImageProcessing(imageId, token)),
@@ -10,6 +10,7 @@ export default {
let reads = 0;
let writes = 0;
let images = [{ id: 'image-1', altText: 'Front of card', sortOrder: 0, isPrimary: true, processingStatus: 'COMPLETED' }];
if (scenario === 'move' || scenario === 'move-error') images.push({ id: 'image-2', altText: 'Back of card', sortOrder: 1, isPrimary: false, 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 = {}) => {
@@ -21,6 +22,15 @@ export default {
return reply(images);
}
writes += 1;
if (scenario === 'move' || scenario === 'move-error') {
await expect(String(url)).toMatch(/\/images\/image-[12]\/move$/);
await expect(options.method).toBe('POST');
const { direction } = JSON.parse(options.body);
await expect(['up', 'down']).toContain(direction);
if (scenario === 'move-error' && writes === 1) return fail('Move failed.');
images = [...images].reverse().map((image, sortOrder) => ({ ...image, sortOrder }));
return reply(images[0]);
}
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' }];
@@ -137,3 +147,29 @@ export const PartialUploadFailure = {
await expect(canvas.getByRole('button', { name: 'front.png' })).toBeVisible();
},
};
export const AtomicMove = {
parameters: { imageScenario: 'move' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const down = await canvas.findAllByRole('button', { name: 'Move down' });
await userEvent.click(down[0]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
await userEvent.click(canvas.getAllByRole('button', { name: 'Move up' })[1]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card'));
await expect(canvas.getAllByRole('button', { name: 'Move up' })[0]).toBeDisabled();
await expect(canvas.getAllByRole('button', { name: 'Move down' })[1]).toBeDisabled();
},
};
export const FailedAtomicMove = {
parameters: { imageScenario: 'move-error' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click((await canvas.findAllByRole('button', { name: 'Move down' }))[0]);
await expect(await canvas.findByRole('alert')).toHaveTextContent('Move failed.');
await waitFor(() => expect(canvas.getAllByRole('button', { name: 'Move down' })[0]).toBeEnabled());
await expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Front of card');
await userEvent.click(canvas.getAllByRole('button', { name: 'Move down' })[0]);
await waitFor(() => expect(canvas.getAllByRole('button', { name: /^(Front|Back) of card$/ })[0]).toHaveTextContent('Back of card'));
},
};
@@ -80,3 +80,11 @@ export async function listAdminImages(inventoryItemId) {
headers: {},
});
}
export function moveImage(imageId, direction, csrfToken) {
return request(`/admin/images/${encodeURIComponent(imageId)}/move`, {
method: 'POST',
headers: { 'x-csrf-token': csrfToken },
body: JSON.stringify({ direction }),
});
}