diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index 2bf833e02..70a6fdad4 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { render } from '../utils'; import { PrintersPage } from '../../pages/PrintersPage'; import { http, HttpResponse } from 'msw'; @@ -60,6 +61,13 @@ const mockPrinterStatus = { vt_tray: [], }; +const selectToolbarDropdownOption = async (triggerName: RegExp, optionName: RegExp) => { + const user = userEvent.setup(); + + await user.click(screen.getByRole('button', { name: triggerName })); + await user.click(await screen.findByRole('button', { name: optionName })); +}; + describe('PrintersPage', () => { beforeEach(() => { localStorage.removeItem('printerCardSize'); @@ -811,9 +819,7 @@ describe('PrintersPage', () => { render(); await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); - // Select "Offline" from the status filter dropdown - const statusSelect = screen.getByDisplayValue('All statuses'); - fireEvent.change(statusSelect, { target: { value: 'offline' } }); + await selectToolbarDropdownOption(/all statuses/i, /^offline$/i); await waitFor(() => { expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument(); @@ -826,8 +832,7 @@ describe('PrintersPage', () => { await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); // Both printers are IDLE; filtering by "printing" should yield no results - const statusSelect = screen.getByDisplayValue('All statuses'); - fireEvent.change(statusSelect, { target: { value: 'printing' } }); + await selectToolbarDropdownOption(/all statuses/i, /^printing$/i); await waitFor(() => { expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument(); @@ -849,7 +854,7 @@ describe('PrintersPage', () => { await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); // Filter to only "printing" printers - fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'printing' } }); + await selectToolbarDropdownOption(/all statuses/i, /^printing$/i); // Then also search for a term that only matches printer 1 fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1' } }); @@ -878,16 +883,14 @@ describe('PrintersPage', () => { expect(screen.getByText('P1S Backup')).toBeInTheDocument(); }); - // Select "Workshop" from the location filter dropdown - fireEvent.change(screen.getByDisplayValue('All locations'), { target: { value: 'Workshop' } }); + await selectToolbarDropdownOption(/all locations/i, /^workshop$/i); await waitFor(() => { expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument(); }); - // Switch to "Office" — the other printer should now be the only one visible - fireEvent.change(screen.getByDisplayValue('Workshop'), { target: { value: 'Office' } }); + await selectToolbarDropdownOption(/^workshop$/i, /^office$/i); await waitFor(() => { expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument(); @@ -910,8 +913,8 @@ describe('PrintersPage', () => { await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); // Status filter is still there, but the location filter should be absent. - expect(screen.getByDisplayValue('All statuses')).toBeInTheDocument(); - expect(screen.queryByDisplayValue('All locations')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /all statuses/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /all locations/i })).not.toBeInTheDocument(); }); }); }); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index e27380fc5..d8af2ceee 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -183,6 +183,11 @@ export default { allStatuses: 'Alle Status', allLocations: 'Alle Standorte', }, + toolbar: { + filters: 'Filter', + view: 'Ansicht', + actions: 'Aktionen', + }, // Printer card readyToPrint: 'Druckbereit', external: 'Extern', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index aaf319f21..336b46bd7 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -183,6 +183,11 @@ export default { allStatuses: 'All statuses', allLocations: 'All locations', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: 'Ready to print', external: 'External', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 0decc8e5e..3a5dc7464 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -183,6 +183,11 @@ export default { allStatuses: 'Tous les statuts', allLocations: 'Tous les emplacements', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: 'Prête à imprimer', external: 'Externe', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index f4c3a9c87..2e5ddb9a2 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -183,6 +183,11 @@ export default { allStatuses: 'Tutti gli stati', allLocations: 'Tutti i luoghi', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: 'Pronta a stampare', external: 'Esterna', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 8c86b3346..8d68f01df 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -182,6 +182,11 @@ export default { allStatuses: 'すべてのステータス', allLocations: 'すべての場所', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: '印刷可能', external: '外部', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index f8796ea2c..3357c683e 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -183,6 +183,11 @@ export default { allStatuses: 'Todos os status', allLocations: 'Todos os locais', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: 'Pronto para imprimir', external: 'Externo', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index f9fc9e482..9d2987002 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -183,6 +183,11 @@ export default { allStatuses: '所有状态', allLocations: '所有位置', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: '准备打印', external: '外部', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 65067a53e..f9cce8f38 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -183,6 +183,11 @@ export default { allStatuses: '所有狀態', allLocations: '所有位置', }, + toolbar: { + filters: 'Filters', + view: 'View', + actions: 'Actions', + }, // Printer card readyToPrint: '準備列印', external: '外部', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index c90fffef1..4a4b187aa 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react'; import { compareFwVersions } from '../utils/firmwareVersion'; import { formatPrintName } from '../utils/printName'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -25,6 +25,7 @@ import { Zap, Wrench, ChevronDown, + Filter, Pencil, ArrowUp, ArrowDown, @@ -57,6 +58,8 @@ import { MoveVertical, LogIn, LogOut, + MoreHorizontal, + SlidersHorizontal, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; @@ -978,7 +981,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { ]; return ( -
+
{badges.map(({ count, dot, label }) => count > 0 && (
@@ -1015,6 +1018,97 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) { type SortOption = 'name' | 'status' | 'model' | 'location'; type ViewMode = 'expanded' | 'compact'; +type ToolbarDropdownOption = { + value: T; + label: string; +}; + +function ToolbarDropdown({ + value, + options, + onChange, + fullWidth = false, +}: { + value: T; + options: ToolbarDropdownOption[]; + onChange: (value: T) => void; + fullWidth?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const selectedOption = options.find(option => option.value === value) ?? options[0]; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+ {options.map(option => ( + + ))} +
+ + )} +
+ ); +} + +function ToolbarMenu({ + label, + icon, + children, +}: { + label: string; + icon: React.ReactNode; + children: React.ReactNode; +}) { + const [isOpen, setIsOpen] = useState(false); + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+ {children} +
+ + )} +
+ ); +} + const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline']; const STATUS_GROUP_META: Record = { @@ -6629,15 +6723,256 @@ export function PrintersPage() { // eslint-disable-next-line react-hooks/exhaustive-deps -- classifyPrinterStatus & filterKnownHMSErrors are stable module-level functions, not reactive deps; statusCacheVersion forces recompute on WebSocket status updates }, [sortBy, sortedPrinters, queryClient, statusCacheVersion]); + const toolbarRef = useRef(null); + const expandedToolbarControlsRef = useRef(null); + const expandedToolbarWidthRef = useRef(0); + const [compactToolbar, setCompactToolbar] = useState(false); + + const measureToolbar = useCallback(() => { + const toolbar = toolbarRef.current; + if (!toolbar) return; + + const measuredControlsWidth = expandedToolbarControlsRef.current?.offsetWidth; + if (measuredControlsWidth) { + expandedToolbarWidthRef.current = measuredControlsWidth; + } + + const searchMinimumWidth = 220; + const gapWidth = 8; + const shouldCompact = expandedToolbarWidthRef.current > 0 && toolbar.clientWidth < expandedToolbarWidthRef.current + searchMinimumWidth + gapWidth; + setCompactToolbar(prev => (prev === shouldCompact ? prev : shouldCompact)); + }, []); + + useLayoutEffect(() => { + measureToolbar(); + + const toolbar = toolbarRef.current; + if (!toolbar) return; + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measureToolbar); + return () => window.removeEventListener('resize', measureToolbar); + } + + const resizeObserver = new ResizeObserver(() => measureToolbar()); + resizeObserver.observe(toolbar); + window.addEventListener('resize', measureToolbar); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', measureToolbar); + }; + }, [ + measureToolbar, + printers?.length, + availableLocations.length, + hideDisconnected, + Object.keys(smartPlugByPrinter).length, + ]); + + const renderFilterControls = (inMenu = false) => ( + <> + {/* Status filter */} + {printers && printers.length > 0 && ( + + )} + + {/* Location filter — only shown when at least one printer has a location */} + {printers && printers.length > 0 && availableLocations.length > 0 && ( + ({ value: loc, label: loc })), + ]} + /> + )} + + + + ); + + const renderViewControls = (inMenu = false) => ( + <> + {/* Sort dropdown */} +
+ + value={sortBy} + onChange={handleSortChange} + fullWidth={inMenu} + options={[ + { value: 'name', label: t('printers.sort.name') }, + { value: 'status', label: t('printers.sort.status') }, + { value: 'model', label: t('printers.sort.model') }, + { value: 'location', label: t('printers.sort.location') }, + ]} + /> + +
+ + {/* Card size selector */} +
+ {cardSizeLabels.map((label, index) => { + const size = index + 1; + const isSelected = cardSize === size; + return ( + + ); + })} +
+ + ); + + const renderActionControls = (inMenu = false) => ( + <> + {/* Bulk select toggle */} + + + {/* Power dropdown for offline printers with smart plugs */} + {hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && ( +
+ + {showPowerDropdown && ( + <> + {/* Backdrop to close dropdown */} +
setShowPowerDropdown(false)} + /> +
+
+ {t('printers.offlinePrintersWithPlugs')} +
+ {printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => ( + { + setPoweringOn(plugId); + powerOnMutation.mutate(plugId); + }} + isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id} + /> + ))} + {printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && ( +
+ No printers with smart plugs +
+ )} +
+ + )} +
+ )} + + + ); + return (
-
+
-

{t('printers.title')}

+

+ + {t('printers.title')} +

+
+
{/* Only show search bar when printers exist */} {printers && printers.length > 0 && ( -
+
setSearch(e.target.value)} placeholder={t('printers.search')} aria-label={t('printers.search')} - className="w-full pl-10 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green" + className="w-full h-8 pl-9 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green" /> {search && (
-
- {/* Sort dropdown */} -
- - -
- - {/* Status filter */} - {printers && printers.length > 0 && ( - - )} - - {/* Location filter — only shown when at least one printer has a location */} - {printers && printers.length > 0 && availableLocations.length > 0 && ( - - )} - - {/* Card size selector */} -
- {cardSizeLabels.map((label, index) => { - const size = index + 1; - const isSelected = cardSize === size; - return ( - - ); - })} -
- - {/* Bulk select toggle */} - +
+
{renderFilterControls()}
+
+
{renderViewControls()}
+
+
{renderActionControls()}
+
-
- - - {/* Power dropdown for offline printers with smart plugs */} - {hideDisconnected && Object.keys(smartPlugByPrinter).length > 0 && ( -
- - {showPowerDropdown && ( - <> - {/* Backdrop to close dropdown */} -
setShowPowerDropdown(false)} - /> -
-
- {t('printers.offlinePrintersWithPlugs')} -
- {printers?.filter(p => smartPlugByPrinter[p.id]).map(printer => ( - { - setPoweringOn(plugId); - powerOnMutation.mutate(plugId); - }} - isPowering={poweringOn === smartPlugByPrinter[printer.id]?.id} - /> - ))} - {printers?.filter(p => smartPlugByPrinter[p.id]).length === 0 && ( -
- No printers with smart plugs -
- )} -
- - )} + {compactToolbar && ( +
+ }> +
{renderFilterControls(true)}
+
+ }> +
{renderViewControls(true)}
+
+ }> +
{renderActionControls(true)}
+
)} -