mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user