diff --git a/CHANGELOG.md b/CHANGELOG.md index 467fcf2e4..13a577bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.6b11] - 2026-01-20 + +### New Features +- **Unified Print Modal** - Consolidated three separate modals into one unified component: + - Single modal handles reprint, add-to-queue, and edit-queue-item operations + - Consistent UI/UX across all print operations + - Reduced code duplication (~1300 LOC removed) +- **Multi-Printer Selection** - Send prints or queue items to multiple printers at once: + - Checkbox selection for multiple printers in reprint and add-to-queue modes + - "Select all" / "Clear" buttons for quick selection + - Same filament slot mapping applies to all selected printers + - Progress indicator during multi-printer submission + - Ideal for print farms with identical filament configurations +- **Enhanced Add-to-Queue** - Now includes plate selection and print options: + - Configure all print settings upfront instead of editing afterward + - Filament mapping with manual override capability + +### Fixed +- **File Manager folder navigation** - Fixed bug where opening a folder would briefly show files then jump back to root: + - Removed `selectedFolderId` from useEffect dependency array that was causing a reset loop + - Folder navigation now works correctly without resetting + ## [0.1.6b10] - 2026-01-20 ### New Features diff --git a/README.md b/README.md index 270cb8e9f..ff6223ca9 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ ### ⏰ Scheduling & Automation - Print queue with drag-and-drop +- Multi-printer selection (send to multiple printers at once) - Scheduled prints (date/time) - Queue Only mode (stage without auto-start) - Smart plug integration (Tasmota, Home Assistant) diff --git a/frontend/src/__tests__/components/AddToQueueModal.test.tsx b/frontend/src/__tests__/components/AddToQueueModal.test.tsx deleted file mode 100644 index be5729b8e..000000000 --- a/frontend/src/__tests__/components/AddToQueueModal.test.tsx +++ /dev/null @@ -1,196 +0,0 @@ -/** - * Tests for the AddToQueueModal component. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render } from '../utils'; -import { AddToQueueModal } from '../../components/AddToQueueModal'; -import { http, HttpResponse } from 'msw'; -import { server } from '../mocks/server'; - -const mockPrinters = [ - { - id: 1, - name: 'X1 Carbon', - ip_address: '192.168.1.100', - model: 'X1C', - enabled: true, - }, - { - id: 2, - name: 'P1S', - ip_address: '192.168.1.101', - model: 'P1S', - enabled: true, - }, -]; - -const mockPlates = [ - { id: 1, plate_number: 1, name: 'Plate 1' }, - { id: 2, plate_number: 2, name: 'Plate 2' }, -]; - -describe('AddToQueueModal', () => { - const mockOnClose = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - server.use( - http.get('/api/v1/printers/', () => { - return HttpResponse.json(mockPrinters); - }), - http.get('/api/v1/archives/:id/plates', () => { - return HttpResponse.json(mockPlates); - }), - http.get('/api/v1/archives/:id/filament-requirements', () => { - return HttpResponse.json([]); - }), - http.post('/api/v1/queue/', () => { - return HttpResponse.json({ id: 1, status: 'pending' }); - }) - ); - }); - - describe('rendering', () => { - it('renders the modal title', () => { - render( - - ); - - expect(screen.getByText('Schedule Print')).toBeInTheDocument(); - }); - - it('shows archive name', () => { - render( - - ); - - expect(screen.getByText('Test Print')).toBeInTheDocument(); - }); - - it('shows printer selector', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('Printer')).toBeInTheDocument(); - }); - }); - - it('shows add button', () => { - render( - - ); - - expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument(); - }); - - it('shows cancel button', () => { - render( - - ); - - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - }); - - describe('queue options', () => { - it('shows Queue Only option', () => { - render( - - ); - - expect(screen.getByText('Queue Only')).toBeInTheDocument(); - }); - - it('shows power off option', () => { - render( - - ); - - expect(screen.getByText(/power off/i)).toBeInTheDocument(); - }); - }); - - describe('print options', () => { - it('has print configuration options', async () => { - render( - - ); - - // Modal should render and have configuration options - await waitFor(() => { - expect(screen.getByText('Schedule Print')).toBeInTheDocument(); - }); - }); - }); - - describe('actions', () => { - it('calls onClose when cancel is clicked', async () => { - const user = userEvent.setup(); - render( - - ); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - expect(mockOnClose).toHaveBeenCalled(); - }); - }); - - describe('plate selection', () => { - it('shows plate selector when plates exist', async () => { - render( - - ); - - // Modal should render - plate selector may be conditional - await waitFor(() => { - expect(screen.getByText('Schedule Print')).toBeInTheDocument(); - }); - }); - }); -}); diff --git a/frontend/src/__tests__/components/EditQueueItemModal.test.tsx b/frontend/src/__tests__/components/EditQueueItemModal.test.tsx deleted file mode 100644 index d2ac498a8..000000000 --- a/frontend/src/__tests__/components/EditQueueItemModal.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Tests for the EditQueueItemModal component. - * - * These tests focus on: - * - Basic rendering and modal controls - * - Print options (bed levelling, flow calibration, etc.) - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render } from '../utils'; -import { EditQueueItemModal } from '../../components/EditQueueItemModal'; -import type { PrintQueueItem, Printer } from '../../api/client'; - -// Mock the API client to prevent actual API calls -vi.mock('../../api/client', async () => { - const actual = await vi.importActual('../../api/client'); - return { - ...actual, - fetchArchivePlates: vi.fn().mockResolvedValue([]), - fetchFilamentRequirements: vi.fn().mockResolvedValue([]), - }; -}); - -// Mock data -const createMockPrinter = (overrides: Partial = {}): Printer => ({ - id: 1, - name: 'Test Printer', - ip_address: '192.168.1.100', - serial_number: 'TESTSERIAL0001', - access_code: '12345678', - model: 'X1C', - enabled: true, - created_at: '2024-01-01T00:00:00Z', - ...overrides, -}); - -const createMockQueueItem = (overrides: Partial = {}): PrintQueueItem => ({ - id: 1, - printer_id: 1, - archive_id: 1, - position: 1, - scheduled_time: null, - require_previous_success: false, - auto_off_after: false, - manual_start: false, - ams_mapping: null, - plate_id: null, - bed_levelling: true, - flow_cali: false, - vibration_cali: true, - layer_inspect: false, - timelapse: false, - use_ams: true, - status: 'pending', - started_at: null, - completed_at: null, - error_message: null, - created_at: '2024-01-01T00:00:00Z', - archive_name: 'Test Print', - archive_thumbnail: null, - printer_name: 'Test Printer', - print_time_seconds: 3600, - ...overrides, -}); - -describe('EditQueueItemModal', () => { - const mockOnClose = vi.fn(); - const mockOnSave = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('rendering', () => { - it('renders the modal with title', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - expect(screen.getByText('Edit Queue Item')).toBeInTheDocument(); - }); - - it('shows printer selector label', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter({ name: 'My Printer' })]; - - render( - - ); - - // The printer label should be present - expect(screen.getByText('Printer')).toBeInTheDocument(); - }); - - it('shows print options toggle', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - expect(screen.getByText('Print Options')).toBeInTheDocument(); - }); - }); - - describe('print options', () => { - it('has print options toggle button', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - // Print Options toggle should be present - expect(screen.getByText('Print Options')).toBeInTheDocument(); - }); - - it('print options toggle is clickable', async () => { - const user = userEvent.setup(); - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - // Click should not throw an error - const printOptionsButton = screen.getByText('Print Options'); - await user.click(printOptionsButton); - - // The button should still be in the document after clicking - expect(screen.getByText('Print Options')).toBeInTheDocument(); - }); - }); - - describe('modal controls', () => { - it('has save button', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - const saveButton = screen.getByRole('button', { name: /save/i }); - expect(saveButton).toBeInTheDocument(); - }); - - it('has cancel button', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - expect(cancelButton).toBeInTheDocument(); - }); - - it('calls onClose when cancel button is clicked', async () => { - const user = userEvent.setup(); - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - await user.click(cancelButton); - - expect(mockOnClose).toHaveBeenCalled(); - }); - }); - - describe('queue options', () => { - it('shows queue only option', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - expect(screen.getByText('Queue Only')).toBeInTheDocument(); - }); - - it('shows power off option', () => { - const item = createMockQueueItem(); - const printers = [createMockPrinter()]; - - render( - - ); - - expect(screen.getByText(/power off/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx new file mode 100644 index 000000000..21883a0b0 --- /dev/null +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -0,0 +1,536 @@ +/** + * Tests for the unified PrintModal component. + * + * The PrintModal supports three modes: + * - 'reprint': Immediate print from archive (multi-printer support) + * - 'add-to-queue': Schedule print to queue (multi-printer support) + * - 'edit-queue-item': Edit existing queue item (single printer) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from '../utils'; +import { PrintModal } from '../../components/PrintModal'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; +import type { PrintQueueItem } from '../../api/client'; + +const mockPrinters = [ + { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true }, + { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true }, +]; + +const createMockQueueItem = (overrides: Partial = {}): PrintQueueItem => ({ + id: 1, + printer_id: 1, + archive_id: 1, + position: 1, + scheduled_time: null, + require_previous_success: false, + auto_off_after: false, + manual_start: false, + ams_mapping: null, + plate_id: null, + bed_levelling: true, + flow_cali: false, + vibration_cali: true, + layer_inspect: false, + timelapse: false, + use_ams: true, + status: 'pending', + started_at: null, + completed_at: null, + error_message: null, + created_at: '2024-01-01T00:00:00Z', + archive_name: 'Test Print', + archive_thumbnail: null, + printer_name: 'Test Printer', + print_time_seconds: 3600, + ...overrides, +}); + +describe('PrintModal', () => { + const mockOnClose = vi.fn(); + const mockOnSuccess = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + server.use( + http.get('/api/v1/printers/', () => { + return HttpResponse.json(mockPrinters); + }), + http.get('/api/v1/archives/:id/plates', () => { + return HttpResponse.json({ is_multi_plate: false, plates: [] }); + }), + http.get('/api/v1/archives/:id/filament-requirements', () => { + return HttpResponse.json({ filaments: [] }); + }), + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null }); + }), + http.post('/api/v1/archives/:id/reprint', () => { + return HttpResponse.json({ success: true }); + }), + http.post('/api/v1/queue/', () => { + return HttpResponse.json({ id: 1, status: 'pending' }); + }), + http.patch('/api/v1/queue/:id', () => { + return HttpResponse.json({ id: 1, status: 'pending' }); + }) + ); + }); + + describe('reprint mode', () => { + it('renders the modal title', () => { + render( + + ); + + expect(screen.getByText('Re-print')).toBeInTheDocument(); + }); + + it('shows archive name', () => { + render( + + ); + + expect(screen.getByText('Benchy')).toBeInTheDocument(); + }); + + it('shows printer selection with checkboxes for multi-select', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.getByText('P1S')).toBeInTheDocument(); + }); + }); + + it('has print button', () => { + render( + + ); + + // Get the submit button specifically (not printer selection buttons) + const submitButton = screen.getByRole('button', { name: /^print$/i }); + expect(submitButton).toBeInTheDocument(); + }); + + it('has cancel button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('calls onClose when cancel is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('print button is disabled until printer is selected', () => { + render( + + ); + + // Get the submit button specifically (not printer selection buttons) + const printButton = screen.getByRole('button', { name: /^print$/i }); + expect(printButton).toBeDisabled(); + }); + + it('shows no printers message when none active', async () => { + server.use( + http.get('/api/v1/printers/', () => { + return HttpResponse.json([]); + }) + ); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('No active printers available')).toBeInTheDocument(); + }); + }); + + it('shows print options toggle', () => { + render( + + ); + + expect(screen.getByText('Print Options')).toBeInTheDocument(); + }); + }); + + describe('add-to-queue mode', () => { + it('renders the modal title', () => { + render( + + ); + + expect(screen.getByText('Schedule Print')).toBeInTheDocument(); + }); + + it('shows archive name', () => { + render( + + ); + + expect(screen.getByText('Test Print')).toBeInTheDocument(); + }); + + it('shows add button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument(); + }); + + it('shows cancel button', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('shows Queue Only option', () => { + render( + + ); + + expect(screen.getByText('Queue Only')).toBeInTheDocument(); + }); + + it('shows power off option', () => { + render( + + ); + + expect(screen.getByText(/power off/i)).toBeInTheDocument(); + }); + + it('shows schedule options', () => { + render( + + ); + + expect(screen.getByText('ASAP')).toBeInTheDocument(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + }); + + it('calls onClose when cancel is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + describe('edit-queue-item mode', () => { + it('renders the modal title', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByText('Edit Queue Item')).toBeInTheDocument(); + }); + + it('shows save button', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('shows cancel button', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + it('shows print options toggle', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByText('Print Options')).toBeInTheDocument(); + }); + + it('shows Queue Only option', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByText('Queue Only')).toBeInTheDocument(); + }); + + it('shows power off option', () => { + const item = createMockQueueItem(); + + render( + + ); + + expect(screen.getByText(/power off/i)).toBeInTheDocument(); + }); + + it('calls onClose when cancel button is clicked', async () => { + const user = userEvent.setup(); + const item = createMockQueueItem(); + + render( + + ); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows printer dropdown for single selection', async () => { + const item = createMockQueueItem(); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Printer')).toBeInTheDocument(); + }); + }); + }); + + describe('multi-printer selection', () => { + it('shows select all button when multiple printers available', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Select all')).toBeInTheDocument(); + }); + }); + + it('shows info message when multiple printers selected', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Select all')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Select all')); + + await waitFor(() => { + expect(screen.getByText(/Slot mapping below applies to all/)).toBeInTheDocument(); + }); + }); + + it('updates button text when multiple printers selected', async () => { + const user = userEvent.setup(); + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Select all')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Select all')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /print to 2 printers/i })).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/components/ReprintModal.test.tsx b/frontend/src/__tests__/components/ReprintModal.test.tsx deleted file mode 100644 index 2fff6ad86..000000000 --- a/frontend/src/__tests__/components/ReprintModal.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Tests for the ReprintModal component. - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render } from '../utils'; -import { ReprintModal } from '../../components/ReprintModal'; -import { http, HttpResponse } from 'msw'; -import { server } from '../mocks/server'; - -const mockPrinters = [ - { id: 1, name: 'X1 Carbon', model: 'X1C', ip_address: '192.168.1.100', enabled: true, is_active: true }, - { id: 2, name: 'P1S', model: 'P1S', ip_address: '192.168.1.101', enabled: true, is_active: true }, -]; - -describe('ReprintModal', () => { - const mockOnClose = vi.fn(); - const mockOnSuccess = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - server.use( - http.get('/api/v1/printers/', () => { - return HttpResponse.json(mockPrinters); - }), - http.get('/api/v1/archives/:id/plates', () => { - return HttpResponse.json({ is_multi_plate: false, plates: [] }); - }), - http.get('/api/v1/archives/:id/filament-requirements', () => { - return HttpResponse.json({ filaments: [] }); - }), - http.get('/api/v1/printers/:id/status', () => { - return HttpResponse.json({ connected: true, state: 'IDLE', ams: [], vt_tray: null }); - }), - http.post('/api/v1/archives/:id/reprint', () => { - return HttpResponse.json({ success: true }); - }) - ); - }); - - describe('rendering', () => { - it('renders the modal title', () => { - render( - - ); - - expect(screen.getByText('Re-print')).toBeInTheDocument(); - }); - - it('shows archive name', () => { - render( - - ); - - expect(screen.getByText('Benchy')).toBeInTheDocument(); - }); - - it('shows printer selection buttons', async () => { - render( - - ); - - await waitFor(() => { - expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); - expect(screen.getByText('P1S')).toBeInTheDocument(); - }); - }); - }); - - describe('printer selection', () => { - it('shows active printers as buttons', async () => { - render( - - ); - - await waitFor(() => { - // Printer buttons should be present - expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); - }); - }); - - it('shows no printers message when none active', async () => { - server.use( - http.get('/api/v1/printers/', () => { - return HttpResponse.json([]); - }) - ); - - render( - - ); - - await waitFor(() => { - expect(screen.getByText('No active printers available')).toBeInTheDocument(); - }); - }); - }); - - describe('actions', () => { - it('has print button', () => { - render( - - ); - - expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument(); - }); - - it('has cancel button', () => { - render( - - ); - - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); - }); - - it('calls onClose when cancel is clicked', async () => { - const user = userEvent.setup(); - render( - - ); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - expect(mockOnClose).toHaveBeenCalled(); - }); - - it('print button is disabled until printer is selected', async () => { - render( - - ); - - // Print button should be disabled initially (no printer selected) - const printButton = screen.getByRole('button', { name: /print/i }); - expect(printButton).toBeDisabled(); - }); - }); -}); diff --git a/frontend/src/components/PrintModal/PrinterSelector.tsx b/frontend/src/components/PrintModal/PrinterSelector.tsx index bad101262..4d91ccde8 100644 --- a/frontend/src/components/PrintModal/PrinterSelector.tsx +++ b/frontend/src/components/PrintModal/PrinterSelector.tsx @@ -1,17 +1,20 @@ -import { Printer as PrinterIcon, Loader2, AlertCircle } from 'lucide-react'; +import { Printer as PrinterIcon, Loader2, AlertCircle, Check } from 'lucide-react'; import type { PrinterSelectorProps } from './types'; /** - * Printer selection component with two modes: - * - Grid mode (default): Shows printers as selectable cards + * Printer selection component with multiple modes: + * - Grid mode (default): Shows printers as selectable cards (single or multi-select) * - Dropdown mode: Shows printers in a select dropdown (used when allowUnassigned is true) */ export function PrinterSelector({ printers, selectedPrinterId, + selectedPrinterIds = [], onSelect, + onMultiSelect, isLoading = false, allowUnassigned = false, + allowMultiple = false, }: PrinterSelectorProps) { const activePrinters = printers.filter((p) => p.is_active); @@ -68,36 +71,114 @@ export function PrinterSelector({ ); } + const handlePrinterClick = (printerId: number) => { + if (allowMultiple && onMultiSelect) { + // Multi-select mode: toggle printer in selection + if (selectedPrinterIds.includes(printerId)) { + onMultiSelect(selectedPrinterIds.filter((id) => id !== printerId)); + } else { + onMultiSelect([...selectedPrinterIds, printerId]); + } + } else { + // Single-select mode + onSelect(printerId); + } + }; + + const handleSelectAll = () => { + if (onMultiSelect) { + onMultiSelect(activePrinters.map((p) => p.id)); + } + }; + + const handleDeselectAll = () => { + if (onMultiSelect) { + onMultiSelect([]); + } + }; + + const isSelected = (printerId: number) => { + if (allowMultiple) { + return selectedPrinterIds.includes(printerId); + } + return selectedPrinterId === printerId; + }; + + const selectedCount = allowMultiple ? selectedPrinterIds.length : (selectedPrinterId ? 1 : 0); + return (
+ {/* Multi-select header */} + {allowMultiple && activePrinters.length > 1 && ( +
+ + {selectedCount === 0 + ? 'Select printers' + : `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`} + +
+ {selectedCount < activePrinters.length && ( + + )} + {selectedCount > 0 && ( + + )} +
+
+ )} + {activePrinters.map((printer) => ( ))}
diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 112162580..62ed763d8 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { X, Printer, Loader2, Calendar, Pencil } from 'lucide-react'; +import { X, Printer, Loader2, Calendar, Pencil, AlertCircle } from 'lucide-react'; import { api } from '../../api/client'; import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client'; import { Card, CardContent } from '../Card'; @@ -23,9 +23,9 @@ import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types'; /** * Unified PrintModal component that handles three modes: - * - 'reprint': Immediate print from archive - * - 'add-to-queue': Schedule print to queue - * - 'edit-queue-item': Edit existing queue item + * - 'reprint': Immediate print from archive (supports multi-printer) + * - 'add-to-queue': Schedule print to queue (supports multi-printer) + * - 'edit-queue-item': Edit existing queue item (single printer only) */ export function PrintModal({ mode, @@ -38,7 +38,7 @@ export function PrintModal({ const queryClient = useQueryClient(); const { showToast } = useToast(); - // Initialize state based on mode + // Single printer selection (for edit mode and backward compatibility) const [selectedPrinter, setSelectedPrinter] = useState(() => { if (mode === 'edit-queue-item' && queueItem) { return queueItem.printer_id; @@ -46,6 +46,9 @@ export function PrintModal({ return null; }); + // Multiple printer selection (for reprint and add-to-queue modes) + const [selectedPrinters, setSelectedPrinters] = useState([]); + const [selectedPlate, setSelectedPlate] = useState(() => { if (mode === 'edit-queue-item' && queueItem) { return queueItem.plate_id; @@ -68,7 +71,6 @@ export function PrintModal({ const [scheduleOptions, setScheduleOptions] = useState(() => { if (mode === 'edit-queue-item' && queueItem) { - // Determine schedule type from queue item let scheduleType: ScheduleType = 'asap'; if (queueItem.manual_start) { scheduleType = 'manual'; @@ -76,7 +78,6 @@ export function PrintModal({ scheduleType = 'scheduled'; } - // Convert scheduled time to local datetime-local format let scheduledTime = ''; if (queueItem.scheduled_time && !isPlaceholderDate(queueItem.scheduled_time)) { const date = new Date(queueItem.scheduled_time); @@ -111,6 +112,18 @@ export function PrintModal({ const [initialPrinterId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.printer_id : null)); const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null)); + // Submission state for multi-printer + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitProgress, setSubmitProgress] = useState({ current: 0, total: 0 }); + + // Determine if we're in multi-printer mode + const isMultiPrinterMode = mode !== 'edit-queue-item'; + const effectivePrinterCount = isMultiPrinterMode ? selectedPrinters.length : (selectedPrinter ? 1 : 0); + // For filament mapping, use first selected printer (mapping applies to all) + const effectivePrinterId = isMultiPrinterMode + ? (selectedPrinters.length > 0 ? selectedPrinters[0] : null) + : selectedPrinter; + // Queries const { data: printers, isLoading: loadingPrinters } = useQuery({ queryKey: ['printers'], @@ -128,13 +141,14 @@ export function PrintModal({ enabled: selectedPlate !== null || !platesData?.is_multi_plate, }); + // Only fetch printer status when single printer selected (for filament mapping) const { data: printerStatus } = useQuery({ - queryKey: ['printer-status', selectedPrinter], - queryFn: () => api.getPrinterStatus(selectedPrinter!), - enabled: !!selectedPrinter, + queryKey: ['printer-status', effectivePrinterId], + queryFn: () => api.getPrinterStatus(effectivePrinterId!), + enabled: !!effectivePrinterId, }); - // Get AMS mapping from hook + // Get AMS mapping from hook (only when single printer selected) const { amsMapping } = useFilamentMapping(filamentReqs, printerStatus, manualMappings); // Auto-select first plate for single-plate files @@ -144,70 +158,41 @@ export function PrintModal({ } }, [platesData, selectedPlate]); - // Auto-select first printer when only one available (add-to-queue mode) + // Auto-select first printer when only one available (non-multi mode) useEffect(() => { - if (mode === 'add-to-queue' && printers?.length === 1 && !selectedPrinter) { - setSelectedPrinter(printers[0].id); + if (mode === 'edit-queue-item') return; + const activePrinters = printers?.filter(p => p.is_active) || []; + if (activePrinters.length === 1 && selectedPrinters.length === 0) { + setSelectedPrinters([activePrinters[0].id]); } - }, [mode, printers, selectedPrinter]); + }, [mode, printers, selectedPrinters.length]); // Clear manual mappings when printer or plate changes useEffect(() => { if (mode === 'edit-queue-item') { - // Only clear if changed from initial values if (selectedPrinter !== initialPrinterId || selectedPlate !== initialPlateId) { setManualMappings({}); } } else { - // Always clear on change for non-edit modes setManualMappings({}); } - }, [mode, selectedPrinter, selectedPlate, initialPrinterId, initialPlateId]); + }, [mode, selectedPrinter, selectedPrinters, selectedPlate, initialPrinterId, initialPlateId]); // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape' && !isSubmitting) onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [onClose]); + }, [onClose, isSubmitting]); const isMultiPlate = platesData?.is_multi_plate ?? false; const plates = platesData?.plates ?? []; - // Reprint mutation - const reprintMutation = useMutation({ - mutationFn: () => { - if (!selectedPrinter) throw new Error('No printer selected'); - return api.reprintArchive(archiveId, selectedPrinter, { - plate_id: selectedPlate ?? undefined, - ams_mapping: amsMapping, - ...printOptions, - }); - }, - onSuccess: () => { - showToast('Print started'); - onSuccess?.(); - onClose(); - }, - onError: (error: Error) => { - showToast(error.message || 'Failed to start print', 'error'); - }, - }); - - // Add to queue mutation + // Add to queue mutation (single printer) const addToQueueMutation = useMutation({ mutationFn: (data: PrintQueueItemCreate) => api.addToQueue(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['queue'] }); - showToast('Added to print queue'); - onSuccess?.(); - onClose(); - }, - onError: (error: Error) => { - showToast(error.message || 'Failed to add to queue', 'error'); - }, }); // Update queue item mutation @@ -224,38 +209,11 @@ export function PrintModal({ }, }); - const handleSubmit = (e?: React.FormEvent) => { + const handleSubmit = async (e?: React.FormEvent) => { e?.preventDefault(); - if (mode === 'reprint') { - if (!selectedPrinter) { - showToast('Please select a printer', 'error'); - return; - } - reprintMutation.mutate(); - } else if (mode === 'add-to-queue') { - if (!selectedPrinter) { - showToast('Please select a printer', 'error'); - return; - } - - const data: PrintQueueItemCreate = { - printer_id: selectedPrinter, - archive_id: archiveId, - require_previous_success: scheduleOptions.requirePreviousSuccess, - auto_off_after: scheduleOptions.autoOffAfter, - manual_start: scheduleOptions.scheduleType === 'manual', - ams_mapping: amsMapping, - plate_id: selectedPlate, - ...printOptions, - }; - - if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) { - data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString(); - } - - addToQueueMutation.mutate(data); - } else if (mode === 'edit-queue-item') { + if (mode === 'edit-queue-item') { + // Edit mode - single printer update const data: PrintQueueItemUpdate = { printer_id: selectedPrinter, require_previous_success: scheduleOptions.requirePreviousSuccess, @@ -273,56 +231,150 @@ export function PrintModal({ } updateQueueMutation.mutate(data); + return; + } + + // Multi-printer modes (reprint or add-to-queue) + if (selectedPrinters.length === 0) { + showToast('Please select at least one printer', 'error'); + return; + } + + setIsSubmitting(true); + setSubmitProgress({ current: 0, total: selectedPrinters.length }); + + const results: { success: number; failed: number; errors: string[] } = { + success: 0, + failed: 0, + errors: [], + }; + + for (let i = 0; i < selectedPrinters.length; i++) { + const printerId = selectedPrinters[i]; + setSubmitProgress({ current: i + 1, total: selectedPrinters.length }); + + try { + // Use the same AMS mapping for all printers (configured via UI based on first printer) + // This assumes all printers have the same filament configuration + if (mode === 'reprint') { + await api.reprintArchive(archiveId, printerId, { + plate_id: selectedPlate ?? undefined, + ams_mapping: amsMapping, + ...printOptions, + }); + } else { + // add-to-queue mode + const data: PrintQueueItemCreate = { + printer_id: printerId, + archive_id: archiveId, + require_previous_success: scheduleOptions.requirePreviousSuccess, + auto_off_after: scheduleOptions.autoOffAfter, + manual_start: scheduleOptions.scheduleType === 'manual', + ams_mapping: amsMapping, + plate_id: selectedPlate, + ...printOptions, + }; + + if (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime) { + data.scheduled_time = new Date(scheduleOptions.scheduledTime).toISOString(); + } + + await addToQueueMutation.mutateAsync(data); + } + results.success++; + } catch (error) { + results.failed++; + const printerName = printers?.find(p => p.id === printerId)?.name || `Printer ${printerId}`; + results.errors.push(`${printerName}: ${(error as Error).message}`); + } + } + + setIsSubmitting(false); + + // Show result toast + if (results.failed === 0) { + const action = mode === 'reprint' ? 'sent to' : 'queued for'; + if (results.success === 1) { + showToast(`Print ${action} printer`); + } else { + showToast(`Print ${action} ${results.success} printers`); + } + queryClient.invalidateQueries({ queryKey: ['queue'] }); + onSuccess?.(); + onClose(); + } else if (results.success === 0) { + showToast(`Failed: ${results.errors[0]}`, 'error'); + } else { + showToast(`${results.success} succeeded, ${results.failed} failed`, 'error'); + queryClient.invalidateQueries({ queryKey: ['queue'] }); } }; - const isPending = - reprintMutation.isPending || addToQueueMutation.isPending || updateQueueMutation.isPending; + const isPending = isSubmitting || updateQueueMutation.isPending; const canSubmit = useMemo(() => { + if (isPending) return false; + // For edit mode, printer can be null (unassigned) if (mode === 'edit-queue-item') { - return !isPending && (printers?.length ?? 0) > 0; + return (printers?.length ?? 0) > 0; } - // For reprint and add-to-queue, need a selected printer - if (!selectedPrinter) return false; + + // For reprint and add-to-queue, need at least one selected printer + if (selectedPrinters.length === 0) return false; + // For multi-plate files, need a selected plate if (isMultiPlate && !selectedPlate) return false; - return !isPending; - }, [mode, selectedPrinter, isMultiPlate, selectedPlate, isPending, printers]); + + return true; + }, [mode, selectedPrinters.length, isMultiPlate, selectedPlate, isPending, printers]); // Modal title and action button text based on mode - const modalConfig = { - reprint: { - title: 'Re-print', - icon: Printer, - submitText: 'Print', - submitIcon: Printer, - loadingText: 'Sending...', - }, - 'add-to-queue': { - title: 'Schedule Print', - icon: Calendar, - submitText: 'Add to Queue', - submitIcon: Calendar, - loadingText: 'Adding...', - }, - 'edit-queue-item': { + const getModalConfig = () => { + const printerCount = isMultiPrinterMode ? selectedPrinters.length : 1; + + if (mode === 'reprint') { + return { + title: 'Re-print', + icon: Printer, + submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print', + submitIcon: Printer, + loadingText: submitProgress.total > 1 + ? `Sending ${submitProgress.current}/${submitProgress.total}...` + : 'Sending...', + }; + } + if (mode === 'add-to-queue') { + return { + title: 'Schedule Print', + icon: Calendar, + submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue', + submitIcon: Calendar, + loadingText: submitProgress.total > 1 + ? `Adding ${submitProgress.current}/${submitProgress.total}...` + : 'Adding...', + }; + } + return { title: 'Edit Queue Item', icon: Pencil, submitText: 'Save Changes', submitIcon: Pencil, loadingText: 'Saving...', - }, - }[mode]; + }; + }; + const modalConfig = getModalConfig(); const TitleIcon = modalConfig.icon; const SubmitIcon = modalConfig.submitIcon; + // Show filament mapping only when single printer selected + const showFilamentMapping = effectivePrinterId && (isMultiPlate ? selectedPlate !== null : true); + return (

{modalConfig.title}

- @@ -349,7 +401,8 @@ export function PrintModal({

{mode === 'reprint' ? ( <> - Send {archiveName} to a printer + Send {archiveName} to{' '} + {isMultiPrinterMode ? 'printer(s)' : 'a printer'} ) : ( <> @@ -363,11 +416,24 @@ export function PrintModal({ + {/* Multi-printer filament mapping note */} + {isMultiPrinterMode && selectedPrinters.length > 1 && ( +

+ +

+ Slot mapping below applies to all {selectedPrinters.length} printers. Ensure they have matching filament configurations. +

+
+ )} + {/* Plate selection */} - {/* Filament mapping - show when printer selected and plate ready */} - {selectedPrinter && (isMultiPlate ? selectedPlate !== null : true) && ( + {/* Filament mapping - show when single printer selected and plate ready */} + {showFilamentMapping && ( 0) && ( )} @@ -399,16 +465,15 @@ export function PrintModal({ )} {/* Error message */} - {(reprintMutation.isError || addToQueueMutation.isError || updateQueueMutation.isError) && ( + {updateQueueMutation.isError && (
- {((reprintMutation.error || addToQueueMutation.error || updateQueueMutation.error) as Error)?.message || - 'Failed to complete operation'} + {(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
)} {/* Actions */}
-