From fb3821630fb990354dd7804dae25037386b99e41 Mon Sep 17 00:00:00 2001 From: maziggy Date: Tue, 23 Jun 2026 11:34:15 +0200 Subject: [PATCH 1/2] feat(inventory): batch / mass edit on the Filament tab (#1795) Bulk operations on the Inventory page in both built-in and Spoolman modes. Reporter wanted ten-of-the-same-spool edits without ten round-trips through the per-spool editor. Frontend - New checkbox column on the inventory table (header / row / group). Sticky toolbar appears when at least one row is selected with Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear. Selection clears on any filter / tab / search change so the count can't drift from what is on screen. - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per field by ticking its checkbox or just typing into it; only ticked + non- empty fields are sent. Clearing fields in bulk is intentionally NOT supported per the issue discussion. - A new SearchableSelect renders all categorical fields (material, sub-type, brand, category, slicer preset name, slicer filament, storage location) with the same dropdown pattern the per-spool editor uses - text input + chevron + filtered button list, click-outside / Escape closes. No native select anywhere in the modal. Options merge the canonical constants from spool-form/constants.ts with whatever already exists in the user's inventory. Slicer-preset dropdowns fetch the same sources as the per-spool form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament Options() and three useQuery calls gated on isOpen. - onSuccess handlers surface three outcomes: all-succeeded (green toast), partial-success (yellow toast with ok / failed counts), all-failed (red toast that keeps the selection and modal open so the user can retry). The first cut silently dropped errors / not_found arrays - audited and fixed before merge. - Invalid rgba hex is flagged inline with a red border + helper text and the Apply button is gated on a hasDroppedTickedField guard, so silently dropping a ticked field is no longer possible. - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal + clears selection, matching the other three bulk mutations. Backend - Four new endpoints per inventory mode (eight total): POST /api/v1/inventory/spools/bulk-update INVENTORY_UPDATE POST /api/v1/inventory/spools/bulk-delete INVENTORY_UPDATE POST /api/v1/inventory/spools/bulk-archive INVENTORY_UPDATE POST /api/v1/inventory/spools/bulk-restore INVENTORY_UPDATE POST /api/v1/spoolman/inventory/spools/bulk-* FILAMENTS_UPDATE - Built-in update runs the same prepare_internal_spool_payload(...) + weight_used / weight_locked auto-stamp as the per-spool PATCH. - Spoolman update loops the per-spool update_spool route function so the filament re-linking / extra-dict / extra-lock / shared-filament rules stay byte-identical to single-spool edits. - Per-spool failures inside the batch are collected. Spoolman bulk-delete / archive / restore now catch non-HTTPException too (matches bulk-update) - a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route with a 500 and skips the WS broadcast. - Both modes broadcast a single inventory_changed WS event at the end of the batch. --- CHANGELOG.md | 2 + backend/app/api/routes/inventory.py | 120 ++++ backend/app/api/routes/spoolman_inventory.py | 117 ++++ .../tests/integration/test_inventory_bulk.py | 212 +++++++ .../test_spoolman_inventory_bulk.py | 200 +++++++ frontend/src/api/client.ts | 40 ++ .../src/components/BulkEditSpoolsModal.tsx | 525 ++++++++++++++++++ frontend/src/i18n/locales/de.ts | 46 ++ frontend/src/i18n/locales/en.ts | 46 ++ frontend/src/i18n/locales/es.ts | 46 ++ frontend/src/i18n/locales/fr.ts | 46 ++ frontend/src/i18n/locales/it.ts | 46 ++ frontend/src/i18n/locales/ja.ts | 46 ++ frontend/src/i18n/locales/ko.ts | 46 ++ frontend/src/i18n/locales/pt-BR.ts | 46 ++ frontend/src/i18n/locales/tr.ts | 46 ++ frontend/src/i18n/locales/zh-CN.ts | 46 ++ frontend/src/i18n/locales/zh-TW.ts | 46 ++ frontend/src/pages/InventoryPage.tsx | 328 ++++++++++- .../{index-CHyRb--b.js => index-ClT8LTw_.js} | 338 +++++------ static/assets/index-DSFMlFH_.css | 2 - static/assets/index-Dteqqsyc.css | 2 + static/index.html | 4 +- 23 files changed, 2221 insertions(+), 175 deletions(-) create mode 100644 backend/tests/integration/test_inventory_bulk.py create mode 100644 backend/tests/integration/test_spoolman_inventory_bulk.py create mode 100644 frontend/src/components/BulkEditSpoolsModal.tsx rename static/assets/{index-CHyRb--b.js => index-ClT8LTw_.js} (66%) delete mode 100644 static/assets/index-DSFMlFH_.css create mode 100644 static/assets/index-Dteqqsyc.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 453c610c3..44ae33768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased ### Added +- **Batch / mass edit on the Filament tab (#1795, requested by @RoBoT24-web)** — Bulk operations land on the Inventory page in both built-in and Spoolman modes. Reporter wanted "ten of the same spool, set a pressure advance value, save once" — the existing flow forced ten round-trips through the per-spool editor. **Frontend.** A new checkbox column anchors the leftmost slot of every row in the table view (header checkbox toggles every visible row; group rows expose a single checkbox that selects every member). As soon as one row is selected, a sticky toolbar appears above the list with **Edit / Print labels / Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear selection**. The selection clears automatically on any filter or tab change so the toolbar count can never drift from what's on screen. A new `BulkEditSpoolsModal` is the entry point for the bulk-edit action: a three-state-per-field form (untouched / set-to-value) over the flat spool attributes — material, subtype, brand, color name + RGBA, storage location, slicer filament name + ID, cost / kg, note, label weight, core weight, category, low-stock threshold %. The reporter's pressure-advance use case (K-profile) stays per-spool because K-profiles are scoped per `(printer, extruder, nozzle_diameter)` and bulk-applying a single K-value across heterogeneous printers would create wrong calibration — they're handled in the existing per-spool K-profile editor instead. **Clearing fields in bulk is intentionally NOT supported** (user decision on #1795): bulk-set lets you only WRITE non-empty values; emptying ten notes by mistake is a one-click disaster the dialog doesn't expose. The per-spool editor remains the path for clearing. **Same dropdown controls the per-spool editor uses.** Material, sub-type, brand, category, slicer preset name, and slicer filament are all rendered through a new `SearchableSelect` component matching the per-spool form's pattern (text input + chevron + filtered list of buttons, click-outside + Escape close). No native `` is intentionally avoided per the project's UI conventions. */ +function SearchableSelect({ value, onChange, options, allowCustom, placeholderKey, disabled }: SearchableSelectProps) { + const { t } = useTranslation(); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + + useEffect(() => { + if (!open) return; + const onDocClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + setSearch(''); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + setSearch(''); + } + }; + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onEsc); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onEsc); + }; + }, [open]); + + const displayValue = (() => { + if (open) return search; + const match = options.find((o) => o.value === value); + return match?.label ?? value; + })(); + + const filteredOptions = useMemo(() => { + if (!open) return options; + const q = search.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q)); + }, [open, search, options]); + + const noOptionMatch = open && search.trim() && !options.some((o) => o.value.toLowerCase() === search.trim().toLowerCase()); + + return ( +
+ { + setSearch(e.target.value); + setOpen(true); + if (allowCustom) onChange(e.target.value); + }} + onFocus={() => { + setOpen(true); + setSearch(''); + }} + placeholder={placeholderKey ? t(placeholderKey) : undefined} + className="w-full px-3 py-2 pr-9 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none" + /> + + {open && ( +
+ {filteredOptions.length === 0 && !allowCustom && ( +
{t('inventory.noResults')}
+ )} + {filteredOptions.map((opt) => ( + + ))} + {allowCustom && noOptionMatch && ( + + )} +
+ )} +
+ ); +} + +function combineUnique(...lists: string[][]): string[] { + const set = new Set(); + for (const list of lists) for (const v of list) { + const trimmed = v?.trim(); + if (trimmed) set.add(trimmed); + } + return Array.from(set).sort((a, b) => a.localeCompare(b)); +} + +export function BulkEditSpoolsModal({ + isOpen, selectedCount, isPending, + availableLocations, availableMaterials, availableSubtypes, availableBrands, availableCategories, + availableSlicerFilaments, availableSlicerFilamentNames, + onClose, onApply, +}: BulkEditSpoolsModalProps) { + const { t } = useTranslation(); + + // Slicer preset sources — match the per-spool form (cloud Bambu + cloud Orca + // + local + built-in). Gated on `isOpen` so closed modal doesn't fetch. + const { data: cloudPresets = [] } = useQuery({ + queryKey: ['bulk-edit-cloud-presets'], + enabled: isOpen, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + const out: Awaited> = []; + try { + const status = await api.getCloudStatus(); + if (status.is_authenticated) { + const bambu = await api.getFilamentPresets(); + out.push(...bambu); + } + } catch {/* cloud offline → empty */} + try { + const orca = await api.orcaCloudStatus(); + if (orca.connected) { + const list = await api.orcaCloudListProfiles(); + out.push(...(list.filament as unknown as typeof out)); + } + } catch {/* orca offline → empty */} + return out; + }, + }); + const { data: localPresetsResp } = useQuery({ + queryKey: ['bulk-edit-local-presets'], + enabled: isOpen, + staleTime: 5 * 60 * 1000, + queryFn: api.getLocalPresets, + }); + const { data: builtinFilaments = [] } = useQuery({ + queryKey: ['builtin-filaments'], + enabled: isOpen, + staleTime: 5 * 60 * 1000, + queryFn: api.getBuiltinFilaments, + }); + const filamentOptions = useMemo( + () => buildFilamentOptions(cloudPresets, new Set(), localPresetsResp?.filament ?? [], builtinFilaments), + [cloudPresets, localPresetsResp, builtinFilaments], + ); + // Per-field state: each entry is either undefined (leave unchanged) or + // the new value. Clearing fields in bulk is intentionally NOT supported + // (user decision on #1795): leave clearing to the per-spool editor so + // an accidental "blank everything" isn't a single mis-click away. + const [values, setValues] = useState>({}); + + // Merge inventory-seen values with the canonical option lists so users + // see the same dropdown choices the per-spool editor surfaces. + const materialOptions: Option[] = useMemo( + () => combineUnique(MATERIALS, availableMaterials).map((m) => ({ value: m, label: m })), + [availableMaterials], + ); + const subtypeOptions: Option[] = useMemo( + () => combineUnique(KNOWN_VARIANTS, availableSubtypes).map((m) => ({ value: m, label: m })), + [availableSubtypes], + ); + const brandOptions: Option[] = useMemo( + () => combineUnique(DEFAULT_BRANDS, availableBrands).map((m) => ({ value: m, label: m })), + [availableBrands], + ); + const categoryOptions: Option[] = useMemo( + () => combineUnique(availableCategories).map((m) => ({ value: m, label: m })), + [availableCategories], + ); + const slicerFilamentOptions: Option[] = useMemo(() => { + // value = preset code (what goes into spool.slicer_filament), + // label = display name so the user can find it by name. + const fromPresets = filamentOptions.map((p) => ({ value: p.code, label: p.displayName })); + const fromInventory = availableSlicerFilaments + .filter((code) => !fromPresets.some((p) => p.value === code)) + .map((code) => ({ value: code, label: code })); + return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label)); + }, [filamentOptions, availableSlicerFilaments]); + const slicerFilamentNameOptions: Option[] = useMemo(() => { + const fromPresets = filamentOptions.map((p) => ({ value: p.displayName, label: p.displayName })); + const fromInventory = availableSlicerFilamentNames + .filter((name) => !fromPresets.some((p) => p.value === name)) + .map((name) => ({ value: name, label: name })); + return [...fromPresets, ...fromInventory].sort((a, b) => a.label.localeCompare(b.label)); + }, [filamentOptions, availableSlicerFilamentNames]); + const locationOptions: Option[] = useMemo( + () => availableLocations.map((l) => ({ value: String(l.id), label: l.name })), + [availableLocations], + ); + + if (!isOpen) return null; + + const setField = (id: EditableField, value: string) => { + setValues((prev) => ({ ...prev, [id]: value })); + }; + + const unsetField = (id: EditableField) => { + setValues((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }; + + const buildPatch = (): Record => { + const patch: Record = {}; + for (const f of FIELDS) { + const raw = values[f.id]; + if (raw === undefined) continue; + const trimmed = typeof raw === 'string' ? raw.trim() : raw; + if (trimmed === '' || trimmed === null) continue; + if (f.type === 'number') { + const n = Number(trimmed); + if (Number.isFinite(n)) patch[f.id] = n; + } else if (f.id === 'location_id') { + const n = Number(trimmed); + if (Number.isFinite(n) && n > 0) patch[f.id] = n; + } else if (f.id === 'rgba') { + const hex = String(trimmed).replace(/^#/, ''); + const normalized = hex.length === 6 ? `${hex}FF` : hex; + if (/^[0-9A-Fa-f]{8}$/.test(normalized)) patch[f.id] = normalized.toUpperCase(); + } else { + patch[f.id] = String(trimmed); + } + } + return patch; + }; + + const patch = buildPatch(); + const hasChanges = Object.keys(patch).length > 0; + // Block Apply when any ticked-and-non-empty field has invalid input that + // would be silently dropped from the patch — e.g. a malformed rgba hex. + // Without this guard the user clicks Apply, the field is dropped, and the + // success toast still fires for the OTHER fields. + const hasDroppedTickedField = FIELDS.some((f) => { + const raw = values[f.id]; + if (raw === undefined) return false; + if (raw.trim() === '') return false; + return patch[f.id] === undefined; + }); + + const optionsFor = (id: EditableField): Option[] => { + if (id === 'material') return materialOptions; + if (id === 'subtype') return subtypeOptions; + if (id === 'brand') return brandOptions; + if (id === 'category') return categoryOptions; + if (id === 'slicer_filament') return slicerFilamentOptions; + if (id === 'slicer_filament_name') return slicerFilamentNameOptions; + if (id === 'location_id') return locationOptions; + return []; + }; + + const renderInput = (f: FieldSpec) => { + const value = values[f.id] ?? ''; + + if (f.type === 'searchable' || f.type === 'searchableClosed') { + return ( + { + if (next === '') unsetField(f.id); + else setField(f.id, next); + }} + options={optionsFor(f.id)} + allowCustom={f.type === 'searchable'} + disabled={isPending} + /> + ); + } + + if (f.type === 'textarea') { + return ( +