fix(assign-spool): refresh printer status after assignment so card updates without manual Force-refresh (#1414 follow-up)

Reporter (@snozzlebert on A1 mini external slot) saw the
  Filament page Location column update correctly after Assign
  Spool, but the Printer card kept showing "Empty slot" until
  they manually pressed Force-refresh. MQTT command was going
  through fine; gap was client-side.

  AssignSpoolModal's two onSuccess callbacks invalidated the
  inventory / slot-assignment queries but never invalidated
  ['printerStatus', printerId] and never issued a pushall. For
  Bambu RFID-tagged spools the printer echoes the new tray_type
  on its own; for non-RFID spools and A1 mini external slots
  the firmware doesn't volunteer that state change.

  Added nudgePrinterRepublish() helper called from both onSuccess
  paths: api.refreshPrinterStatus(printerId) to issue the pushall
  (same call the Force-refresh button uses) plus invalidate
  printerStatus so the refetch lands. Refresh failures are
  swallowed — the assignment itself succeeded; a stale-cache
  nudge that didn't go through shouldn't surface as "assign
  failed". Same pattern as ConfigureAmsSlotModal since #1235,
  with the extra pushall because assign-spool affects firmware-
  side state.
This commit is contained in:
maziggy
2026-05-19 11:41:53 +02:00
parent 9c934c905d
commit 9bcaafbc1a
5 changed files with 123 additions and 67 deletions
+2
View File
File diff suppressed because one or more lines are too long
@@ -9,9 +9,12 @@ vi.mock('../../api/client', () => ({
getSpools: vi.fn(),
getAssignments: vi.fn(),
assignSpool: vi.fn(),
assignSpoolmanSlot: vi.fn(),
getSpoolmanInventorySpools: vi.fn(),
getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({}),
getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
refreshPrinterStatus: vi.fn().mockResolvedValue({ status: 'ok' }),
},
}));
@@ -285,6 +288,41 @@ describe('AssignSpoolModal', () => {
expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
});
});
it('nudges the printer to republish after successful assignment (#1414)', async () => {
// The backend's assign-spool path issues an MQTT command, but firmware
// (esp. A1 mini external slots and any non-RFID assignment) doesn't
// always echo the new tray state back on its own — the printer card
// then sits on stale data until the user hits Force-refresh. Modal
// calls refreshPrinterStatus to issue a pushall so the printer
// republishes state, mirroring the Force-refresh button.
const { default: userEvent } = await import('@testing-library/user-event');
const user = userEvent.setup();
// Tray material matches the spool to skip the mismatch confirm dialog.
(api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
(api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
});
render(
<AssignSpoolModal
{...defaultProps}
printerId={7}
trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
/>
);
await waitFor(() => {
expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
});
await user.click(screen.getByText(/Polymaker/));
await user.click(screen.getByRole('button', { name: /assign spool/i }));
await waitFor(() => {
expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
});
});
});
describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
@@ -113,6 +113,20 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
);
}, [allSpoolmanAssignments, printerId, amsId, trayId]);
// #1414: nudge the printer to republish its state after we assign a
// spool. The backend assign-spool path already issues an MQTT command,
// but firmware (especially A1 mini external slots and any non-RFID
// assignment) doesn't always echo the new tray state back on its own,
// so the printer card sits on stale data and the user has to press
// Force-refresh to see the assignment. Calling /refresh-status forces
// a pushall the way the Force-refresh button does. Failures are
// intentionally swallowed — the assignment itself succeeded; if the
// refresh is offline the next poll / websocket update will catch up.
const nudgePrinterRepublish = () => {
api.refreshPrinterStatus(printerId).catch(() => {});
queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
};
const assignMutation = useMutation({
mutationFn: (spoolId: number) =>
api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
@@ -126,6 +140,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
return filtered;
});
queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
nudgePrinterRepublish();
showToast(t('inventory.assignSuccess'), 'success');
setShowMismatchConfirm(false);
setPendingAssignId(null);
@@ -148,6 +163,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
nudgePrinterRepublish();
showToast(t('inventory.assignSuccess'), 'success');
onClose();
},
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CcGEQ2Qx.js"></script>
<script type="module" crossorigin src="/assets/index-D9JqDSB2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
</head>
<body>