Feature/293 remove backgrounds from inventory #296
@@ -66,6 +66,11 @@ function Inventory() {
|
||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
||||
// Whether this environment has a background-removal sidecar. False hides the
|
||||
// control rather than offering one that would report zero of four done every
|
||||
// time — an unconfigured environment is a working one, not a broken one.
|
||||
const [backgroundRemoval, setBackgroundRemoval] = useState(false);
|
||||
const [busyBackgrounds, setBusyBackgrounds] = useState(false);
|
||||
const { mode } = useThemeMode();
|
||||
|
||||
// Typing in the price fields fires a request per keystroke, so responses can
|
||||
@@ -91,7 +96,11 @@ function Inventory() {
|
||||
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||
const loadOptions = useCallback(() => Promise.all([
|
||||
fetchAdminCategories().then(setCategories),
|
||||
fetchAdminTags().then(setTags)
|
||||
fetchAdminTags().then(setTags),
|
||||
fetch('/api/admin/config')
|
||||
.then((res) => (res.ok ? res.json() : { backgroundRemoval: false }))
|
||||
.then((config) => setBackgroundRemoval(config.backgroundRemoval))
|
||||
.catch(() => setBackgroundRemoval(false))
|
||||
]).catch(() => message.error('Could not load categories and tags')), []);
|
||||
|
||||
// Refetch whenever the filters change — filtering is server-side so the
|
||||
@@ -192,6 +201,49 @@ function Inventory() {
|
||||
: prev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut out every photo of the item being edited, or put every original back.
|
||||
*
|
||||
* Per item, not per photo: an upload is one item, and its photos are views of
|
||||
* one thing (#293).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
async function handleBackgrounds(itemId: number, action: 'remove-backgrounds' | 'restore-originals') {
|
||||
setBusyBackgrounds(true);
|
||||
try {
|
||||
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.');
|
||||
}
|
||||
|
||||
// Re-read the item so the thumbnails match what is now on the server.
|
||||
const fresh = await fetch('/api/admin/items');
|
||||
if (fresh.ok) {
|
||||
const all: Item[] = await fresh.json();
|
||||
const updated = all.find((candidate) => candidate.id === itemId);
|
||||
if (updated) setEditingItem(prev => (prev && prev.id === itemId ? updated : prev));
|
||||
setItems(all);
|
||||
}
|
||||
} finally {
|
||||
setBusyBackgrounds(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Image',
|
||||
@@ -322,6 +374,24 @@ 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>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
||||
|
||||
+9
-1
@@ -13,7 +13,15 @@ export interface Item {
|
||||
name: string;
|
||||
description: string | null;
|
||||
price_cents: number;
|
||||
images: { id: number; image_path: string; sort_order: number }[];
|
||||
images: {
|
||||
id: number;
|
||||
image_path: string;
|
||||
sort_order: number;
|
||||
// Present on admin responses only — ADMIN_ITEM_SELECT's images aggregate
|
||||
// carries it, PUBLIC_ITEM_SELECT's does not — so it stays optional on this
|
||||
// shared type rather than a lie the public fetchItems() response can't back up.
|
||||
original_image_path?: string | null;
|
||||
}[];
|
||||
status: 'pending' | 'available' | 'reserved' | 'sold';
|
||||
category_id: number | null;
|
||||
category_name: string | null;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test, expect, createAdminContext, uniqueSuffix } from './fixtures';
|
||||
|
||||
/**
|
||||
* The per-item background control in the inventory editor (#293).
|
||||
*
|
||||
* Asserts the control is offered, not that a cut-out happens. Pressing it needs
|
||||
* a live rembg sidecar, which takes forty seconds to start and which no test
|
||||
* should depend on — the swap itself is covered in the integration suite
|
||||
* against a stub.
|
||||
*
|
||||
* "Does not appear when the feature is unconfigured" is not written here as an
|
||||
* e2e case: unsetting REMBG_URL means restarting the backend mid-suite, which
|
||||
* this run has no way to do and should not gain one. GET /api/admin/config
|
||||
* answering `backgroundRemoval: false` is covered by Task 2's integration
|
||||
* tests, and the render condition that gates the button on it is plain enough
|
||||
* to read in Admin.tsx.
|
||||
*/
|
||||
const RUN = uniqueSuffix();
|
||||
const NAME = `Vase ${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.
|
||||
const PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
|
||||
test.beforeAll(async ({ playwright }) => {
|
||||
const api = await createAdminContext(playwright);
|
||||
// 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', {
|
||||
multipart: {
|
||||
name: NAME,
|
||||
description: '',
|
||||
price: '50',
|
||||
category_id: '',
|
||||
tags: '[]',
|
||||
images: { name: `${RUN}.png`, mimeType: 'image/png', buffer: PNG }
|
||||
}
|
||||
});
|
||||
expect(res.ok(), `seeding ${NAME}`).toBeTruthy();
|
||||
await api.dispose();
|
||||
});
|
||||
|
||||
test.describe('Removing backgrounds from the item editor', () => {
|
||||
test('offers the control on an item that has photos', async ({ page, admin }) => {
|
||||
await admin.open('Inventory');
|
||||
|
||||
await page.getByRole('row', { name: new RegExp(NAME) }).getByRole('button', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Remove backgrounds' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user