Feature: Collapsible folders for printer filters (#968)

This commit is contained in:
Thomas Rambach
2026-04-15 08:31:28 +02:00
committed by GitHub
parent 62950e37c8
commit 360a211373
3 changed files with 198 additions and 82 deletions
@@ -108,7 +108,7 @@ export function BulkPrinterToolbar({
printing: t('printers.status.printing'),
paused: t('printers.status.paused', 'Paused'),
finished: t('printers.status.finished', 'Finished'),
idle: t('printers.status.available'),
idle: t('printers.status.idle'),
error: t('printers.status.problem'),
offline: t('printers.status.offline'),
};
+34 -12
View File
@@ -8,12 +8,21 @@ interface CollapsibleProps {
defaultOpen?: boolean;
className?: string;
summaryClassName?: string;
/** When provided, the component is controlled — parent owns the open state. */
open?: boolean;
/** Called when the user clicks the toggle. Use with `open` for controlled mode. */
onToggle?: (open: boolean) => void;
}
/**
* Lightweight disclosure used for densifying the Settings page.
* Renders a clickable summary row and animates open/close via a simple
* display swap (no height animation — keeps it snappy and layout-stable).
* Lightweight disclosure widget.
* Renders a clickable summary row and conditionally displays children.
*
* The toggle region is a plain <div> with role="button" so that the summary
* slot may safely contain interactive elements (buttons, links) without
* nesting a <button> inside a <button>.
*
* Supports both uncontrolled (internal state) and controlled (`open`/`onToggle`) modes.
*/
export function Collapsible({
summary,
@@ -21,22 +30,35 @@ export function Collapsible({
defaultOpen = false,
className = '',
summaryClassName = '',
open: controlledOpen,
onToggle,
}: CollapsibleProps) {
const [open, setOpen] = useState(defaultOpen);
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const handleToggle = () => {
const next = !isOpen;
if (!isControlled) setInternalOpen(next);
onToggle?.(next);
};
return (
<div className={className}>
<button
type="button"
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between gap-2 text-left ${summaryClassName}`}
aria-expanded={open}
<div
role="button"
tabIndex={0}
onClick={handleToggle}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggle(); } }}
className={`w-full flex items-center justify-between gap-2 text-left cursor-pointer ${summaryClassName}`}
aria-expanded={isOpen}
>
<div className="flex-1 min-w-0">{summary}</div>
<ChevronDown
className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`}
className={`w-4 h-4 text-bambu-gray flex-shrink-0 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{open && <div className="mt-3">{children}</div>}
</div>
{isOpen && <div className="mt-3">{children}</div>}
</div>
);
}
+163 -69
View File
@@ -83,6 +83,7 @@ import { PrinterInfoModal } from '../components/PrinterInfoModal';
import { getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag } from '../utils/amsHelpers';
import { getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
import { Collapsible } from '../components/Collapsible';
import { getColorName, parseFilamentColor, isLightColor } from '../utils/colors';
// Color names resolve via getColorName() which reads the backend color_catalog
@@ -988,6 +989,33 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
type SortOption = 'name' | 'status' | 'model' | 'location';
type ViewMode = 'expanded' | 'compact';
const STATUS_GROUP_ORDER: string[] = ['error', 'printing', 'paused', 'finished', 'idle', 'offline'];
const STATUS_GROUP_META: Record<string, { labelKey: string; dot: string }> = {
error: { labelKey: 'printers.status.problem', dot: 'bg-status-error' },
printing: { labelKey: 'printers.status.printing', dot: 'bg-bambu-green animate-pulse' },
paused: { labelKey: 'printers.status.paused', dot: 'bg-status-warning' },
finished: { labelKey: 'printers.status.finished', dot: 'bg-blue-400' },
idle: { labelKey: 'printers.status.idle', dot: 'bg-bambu-green' },
offline: { labelKey: 'printers.status.offline', dot: 'bg-gray-400' },
};
/** Classify a printer into one of the UI status buckets. */
function classifyPrinterStatus(
status: { connected: boolean; state: string | null; hms_errors?: HMSError[] } | undefined,
): PrinterState {
if (!status?.connected) return 'offline';
const hmsErrors = status.hms_errors ? filterKnownHMSErrors(status.hms_errors) : [];
if (hmsErrors.length > 0) return 'error';
switch (status.state) {
case 'RUNNING': return 'printing';
case 'PAUSE': return 'paused';
case 'FINISH': return 'finished';
case 'FAILED': return 'error';
default: return 'idle';
}
}
/**
* Get human-readable status display text for a printer.
* Uses stg_cur_name for detailed calibration/preparation stages,
@@ -5841,6 +5869,12 @@ export function PrintersPage() {
const [statusFilter, setStatusFilter] = useState<string>('all');
const [locationFilter, setLocationFilter] = useState<string>('all');
const [statusCacheVersion, setStatusCacheVersion] = useState(0);
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('printerCollapsedSections');
return saved ? JSON.parse(saved) : {};
} catch { return {}; }
});
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
@@ -6265,15 +6299,7 @@ export function PrintersPage() {
const next = new Set(prev);
sortedPrinters.forEach(p => {
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', p.id]);
if (!status) return;
switch (state) {
case 'printing': if (status.connected && status.state === 'RUNNING') next.add(p.id); break;
case 'paused': if (status.connected && status.state === 'PAUSE') next.add(p.id); break;
case 'finished': if (status.connected && status.state === 'FINISH') next.add(p.id); break;
case 'idle': if (status.connected && status.state !== 'RUNNING' && status.state !== 'PAUSE' && status.state !== 'FINISH' && status.state !== 'FAILED') next.add(p.id); break;
case 'error': if (status.connected && (status.state === 'FAILED' || (status.hms_errors && filterKnownHMSErrors(status.hms_errors).length > 0))) next.add(p.id); break;
case 'offline': if (!status.connected) next.add(p.id); break;
}
if (classifyPrinterStatus(status) === state) next.add(p.id);
});
return next;
});
@@ -6289,18 +6315,53 @@ export function PrintersPage() {
setIsSelectionMode(true);
}, [sortedPrinters]);
// Group printers by location when sorted by location
const selectByModel = useCallback((model: string) => {
setSelectedPrinterIds(prev => {
const next = new Set(prev);
sortedPrinters.filter(p => (p.model || 'Unknown') === model).forEach(p => next.add(p.id));
return next;
});
setIsSelectionMode(true);
}, [sortedPrinters]);
const toggleSectionCollapse = useCallback((key: string) => {
setCollapsedSections(prev => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem('printerCollapsedSections', JSON.stringify(next)); } catch { /* quota exceeded / private mode */ }
return next;
});
}, []);
// Group printers when sorted by location, status, or model
const groupedPrinters = useMemo(() => {
if (sortBy !== 'location') return null;
if (sortBy === 'name') return null;
const groups: Record<string, typeof sortedPrinters> = {};
sortedPrinters.forEach(printer => {
const location = printer.location || 'Ungrouped';
if (!groups[location]) groups[location] = [];
groups[location].push(printer);
});
if (sortBy === 'location') {
sortedPrinters.forEach(printer => {
const location = printer.location || 'Ungrouped';
if (!groups[location]) groups[location] = [];
groups[location].push(printer);
});
} else if (sortBy === 'model') {
sortedPrinters.forEach(printer => {
const model = printer.model || 'Unknown';
if (!groups[model]) groups[model] = [];
groups[model].push(printer);
});
} else if (sortBy === 'status') {
sortedPrinters.forEach(printer => {
const status = queryClient.getQueryData<{ connected: boolean; state: string | null; hms_errors?: HMSError[] }>(['printerStatus', printer.id]);
const group = classifyPrinterStatus(status);
if (!groups[group]) groups[group] = [];
groups[group].push(printer);
});
}
return groups;
}, [sortBy, sortedPrinters]);
// 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]);
return (
<div className="p-4 md:p-8">
@@ -6525,59 +6586,92 @@ export function PrintersPage() {
</CardContent>
</Card>
) : groupedPrinters ? (
/* Grouped by location view */
/* Grouped view (location, status, or model) */
<div className="space-y-6">
{Object.entries(groupedPrinters).map(([location, locationPrinters]) => (
<div key={location}>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-bambu-green" />
{location}
<span className="text-sm font-normal text-bambu-gray">({locationPrinters.length})</span>
{selectionMode && (
<button
onClick={() => selectByLocation(location === 'Ungrouped' ? '' : location)}
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
>
{t('printers.bulk.selectAll')}
</button>
)}
</h2>
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
{locationPrinters.map((printer) => (
<PrinterCard
key={printer.id}
printer={printer}
hideIfDisconnected={hideDisconnected}
maintenanceInfo={maintenanceByPrinter[printer.id]}
viewMode={viewMode}
cardSize={cardSize}
amsThresholds={settings ? {
humidityGood: Number(settings.ams_humidity_good) || 40,
humidityFair: Number(settings.ams_humidity_fair) || 60,
tempGood: Number(settings.ams_temp_good) || 28,
tempFair: Number(settings.ams_temp_fair) || 35,
} : undefined}
spoolmanEnabled={spoolmanEnabled}
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
spoolmanSyncMode={spoolmanSyncMode}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
dryingPresets={effectiveDryingPresets}
requirePlateClear={settings?.require_plate_clear === true}
selectionMode={selectionMode}
isSelected={selectedPrinterIds.has(printer.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
</div>
))}
{(() => {
const keys = sortBy === 'status'
? STATUS_GROUP_ORDER.filter(k => groupedPrinters[k]?.length > 0)
: Object.keys(groupedPrinters);
// For status grouping, asc/desc flips the fixed priority order
// (asc = error→offline, desc = offline→error). This matches the
// sort-toggle behaviour for other groupings.
return (sortAsc ? keys : [...keys].reverse());
})().map((groupKey) => {
const groupPrinters = groupedPrinters[groupKey];
const collapseKey = `${sortBy}:${groupKey}`;
const isOpen = !collapsedSections[collapseKey];
const dot = sortBy === 'status'
? STATUS_GROUP_META[groupKey]?.dot || 'bg-bambu-green'
: 'bg-bambu-green';
const label = sortBy === 'status'
? t(STATUS_GROUP_META[groupKey]?.labelKey || groupKey)
: groupKey;
return (
<Collapsible
key={groupKey}
open={isOpen}
onToggle={() => toggleSectionCollapse(collapseKey)}
summaryClassName="py-1"
summary={
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dot}`} />
{label}
<span className="text-sm font-normal text-bambu-gray">({groupPrinters.length})</span>
{selectionMode && (
<button
onClick={(e) => {
e.stopPropagation();
if (sortBy === 'location') selectByLocation(groupKey === 'Ungrouped' ? '' : groupKey);
else if (sortBy === 'status') selectByState(groupKey as PrinterState);
else if (sortBy === 'model') selectByModel(groupKey);
}}
className="text-xs text-bambu-green hover:text-bambu-green-light transition-colors ml-1"
>
{t('printers.bulk.selectAll')}
</button>
)}
</h2>
}
>
<div className={`grid gap-4 ${cardSize >= 3 ? 'gap-6' : ''} ${getGridClasses()}`}>
{groupPrinters.map((printer) => (
<PrinterCard
key={printer.id}
printer={printer}
hideIfDisconnected={hideDisconnected}
maintenanceInfo={maintenanceByPrinter[printer.id]}
viewMode={viewMode}
cardSize={cardSize}
amsThresholds={settings ? {
humidityGood: Number(settings.ams_humidity_good) || 40,
humidityFair: Number(settings.ams_humidity_fair) || 60,
tempGood: Number(settings.ams_temp_good) || 28,
tempFair: Number(settings.ams_temp_fair) || 35,
} : undefined}
spoolmanEnabled={spoolmanEnabled}
hasUnlinkedSpools={hasUnlinkedSpools}
linkedSpools={linkedSpools}
spoolmanUrl={spoolmanStatus?.url}
spoolmanSyncMode={spoolmanSyncMode}
onGetAssignment={getAssignment}
onUnassignSpool={(pid, aid, tid) => unassignMutation.mutate({ printerId: pid, amsId: aid, trayId: tid })}
timeFormat={settings?.time_format || 'system'}
cameraViewMode={settings?.camera_view_mode || 'window'}
onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
checkPrinterFirmware={settings?.check_printer_firmware !== false}
dryingPresets={effectiveDryingPresets}
requirePlateClear={settings?.require_plate_clear === true}
selectionMode={selectionMode}
isSelected={selectedPrinterIds.has(printer.id)}
onToggleSelect={toggleSelect}
/>
))}
</div>
</Collapsible>
);
})}
</div>
) : (
/* Regular grid view */