mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
fix(#1133): list every spool in the AMS-slot inventory picker
The picker that opens from <FilamentHoverCard> / SpoolBuddy's slot-action
sheet had two stacked filters that together blocked a real workflow:
1. AssignSpoolModal only listed spools whose tag_uid AND tray_uuid were
both null, hiding any Bambu Lab spool that had been auto-created from
RFID or scanned via SpoolBuddy NFC.
2. FilamentHoverCard rendered its inventory section only when the slot's
vendor was not 'Bambu Lab', so even with #1 fixed the assign button
wasn't visible on a BL slot.
Both filters blocked the same use case: a user has a Bambu Lab spool in
inventory but doesn't want to scan via SpoolBuddy NFC every time and
just wants to pick it from the list.
Both gates removed. Modal lists every spool that isn't already taken by
another (printer/ams_id/tray_id) tuple. Hover-card inventory section
renders for every vendor including Bambu Lab. The AMS-vs-external
special-case in the modal collapsed too — external slots used to be the
only path that allowed picking a tagged spool, that distinction is gone.
Empty slots lost their assign affordance entirely. A physically empty
slot has no spool to attach an inventory record to, and offering the
action there only led to users assigning the wrong spool to a slot the
printer hadn't actually loaded yet. Bambuddy: EmptySlotHoverCard's
inventory prop removed; PrintersPage drops the matching inventory
props on three sites. SpoolBuddy: slot-action picker gates the
assign/unassign block on slotActionPicker.tray !== null.
Modal also gets defensive hardening from the rollout investigation:
its own dedicated cache key (['inventory-spools', 'assign-modal']) so
it can't be poisoned by other components calling getSpools() with
different includeArchived args; getSpools(true) + client-side
!archived_at filter so the picker sees the full inventory regardless
of cache priming order; "Show all spools" toggle now bypasses BOTH
filters (was only bypassing material/profile, label was a lie); a
small "X fetched · Y archived · Z assigned" counter in the empty state
so future "missing spool" reports are debuggable from screenshots.
i18n.inventory.noManualSpools renamed to inventory.noAvailableSpools
with new copy ("No spools available. Add a spool to your inventory or
unassign one from another slot first.") since the empty-state premise
changed. Localised across all 8 languages.
15 frontend tests: assign/unassign render for vendor: 'Bambu Lab',
non-BL vendors unchanged, EmptySlotHoverCard renders no assign
affordance, configure button still works on empty slots, picker lists
BL spools alongside manual ones, picker drops spools assigned
elsewhere unless toggle is on, picker drops archived spools always,
toggle escape-hatch shows everything, empty-state copy update.
SpoolBuddy unassign now invalidates both ['spool-assignments'] and
['spool-assignments', printerId] so the modal's cache stays fresh
(dual cache-key consolidation deferred to its own PR).
This commit is contained in:
@@ -49,9 +49,22 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Unique cache key — different consumers of `['inventory-spools']` call
|
||||
// `getSpools()` with different `includeArchived` arguments (InventoryPage:
|
||||
// true, SpoolBuddyDashboard / SpoolBuddyInventoryPage: false), but they
|
||||
// all share the same key. React Query treats them as one query and
|
||||
// serves whichever response landed first, so a SpoolBuddy component
|
||||
// priming the cache with the archived-excluded payload makes the picker
|
||||
// miss spools that *are* archived OR (more subtly) miss any spool that
|
||||
// wasn't yet present when SpoolBuddy ran its initial fetch. The picker
|
||||
// gets its own key + a fetch-everything call so this consumer is never
|
||||
// at the mercy of someone else's cache state. Archived spools are then
|
||||
// explicitly excluded client-side because the backend rejects archived
|
||||
// assignments with HTTP 400 anyway, so listing them would only let the
|
||||
// user click a button that fails.
|
||||
const { data: spools, isLoading } = useQuery({
|
||||
queryKey: ['inventory-spools'],
|
||||
queryFn: () => api.getSpools(),
|
||||
queryKey: ['inventory-spools', 'assign-modal'],
|
||||
queryFn: () => api.getSpools(true),
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
@@ -137,11 +150,27 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
.filter(a => !(a.printer_id === printerId && a.ams_id === amsId && a.tray_id === trayId))
|
||||
.map(a => a.spool_id)
|
||||
);
|
||||
// External slots (amsId 254 or 255) have no RFID reader, so show all spools.
|
||||
// AMS slots only show manual spools (no tag_uid or tray_uuid).
|
||||
const isExternalSlot = amsId === 254 || amsId === 255;
|
||||
const manualSpools = spools?.filter((spool: InventorySpool) =>
|
||||
!assignedSpoolIds.has(spool.id) && (isExternalSlot || (!spool.tag_uid && !spool.tray_uuid))
|
||||
// Show every spool that isn't already taken by another slot — including
|
||||
// RFID-tagged Bambu Lab spools (#1133). The earlier "manual spools only"
|
||||
// gate (tag_uid && tray_uuid both null) blocked the workflow where a
|
||||
// user has a Bambu Lab spool in inventory but doesn't want to scan it
|
||||
// via SpoolBuddy NFC every time and just wants to pick it from the list.
|
||||
// External slots (amsId 254/255) have always been allowed to pick from
|
||||
// any spool because the slot itself has no RFID reader; that
|
||||
// distinction collapses now that AMS slots also accept any spool.
|
||||
//
|
||||
// The "Show all spools" toggle (disableFiltering) bypasses BOTH this
|
||||
// gate and the material/profile filter below, making it a real escape
|
||||
// hatch for cases where MQTT has auto-reassigned a spool to another
|
||||
// slot a fraction of a second after a manual unassign — without this,
|
||||
// the toggle's label is a lie ("Show all" but actually filters by
|
||||
// assignment). The backend's assign_spool route is upsert-per-
|
||||
// (printer, ams, tray), so picking a spool that's currently taken by
|
||||
// a different slot creates a second assignment row; that's a foot-gun
|
||||
// for normal flows but exactly the recovery path the toggle is for.
|
||||
const availableSpools = spools?.filter((spool: InventorySpool) =>
|
||||
!spool.archived_at &&
|
||||
(disableFiltering || !assignedSpoolIds.has(spool.id))
|
||||
);
|
||||
|
||||
// Filtering logic with toggle: search filter always applies, AMS tray profile filter is optional.
|
||||
@@ -149,7 +178,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
// tray's material (partial-match both directions — "PLA" spool accepts a "PLA Basic" slot and
|
||||
// vice versa). Manually-added inventory spools typically have no slicer_filament_name; gating
|
||||
// on strict profile equality alone hid them even when the material matched (#1047).
|
||||
let filteredSpools = manualSpools;
|
||||
let filteredSpools = availableSpools;
|
||||
if (!disableFiltering) {
|
||||
const trayProfile = stripProfileQualifier(normalizeValue(trayInfo?.profile));
|
||||
const trayMaterial = normalizeValue(trayInfo?.material || trayInfo?.type);
|
||||
@@ -326,13 +355,31 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : manualSpools && manualSpools.length === 0 ? (
|
||||
) : availableSpools && availableSpools.length === 0 ? (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>{t('inventory.noManualSpools')}</p>
|
||||
<p>{t('inventory.noAvailableSpools')}</p>
|
||||
{/* Diagnostic counter — when the picker is empty, having
|
||||
the raw fetch / filter counts visible makes a
|
||||
"spool I expected to see is missing" report
|
||||
immediately answerable: if `total fetched` is 0 the
|
||||
backend / cache returned nothing; if it's > 0 then
|
||||
the archived / assigned-elsewhere filter ate the
|
||||
spool and the toggle is the right escape hatch. */}
|
||||
{spools && (
|
||||
<p className="text-[10px] mt-2 opacity-60">
|
||||
{spools.length} fetched · {spools.filter(s => s.archived_at).length} archived ·{' '}
|
||||
{spools.filter(s => assignedSpoolIds.has(s.id)).length} assigned to other slots
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-bambu-gray">
|
||||
<p>{t('inventory.noSpoolsMatch')}</p>
|
||||
{availableSpools && (
|
||||
<p className="text-[10px] mt-2 opacity-60">
|
||||
{availableSpools.length} unassigned spools — {(availableSpools.length) - (filteredSpools?.length ?? 0)} filtered by tray match. Try "Show all spools".
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -345,8 +345,12 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inventory section - only for non-Bambu spools */}
|
||||
{inventory && data.vendor !== 'Bambu Lab' && (
|
||||
{/* Inventory section — shown for every vendor including
|
||||
Bambu Lab (#1133). The earlier "non-Bambu only" gate
|
||||
prevented users from manually assigning a Bambu spool
|
||||
in inventory to an AMS slot when they didn't want to
|
||||
re-scan via SpoolBuddy NFC. */}
|
||||
{inventory && (
|
||||
<div className="pt-2 mt-2 border-t border-bambu-dark-tertiary space-y-2">
|
||||
{inventory.assignedSpool ? (
|
||||
<>
|
||||
@@ -468,13 +472,18 @@ interface EmptySlotHoverCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
configureSlot?: ConfigureSlotConfig;
|
||||
inventory?: InventoryConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper for empty slots - shows "Empty" on hover with optional configure button
|
||||
* Wrapper for empty slots - shows "Empty" on hover with optional configure button.
|
||||
*
|
||||
* The "Assign spool" affordance was removed from empty slots in #1133: a
|
||||
* physically empty slot has no spool to attach to, and offering the
|
||||
* action there only led to users assigning the wrong spool to a slot
|
||||
* the printer hadn't actually loaded yet. Assignment now requires a
|
||||
* loaded slot (which renders FilamentHoverCard, where the button lives).
|
||||
*/
|
||||
export function EmptySlotHoverCard({ children, className = '', configureSlot, inventory }: EmptySlotHoverCardProps) {
|
||||
export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -531,21 +540,6 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Assign spool button - allows assigning inventory spool to empty slot */}
|
||||
{inventory?.onAssignSpool && (
|
||||
<div className="px-2 pb-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
inventory.onAssignSpool?.();
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
|
||||
>
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
{t('inventory.assignSpool')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="
|
||||
absolute left-1/2 -translate-x-1/2 top-full w-0 h-0
|
||||
|
||||
Reference in New Issue
Block a user