ToastContext.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
  2. import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { formatFileSize } from '../utils/file';
  5. type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
  6. // Dispatch-toast types — ported verbatim from
  7. // 0b43ac0d:frontend/src/contexts/ToastContext.tsx. The visual rendering
  8. // block below is the legacy code 1:1; the only swap is the event ingestion
  9. // (now sourced from `bambuddy:dispatch-toast` window events that
  10. // useWebSocket forwards from the four backend WS event types added in
  11. // the #1625 follow-up). Same shape, same DOM, same styling, same i18n
  12. // surface — guarantees the modal looks identical to the pre-scheduler
  13. // experience that users remember.
  14. type DispatchJobStatus = 'processing' | 'completed' | 'failed';
  15. interface DispatchToastJob {
  16. jobId: number;
  17. sourceName: string;
  18. printerName: string;
  19. status: DispatchJobStatus;
  20. uploadBytes?: number;
  21. uploadTotalBytes?: number;
  22. uploadProgressPct?: number;
  23. failReason?: string;
  24. }
  25. interface DispatchToastData {
  26. total: number;
  27. processing: number;
  28. completed: number;
  29. failed: number;
  30. jobs: DispatchToastJob[];
  31. }
  32. interface ToastAction {
  33. label: string;
  34. href: string;
  35. onClick?: () => void;
  36. }
  37. type ShowPersistentToast = (
  38. id: string,
  39. message: string,
  40. type?: ToastType,
  41. options?: { action?: ToastAction },
  42. ) => void;
  43. interface Toast {
  44. id: string;
  45. message: string;
  46. type: ToastType;
  47. persistent?: boolean;
  48. action?: ToastAction;
  49. dispatchData?: DispatchToastData;
  50. }
  51. interface ToastContextType {
  52. showToast: (message: string, type?: ToastType) => void;
  53. showPersistentToast: ShowPersistentToast;
  54. dismissToast: (id: string) => void;
  55. setViewportSuppressed: (suppressed: boolean) => void;
  56. }
  57. const ToastContext = createContext<ToastContextType | undefined>(undefined);
  58. export function useToast() {
  59. const context = useContext(ToastContext);
  60. if (!context) {
  61. throw new Error('useToast must be used within a ToastProvider');
  62. }
  63. return context;
  64. }
  65. const icons = {
  66. success: <CheckCircle className="w-5 h-5 text-green-400" />,
  67. error: <XCircle className="w-5 h-5 text-red-400" />,
  68. warning: <AlertCircle className="w-5 h-5 text-yellow-400" />,
  69. info: <Info className="w-5 h-5 text-blue-400" />,
  70. loading: <Loader2 className="w-5 h-5 text-bambu-green animate-spin" />,
  71. };
  72. const bgColors = {
  73. success: 'bg-green-500/10 border-green-500/30',
  74. error: 'bg-red-500/10 border-red-500/30',
  75. warning: 'bg-yellow-500/10 border-yellow-500/30',
  76. info: 'bg-blue-500/10 border-blue-500/30',
  77. loading: 'bg-bambu-green/10 border-bambu-green/30',
  78. };
  79. const DISPATCH_TOAST_ID = 'background-dispatch';
  80. const DISPATCH_TERMINAL_DISMISS_MS = 3500;
  81. interface DispatchEventDetail {
  82. type: string;
  83. queue_item_id: number;
  84. printer_id?: number | null;
  85. printer_name?: string | null;
  86. file_name?: string;
  87. total_bytes?: number;
  88. bytes_transferred?: number;
  89. pct?: number;
  90. reason?: string;
  91. }
  92. function isAwaitingPrinter(job: DispatchToastJob): boolean {
  93. // Same trick the legacy code used to derive "Awaiting printer…" without
  94. // a separate status. While the job is still 'processing' AND upload pct
  95. // has reached 99.9%, the printer hasn't yet acked our project_file.
  96. return (
  97. job.status === 'processing'
  98. && typeof job.uploadProgressPct === 'number'
  99. && job.uploadProgressPct >= 99.9
  100. );
  101. }
  102. function recomputeAggregate(jobs: DispatchToastJob[]): DispatchToastData {
  103. return {
  104. total: jobs.length,
  105. processing: jobs.filter((j) => j.status === 'processing').length,
  106. completed: jobs.filter((j) => j.status === 'completed').length,
  107. failed: jobs.filter((j) => j.status === 'failed').length,
  108. jobs,
  109. };
  110. }
  111. export function ToastProvider({ children }: { children: ReactNode }) {
  112. const { t } = useTranslation();
  113. const [toasts, setToasts] = useState<Toast[]>([]);
  114. const [viewportSuppressed, setViewportSuppressed] = useState(false);
  115. const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
  116. const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  117. // Tracks whether the provider is still mounted. A toast can be triggered by
  118. // an async callback that resolves AFTER React has unmounted us (common in
  119. // tests: `cleanup()` runs while a login promise is still in flight, then
  120. // the error handler calls showToast). In that case, scheduling a setTimeout
  121. // that later calls setToasts produces "window is not defined" once the jsdom
  122. // environment is torn down. Guard every setToasts call behind this ref so a
  123. // post-unmount showToast is a no-op instead of crashing.
  124. const isMountedRef = useRef(true);
  125. // Clean up all timeouts on unmount
  126. useEffect(() => {
  127. isMountedRef.current = true;
  128. const timeouts = timeoutRefs.current;
  129. return () => {
  130. isMountedRef.current = false;
  131. timeouts.forEach((timeout) => clearTimeout(timeout));
  132. timeouts.clear();
  133. };
  134. }, []);
  135. const showToast = useCallback((message: string, type: ToastType = 'success') => {
  136. if (!isMountedRef.current) return;
  137. const id = Math.random().toString(36).substr(2, 9);
  138. setToasts((prev) => [...prev, { id, message, type }]);
  139. // Auto-dismiss after 3 seconds
  140. const timeout = setTimeout(() => {
  141. if (!isMountedRef.current) return;
  142. setToasts((prev) => prev.filter((t) => t.id !== id));
  143. timeoutRefs.current.delete(id);
  144. }, 3000);
  145. timeoutRefs.current.set(id, timeout);
  146. }, []);
  147. const showPersistentToast = useCallback(
  148. (id: string, message: string, type: ToastType = 'info', options?: { action?: ToastAction }) => {
  149. if (!isMountedRef.current) return;
  150. setToasts((prev) => {
  151. // Update existing toast if same id, otherwise add new one
  152. const exists = prev.find((t) => t.id === id);
  153. if (exists) {
  154. return prev.map((t) =>
  155. t.id === id ? { ...t, message, type, persistent: true, action: options?.action } : t,
  156. );
  157. }
  158. return [...prev, { id, message, type, persistent: true, action: options?.action }];
  159. });
  160. },
  161. [],
  162. );
  163. const dismissToast = useCallback((id: string) => {
  164. if (!isMountedRef.current) return;
  165. // Clear any pending auto-dismiss timeout
  166. const timeout = timeoutRefs.current.get(id);
  167. if (timeout) {
  168. clearTimeout(timeout);
  169. timeoutRefs.current.delete(id);
  170. }
  171. setToasts((prev) => prev.filter((t) => t.id !== id));
  172. }, []);
  173. // Dispatch-toast ingestion. The four event types from the backend
  174. // (queue_item_uploading / upload_progress / acked / failed) map to
  175. // the legacy DispatchToastJob shape, then the same auto-dismiss +
  176. // aggregate-recompute logic from 0b43ac0d takes over.
  177. useEffect(() => {
  178. const onDispatchEvent = (event: Event) => {
  179. if (!isMountedRef.current) return;
  180. const detail = (event as CustomEvent<DispatchEventDetail>).detail;
  181. if (!detail || typeof detail.queue_item_id !== 'number') return;
  182. const jobId = detail.queue_item_id;
  183. setToasts((prev) => {
  184. const existing = prev.find((toastItem) => toastItem.id === DISPATCH_TOAST_ID);
  185. const existingJobs = existing?.dispatchData?.jobs ?? [];
  186. const existingJobIndex = existingJobs.findIndex((j) => j.jobId === jobId);
  187. const existingJob = existingJobIndex >= 0 ? existingJobs[existingJobIndex] : undefined;
  188. let nextJob: DispatchToastJob | null = null;
  189. const sourceName =
  190. detail.file_name
  191. || existingJob?.sourceName
  192. || t('dispatchToast.untitled');
  193. const printerName =
  194. detail.printer_name
  195. || existingJob?.printerName
  196. || (detail.printer_id ? `Printer ${detail.printer_id}` : '');
  197. switch (detail.type) {
  198. case 'queue_item_uploading':
  199. // Materialization point — job appears here, never on queue-add.
  200. nextJob = {
  201. jobId,
  202. sourceName,
  203. printerName,
  204. status: 'processing',
  205. uploadBytes: 0,
  206. uploadTotalBytes: detail.total_bytes,
  207. uploadProgressPct: 0,
  208. };
  209. break;
  210. case 'queue_item_upload_progress':
  211. if (!existingJob) return prev;
  212. nextJob = {
  213. ...existingJob,
  214. uploadBytes: detail.bytes_transferred,
  215. uploadTotalBytes: detail.total_bytes ?? existingJob.uploadTotalBytes,
  216. uploadProgressPct: detail.pct,
  217. };
  218. break;
  219. case 'queue_item_acked':
  220. if (!existingJob) return prev;
  221. nextJob = {
  222. ...existingJob,
  223. status: 'completed',
  224. uploadProgressPct: 100,
  225. };
  226. break;
  227. case 'queue_item_failed':
  228. if (!existingJob) return prev;
  229. nextJob = {
  230. ...existingJob,
  231. status: 'failed',
  232. failReason: detail.reason,
  233. };
  234. break;
  235. default:
  236. return prev;
  237. }
  238. // Compose the updated jobs list
  239. let updatedJobs: DispatchToastJob[];
  240. if (existingJob) {
  241. updatedJobs = [...existingJobs];
  242. updatedJobs[existingJobIndex] = nextJob;
  243. } else {
  244. updatedJobs = [...existingJobs, nextJob];
  245. }
  246. const dispatchData = recomputeAggregate(updatedJobs);
  247. const toastShape: Toast = {
  248. id: DISPATCH_TOAST_ID,
  249. message: t('dispatchToast.startingPrints'),
  250. type: 'loading',
  251. persistent: true,
  252. dispatchData,
  253. };
  254. if (existing) {
  255. return prev.map((toastItem) =>
  256. toastItem.id === DISPATCH_TOAST_ID ? toastShape : toastItem,
  257. );
  258. }
  259. return [...prev, toastShape];
  260. });
  261. };
  262. window.addEventListener('bambuddy:dispatch-toast', onDispatchEvent);
  263. return () => window.removeEventListener('bambuddy:dispatch-toast', onDispatchEvent);
  264. }, [t]);
  265. // Auto-dismiss the wrapper once every job has reached a terminal state.
  266. useEffect(() => {
  267. const dispatchToast = toasts.find((tst) => tst.id === DISPATCH_TOAST_ID);
  268. if (!dispatchToast?.dispatchData) return;
  269. const data = dispatchToast.dispatchData;
  270. if (data.total === 0 || data.processing !== 0) return;
  271. const existing = timeoutRefs.current.get(DISPATCH_TOAST_ID);
  272. if (existing) clearTimeout(existing);
  273. const timeout = setTimeout(() => {
  274. if (!isMountedRef.current) return;
  275. setToasts((prev) => prev.filter((tst) => tst.id !== DISPATCH_TOAST_ID));
  276. timeoutRefs.current.delete(DISPATCH_TOAST_ID);
  277. }, DISPATCH_TERMINAL_DISMISS_MS);
  278. timeoutRefs.current.set(DISPATCH_TOAST_ID, timeout);
  279. }, [toasts]);
  280. return (
  281. <ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast, setViewportSuppressed }}>
  282. {children}
  283. {/* Toast Container — to the left of the bug-report bubble (bottom-4 right-4 w-12).
  284. The kiosk layout suppresses this entire viewport so SpoolBuddy displays stay
  285. free of main-app notifications. */}
  286. <div className={`fixed bottom-4 right-20 z-[60] flex flex-col items-end gap-2 ${viewportSuppressed ? 'hidden' : ''}`}>
  287. {toasts.map((toast) => (
  288. <div
  289. key={toast.id}
  290. className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
  291. toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
  292. }`}
  293. data-testid={toast.dispatchData ? 'dispatch-toast-wrapper' : undefined}
  294. >
  295. {toast.dispatchData ? (
  296. // Legacy dispatch-toast rendering — verbatim port from
  297. // 0b43ac0d:frontend/src/contexts/ToastContext.tsx lines
  298. // 515–650. Same DOM, same Tailwind classes, same uppercase
  299. // status chip, same `awaitingPrinter` derivation. Only
  300. // diff vs legacy: no cancel button (the BG dispatch
  301. // cancel endpoint doesn't exist in the scheduler model).
  302. <>
  303. <div className="flex items-start justify-between gap-3">
  304. <div className="flex items-start gap-2">
  305. {icons[toast.type]}
  306. <div>
  307. <p className="text-white text-sm font-medium">{t('dispatchToast.startingPrints')}</p>
  308. <p className="text-xs text-bambu-gray mt-0.5">
  309. {t('dispatchToast.progressSummary', {
  310. complete: toast.dispatchData.completed + toast.dispatchData.failed,
  311. total: toast.dispatchData.total,
  312. processing: toast.dispatchData.processing,
  313. })}
  314. </p>
  315. </div>
  316. </div>
  317. <div className="flex items-center gap-1">
  318. <button
  319. onClick={() => setIsDispatchCollapsed((prev) => !prev)}
  320. className="text-bambu-gray hover:text-white transition-colors"
  321. aria-label={isDispatchCollapsed ? t('dispatchToast.expandDetails') : t('dispatchToast.collapseDetails')}
  322. data-testid="dispatch-toast-collapse"
  323. >
  324. {isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
  325. </button>
  326. <button
  327. onClick={() => dismissToast(toast.id)}
  328. className="text-bambu-gray hover:text-white transition-colors"
  329. aria-label={t('dispatchToast.dismiss')}
  330. data-testid="dispatch-toast-dismiss"
  331. >
  332. <X className="w-4 h-4" />
  333. </button>
  334. </div>
  335. </div>
  336. {!isDispatchCollapsed && (
  337. <div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
  338. {toast.dispatchData.jobs.map((job) => {
  339. const uploadDoneAwaitingPrinter = isAwaitingPrinter(job);
  340. const barColorByStatus: Record<DispatchJobStatus, string> = {
  341. processing: 'bg-bambu-green',
  342. completed: 'bg-green-500',
  343. failed: 'bg-red-500',
  344. };
  345. const progressByStatus: Record<DispatchJobStatus, number> = {
  346. processing: 60,
  347. completed: 100,
  348. failed: 100,
  349. };
  350. return (
  351. <div
  352. key={job.jobId}
  353. className="rounded border border-white/10 bg-black/15 p-2"
  354. data-testid={`dispatch-toast-job-${job.jobId}`}
  355. >
  356. <div className="flex items-center justify-between gap-2">
  357. <span className="text-xs text-white truncate" title={job.sourceName}>
  358. {job.sourceName}
  359. </span>
  360. <span
  361. className="text-[11px] uppercase tracking-wide text-bambu-gray"
  362. data-testid={`dispatch-toast-status-${job.jobId}`}
  363. >
  364. {t(`dispatchToast.status.${job.status}`)}
  365. </span>
  366. </div>
  367. {job.printerName && (
  368. <div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
  369. {job.printerName}
  370. </div>
  371. )}
  372. {job.status === 'processing' ? (
  373. uploadDoneAwaitingPrinter ? (
  374. <div className="text-[11px] text-bambu-gray truncate">
  375. {t('dispatchToast.awaitingPrinter')}
  376. </div>
  377. ) : typeof job.uploadBytes === 'number'
  378. && typeof job.uploadTotalBytes === 'number'
  379. && job.uploadTotalBytes > 0 ? (
  380. <div className="text-[11px] text-bambu-gray truncate">
  381. {formatFileSize(job.uploadBytes)} / {formatFileSize(job.uploadTotalBytes)}
  382. {typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
  383. </div>
  384. ) : null
  385. ) : job.status === 'failed' && job.failReason ? (
  386. <div className="text-[11px] text-red-400 truncate">
  387. {t(`dispatchToast.failed.${job.failReason}`, { defaultValue: t('dispatchToast.failed.generic') })}
  388. </div>
  389. ) : null}
  390. <div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
  391. <div
  392. className={`h-full ${barColorByStatus[job.status]} transition-all duration-300 ${uploadDoneAwaitingPrinter ? 'animate-pulse' : ''}`}
  393. style={{
  394. width: `${
  395. job.status === 'processing' && typeof job.uploadProgressPct === 'number'
  396. ? Math.max(0, Math.min(100, job.uploadProgressPct))
  397. : progressByStatus[job.status]
  398. }%`,
  399. }}
  400. />
  401. </div>
  402. </div>
  403. );
  404. })}
  405. </div>
  406. )}
  407. </>
  408. ) : (
  409. <>
  410. {icons[toast.type]}
  411. <span className="text-white text-sm">{toast.message}</span>
  412. {toast.action && (
  413. <a
  414. href={toast.action.href}
  415. target="_blank"
  416. rel="noopener noreferrer"
  417. onClick={() => {
  418. toast.action?.onClick?.();
  419. dismissToast(toast.id);
  420. }}
  421. className="ml-2 px-2 py-1 rounded text-xs font-medium bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 whitespace-nowrap"
  422. >
  423. {toast.action.label}
  424. </a>
  425. )}
  426. <button
  427. onClick={() => dismissToast(toast.id)}
  428. className="ml-2 text-bambu-gray hover:text-white transition-colors"
  429. >
  430. <X className="w-4 h-4" />
  431. </button>
  432. </>
  433. )}
  434. </div>
  435. ))}
  436. </div>
  437. </ToastContext.Provider>
  438. );
  439. }