ソースを参照

Feature/printer search filter (#920)

* feat(printers): add live search/filter bar to printers page

Adds a search bar below the page title that filters the printer list
in real time by name, model, location, and serial number. Includes an
X button to clear the search and an empty-state message when no
printers match. Filtering is purely client-side — no backend changes
needed.

Closes #852
lietschaend 1 ヶ月 前
コミット
8305ba9fb7

+ 150 - 0
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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<PrintersPage />);
+      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(<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 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(<PrintersPage />);
+      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();
+      });
+    });
+  });
 });

+ 6 - 0
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',

+ 6 - 0
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',

+ 6 - 0
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',

+ 6 - 0
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',

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -172,6 +172,12 @@ export default {
     powerOn: '電源オン',
     offlinePrintersWithPlugs: 'スマートプラグ付きオフラインプリンター',
     noPrintersConfigured: 'プリンターが設定されていません',
+    search: 'プリンターを検索...',
+    noSearchResults: '検索またはフィルターに一致するプリンターがありません',
+    filter: {
+      allStatuses: 'すべてのステータス',
+      allLocations: 'すべての場所',
+    },
     // Printer card
     readyToPrint: '印刷可能',
     external: '外部',

+ 6 - 0
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',

+ 6 - 0
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: '外部',

+ 131 - 3
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<string>('all');
+  const [locationFilter, setLocationFilter] = useState<string>('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() {
         <div>
           <h1 className="text-2xl font-bold text-white">{t('printers.title')}</h1>
           <StatusSummaryBar printers={printers} />
+          {/* Only show search bar when printers exist */}
+          {printers && printers.length > 0 && (
+            <div className="relative w-full sm:max-w-sm mt-3">
+              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray/50" />
+              <input
+                type="text"
+                value={search}
+                onChange={(e) => 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 && (
+                <button
+                  type="button"
+                  aria-label={t('common.clear')}
+                  onClick={() => setSearch('')}
+                  className="absolute right-3 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                >
+                  <X className="w-4 h-4" />
+                </button>
+              )}
+            </div>
+          )}
         </div>
         <div className="flex items-center gap-2 sm:gap-3 flex-wrap">
           {/* Sort dropdown */}
@@ -6193,6 +6284,37 @@ export function PrintersPage() {
             </button>
           </div>
 
+          {/* Status filter */}
+          {printers && printers.length > 0 && (
+            <select
+              value={statusFilter}
+              onChange={(e) => setStatusFilter(e.target.value)}
+              className="text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none"
+            >
+              <option value="all">{t('printers.filter.allStatuses')}</option>
+              <option value="printing">{t('printers.status.printing')}</option>
+              <option value="paused">{t('printers.status.paused')}</option>
+              <option value="idle">{t('printers.status.idle')}</option>
+              <option value="finished">{t('printers.status.finished')}</option>
+              <option value="error">{t('printers.status.error')}</option>
+              <option value="offline">{t('printers.status.offline')}</option>
+            </select>
+          )}
+
+          {/* Location filter — only shown when at least one printer has a location */}
+          {printers && printers.length > 0 && availableLocations.length > 0 && (
+            <select
+              value={locationFilter}
+              onChange={(e) => setLocationFilter(e.target.value)}
+              className="text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg px-2 py-1.5 text-white focus:border-bambu-green focus:outline-none"
+            >
+              <option value="all">{t('printers.filter.allLocations')}</option>
+              {availableLocations.map(loc => (
+                <option key={loc} value={loc}>{loc}</option>
+              ))}
+            </select>
+          )}
+
           {/* Card size selector */}
           <div className="flex items-center bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
             {cardSizeLabels.map((label, index) => {
@@ -6321,6 +6443,12 @@ export function PrintersPage() {
             </Button>
           </CardContent>
         </Card>
+      ) : sortedPrinters.length === 0 && (search.trim() || statusFilter !== 'all' || locationFilter !== 'all') ? (
+        <Card>
+          <CardContent className="text-center py-12">
+            <p className="text-bambu-gray">{t('printers.noSearchResults')}</p>
+          </CardContent>
+        </Card>
       ) : groupedPrinters ? (
         /* Grouped by location view */
         <div className="space-y-6">

+ 0 - 11
test_backend.sh

@@ -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 ..