diff --git a/frontend/src/__tests__/pages/PrintersPage.test.tsx b/frontend/src/__tests__/pages/PrintersPage.test.tsx index 0f872fd3d..d78f1119b 100644 --- a/frontend/src/__tests__/pages/PrintersPage.test.tsx +++ b/frontend/src/__tests__/pages/PrintersPage.test.tsx @@ -476,4 +476,154 @@ describe('PrintersPage', () => { }); }); }); + + describe('search and filter', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)), + http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockPrinterStatus)), + http.get('/api/v1/queue/', () => HttpResponse.json([])) + ); + }); + + it('filters by name (case-insensitive)', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'x1 carbon' } }); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument(); + }); + }); + + it('trims leading and trailing whitespace from search', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + // " X1 Carbon " with surrounding spaces must still match + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: ' X1 Carbon ' } }); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument(); + }); + }); + + it('filters by model', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'P1S' } }); + + await waitFor(() => { + expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument(); + expect(screen.getByText('P1S Backup')).toBeInTheDocument(); + }); + }); + + it('filters by serial number', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: '00M09A' } }); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument(); + }); + }); + + it('shows empty state when no printers match search', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'ZZZ_NO_MATCH' } }); + + await waitFor(() => { + expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument(); + }); + }); + + it('clear button resets search and shows all printers', async () => { + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1 Carbon' } }); + + await waitFor(() => expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument()); + + // Click the accessible clear button + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.getByText('P1S Backup')).toBeInTheDocument(); + }); + }); + + it('filters by status (offline) via dropdown', async () => { + // Override: printer 1 online, printer 2 offline + server.use( + http.get('/api/v1/printers/:id/status', ({ params }) => { + if (Number(params.id) === 2) { + return HttpResponse.json({ ...mockPrinterStatus, connected: false }); + } + return HttpResponse.json(mockPrinterStatus); + }) + ); + + 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 waitFor(() => { + expect(screen.queryByText('X1 Carbon')).not.toBeInTheDocument(); + expect(screen.getByText('P1S Backup')).toBeInTheDocument(); + }); + }); + + it('shows empty state when status filter matches nothing', async () => { + render(); + 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 waitFor(() => { + expect(screen.getByText('No printers match your search or filters')).toBeInTheDocument(); + }); + }); + + it('combines search and status filter', async () => { + // Printer 1 = RUNNING (printing), printer 2 = IDLE + server.use( + http.get('/api/v1/printers/:id/status', ({ params }) => { + if (Number(params.id) === 1) { + return HttpResponse.json({ ...mockPrinterStatus, state: 'RUNNING' }); + } + return HttpResponse.json(mockPrinterStatus); + }) + ); + + render(); + await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument()); + + // Filter to only "printing" printers + fireEvent.change(screen.getByDisplayValue('All statuses'), { target: { value: 'printing' } }); + + // Then also search for a term that only matches printer 1 + fireEvent.change(screen.getByPlaceholderText('Search printers...'), { target: { value: 'X1' } }); + + await waitFor(() => { + expect(screen.getByText('X1 Carbon')).toBeInTheDocument(); + expect(screen.queryByText('P1S Backup')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index cd382c64f..dec4a794f 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -173,6 +173,12 @@ export default { powerOn: 'Einschalten', offlinePrintersWithPlugs: 'Offline-Drucker mit Smart-Plugs', noPrintersConfigured: 'Noch keine Drucker konfiguriert', + search: 'Drucker suchen...', + noSearchResults: 'Keine Drucker entsprechen deiner Suche oder deinen Filtern', + filter: { + allStatuses: 'Alle Status', + allLocations: 'Alle Standorte', + }, // Printer card readyToPrint: 'Druckbereit', external: 'Extern', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d472fa3b6..0dbd6f50d 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -173,6 +173,12 @@ export default { powerOn: 'Power On', offlinePrintersWithPlugs: 'Offline printers with smart plugs', noPrintersConfigured: 'No printers configured yet', + search: 'Search printers...', + noSearchResults: 'No printers match your search or filters', + filter: { + allStatuses: 'All statuses', + allLocations: 'All locations', + }, // 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 145f22bbf..6f8d29341 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -173,6 +173,12 @@ export default { powerOn: 'Allumer', offlinePrintersWithPlugs: 'Imprimantes hors ligne avec prises connectées', noPrintersConfigured: 'Aucune imprimante configurée pour le moment', + search: 'Rechercher des imprimantes...', + noSearchResults: 'Aucune imprimante ne correspond à votre recherche ou à vos filtres', + filter: { + allStatuses: 'Tous les statuts', + allLocations: 'Tous les emplacements', + }, // 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 495532c23..beae8cd66 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -173,6 +173,12 @@ export default { powerOn: 'Accendi', offlinePrintersWithPlugs: 'Stampanti offline con smart plug', noPrintersConfigured: 'Nessuna stampante configurata', + search: 'Cerca stampanti...', + noSearchResults: 'Nessuna stampante corrisponde alla tua ricerca o ai tuoi filtri', + filter: { + allStatuses: 'Tutti gli stati', + allLocations: 'Tutti i luoghi', + }, // 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 e551084a1..8c3323bbd 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -172,6 +172,12 @@ export default { powerOn: '電源オン', offlinePrintersWithPlugs: 'スマートプラグ付きオフラインプリンター', noPrintersConfigured: 'プリンターが設定されていません', + search: 'プリンターを検索...', + noSearchResults: '検索またはフィルターに一致するプリンターがありません', + filter: { + allStatuses: 'すべてのステータス', + allLocations: 'すべての場所', + }, // Printer card readyToPrint: '印刷可能', external: '外部', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index f4a0cdb2e..1ecde0232 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -173,6 +173,12 @@ export default { powerOn: 'Ligar', offlinePrintersWithPlugs: 'Impressoras offline com tomadas inteligentes', noPrintersConfigured: 'Nenhuma impressora configurada ainda', + search: 'Pesquisar impressoras...', + noSearchResults: 'Nenhuma impressora corresponde à sua pesquisa ou filtros', + filter: { + allStatuses: 'Todos os status', + allLocations: 'Todos os locais', + }, // 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 8421cc030..7f956578b 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -173,6 +173,12 @@ export default { powerOn: '开机', offlinePrintersWithPlugs: '带智能插座的离线打印机', noPrintersConfigured: '尚未配置打印机', + search: '搜索打印机...', + noSearchResults: '没有打印机符合您的搜索或筛选条件', + filter: { + allStatuses: '所有状态', + allLocations: '所有位置', + }, // Printer card readyToPrint: '准备打印', external: '外部', diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index a9782fb64..aca2b1333 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -5762,6 +5762,10 @@ export function PrintersPage() { }); // Derive viewMode from cardSize: S=compact, M/L/XL=expanded const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded'; + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [locationFilter, setLocationFilter] = useState('all'); + const [statusCacheVersion, setStatusCacheVersion] = useState(0); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); @@ -6064,10 +6068,73 @@ export function PrintersPage() { const cardSizeLabels = ['S', 'M', 'L', 'XL']; + // Increment version counter whenever a printer status cache entry is updated so + // filteredPrinters re-computes reactively on WebSocket-driven status changes. + useEffect(() => { + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + Array.isArray(event.query.queryKey) && + event.query.queryKey[0] === 'printerStatus' + ) { + setStatusCacheVersion(v => v + 1); + } + }); + return unsubscribe; + }, [queryClient]); + + // Filter printers by search term, status, and location + const filteredPrinters = useMemo(() => { + if (!printers) return []; + let result = printers; + + // Text search + if (search.trim()) { + const q = search.trim().toLowerCase(); + result = result.filter(p => + p.name.toLowerCase().includes(q) || + (p.model || '').toLowerCase().includes(q) || + (p.location || '').toLowerCase().includes(q) || + (p.serial_number || '').toLowerCase().includes(q) + ); + } + + // Location filter + if (locationFilter !== 'all') { + result = result.filter(p => (p.location || '') === locationFilter); + } + + // Status filter + if (statusFilter !== 'all') { + result = result.filter(p => { + const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]); + if (!status?.connected) return statusFilter === 'offline'; + const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : []; + switch (statusFilter) { + case 'printing': return status.state === 'RUNNING'; + case 'paused': return status.state === 'PAUSE'; + case 'finished': return status.state === 'FINISH'; + case 'error': return status.state === 'FAILED' || hmsErrors.length > 0; + case 'idle': return status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED' && hmsErrors.length === 0; + case 'offline': return false; // Connected printers are never offline + default: return true; + } + }); + } + + return result; + // eslint-disable-next-line react-hooks/exhaustive-deps -- statusCacheVersion is intentional: it forces recompute when WebSocket updates printer status cache + }, [printers, search, statusFilter, locationFilter, queryClient, statusCacheVersion]); + + // Derive unique locations for the location filter dropdown + const availableLocations = useMemo(() => { + if (!printers) return []; + return [...new Set(printers.map(p => p.location || '').filter(Boolean))].sort(); + }, [printers]); + // Sort printers based on selected option const sortedPrinters = useMemo(() => { - if (!printers) return []; - const sorted = [...printers]; + const sorted = [...filteredPrinters]; switch (sortBy) { case 'name': @@ -6111,7 +6178,7 @@ export function PrintersPage() { } return sorted; - }, [printers, sortBy, sortAsc, queryClient]); + }, [filteredPrinters, sortBy, sortAsc, queryClient]); const selectAll = useCallback(() => { setSelectedPrinterIds(new Set(sortedPrinters.map(p => p.id))); @@ -6166,6 +6233,30 @@ export function PrintersPage() {

{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" + /> + {search && ( + + )} +
+ )}
{/* Sort dropdown */} @@ -6193,6 +6284,37 @@ export function PrintersPage() {
+ {/* 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) => { @@ -6321,6 +6443,12 @@ export function PrintersPage() { + ) : sortedPrinters.length === 0 && (search.trim() || statusFilter !== 'all' || locationFilter !== 'all') ? ( + + +

{t('printers.noSearchResults')}

+
+
) : groupedPrinters ? ( /* Grouped by location view */
diff --git a/test_backend.sh b/test_backend.sh deleted file mode 100755 index 48e575416..000000000 --- a/test_backend.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh - -cd backend -ruff check && ruff format --check - -#if [ "$1" = "--full" ]; then -../venv/bin/python3 -m pytest tests/ -v -n 30 -#else -#../venv/bin/python3 -m pytest tests/ -v -n 30 --ignore=tests/unit/services/test_bambu_ftp.py -#fi -#cd ..