Multi-printer selection for print modal

Added ability to 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 printer selection
- Same filament slot mapping applies to all selected printers
- Progress indicator during multi-printer submission (e.g., "Sending 2/3...")
- Info message: "Slot mapping below applies to all N printers"
- Ideal for print farms with identical filament configurations

Implementation:
- Added multi-select props to PrinterSelector component
- Added computeAmsMapping() utility function for imperative AMS mapping
- Updated PrintModal to handle array of selected printers
- Created unified PrintModal.test.tsx replacing three separate test files

Closes #104
This commit is contained in:
maziggy
2026-01-20 14:05:20 +01:00
parent 0ac344a764
commit ec66e0a665
12 changed files with 1063 additions and 871 deletions
+22
View File
@@ -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
+1
View File
@@ -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)
@@ -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(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Test Print')).toBeInTheDocument();
});
it('shows printer selector', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Printer')).toBeInTheDocument();
});
});
it('shows add button', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
});
describe('queue options', () => {
it('shows Queue Only option', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
});
describe('print options', () => {
it('has print configuration options', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
// 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(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('plate selection', () => {
it('shows plate selector when plates exist', async () => {
render(
<AddToQueueModal
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
// Modal should render - plate selector may be conditional
await waitFor(() => {
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
});
});
});
@@ -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> = {}): 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> = {}): 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(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
});
it('shows printer selector label', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter({ name: 'My Printer' })];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// The printer label should be present
expect(screen.getByText('Printer')).toBeInTheDocument();
});
it('shows print options toggle', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
describe('print options', () => {
it('has print options toggle button', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// 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(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
// 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(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
const saveButton = screen.getByRole('button', { name: /save/i });
expect(saveButton).toBeInTheDocument();
});
it('has cancel button', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
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(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
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(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
const item = createMockQueueItem();
const printers = [createMockPrinter()];
render(
<EditQueueItemModal
item={item}
printers={printers}
onClose={mockOnClose}
onSave={mockOnSave}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
});
});
@@ -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> = {}): 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(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Re-print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
it('shows printer selection with checkboxes for multi-select', async () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
it('has print button', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('print button is disabled until printer is selected', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// 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(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('No active printers available')).toBeInTheDocument();
});
});
it('shows print options toggle', () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
});
describe('add-to-queue mode', () => {
it('renders the modal title', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Schedule Print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Test Print')).toBeInTheDocument();
});
it('shows add button', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /add to queue/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows Queue Only option', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('shows schedule options', () => {
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
expect(screen.getByText('ASAP')).toBeInTheDocument();
expect(screen.getByText('Scheduled')).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="add-to-queue"
archiveId={1}
archiveName="Test Print"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Edit Queue Item')).toBeInTheDocument();
});
it('shows save button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('shows cancel button', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('shows print options toggle', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Print Options')).toBeInTheDocument();
});
it('shows Queue Only option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText('Queue Only')).toBeInTheDocument();
});
it('shows power off option', () => {
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
expect(screen.getByText(/power off/i)).toBeInTheDocument();
});
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup();
const item = createMockQueueItem();
render(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="edit-queue-item"
archiveId={1}
archiveName="Test Print"
queueItem={item}
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Printer')).toBeInTheDocument();
});
});
});
describe('multi-printer selection', () => {
it('shows select all button when multiple printers available', async () => {
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByText('Select all')).toBeInTheDocument();
});
});
it('shows info message when multiple printers selected', async () => {
const user = userEvent.setup();
render(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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(
<PrintModal
mode="reprint"
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
/>
);
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();
});
});
});
});
@@ -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(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Re-print')).toBeInTheDocument();
});
it('shows archive name', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByText('Benchy')).toBeInTheDocument();
});
it('shows printer selection buttons', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
expect(screen.getByText('P1S')).toBeInTheDocument();
});
});
});
describe('printer selection', () => {
it('shows active printers as buttons', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
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(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await waitFor(() => {
expect(screen.getByText('No active printers available')).toBeInTheDocument();
});
});
});
describe('actions', () => {
it('has print button', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /print/i })).toBeInTheDocument();
});
it('has cancel button', () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
});
it('calls onClose when cancel is clicked', async () => {
const user = userEvent.setup();
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnClose).toHaveBeenCalled();
});
it('print button is disabled until printer is selected', async () => {
render(
<ReprintModal
archiveId={1}
archiveName="Benchy"
onClose={mockOnClose}
onSuccess={mockOnSuccess}
/>
);
// Print button should be disabled initially (no printer selected)
const printButton = screen.getByRole('button', { name: /print/i });
expect(printButton).toBeDisabled();
});
});
});
@@ -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 (
<div className="space-y-2 mb-6">
{/* Multi-select header */}
{allowMultiple && activePrinters.length > 1 && (
<div className="flex items-center justify-between text-xs text-bambu-gray mb-2">
<span>
{selectedCount === 0
? 'Select printers'
: `${selectedCount} printer${selectedCount !== 1 ? 's' : ''} selected`}
</span>
<div className="flex gap-2">
{selectedCount < activePrinters.length && (
<button
type="button"
onClick={handleSelectAll}
className="text-bambu-green hover:text-bambu-green/80 transition-colors"
>
Select all
</button>
)}
{selectedCount > 0 && (
<button
type="button"
onClick={handleDeselectAll}
className="text-bambu-gray hover:text-white transition-colors"
>
Clear
</button>
)}
</div>
</div>
)}
{activePrinters.map((printer) => (
<button
key={printer.id}
type="button"
onClick={() => onSelect(printer.id)}
onClick={() => handlePrinterClick(printer.id)}
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors ${
selectedPrinterId === printer.id
isSelected(printer.id)
? 'border-bambu-green bg-bambu-green/10'
: 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
}`}
>
<div
className={`p-2 rounded-lg ${
selectedPrinterId === printer.id ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
isSelected(printer.id) ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'
}`}
>
<PrinterIcon
className={`w-5 h-5 ${
selectedPrinterId === printer.id ? 'text-bambu-green' : 'text-bambu-gray'
isSelected(printer.id) ? 'text-bambu-green' : 'text-bambu-gray'
}`}
/>
</div>
<div className="text-left">
<div className="text-left flex-1">
<p className="text-white font-medium">{printer.name}</p>
<p className="text-xs text-bambu-gray">
{printer.model || 'Unknown model'} • {printer.ip_address}
</p>
</div>
{/* Checkbox indicator for multi-select */}
{allowMultiple && (
<div
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
isSelected(printer.id)
? 'bg-bambu-green border-bambu-green'
: 'border-bambu-gray/50'
}`}
>
{isSelected(printer.id) && <Check className="w-3 h-3 text-white" />}
</div>
)}
</button>
))}
</div>
+181 -116
View File
@@ -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<number | null>(() => {
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<number[]>([]);
const [selectedPlate, setSelectedPlate] = useState<number | null>(() => {
if (mode === 'edit-queue-item' && queueItem) {
return queueItem.plate_id;
@@ -68,7 +71,6 @@ export function PrintModal({
const [scheduleOptions, setScheduleOptions] = useState<ScheduleOptions>(() => {
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 (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={onClose}
onClick={isSubmitting ? undefined : onClose}
>
<Card
className="w-full max-w-lg max-h-[90vh] overflow-y-auto"
@@ -339,7 +391,7 @@ export function PrintModal({
<TitleIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{modalConfig.title}</h2>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<Button variant="ghost" size="sm" onClick={onClose} disabled={isSubmitting}>
<X className="w-5 h-5" />
</Button>
</div>
@@ -349,7 +401,8 @@ export function PrintModal({
<p className={`text-sm text-bambu-gray ${mode === 'reprint' ? 'mb-4' : ''}`}>
{mode === 'reprint' ? (
<>
Send <span className="text-white">{archiveName}</span> to a printer
Send <span className="text-white">{archiveName}</span> to{' '}
{isMultiPrinterMode ? 'printer(s)' : 'a printer'}
</>
) : (
<>
@@ -363,11 +416,24 @@ export function PrintModal({
<PrinterSelector
printers={printers || []}
selectedPrinterId={selectedPrinter}
selectedPrinterIds={selectedPrinters}
onSelect={setSelectedPrinter}
onMultiSelect={setSelectedPrinters}
isLoading={loadingPrinters}
allowUnassigned={mode === 'edit-queue-item'}
allowMultiple={isMultiPrinterMode}
/>
{/* Multi-printer filament mapping note */}
{isMultiPrinterMode && selectedPrinters.length > 1 && (
<div className="flex items-start gap-2 p-3 mb-2 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm">
<AlertCircle className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" />
<p className="text-blue-400">
Slot mapping below applies to all {selectedPrinters.length} printers. Ensure they have matching filament configurations.
</p>
</div>
)}
{/* Plate selection */}
<PlateSelector
plates={plates}
@@ -376,10 +442,10 @@ export function PrintModal({
onSelect={setSelectedPlate}
/>
{/* 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 && (
<FilamentMapping
printerId={selectedPrinter}
printerId={effectivePrinterId!}
archiveId={archiveId}
selectedPlate={selectedPlate}
isMultiPlate={isMultiPlate}
@@ -389,7 +455,7 @@ export function PrintModal({
)}
{/* Print options */}
{(mode === 'reprint' || selectedPrinter) && (
{(mode === 'reprint' || effectivePrinterCount > 0) && (
<PrintOptionsPanel options={printOptions} onChange={setPrintOptions} />
)}
@@ -399,16 +465,15 @@ export function PrintModal({
)}
{/* Error message */}
{(reprintMutation.isError || addToQueueMutation.isError || updateQueueMutation.isError) && (
{updateQueueMutation.isError && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
{((reprintMutation.error || addToQueueMutation.error || updateQueueMutation.error) as Error)?.message ||
'Failed to complete operation'}
{(updateQueueMutation.error as Error)?.message || 'Failed to complete operation'}
</div>
)}
{/* Actions */}
<div className={`flex gap-3 ${mode === 'reprint' ? '' : 'pt-2'}`}>
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1" disabled={isSubmitting}>
Cancel
</Button>
<Button
@@ -104,9 +104,12 @@ export interface PlatesResponse {
export interface PrinterSelectorProps {
printers: Printer[];
selectedPrinterId: number | null;
selectedPrinterIds?: number[];
onSelect: (printerId: number | null) => void;
onMultiSelect?: (printerIds: number[]) => void;
isLoading?: boolean;
allowUnassigned?: boolean;
allowMultiple?: boolean;
}
/**
+121
View File
@@ -9,6 +9,127 @@ import {
} from '../utils/amsHelpers';
import type { PrinterStatus } from '../api/client';
/**
* Build loaded filaments list from printer status (non-hook version).
* Extracts filaments from all AMS units (regular and HT) and external spool.
*/
export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
const filaments: LoadedFilament[] = [];
// Add filaments from all AMS units (regular and HT)
printerStatus?.ams?.forEach((amsUnit) => {
const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
amsUnit.tray.forEach((tray) => {
if (tray.tray_type) {
const color = normalizeColor(tray.tray_color);
filaments.push({
type: tray.tray_type,
color,
colorName: getColorName(color),
amsId: amsUnit.id,
trayId: tray.id,
isHt,
isExternal: false,
label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
});
}
});
});
// Add external spool if loaded
if (printerStatus?.vt_tray?.tray_type) {
const color = normalizeColor(printerStatus.vt_tray.tray_color);
filaments.push({
type: printerStatus.vt_tray.tray_type,
color,
colorName: getColorName(color),
amsId: -1,
trayId: 0,
isHt: false,
isExternal: true,
label: 'External',
globalTrayId: 254,
});
}
return filaments;
}
/**
* Compute AMS mapping for a printer given filament requirements and printer status.
* This is a non-hook version that can be called imperatively (e.g., in a loop for multiple printers).
*
* @param filamentReqs - Required filaments from the 3MF file
* @param printerStatus - Current printer status with AMS information
* @returns AMS mapping array or undefined if no mapping needed
*/
export function computeAmsMapping(
filamentReqs: { filaments: FilamentRequirement[] } | undefined,
printerStatus: PrinterStatus | undefined
): number[] | undefined {
if (!filamentReqs?.filaments || filamentReqs.filaments.length === 0) return undefined;
const loadedFilaments = buildLoadedFilaments(printerStatus);
if (loadedFilaments.length === 0) return undefined;
// Track which trays have been assigned to avoid duplicates
const usedTrayIds = new Set<number>();
const comparisons = filamentReqs.filaments.map((req) => {
// Auto-match: Find a loaded filament that matches by TYPE
// Priority: exact color match > similar color match > type-only match
const exactMatch = loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
);
const similarMatch =
!exactMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) &&
f.type?.toUpperCase() === req.type?.toUpperCase() &&
colorsAreSimilar(f.color, req.color)
);
const typeOnlyMatch =
!exactMatch &&
!similarMatch &&
loadedFilaments.find(
(f) =>
!usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
);
const loaded = exactMatch || similarMatch || typeOnlyMatch || undefined;
// Mark this tray as used so it won't be assigned to another slot
if (loaded) {
usedTrayIds.add(loaded.globalTrayId);
}
return {
slot_id: req.slot_id,
globalTrayId: loaded?.globalTrayId ?? -1,
};
});
// Find the max slot_id to determine array size
const maxSlotId = Math.max(...comparisons.map((f) => f.slot_id || 0));
if (maxSlotId <= 0) return undefined;
// Create array with -1 for all positions
const mapping = new Array(maxSlotId).fill(-1);
// Fill in tray IDs at correct positions (slot_id - 1)
comparisons.forEach((f) => {
if (f.slot_id && f.slot_id > 0) {
mapping[f.slot_id - 1] = f.globalTrayId;
}
});
return mapping;
}
/**
* Represents a loaded filament in the printer's AMS/HT/External spool holder.
*/
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-qwbvIwuo.js"></script>
<script type="module" crossorigin src="/assets/index-DE9m9l_f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-t4RhRNeD.css">
</head>
<body>