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 */}
-