Feature/293 remove backgrounds from inventory #296

Merged
bermudalamb merged 9 commits from feature/293-remove-backgrounds-from-inventory into main 2026-09-04 12:20:01 -05:00
2 changed files with 74 additions and 34 deletions
Showing only changes of commit 64f8efb617 - Show all commits
+58 -30
View File
@@ -214,7 +214,10 @@ function Inventory() {
* The response replaces the open modal's images rather than being trusted to
* have changed nothing else. The editor is a modal and this changes files on
* the server while it is open, so without a refresh the thumbnails keep
* showing the previous files and the button looks like it did nothing.
* showing the previous files and the button looks like it did nothing. That
* holds even when the server answers something other than 200: a restore
* that fails partway can still have swapped some files back before it did,
* so the refresh below runs whether the call succeeded or not.
*/
async function handleBackgrounds(itemId: number, action: 'remove-backgrounds' | 'restore-originals') {
const label = action === 'remove-backgrounds' ? 'remove backgrounds' : 'restore originals';
@@ -223,24 +226,26 @@ function Inventory() {
const res = await fetch(`/api/admin/items/${itemId}/${action}`, { method: 'POST' });
if (!res.ok) {
message.error('That did not work.');
return;
}
const summary = await res.json();
if (action === 'remove-backgrounds' && summary.failed) {
// Said plainly rather than as a generic failure. How far it got is what
// decides whether pressing it again is worth anything, and it is —
// a retry skips the ones that already worked.
message.warning(`${summary.removed} of ${summary.total} photos done. Try again to finish.`);
} else {
message.success('Done.');
const summary = await res.json();
const done = action === 'remove-backgrounds' ? summary.removed : summary.restored;
if (summary.failed) {
// Said plainly rather than as a generic failure. How far it got is
// what decides whether pressing it again is worth anything, and it
// is — a retry skips the ones that already worked.
message.warning(`${done} of ${summary.total} photos done. Try again to finish.`);
} else {
message.success('Done.');
}
}
// Re-read the list through load() — same as every other mutation here —
// so a filtered view survives this, then pick this item's fresh images
// back out of it for the open modal. The item's own filtered fields
// (category, tags, status, search) are untouched by a background swap,
// so it stays in the result whenever it was in it before.
// so it stays in the result whenever it was in it before. Run for a
// failed response too — see the doc comment above.
const rows = await load();
const updated = rows?.find(candidate => candidate.id === itemId);
if (updated) setEditingItem(prev => (prev && prev.id === itemId ? updated : prev));
@@ -381,24 +386,47 @@ function Inventory() {
</div>
))}
</Space>
{(backgroundRemoval || editingItem.images.every(img => img.original_image_path !== null)) && (
<div style={{ marginTop: 8 }}>
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(
editingItem.id,
editingItem.images.every(img => img.original_image_path !== null)
? 'restore-originals'
: 'remove-backgrounds'
)}
>
{editingItem.images.every(img => img.original_image_path !== null)
? 'Restore originals'
: 'Remove backgrounds'}
</Button>
</div>
)}
{(() => {
// `!= null` rather than `!== null`: original_image_path is
// optional on the shared Item type (it is absent from the
// public storefront response), so a stray `undefined` has to
// count as "not cut out" too — `undefined !== null` is `true`,
// which would misread a public-shaped item as fully cut out.
const allCutOut = editingItem.images.every(img => img.original_image_path != null);
const someCutOut = editingItem.images.some(img => img.original_image_path != null);
// Remove and Restore are independent, not two labels for one
// button: a partly cut-out item — a partial removal's normal
// result, or REMBG_URL going away after the fact — needs both,
// or the cut-out photos it already has can never be restored
// from this screen (#293).
const showRemove = backgroundRemoval && !allCutOut;
const showRestore = someCutOut;
if (!showRemove && !showRestore) return null;
return (
<div style={{ marginTop: 8 }}>
<Space>
{showRemove && (
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(editingItem.id, 'remove-backgrounds')}
>
Remove backgrounds
</Button>
)}
{showRestore && (
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(editingItem.id, 'restore-originals')}
>
Restore originals
</Button>
)}
</Space>
</div>
);
})()}
</Form.Item>
)}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
@@ -1,4 +1,4 @@
import { test, expect, createAdminContext, uniqueSuffix } from './fixtures';
import { test, expect, createAdminContext, createCategory, uniqueSuffix } from './fixtures';
/**
* The per-item background control in the inventory editor (#293).
@@ -17,6 +17,10 @@ import { test, expect, createAdminContext, uniqueSuffix } from './fixtures';
*/
const RUN = uniqueSuffix();
const NAME = `Vase ${RUN}`;
// A category of its own, not shared: it exists only so filterByCategory below
// has something unique to narrow the table down to, the same way
// admin-inventory-filters.spec.ts uses one per run.
const CATEGORY = `Backgrounds ${RUN}`;
// The 1x1 PNG the other upload specs use, so this goes through the real
// validated upload path rather than a buffer that merely starts correctly.
@@ -27,6 +31,7 @@ const PNG = Buffer.from(
test.beforeAll(async ({ playwright }) => {
const api = await createAdminContext(playwright);
const categoryId = await createCategory(api, CATEGORY);
// Seeded with an image directly: createItem cannot attach one, and the
// control does not render for an item with no photos.
const res = await api.post('/api/admin/items', {
@@ -34,7 +39,7 @@ test.beforeAll(async ({ playwright }) => {
name: NAME,
description: '',
price: '50',
category_id: '',
category_id: String(categoryId),
tags: '[]',
images: { name: `${RUN}.png`, mimeType: 'image/png', buffer: PNG }
}
@@ -44,10 +49,17 @@ test.beforeAll(async ({ playwright }) => {
});
test.describe('Removing backgrounds from the item editor', () => {
test('offers the control on an item that has photos', async ({ page, admin }) => {
test('offers the control on an item that has photos', async ({ page, admin, adminInventory }) => {
await admin.open('Inventory');
await page.getByRole('row', { name: new RegExp(NAME) }).getByRole('button', { name: 'Edit' }).click();
// The table paginates at 10 and this suite runs fullyParallel, so an
// unfiltered page 1 is not a reliable place to find this fixture — see
// AdminInventory.filterByCategory. Filtering to this item's own category
// narrows the table down to just it, the way admin-inventory-filters.spec.ts
// does.
await adminInventory.filterByCategory(CATEGORY, NAME);
await adminInventory.row(NAME).getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('button', { name: 'Remove backgrounds' })).toBeVisible();
});