diff --git a/README.md b/README.md index 80c8902..443370e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ You can fight against AI usage or learn to embrace it as a new way of working. I - **Label settings** -- tape size, margins, minimum length, justify, foreground/background colors - **Per-widget font styles** -- each text widget can have its own font style, scale, frame, and alignment - **Multi-printer support** -- automatically detects all connected DYMO printers; select specific printer when multiple are available +- **Per-printer settings** -- the tape size and colors loaded in each printer are remembered across visits, so you don't re-pick them every time - **Virtual printers** -- configure virtual printers that save labels as PNG images, JSON data, or both (great for testing, archiving, and development) - **Batch print** -- print multiple labels with variable content using `{{varname}}` placeholders, with a table to fill in values per row, configurable copies and pause time, SSE progress streaming, and cancellation support - **Cut mark** -- optional dotted column painted into the trailing margin between batch labels so you can tear/cut between them; uses the existing inter-label gap, no extra tape diff --git a/client/src/App.tsx b/client/src/App.tsx index 8f11008..f7c167c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,7 +21,10 @@ export default function App() { setAvailablePrinters(printers); } catch (error) { console.error("Failed to load printers:", error); - // Silently fail - UI will work without printer list + // Mark the fetch as settled (with no printers) so the per-printer + // settings gate lifts — otherwise the persisted controls would stay + // disabled forever when /api/printers is unreachable. + setAvailablePrinters([]); } }; loadPrinters(); diff --git a/client/src/components/SettingsBar.test.tsx b/client/src/components/SettingsBar.test.tsx index 808119b..13b831e 100644 --- a/client/src/components/SettingsBar.test.tsx +++ b/client/src/components/SettingsBar.test.tsx @@ -15,9 +15,13 @@ vi.mock("../lib/api", () => ({ }), powerOn: vi.fn(), powerOff: vi.fn(), + // usePrinterSettings runs inside SettingsBar. + fetchPrinterSettings: vi.fn().mockResolvedValue({}), + savePrinterSettings: vi.fn().mockResolvedValue(undefined), })); import { SettingsBar } from "./SettingsBar"; +import { fetchPrinterSettings, savePrinterSettings } from "../lib/api"; import { useLabelStore } from "../state/useLabelStore"; import type { PrinterInfo } from "../types/label"; @@ -31,9 +35,12 @@ afterEach(() => { }); beforeEach(() => { - // Reset store to defaults + vi.clearAllMocks(); + // Reset store to defaults. availablePrintersLoaded: true so the persisted + // controls aren't gated off in tests that don't exercise the loading gate. useLabelStore.setState({ availablePrinters: [], + availablePrintersLoaded: true, settings: { tapeSizeMm: 12, marginPx: 56, @@ -121,6 +128,135 @@ describe("SettingsBar printer selector", () => { }); }); +describe("SettingsBar per-printer settings persistence", () => { + it("applies saved settings when a printer is selected", async () => { + vi.mocked(fetchPrinterSettings).mockResolvedValueOnce({ tapeSizeMm: 6 }); + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + + await waitFor(() => { + expect(useLabelStore.getState().settings.tapeSizeMm).toBe(6); + }); + expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1"); + }); + + it("resets persisted fields to defaults for an unconfigured printer", async () => { + // Regression: switching to a printer with no saved settings must reset + // tape/colors to defaults, not inherit the previous printer's values. + vi.mocked(fetchPrinterSettings).mockResolvedValueOnce({}); + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { + ...useLabelStore.getState().settings, + printerId: "usb:1", + tapeSizeMm: 19, + foregroundColor: "white", + backgroundColor: "blue", + }, + }); + render(); + + await waitFor(() => { + const s = useLabelStore.getState().settings; + expect(s.tapeSizeMm).toBe(12); + expect(s.foregroundColor).toBe("black"); + expect(s.backgroundColor).toBe("white"); + }); + }); + + it("persists the full subset when the tape size changes", async () => { + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + // The control is gated until the printer's settings resolve. + await waitFor(() => expect(tapeSelect).not.toBeDisabled()); + await userEvent.selectOptions(tapeSelect, "19"); + + expect(savePrinterSettings).toHaveBeenCalledWith("usb:1", { + tapeSizeMm: 19, + foregroundColor: "black", + backgroundColor: "white", + }); + }); + + it("disables persisted controls until the printer list loads", () => { + useLabelStore.setState({ availablePrinters: [], availablePrintersLoaded: false }); + render(); + screen.getByText("Settings").click(); + expect(screen.getByDisplayValue("12")).toBeDisabled(); + }); + + it("disables then re-enables persisted controls around the settings fetch", async () => { + // A never-resolving fetch keeps the control gated. + let resolve!: (v: Record) => void; + vi.mocked(fetchPrinterSettings).mockReturnValueOnce( + new Promise((r) => { + resolve = r; + }), + ); + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + await waitFor(() => expect(tapeSelect).toBeDisabled()); + resolve({}); + await waitFor(() => expect(tapeSelect).not.toBeDisabled()); + }); + + it("re-enables controls (graceful unlock) when the settings fetch errors", async () => { + vi.mocked(fetchPrinterSettings).mockRejectedValueOnce(new Error("Pi down")); + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: "usb:1" }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + await waitFor(() => expect(tapeSelect).not.toBeDisabled()); + }); + + it("uses the single connected printer even on Auto-select", async () => { + useLabelStore.setState({ + availablePrinters: [twoPrinters[0]!], + settings: { ...useLabelStore.getState().settings, printerId: undefined }, + }); + render(); + + await waitFor(() => { + expect(fetchPrinterSettings).toHaveBeenCalledWith("usb:1"); + }); + }); + + it("does not persist on Auto-select with multiple printers", async () => { + useLabelStore.setState({ + availablePrinters: twoPrinters, + settings: { ...useLabelStore.getState().settings, printerId: undefined }, + }); + render(); + screen.getByText("Settings").click(); + + const tapeSelect = screen.getByDisplayValue("12"); + await userEvent.selectOptions(tapeSelect, "19"); + + expect(savePrinterSettings).not.toHaveBeenCalled(); + // Ambiguous which printer the server picks, so we don't read either. + expect(fetchPrinterSettings).not.toHaveBeenCalled(); + }); +}); + describe("SettingsBar power toggle visibility", () => { it("shows the power toggle when a real USB printer is selected", async () => { useLabelStore.setState({ diff --git a/client/src/components/SettingsBar.tsx b/client/src/components/SettingsBar.tsx index fb88a46..d5b786d 100644 --- a/client/src/components/SettingsBar.tsx +++ b/client/src/components/SettingsBar.tsx @@ -1,4 +1,5 @@ import { useLabelStore } from "../state/useLabelStore"; +import { usePrinterSettings } from "../state/usePrinterSettings"; import { TAPE_SIZES, LABEL_COLORS } from "../lib/constants"; import { fetchPrinters } from "../lib/api"; import type { TapeSize, Alignment, LabelColor } from "../types/label"; @@ -12,6 +13,12 @@ export function SettingsBar() { const setAvailablePrinters = useLabelStore((s) => s.setAvailablePrinters); const [isRefreshing, setIsRefreshing] = useState(false); + // Per-printer settings persistence: applies saved tape/color for the + // selected printer, and `persist` saves user changes to those. `loading` + // is true until those settings resolve — we disable the persisted controls + // until then so a late-arriving fetch can't clobber an edit. + const { persist, loading: settingsLoading } = usePrinterSettings(); + const handleRefreshPrinters = async () => { setIsRefreshing(true); try { @@ -24,13 +31,10 @@ export function SettingsBar() { } }; - // Only show printer selector if multiple printers are detected - // TODO: Future improvements for multi-printer UI: - // - Show printer status indicators (online/offline) - // - Display tape type/color/width for each printer - // - Allow users to set friendly aliases for printers - // - Remember last selected printer per user - // - Support printer-specific preset configurations + // Only show printer selector if multiple printers are detected. + // Tape size + colors now persist per printer; the rest of + // the multi-printer UI cluster (status indicators, capability-aware tape + // display, aliases, presets) is tracked in #38. const showPrinterSelector = availablePrinters.length > 1; // The selection shown in the dropdown. Fall back to the first printer @@ -84,13 +88,22 @@ export function SettingsBar() { {!selectedIsVirtual && } + {settingsLoading && ( + + Loading printer settings… + + )} + - update({ foregroundColor: e.target.value as LabelColor }) - } + disabled={settingsLoading} + onChange={(e) => { + const foregroundColor = e.target.value as LabelColor; + update({ foregroundColor }); + persist({ foregroundColor }); + }} > {LABEL_COLORS.map((c) => (