mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
fix(print-modal): disable printers between dispatch-accept and PRINT_START
Reported off-list by a corporate supporter running a multi-operator
farm shift. Backend already rejects double-sends with HTTP 409 (see
background_dispatch._dispatch at lines 283-290), so no double-print
was possible, but PrinterSelector consulted only PrinterStatus.state
({IDLE, FINISH, FAILED}) to decide whether a printer was selectable.
PRINT_START is the only signal that flips the printer out of IDLE, so
during the upload + print-command + firmware-ack window the card
stayed clickable and operators only found out after submitting.
New useDispatchedPrinterIds hook subscribes to the existing
background-dispatch WebSocket event (already consumed for the toast
overlay), collects printer_ids from dispatched_jobs + active_jobs,
and exposes them via useSyncExternalStore so every PrinterSelector
instance shares one snapshot. OR'd into isPrinterBusy; badge label
flips to "Dispatching..." instead of a misleading "Idle".
No backend change — the reservation already exists, the UI just
didn't reflect it. No behaviour change for add-to-queue / edit-queue
modes (disableBusy stays false for those).
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Tests for useDispatchedPrinterIds — the hook that exposes printer IDs with
|
||||
* a queued/active background-dispatch job so PrinterSelector can grey them
|
||||
* out between dispatch-accepted and the printer's PRINT_START report.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import {
|
||||
useDispatchedPrinterIds,
|
||||
__resetDispatchedPrinterIdsForTests,
|
||||
} from '../../hooks/useDispatchedPrinterIds';
|
||||
|
||||
function fire(detail: Record<string, unknown>) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('background-dispatch', { detail }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('useDispatchedPrinterIds', () => {
|
||||
beforeEach(() => {
|
||||
__resetDispatchedPrinterIdsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetDispatchedPrinterIdsForTests();
|
||||
});
|
||||
|
||||
it('returns an empty set initially', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it('picks up printer IDs from dispatched_jobs', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({
|
||||
dispatched_jobs: [
|
||||
{ job_id: 1, printer_id: 42, printer_name: 'Farm-A' },
|
||||
],
|
||||
active_jobs: [],
|
||||
});
|
||||
expect(result.current.has(42)).toBe(true);
|
||||
expect(result.current.size).toBe(1);
|
||||
});
|
||||
|
||||
it('picks up printer IDs from active_jobs', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({
|
||||
dispatched_jobs: [],
|
||||
active_jobs: [
|
||||
{ job_id: 1, printer_id: 7, printer_name: 'Farm-B' },
|
||||
],
|
||||
});
|
||||
expect(result.current.has(7)).toBe(true);
|
||||
});
|
||||
|
||||
it('unions both lists', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({
|
||||
dispatched_jobs: [{ job_id: 1, printer_id: 1 }],
|
||||
active_jobs: [{ job_id: 2, printer_id: 2 }],
|
||||
});
|
||||
expect(result.current.size).toBe(2);
|
||||
expect(result.current.has(1)).toBe(true);
|
||||
expect(result.current.has(2)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears printers when subsequent event reports no jobs', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({ dispatched_jobs: [{ job_id: 1, printer_id: 9 }], active_jobs: [] });
|
||||
expect(result.current.has(9)).toBe(true);
|
||||
fire({ dispatched_jobs: [], active_jobs: [] });
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores jobs without a numeric printer_id', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({
|
||||
dispatched_jobs: [
|
||||
{ job_id: 1, printer_id: 'not-a-number' },
|
||||
{ job_id: 2 },
|
||||
{ job_id: 3, printer_id: 5 },
|
||||
],
|
||||
active_jobs: [],
|
||||
});
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.has(5)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps snapshot reference stable when content is unchanged', () => {
|
||||
const { result } = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
|
||||
const first = result.current;
|
||||
fire({ dispatched_jobs: [{ printer_id: 1 }], active_jobs: [] });
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it('shares state across hook instances', () => {
|
||||
const a = renderHook(() => useDispatchedPrinterIds());
|
||||
const b = renderHook(() => useDispatchedPrinterIds());
|
||||
fire({ dispatched_jobs: [{ printer_id: 11 }], active_jobs: [] });
|
||||
expect(a.result.current.has(11)).toBe(true);
|
||||
expect(b.result.current.has(11)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { api, type PrinterStatus } from '../../api/client';
|
||||
import { useDispatchedPrinterIds } from '../../hooks/useDispatchedPrinterIds';
|
||||
import { getColorName } from '../../utils/colors';
|
||||
import {
|
||||
normalizeColorForCompare,
|
||||
@@ -255,7 +256,14 @@ export function PrinterSelector({
|
||||
return map;
|
||||
}, [activePrinters, statusQueries]);
|
||||
|
||||
// Printers with a queued/active background dispatch — accepted by Bambuddy
|
||||
// but not yet reflected in PrinterStatus.state (which only flips on
|
||||
// PRINT_START from the printer itself). Backend rejects double-sends with
|
||||
// 409 anyway; this just stops the operator from picking them in the modal.
|
||||
const dispatchedPrinterIds = useDispatchedPrinterIds();
|
||||
|
||||
const isPrinterBusy = (printerId: number): boolean => {
|
||||
if (dispatchedPrinterIds.has(printerId)) return true;
|
||||
const status = printerStatusMap.get(printerId);
|
||||
if (!status) return false; // Unknown state — don't block
|
||||
if (!status.connected) return true;
|
||||
@@ -263,6 +271,7 @@ export function PrinterSelector({
|
||||
};
|
||||
|
||||
const getPrinterStateLabel = (printerId: number): string | null => {
|
||||
if (dispatchedPrinterIds.has(printerId)) return 'Dispatching...';
|
||||
const status = printerStatusMap.get(printerId);
|
||||
if (!status) return null;
|
||||
if (!status.connected) return 'Offline';
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Subscribes to background-dispatch WebSocket events and returns the set of
|
||||
* printer IDs that currently have a queued or active dispatch job.
|
||||
*
|
||||
* Used by PrinterSelector to disable printers between the moment Bambuddy
|
||||
* accepts a dispatch (FTP upload, print command) and the moment the printer
|
||||
* itself reports PRINT_START. The backend already rejects double-sends with
|
||||
* HTTP 409, but the UI gap still let operators pick a printer the server would
|
||||
* refuse — surfaced by a corporate user running multi-operator farm shifts.
|
||||
*
|
||||
* Module-level state + useSyncExternalStore so every PrinterSelector instance
|
||||
* sees the same snapshot, and component mounts mid-batch pick up the latest
|
||||
* state without re-fetching.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
interface DispatchEventJob {
|
||||
printer_id?: unknown;
|
||||
}
|
||||
|
||||
interface DispatchEventDetail {
|
||||
dispatched_jobs?: DispatchEventJob[];
|
||||
active_jobs?: DispatchEventJob[];
|
||||
total?: number;
|
||||
dispatched?: number;
|
||||
processing?: number;
|
||||
}
|
||||
|
||||
const EMPTY: ReadonlySet<number> = new Set();
|
||||
let currentSet: ReadonlySet<number> = EMPTY;
|
||||
const subscribers = new Set<() => void>();
|
||||
let attached = false;
|
||||
|
||||
function recompute(detail: DispatchEventDetail): ReadonlySet<number> {
|
||||
const next = new Set<number>();
|
||||
for (const job of detail.dispatched_jobs ?? []) {
|
||||
if (typeof job.printer_id === 'number') next.add(job.printer_id);
|
||||
}
|
||||
for (const job of detail.active_jobs ?? []) {
|
||||
if (typeof job.printer_id === 'number') next.add(job.printer_id);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function handleEvent(event: Event) {
|
||||
const detail = (event as CustomEvent<DispatchEventDetail>).detail ?? {};
|
||||
const next = recompute(detail);
|
||||
// Keep reference stable when content didn't change — useSyncExternalStore
|
||||
// compares snapshots via Object.is and re-renders on any new reference.
|
||||
if (next.size === currentSet.size && [...next].every((id) => currentSet.has(id))) {
|
||||
return;
|
||||
}
|
||||
currentSet = next;
|
||||
subscribers.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
function ensureAttached() {
|
||||
if (attached || typeof window === 'undefined') return;
|
||||
window.addEventListener('background-dispatch', handleEvent);
|
||||
attached = true;
|
||||
}
|
||||
|
||||
const subscribe = (callback: () => void): (() => void) => {
|
||||
ensureAttached();
|
||||
subscribers.add(callback);
|
||||
return () => {
|
||||
subscribers.delete(callback);
|
||||
};
|
||||
};
|
||||
|
||||
const getSnapshot = (): ReadonlySet<number> => currentSet;
|
||||
|
||||
export function useDispatchedPrinterIds(): ReadonlySet<number> {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
}
|
||||
|
||||
/** Test-only helper — resets the module-level singleton between tests. */
|
||||
export function __resetDispatchedPrinterIdsForTests(): void {
|
||||
currentSet = EMPTY;
|
||||
subscribers.clear();
|
||||
if (attached && typeof window !== 'undefined') {
|
||||
window.removeEventListener('background-dispatch', handleEvent);
|
||||
attached = false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-BUDdM_BW.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BdzWqWAS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-7mua31Zk.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user