Let the external spool be hidden from the printer card (#1782)

An external spool holder that never gets used still takes a full card's
width in the Filaments row, next to the AMS units that are actually in
use. An eye icon at the right-hand end of that row's header now hides
it, and clicking it again brings it back -- the affordance stays in
place rather than moving to a settings page, so the choice is
discoverable and reversible where it applies.

Per printer rather than global. A global flag would suit a toolbar
button, but an icon on the card that silently rearranged every other
card would surprise; it is keyed by printer id in one localStorage
entry, the same shape as printerCollapsedSections, and sits alongside
the other browser-local printer-page view preferences.

The toggle is offered only when the printer has at least one AMS. On a
machine with no AMS the external spool is the entire filament section,
so hiding it would leave an empty row with no control to undo it. The
icon and the hide condition read the same canHideExternalSpool, so a
preference stored before an AMS was unplugged cannot blank the row
either -- the spool reappears instead.

The store lives in a new utils/printerCardPrefs.ts rather than in the
9,157-line page. It re-reads before writing so two cards toggled in one
session cannot clobber each other's entry, deletes the key instead of
storing false, and treats a malformed or unavailable localStorage as
"nothing hidden" so a private-mode browser cannot throw out of a render.
This commit is contained in:
maziggy
2026-08-04 13:08:30 +02:00
parent 9bc96aeb83
commit 3db8ac9da7
20 changed files with 612 additions and 153 deletions
@@ -0,0 +1,181 @@
/**
* Hiding the external spool from the printer card (#1782, reporter @Arn0uDz).
*
* The toggle lives in the filament section header next to the AMS Backup
* badge. It is offered only when an AMS is present: on a printer that feeds
* from the external spool alone, the external spool IS the filament section,
* so hiding it would leave an empty row and no way to see the loaded filament.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor } 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';
import { server } from '../mocks/server';
const STORE_KEY = 'printerHiddenExternalSpools';
const mockPrinter = {
id: 1,
name: 'X1C',
ip_address: '192.168.1.100',
serial_number: '01P00A000000001',
access_code: '12345678',
model: 'X1C',
enabled: true,
nozzle_diameter: 0.4,
nozzle_type: 'stainless_steel',
location: 'Workshop',
auto_archive: true,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const baseTray = {
tray_color: 'FF0000FF',
tray_type: 'PLA',
tray_sub_brands: 'PLA Basic',
tray_id_name: 'A00-R0',
tray_info_idx: 'GFA00',
remain: 80,
k: 0.02,
cali_idx: null,
tag_uid: null,
tray_uuid: null,
nozzle_temp_min: 190,
nozzle_temp_max: 230,
drying_temp: null,
drying_time: null,
state: 3,
};
const amsUnit = {
id: 0,
humidity: 30,
temp: 33,
is_ams_ht: false,
serial_number: 'AMS00',
sw_ver: '03.00.21.29',
dry_time: 0,
dry_status: 0,
dry_sub_status: 0,
dry_sf_reason: [],
module_type: 'n3f',
tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
};
function makeStatus({ withAms }: { withAms: boolean }) {
return {
connected: true,
state: 'IDLE',
progress: 0,
layer_num: 0,
total_layers: 0,
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
remaining_time: 0,
filename: null,
wifi_signal: -29,
speed_level: 2,
supports_drying: true,
drying_screen_only: false,
ams: withAms ? [amsUnit] : [],
vt_tray: [{ id: 254, ...baseTray, tray_type: 'PETG', tray_sub_brands: 'PETG HF' }],
};
}
const WITH_AMS = makeStatus({ withAms: true });
const WITHOUT_AMS = makeStatus({ withAms: false });
const HIDE_TITLE = 'Hide external spool';
const SHOW_TITLE = 'Show external spool';
/** The external spool's own card is labelled with `printers.external`. */
function externalSpoolCards() {
return screen.queryAllByText('External');
}
let store: Record<string, string>;
describe('PrintersPage — hide the external spool (#1782)', () => {
beforeEach(() => {
store = {};
vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
store[key] = String(value);
});
server.use(
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
http.get('/api/v1/queue/', () => HttpResponse.json([])),
);
});
afterEach(() => {
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
});
it('hides the external spool when the toggle is clicked, and brings it back', async () => {
const user = userEvent.setup();
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
render(<PrintersPage />);
const toggle = await screen.findByTitle(HIDE_TITLE);
expect(externalSpoolCards().length).toBeGreaterThan(0);
await user.click(toggle);
await waitFor(() => expect(externalSpoolCards()).toHaveLength(0));
// The toggle itself stays put — it is the only way back.
const restore = await screen.findByTitle(SHOW_TITLE);
await user.click(restore);
await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
});
it('persists the choice per printer', async () => {
const user = userEvent.setup();
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
render(<PrintersPage />);
await user.click(await screen.findByTitle(HIDE_TITLE));
// Keyed by printer id, so a second printer's card is untouched.
await waitFor(() => {
expect(JSON.parse(store[STORE_KEY])).toEqual({ '1': true });
});
});
it('starts hidden when the stored preference says so', async () => {
store[STORE_KEY] = JSON.stringify({ '1': true });
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
render(<PrintersPage />);
await screen.findByTitle(SHOW_TITLE);
expect(externalSpoolCards()).toHaveLength(0);
});
it('does not offer the toggle on a printer with no AMS', async () => {
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
render(<PrintersPage />);
// The external spool is the whole filament section here, so it must stay.
await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
expect(screen.queryByTitle(HIDE_TITLE)).not.toBeInTheDocument();
expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
});
it('ignores a stored preference once the printer has no AMS left', async () => {
// The AMS was unplugged after the user hid the external spool. Honouring
// the stored flag would blank the filament row with no control to undo it.
store[STORE_KEY] = JSON.stringify({ '1': true });
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
render(<PrintersPage />);
await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
/**
* Per-printer printer-card view preferences (#1782).
*
* The store is keyed by printer id so the toggle on one card cannot rearrange
* another, and it has to survive whatever is already sitting in localStorage —
* a value from an older format, or one another tab mangled — without throwing
* out of a render.
*
* The shared test setup stubs localStorage with bare vi.fn()s that store
* nothing, so this file backs them with a real in-memory object; a round-trip
* is the whole point of what's under test here.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
isExternalSpoolHidden,
setExternalSpoolHidden,
} from '../../utils/printerCardPrefs';
const KEY = 'printerHiddenExternalSpools';
let store: Record<string, string>;
function stored(): unknown {
return JSON.parse(store[KEY]);
}
describe('printerCardPrefs — external spool visibility', () => {
beforeEach(() => {
store = {};
vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
store[key] = String(value);
});
});
afterEach(() => {
vi.mocked(localStorage.getItem).mockReset();
vi.mocked(localStorage.setItem).mockReset();
});
it('defaults to visible for a printer that was never toggled', () => {
expect(isExternalSpoolHidden(1)).toBe(false);
});
it('round-trips the hidden flag through localStorage', () => {
setExternalSpoolHidden(7, true);
expect(isExternalSpoolHidden(7)).toBe(true);
expect(stored()).toEqual({ '7': true });
});
it('keeps each printer independent', () => {
setExternalSpoolHidden(1, true);
expect(isExternalSpoolHidden(1)).toBe(true);
expect(isExternalSpoolHidden(2)).toBe(false);
// Hiding a second printer must not disturb the first — the writer
// re-reads before merging rather than overwriting the whole object.
setExternalSpoolHidden(2, true);
expect(isExternalSpoolHidden(1)).toBe(true);
expect(isExternalSpoolHidden(2)).toBe(true);
});
it('drops the key when shown again rather than storing false', () => {
setExternalSpoolHidden(3, true);
setExternalSpoolHidden(3, false);
expect(isExternalSpoolHidden(3)).toBe(false);
// Otherwise the object grows an entry for every printer ever toggled twice.
expect(stored()).toEqual({});
});
it('treats malformed stored values as "nothing hidden"', () => {
for (const junk of ['not json', 'null', '"a string"', '[1,2,3]', '42']) {
store[KEY] = junk;
expect(isExternalSpoolHidden(1)).toBe(false);
}
});
it('recovers from a malformed store on the next write', () => {
store[KEY] = '[1,2,3]';
setExternalSpoolHidden(5, true);
expect(isExternalSpoolHidden(5)).toBe(true);
expect(stored()).toEqual({ '5': true });
});
it('survives localStorage being unavailable', () => {
vi.mocked(localStorage.getItem).mockImplementation(() => {
throw new Error('SecurityError: access denied');
});
vi.mocked(localStorage.setItem).mockImplementation(() => {
throw new Error('QuotaExceededError');
});
// Private-mode browsers throw on both. Neither may escape into a render.
expect(() => isExternalSpoolHidden(1)).not.toThrow();
expect(isExternalSpoolHidden(1)).toBe(false);
expect(() => setExternalSpoolHidden(1, true)).not.toThrow();
});
});
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: 'Filamente',
externalSpool: {
hide: 'Externe Spule ausblenden',
show: 'Externe Spule einblenden',
},
// Camera
openCameraOverlay: 'Kamera-Overlay öffnen',
openCameraWindow: 'Kamera in neuem Fenster öffnen',
+4
View File
@@ -622,6 +622,10 @@ export default {
},
// Filaments section
filaments: 'Filaments',
externalSpool: {
hide: 'Hide external spool',
show: 'Show external spool',
},
// Camera
openCameraOverlay: 'Open camera overlay',
openCameraWindow: 'Open camera in new window',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: 'Filamentos',
externalSpool: {
hide: 'Ocultar bobina externa',
show: 'Mostrar bobina externa',
},
// Camera
openCameraOverlay: 'Abrir la cámara superpuesta',
openCameraWindow: 'Abrir la cámara en una ventana nueva',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: 'Filaments',
externalSpool: {
hide: 'Masquer la bobine externe',
show: 'Afficher la bobine externe',
},
// Camera
openCameraOverlay: 'Ouvrir la caméra en superposition',
openCameraWindow: 'Ouvrir la caméra dans une fenêtre',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: 'Filamenti',
externalSpool: {
hide: 'Nascondi bobina esterna',
show: 'Mostra bobina esterna',
},
// Camera
openCameraOverlay: 'Apri overlay camera',
openCameraWindow: 'Apri camera in nuova finestra',
+4
View File
@@ -617,6 +617,10 @@ export default {
},
// Filaments section
filaments: 'フィラメント',
externalSpool: {
hide: '外部スプールを非表示にする',
show: '外部スプールを表示する',
},
// Camera
openCameraOverlay: 'カメラオーバーレイを開く',
openCameraWindow: 'カメラを新しいウィンドウで開く',
+4
View File
@@ -580,6 +580,10 @@ export default {
external: '외부 스풀',
},
filaments: '필라멘트',
externalSpool: {
hide: '외부 스풀 숨기기',
show: '외부 스풀 표시',
},
openCameraOverlay: '카메라 오버레이 열기',
openCameraWindow: '새 창에서 카메라 열기',
firmwareUpdateAvailable: '펌웨어 업데이트 가능: {{current}} → {{latest}}',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: 'Filamentos',
externalSpool: {
hide: 'Ocultar bobina externa',
show: 'Mostrar bobina externa',
},
// Camera
openCameraOverlay: 'Abrir sobreposição da câmera',
openCameraWindow: 'Abrir câmera em nova janela',
+4
View File
@@ -585,6 +585,10 @@ export default {
external: "Внешняя катушка",
},
filaments: "Филаменты",
externalSpool: {
hide: "Скрыть внешнюю катушку",
show: "Показать внешнюю катушку",
},
openCameraOverlay: "Открыть камеру поверх интерфейса",
openCameraWindow: "Открыть камеру в новом окне",
firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}",
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filamentler bölümü
filaments: 'Filamentler',
externalSpool: {
hide: 'Harici makarayı gizle',
show: 'Harici makarayı göster',
},
// Kamera
openCameraOverlay: 'Kamera bindirmesini aç',
openCameraWindow: 'Kamerayı yeni pencerede aç',
+4
View File
@@ -622,6 +622,10 @@ export default {
},
// Filaments section
filaments: "Філаменти",
externalSpool: {
hide: "Сховати зовнішню котушку",
show: "Показати зовнішню котушку",
},
// Camera
openCameraOverlay: "Відкрити накладання камери",
openCameraWindow: "Відкрити камеру в новому вікні",
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: '耗材',
externalSpool: {
hide: '隐藏外部料卷',
show: '显示外部料卷',
},
// Camera
openCameraOverlay: '打开摄像头叠加层',
openCameraWindow: '在新窗口中打开摄像头',
+4
View File
@@ -618,6 +618,10 @@ export default {
},
// Filaments section
filaments: '耗材',
externalSpool: {
hide: '隱藏外部料卷',
show: '顯示外部料卷',
},
// Camera
openCameraOverlay: '開啟攝影機疊加層',
openCameraWindow: '在新視窗中開啟攝影機',
+69 -1
View File
@@ -3,6 +3,10 @@ import { createPortal } from 'react-dom';
import { compareFwVersions } from '../utils/firmwareVersion';
import { formatPrintName } from '../utils/printName';
import { computePopoverPosition } from '../utils/popoverPosition';
import {
isExternalSpoolHidden,
setExternalSpoolHidden as persistExternalSpoolHidden,
} from '../utils/printerCardPrefs';
import {
BED_TEMP_DEFAULTS,
CHAMBER_TEMP_DEFAULTS,
@@ -42,6 +46,8 @@ import {
Zap,
Wrench,
ChevronDown,
Eye,
EyeOff,
Filter,
Pencil,
ArrowLeft,
@@ -747,6 +753,37 @@ function AmsBackupBadge({ state, onClick }: AmsBackupBadgeProps) {
);
}
// Hide/show the external spool in the filament row (#1782). Sized and shaped
// like AmsBackupBadge so the two sit together in the section header, but
// pinned to the right-hand end of the rule: this is a view preference for the
// row, not a property of the printer.
interface ExternalSpoolToggleProps {
hidden: boolean;
onClick: () => void;
}
function ExternalSpoolToggle({ hidden, onClick }: ExternalSpoolToggleProps) {
const { t } = useTranslation();
const title = hidden ? t('printers.externalSpool.show') : t('printers.externalSpool.hide');
return (
<button
type="button"
onClick={onClick}
aria-pressed={hidden}
className={`flex items-center justify-center w-[18px] h-[18px] rounded transition-colors cursor-pointer ${
hidden
? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
: 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark/80'
}`}
title={title}
aria-label={title}
>
{hidden ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />}
</button>
);
}
// Humidity indicator with water drop that fills based on level (Bambu Lab style)
// Reference: https://github.com/theicedmango/bambu-humidity
interface HumidityIndicatorProps {
@@ -1849,6 +1886,18 @@ function PrinterCard({
const [showAiModal, setShowAiModal] = useState(false);
// #1762: AMS Filament Backup status / control modal — opens from the badge.
const [amsBackupModalOpen, setAmsBackupModalOpen] = useState(false);
// External spool visibility (#1782) — browser-local, per printer. Read once
// per card; the toggle that writes it is the only thing that changes it.
const [externalSpoolHidden, setExternalSpoolHidden] = useState(() =>
isExternalSpoolHidden(printer.id),
);
const toggleExternalSpool = useCallback(() => {
setExternalSpoolHidden((prev) => {
const next = !prev;
persistExternalSpoolHidden(printer.id, next);
return next;
});
}, [printer.id]);
const [showStopConfirm, setShowStopConfirm] = useState(false);
const [showPauseConfirm, setShowPauseConfirm] = useState(false);
const [showSpeedMenu, setShowSpeedMenu] = useState<number | null>(null);
@@ -4635,6 +4684,13 @@ function PrinterCard({
// Separate regular AMS (4-tray) from HT AMS (1-tray)
const regularAms = amsData.filter(ams => ams.tray.length > 1);
const htAms = amsData.filter(ams => ams.tray.length === 1);
// The external spool can only be hidden while some AMS remains to
// fill the row (#1782). On an A1 Mini or a bare P1P it is the whole
// filament section, so the toggle is not offered there and a stored
// preference from a printer that later lost its AMS cannot blank the
// row either — both read through canHideExternalSpool.
const canHideExternalSpool = amsData.length > 0 && status.vt_tray.length > 0;
const showExternalSpool = !(canHideExternalSpool && externalSpoolHidden);
const isDualNozzle = printer.nozzle_count === 2 || status?.temperatures?.nozzle_2 !== undefined;
const filamentSlotClass = 'min-w-14';
// #1762 (comment 2): while a print is running/paused, overlay a small
@@ -4668,6 +4724,18 @@ function PrinterCard({
onClick={() => setAmsBackupModalOpen(true)}
/>
<div className="flex-1 h-[2px] bg-bambu-dark-tertiary" />
{/* Offered only when an AMS is present: on a printer that
feeds from the external spool alone, hiding it would
empty the row entirely (#1782). */}
{canHideExternalSpool && (
<>
<ExternalSpoolToggle
hidden={externalSpoolHidden}
onClick={toggleExternalSpool}
/>
<div className="w-3 h-[2px] bg-bambu-dark-tertiary" />
</>
)}
</div>
{/* AMS Content */}
@@ -5515,7 +5583,7 @@ function PrinterCard({
);
})}
{/* External spool(s) - grouped in one card like regular AMS */}
{status.vt_tray.length > 0 && (
{status.vt_tray.length > 0 && showExternalSpool && (
<div style={getAmsCardStyle(status.vt_tray.length)} className="min-w-0 p-2 bg-bambu-dark rounded-[10px] space-y-1">
<div className="flex w-full min-h-7 items-center gap-1.5 rounded-lg bg-bambu-dark-secondary px-2 py-1">
<span className="block min-w-0 flex-1 truncate text-[10px] text-white font-medium">{t('printers.external')}</span>
+56
View File
@@ -0,0 +1,56 @@
/**
* Per-printer view preferences for the printer card.
*
* These are browser-local, like every other printer-page view preference
* (`printerCardSize`, `hideDisconnectedPrinters`, `printerCollapsedSections`).
* They describe how one person wants their own screen to look, not anything
* about the printer, so they deliberately do not go to the backend.
*
* Keyed by printer id rather than held as a single global flag: the toggle
* lives on the card itself, so hiding the external spool on one printer must
* not silently rearrange every other card in a fleet.
*/
const HIDDEN_EXTERNAL_SPOOLS_KEY = 'printerHiddenExternalSpools';
function readHiddenExternalSpools(): Record<string, boolean> {
try {
const saved = localStorage.getItem(HIDDEN_EXTERNAL_SPOOLS_KEY);
if (!saved) return {};
const parsed: unknown = JSON.parse(saved);
// Anything that isn't a plain object (an older format, or a value another
// tab mangled) is discarded rather than indexed into.
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as Record<string, boolean>;
} catch {
// Malformed JSON, or localStorage unavailable (private mode / blocked
// cookies). Showing the external spool is the safe default either way.
return {};
}
}
/** Whether this printer's external spool should be left out of the card. */
export function isExternalSpoolHidden(printerId: number): boolean {
return readHiddenExternalSpools()[String(printerId)] === true;
}
/**
* Persist the toggle. Re-reads before writing so two cards toggled in the same
* session can't clobber each other's entry, and drops the key entirely when
* shown again so the stored object doesn't accumulate `false` for every printer
* the user ever toggled twice.
*/
export function setExternalSpoolHidden(printerId: number, hidden: boolean): void {
const next = readHiddenExternalSpools();
if (hidden) {
next[String(printerId)] = true;
} else {
delete next[String(printerId)];
}
try {
localStorage.setItem(HIDDEN_EXTERNAL_SPOOLS_KEY, JSON.stringify(next));
} catch {
// Quota exceeded or private mode — the toggle still applies for this
// session, it just won't survive a reload.
}
}