diff --git a/CHANGELOG.md b/CHANGELOG.md index e61ef84a3..ce8d83f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.4.7] - 2026-06-14 ### 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 ( +