diff --git a/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx b/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx new file mode 100644 index 000000000..8d9a789ee --- /dev/null +++ b/frontend/src/__tests__/pages/PrintersPageCameraSplitButton.test.tsx @@ -0,0 +1,224 @@ +/** + * The printer card's camera button chooses its own view mode. + * + * Window-vs-overlay used to be one switch in Settings > General > Camera, so + * watching one printer in an overlay and another in its own window meant a trip + * to another page and back. The button is now a split control: the icon opens + * whichever mode was used last, the caret picks between the two. The stored + * setting survives only as the default a browser that has never chosen starts + * from -- the local choice wins after that, because a user without + * settings:update cannot write theirs back and would otherwise never keep one. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const permissions = { granted: ['camera:view', 'settings:update'] as string[] }; + +const mockUseAuth = { + user: { id: 1, username: 'operator', permissions: [] as string[] }, + authEnabled: true, + requiresSetup: false, + loading: false, + isAdmin: false, + login: vi.fn(), + loginWithToken: vi.fn(), + logout: vi.fn(), + refreshUser: vi.fn(), + refreshAuth: vi.fn(), + hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)), + hasAnyPermission: vi.fn(() => true), + hasAllPermissions: vi.fn(() => true), + canModify: vi.fn(() => true), +}; + +vi.mock('../../contexts/AuthContext', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useAuth: () => mockUseAuth }; +}); + +import { render } from '../utils'; +import { PrintersPage } from '../../pages/PrintersPage'; + +const mockPrinter = { + id: 1, + name: 'X1C', + ip_address: '192.168.1.100', + serial_number: '01P00A000000001', + access_code: '12345678', + model: 'X1C', + enabled: true, + nozzle_diameter: 0.4, + nozzle_type: 'stainless_steel', + location: 'Workshop', + auto_archive: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +/** Bodies of every PUT /settings the page made, so the write-back is checkable. */ +const settingsWrites: Record[] = []; + +function renderPage(storedMode: 'window' | 'embedded' = 'window') { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])), + http.get('/api/v1/printers/:id/status', () => + HttpResponse.json({ + connected: true, + state: 'IDLE', + progress: 0, + layer_num: 0, + total_layers: 0, + temperatures: { nozzle: 25, bed: 25, chamber: 25 }, + remaining_time: 0, + filename: null, + wifi_signal: -29, + speed_level: 2, + vt_tray: [], + ams: [], + }) + ), + http.get('/api/v1/queue/', () => HttpResponse.json([])), + http.get('/api/v1/settings/ui-preferences', () => + HttpResponse.json({ camera_view_mode: storedMode }) + ), + http.put('/api/v1/settings/', async ({ request }) => { + const body = (await request.json()) as Record; + settingsWrites.push(body); + return HttpResponse.json(body); + }), + ); + return render(); +} + +/** The camera icon, whichever of the two modes it currently promises. */ +async function cameraIcon(): Promise { + await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull()); + const el = + screen.queryByTitle('Open camera in new window') ?? screen.queryByTitle('Open camera overlay'); + expect(el).not.toBeNull(); + return el as HTMLElement; +} + +async function openModeMenu(): Promise { + await cameraIcon(); + fireEvent.click(screen.getByLabelText('Camera View Mode')); +} + +let openSpy: ReturnType; + +/** + * A real store behind the suite-wide localStorage mock, which is otherwise a + * set of no-op vi.fn()s -- so setItem would be forgotten and getItem would hand + * back undefined, and "remembers the choice" could not be tested at all. + */ +const storage = new Map(); + +describe('PrintersPage — camera split button', () => { + beforeEach(() => { + settingsWrites.length = 0; + permissions.granted = ['camera:view', 'settings:update']; + storage.clear(); + vi.mocked(localStorage.getItem).mockImplementation((key: string) => storage.get(key) ?? null); + vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => { + storage.set(key, value); + }); + vi.mocked(localStorage.removeItem).mockImplementation((key: string) => { + storage.delete(key); + }); + openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + }); + + afterEach(() => { + openSpy.mockRestore(); + }); + + it('opens a separate window when that is the mode in effect', async () => { + renderPage('window'); + fireEvent.click(await cameraIcon()); + + expect(openSpy).toHaveBeenCalledWith( + '/camera/1', + 'camera-1', + expect.stringContaining('width=640') + ); + }); + + it('offers both modes from the caret', async () => { + renderPage('window'); + await openModeMenu(); + + expect(await screen.findByText(/New Window/)).toBeInTheDocument(); + expect(await screen.findByText(/Embedded Overlay/)).toBeInTheDocument(); + }); + + it('opens the overlay when the overlay is picked, instead of a window', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + // "Refresh stream" is the overlay's own control; the card has no such button. + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('remembers the picked mode for the next visit', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded')); + }); + + it('uses a remembered choice over the stored setting', async () => { + // The case that makes the local choice authoritative: the install-wide + // default still says window, but this browser has asked for the overlay. + storage.set('cameraViewMode', 'embedded'); + renderPage('window'); + fireEvent.click(await cameraIcon()); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('falls back to the stored setting in a browser that has never chosen', async () => { + renderPage('embedded'); + await waitFor(() => expect(screen.queryByTitle('Open camera overlay')).not.toBeNull()); + fireEvent.click(await cameraIcon()); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('marks which mode the plain icon will use', async () => { + storage.set('cameraViewMode', 'embedded'); + renderPage('window'); + await openModeMenu(); + + expect(await screen.findByText('Embedded Overlay ✓')).toBeInTheDocument(); + expect(await screen.findByText('New Window')).toBeInTheDocument(); + }); + + it('saves the pick as the install-wide default when allowed to', async () => { + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + await waitFor(() => expect(settingsWrites).toEqual([{ camera_view_mode: 'embedded' }])); + }); + + it('still applies the pick for a user who cannot write settings', async () => { + // A viewer has camera:view but not settings:update. Their choice has to + // stick locally, or the menu would appear to do nothing on the next click. + permissions.granted = ['camera:view']; + renderPage('window'); + await openModeMenu(); + fireEvent.click(await screen.findByText(/Embedded Overlay/)); + + expect(await screen.findByTitle('Refresh stream')).toBeInTheDocument(); + await waitFor(() => expect(storage.get('cameraViewMode')).toBe('embedded')); + expect(settingsWrites).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx index c8824ae39..57ecd78e6 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx @@ -192,6 +192,19 @@ describe('SettingsPage', () => { }); }); + it('no longer offers Camera View Mode, which moved to the printer card', async () => { + // The camera button on each printer card is a split control now, so the + // choice is made per stream rather than once for the whole install. The + // External Cameras section is still here, which is what keeps this from + // passing merely because the Camera card failed to render. + render(); + + await waitFor(() => { + expect(screen.getByText('External Cameras')).toBeInTheDocument(); + }); + expect(screen.queryByText('Camera View Mode')).toBeNull(); + }); + it('shows default printer setting', async () => { render(); diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 2bd727cfd..c025d50da 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -3,6 +3,12 @@ import { createPortal } from 'react-dom'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { computePopoverPosition, type PopoverPosition } from '../utils/popoverPosition'; +import { + openCameraWindow, + readStoredCameraViewMode, + storeCameraViewMode, + type CameraViewMode, +} from '../utils/camera'; import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets'; import { computeStartAfter, type DryingStartMode } from '../lib/scheduledDrying'; import { @@ -132,6 +138,7 @@ import { LayoutGrid, MonitorPlay, ExternalLink, + PictureInPicture2, } from 'lucide-react'; // Aliased: lucide-react already exports a `Link` icon into this module. @@ -146,6 +153,7 @@ import { BulkPrinterToolbar, type PrinterState } from '../components/BulkPrinter import { FileManagerModal } from '../components/FileManagerModal'; import { EmbeddedCameraViewer } from '../components/EmbeddedCameraViewer'; import { CameraWall } from '../components/CameraWall'; +import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu'; import { MQTTDebugModal } from '../components/MQTTDebugModal'; import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal'; import { AiDetectionModal } from '../components/AiDetectionModal'; @@ -2013,6 +2021,7 @@ function PrinterCard({ timeFormat = 'system', cameraViewMode = 'window', onOpenEmbeddedCamera, + onSelectCameraViewMode, checkPrinterFirmware = true, dryingPresets = DRYING_PRESETS, requirePlateClear = false, @@ -2052,8 +2061,9 @@ function PrinterCard({ spoolmanLoading?: boolean; onUnassignSpoolmanSpool?: (spoolmanSpoolId: number) => void; timeFormat?: 'system' | '12h' | '24h'; - cameraViewMode?: 'window' | 'embedded'; + cameraViewMode?: CameraViewMode; onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void; + onSelectCameraViewMode?: (mode: CameraViewMode) => void; checkPrinterFirmware?: boolean; dryingPresets?: Record; requirePlateClear?: boolean; @@ -2179,6 +2189,11 @@ function PrinterCard({ const [isDraggingFile, setIsDraggingFile] = useState(false); const [isDropUploading, setIsDropUploading] = useState(false); const printerActionsMenuRef = useRef(null); + // Viewport coordinates, because the camera mode menu is a `ContextMenu` at + // `position: fixed`. The button sits in the card's footer, so a menu drawn + // inside the card would have to open upward into the card body; anchored to + // the viewport it escapes the card and the grid's scroll container both. + const [cameraMenuAnchor, setCameraMenuAnchor] = useState<{ x: number; y: number } | null>(null); const dragCounterRef = useRef(0); const [amsHistoryModal, setAmsHistoryModal] = useState<{ amsId: number; @@ -3373,6 +3388,48 @@ function PrinterCard({ const footerActionButtonClass = '!h-8 !min-h-8 !px-2 !py-0'; const footerIconButtonClass = '!h-8 !min-h-8 !w-8 !px-0 !py-0'; + + // Opening a camera in a given mode, without touching which mode is remembered + // -- the split button's two halves want the same action but disagree about + // whether the choice was deliberate enough to keep. + const openCameraIn = (mode: CameraViewMode) => { + if (mode === 'embedded' && onOpenEmbeddedCamera) { + onOpenEmbeddedCamera(printer.id, printer.name); + } else { + openCameraWindow(printer.id); + } + }; + + // Picking a mode both opens the camera that way and makes it the mode the + // plain icon uses from now on. A menu that only changed a preference would + // leave the user with a second click to do the thing they already asked for. + const cameraViewModeMenuItems: ContextMenuItem[] = ( + [ + { + mode: 'window' as const, + icon: , + label: t('settings.newWindow'), + title: t('settings.cameraWindowDescription'), + }, + { + mode: 'embedded' as const, + icon: , + label: t('settings.embeddedOverlay'), + title: t('settings.cameraOverlayDescription'), + }, + ] + ).map(({ mode, icon, label, title }) => ({ + icon, + // The remembered mode is marked in the label itself: the icon slot already + // carries the mode's own icon, and a separate tick column would push both + // entries out of line with every other card menu. + label: mode === cameraViewMode ? `${label} ✓` : label, + title, + onClick: () => { + onSelectCameraViewMode?.(mode); + openCameraIn(mode); + }, + })); const renderAmsSlotActions = ({ amsId, slotId, @@ -6271,35 +6328,50 @@ function PrinterCard({
{printerActionsMenu}
- {/* Camera Button */} - + {/* Camera split button: the icon opens whichever view was used + last, the caret picks between the two and remembers it. + Replaces the old Settings > General > Camera switch, which + made choosing between an overlay and a window a trip to + another page for something you decide per camera. */} +
+ + +
+ {cameraMenuAnchor && ( + setCameraMenuAnchor(null)} + /> + )}