mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add virtual printer Queue mode and sidebar badge indicators
Virtual Printer Changes: - Add new "Queue" mode that archives files and adds them to print queue - Rename modes: "Immediate" → "Archive", "Queue for Review" → "Review" - Three modes now: Archive (immediate), Review (pending), Queue (print queue) - Queue mode creates unassigned queue items (printer_id=null) Print Queue Changes: - Make printer_id nullable in PrintQueueItem model/schema - Add support for unassigned queue items (no printer assigned) - Queue page shows "Unassigned" filter option - Edit modal allows assigning printer to unassigned items - Unassigned items highlighted in orange Sidebar Badge Indicators: - Queue icon shows yellow badge with pending queue item count - Archive icon shows blue badge with pending upload count - Badges auto-update every 5 seconds and on window focus Other: - Update backup/restore to handle nullable printer_id and ams_mapping - Add database migration for nullable printer_id column - Update VirtualPrinterSettings tests for new modes - Update virtual printer API integration tests - Remove speed controls documentation from wiki (not implemented)
This commit is contained in:
@@ -99,11 +99,11 @@ describe('VirtualPrinterSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders archive mode section', async () => {
|
||||
it('renders mode section', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Archive Mode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Mode')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,28 +259,48 @@ describe('VirtualPrinterSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('archive mode', () => {
|
||||
it('renders immediate mode option', async () => {
|
||||
describe('mode selection', () => {
|
||||
it('renders Archive mode option', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Immediate')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Archive files as soon as they are uploaded')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive')).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive files immediately')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders queue mode option', async () => {
|
||||
it('renders Review mode option', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review and tag files before archiving')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights current mode', async () => {
|
||||
it('renders Queue mode option', async () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights current mode (review)', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'review' })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const reviewButton = screen.getByText('Review').closest('button');
|
||||
expect(reviewButton?.className).toContain('border-bambu-green');
|
||||
});
|
||||
});
|
||||
|
||||
it('highlights current mode (legacy queue maps to review)', async () => {
|
||||
vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'queue' })
|
||||
);
|
||||
@@ -288,30 +308,52 @@ describe('VirtualPrinterSettings', () => {
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
const queueButton = screen.getByText('Queue for Review').closest('button');
|
||||
expect(queueButton?.className).toContain('border-bambu-green');
|
||||
const reviewButton = screen.getByText('Review').closest('button');
|
||||
expect(reviewButton?.className).toContain('border-bambu-green');
|
||||
});
|
||||
});
|
||||
|
||||
it('changes mode on click', async () => {
|
||||
it('changes mode to review on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'queue' })
|
||||
createMockSettings({ mode: 'review' })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue for Review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Review')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const queueButton = screen.getByText('Queue for Review').closest('button');
|
||||
const reviewButton = screen.getByText('Review').closest('button');
|
||||
if (reviewButton) {
|
||||
await user.click(reviewButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'review' });
|
||||
});
|
||||
});
|
||||
|
||||
it('changes mode to print_queue on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
|
||||
createMockSettings({ mode: 'print_queue' })
|
||||
);
|
||||
|
||||
render(<VirtualPrinterSettings />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Queue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const queueButton = screen.getByText('Queue').closest('button');
|
||||
if (queueButton) {
|
||||
await user.click(queueButton);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'queue' });
|
||||
expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'print_queue' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -814,7 +814,7 @@ export interface DiscoveredTasmotaDevice {
|
||||
// Print Queue types
|
||||
export interface PrintQueueItem {
|
||||
id: number;
|
||||
printer_id: number;
|
||||
printer_id: number | null; // null = unassigned
|
||||
archive_id: number;
|
||||
position: number;
|
||||
scheduled_time: string | null;
|
||||
@@ -834,7 +834,7 @@ export interface PrintQueueItem {
|
||||
}
|
||||
|
||||
export interface PrintQueueItemCreate {
|
||||
printer_id: number;
|
||||
printer_id?: number | null; // null = unassigned
|
||||
archive_id: number;
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
@@ -844,7 +844,7 @@ export interface PrintQueueItemCreate {
|
||||
}
|
||||
|
||||
export interface PrintQueueItemUpdate {
|
||||
printer_id?: number;
|
||||
printer_id?: number | null; // null = unassign
|
||||
position?: number;
|
||||
scheduled_time?: string | null;
|
||||
require_previous_success?: boolean;
|
||||
@@ -2516,7 +2516,7 @@ export const discoveryApi = {
|
||||
export interface VirtualPrinterStatus {
|
||||
enabled: boolean;
|
||||
running: boolean;
|
||||
mode: 'immediate' | 'queue';
|
||||
mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review'
|
||||
name: string;
|
||||
serial: string;
|
||||
model: string;
|
||||
@@ -2527,7 +2527,7 @@ export interface VirtualPrinterStatus {
|
||||
export interface VirtualPrinterSettings {
|
||||
enabled: boolean;
|
||||
access_code_set: boolean;
|
||||
mode: 'immediate' | 'queue';
|
||||
mode: 'immediate' | 'queue' | 'review' | 'print_queue'; // 'queue' is legacy, normalized to 'review'
|
||||
model: string;
|
||||
status: VirtualPrinterStatus;
|
||||
}
|
||||
@@ -2558,7 +2558,7 @@ export const virtualPrinterApi = {
|
||||
updateSettings: (data: {
|
||||
enabled?: boolean;
|
||||
access_code?: string;
|
||||
mode?: 'immediate' | 'queue';
|
||||
mode?: 'immediate' | 'review' | 'print_queue';
|
||||
model?: string;
|
||||
}) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -17,7 +17,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [printerId, setPrinterId] = useState<number>(item.printer_id);
|
||||
const [printerId, setPrinterId] = useState<number | null>(item.printer_id);
|
||||
|
||||
// Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
|
||||
const isPlaceholderDate = item.scheduled_time &&
|
||||
@@ -69,8 +69,8 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
// Fetch printer status when a printer is selected
|
||||
const { data: printerStatus } = useQuery({
|
||||
queryKey: ['printer-status', printerId],
|
||||
queryFn: () => api.getPrinterStatus(printerId),
|
||||
enabled: !!printerId,
|
||||
queryFn: () => api.getPrinterStatus(printerId!),
|
||||
enabled: printerId !== null,
|
||||
});
|
||||
|
||||
// Clear manual mappings when printer changes (but not on initial load)
|
||||
@@ -370,21 +370,31 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
|
||||
No printers configured
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
value={printerId}
|
||||
onChange={(e) => setPrinterId(Number(e.target.value))}
|
||||
required
|
||||
>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:border-bambu-green focus:outline-none ${
|
||||
printerId === null ? 'border-orange-400' : 'border-bambu-dark-tertiary'
|
||||
}`}
|
||||
value={printerId ?? ''}
|
||||
onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">-- Select a printer --</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{printerId === null && (
|
||||
<p className="text-xs text-orange-400 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
Assign a printer to enable printing
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filament Mapping Section */}
|
||||
{printerId && hasFilamentReqs && (
|
||||
{printerId !== null && hasFilamentReqs && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
import { SwitchbarPopover } from './SwitchbarPopover';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, supportApi } from '../api/client';
|
||||
import { api, supportApi, pendingUploadsApi } from '../api/client';
|
||||
import { getIconByName } from './IconPicker';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
@@ -126,6 +126,26 @@ export function Layout() {
|
||||
refetchInterval: 60 * 1000, // Refresh every minute
|
||||
});
|
||||
|
||||
// Fetch pending queue items count for badge
|
||||
const { data: queueItems } = useQuery({
|
||||
queryKey: ['queue', 'pending'],
|
||||
queryFn: () => api.getQueue(undefined, 'pending'),
|
||||
staleTime: 5 * 1000, // 5 seconds
|
||||
refetchInterval: 5 * 1000, // Refresh every 5 seconds
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const pendingQueueCount = queueItems?.length ?? 0;
|
||||
|
||||
// Fetch pending uploads count for archive badge (virtual printer review items)
|
||||
const { data: pendingUploadsData } = useQuery({
|
||||
queryKey: ['pending-uploads', 'count'],
|
||||
queryFn: pendingUploadsApi.getCount,
|
||||
staleTime: 5 * 1000, // 5 seconds
|
||||
refetchInterval: 5 * 1000, // Refresh every 5 seconds
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const pendingUploadsCount = pendingUploadsData?.count ?? 0;
|
||||
|
||||
// Calculate debug duration client-side for real-time updates
|
||||
const [debugDuration, setDebugDuration] = useState<number | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -418,6 +438,11 @@ export function Layout() {
|
||||
if (!navItem) return null;
|
||||
|
||||
const { to, icon: Icon, labelKey } = navItem;
|
||||
const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
|
||||
const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
|
||||
const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
|
||||
const showBadge = showQueueBadge || showArchiveBadge;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
@@ -449,7 +474,16 @@ export function Layout() {
|
||||
{sidebarExpanded && !isMobile && (
|
||||
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
|
||||
)}
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
<div className="relative">
|
||||
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||
{showBadge && (
|
||||
<span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
|
||||
showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
|
||||
}`}>
|
||||
{badgeCount > 99 ? '99+' : badgeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
@@ -12,7 +12,7 @@ export function VirtualPrinterSettings() {
|
||||
|
||||
const [localEnabled, setLocalEnabled] = useState(false);
|
||||
const [localAccessCode, setLocalAccessCode] = useState('');
|
||||
const [localMode, setLocalMode] = useState<'immediate' | 'queue'>('immediate');
|
||||
const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
|
||||
const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
|
||||
@@ -34,14 +34,19 @@ export function VirtualPrinterSettings() {
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setLocalEnabled(settings.enabled);
|
||||
setLocalMode(settings.mode);
|
||||
// Map legacy 'queue' mode to 'review'
|
||||
let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
|
||||
if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
|
||||
mode = 'immediate'; // fallback
|
||||
}
|
||||
setLocalMode(mode);
|
||||
setLocalModel(settings.model);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'queue'; model?: string }) =>
|
||||
mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
|
||||
virtualPrinterApi.updateSettings(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
|
||||
@@ -53,7 +58,9 @@ export function VirtualPrinterSettings() {
|
||||
// Revert local state on error
|
||||
if (settings) {
|
||||
setLocalEnabled(settings.enabled);
|
||||
setLocalMode(settings.mode);
|
||||
// Map legacy 'queue' mode to 'review'
|
||||
const mode = settings.mode === 'queue' ? 'review' : settings.mode;
|
||||
setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
|
||||
setLocalModel(settings.model);
|
||||
}
|
||||
setPendingAction(null);
|
||||
@@ -96,7 +103,7 @@ export function VirtualPrinterSettings() {
|
||||
setLocalAccessCode(''); // Clear after saving
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: 'immediate' | 'queue') => {
|
||||
const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
|
||||
setLocalMode(mode);
|
||||
setPendingAction('mode');
|
||||
updateMutation.mutate({ mode });
|
||||
@@ -251,10 +258,10 @@ export function VirtualPrinterSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Archive Mode */}
|
||||
{/* Mode */}
|
||||
<div className="py-3 border-t border-bambu-dark-tertiary">
|
||||
<div className="text-white font-medium mb-2">Archive Mode</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="text-white font-medium mb-2">Mode</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => handleModeChange('immediate')}
|
||||
disabled={pendingAction === 'mode'}
|
||||
@@ -264,20 +271,32 @@ export function VirtualPrinterSettings() {
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium">Immediate</div>
|
||||
<div className="text-xs text-bambu-gray">Archive files as soon as they are uploaded</div>
|
||||
<div className="text-white font-medium">Archive</div>
|
||||
<div className="text-xs text-bambu-gray">Archive files immediately</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('queue')}
|
||||
onClick={() => handleModeChange('review')}
|
||||
disabled={pendingAction === 'mode'}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'queue'
|
||||
localMode === 'review'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium">Queue for Review</div>
|
||||
<div className="text-xs text-bambu-gray">Review and tag files before archiving</div>
|
||||
<div className="text-white font-medium">Review</div>
|
||||
<div className="text-xs text-bambu-gray">Review and tag before archiving</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModeChange('print_queue')}
|
||||
disabled={pendingAction === 'mode'}
|
||||
className={`p-3 rounded-lg border text-left transition-colors ${
|
||||
localMode === 'print_queue'
|
||||
? 'border-bambu-green bg-bambu-green/10'
|
||||
: 'border-bambu-dark-tertiary hover:border-bambu-gray'
|
||||
}`}
|
||||
>
|
||||
<div className="text-white font-medium">Queue</div>
|
||||
<div className="text-xs text-bambu-gray">Archive and add to print queue</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,9 +195,9 @@ function SortableQueueItem({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-sm text-bambu-gray">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`flex items-center gap-1.5 ${item.printer_id === null ? 'text-orange-400' : ''}`}>
|
||||
<Printer className="w-3.5 h-3.5" />
|
||||
{item.printer_name || `Printer #${item.printer_id}`}
|
||||
{item.printer_id === null ? 'Unassigned' : (item.printer_name || `Printer #${item.printer_id}`)}
|
||||
</span>
|
||||
{item.print_time_seconds && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -600,10 +600,16 @@ export function QueuePage() {
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<select
|
||||
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
value={filterPrinter || ''}
|
||||
onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
|
||||
value={filterPrinter === -1 ? 'unassigned' : (filterPrinter || '')}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === 'unassigned') setFilterPrinter(-1);
|
||||
else if (val === '') setFilterPrinter(null);
|
||||
else setFilterPrinter(Number(val));
|
||||
}}
|
||||
>
|
||||
<option value="">All Printers</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{printers?.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user