Feature/293 remove backgrounds from inventory #296
@@ -66,6 +66,11 @@ function Inventory() {
|
|||||||
const [tags, setTags] = useState<TagRecord[]>([]);
|
const [tags, setTags] = useState<TagRecord[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [filters, setFilters] = useState<ItemFilters>(EMPTY_FILTERS);
|
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();
|
const { mode } = useThemeMode();
|
||||||
|
|
||||||
// Typing in the price fields fires a request per keystroke, so responses can
|
// 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.
|
// from the sibling tabs, so they're refetched whenever the modal opens.
|
||||||
const loadOptions = useCallback(() => Promise.all([
|
const loadOptions = useCallback(() => Promise.all([
|
||||||
fetchAdminCategories().then(setCategories),
|
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')), []);
|
]).catch(() => message.error('Could not load categories and tags')), []);
|
||||||
|
|
||||||
// Refetch whenever the filters change — filtering is server-side so the
|
// Refetch whenever the filters change — filtering is server-side so the
|
||||||
@@ -192,6 +201,49 @@ function Inventory() {
|
|||||||
: prev);
|
: 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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Image',
|
title: 'Image',
|
||||||
@@ -322,6 +374,24 @@ function Inventory() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</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>
|
||||||
)}
|
)}
|
||||||
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
|
||||||
|
|||||||
+9
-1
@@ -13,7 +13,15 @@ export interface Item {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
price_cents: number;
|
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';
|
status: 'pending' | 'available' | 'reserved' | 'sold';
|
||||||
category_id: number | null;
|
category_id: number | null;
|
||||||
category_name: string | 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