fix(virtual-printer): wrap the card header instead of overflowing it (#2808)

Every item in the collapsed header was flex-shrink-0, so the row was as
    wide as its contents and the Card doesn't clip -- the remote-interface IP
    and the enable toggle painted outside the card border. The name's
    `truncate` couldn't save it: a flex item defaults to min-width:auto, so
    it never shrank below its text (`flex-shrink-0 truncate` on the target
    name was self-cancelling for the same reason).

    Move the metadata into a flex-1 min-w-0 flex-wrap group so it wraps to a
    second line, and keep the chevron, dot and toggle outside it. Wrapping
    rather than truncating: the bind and remote-interface addresses are what
    the page exists to show.

    Needs three things at once, hence the report -- both IPs set (Bambuddy
    and printer on different subnets), a target named "Printer at <ip>" from
    discovery, and the 3-column card grid.

    overflow-hidden is scoped to this card, not added to Card: half the cards
    in the app render menus that deliberately paint outside their bounds.
This commit is contained in:
maziggy
2026-08-15 14:53:30 +02:00
4 changed files with 135 additions and 20 deletions
@@ -486,3 +486,90 @@ describe('VirtualPrinterCard - access code inherits from target', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
});
// The collapsed header used to be a single flex row of flex-shrink-0 items,
// so it was as wide as its contents and spilled past the card's border --
// the reporter of #2808 saw an IP address and the enable toggle floating on
// the page background. jsdom does no layout, so these pin the CSS contract
// that makes wrapping possible rather than the pixels: a flexible, wrappable
// metadata group, and no item that both refuses to shrink and claims it will
// truncate.
describe('VirtualPrinterCard - header does not overflow the card', () => {
const printers = [
{
id: 9,
// The name a printer adopted without one gets (discovery.py) -- 25
// unbreakable characters, and half of why this overflowed.
name: 'Printer at 192.168.30.210',
ip_address: '192.168.30.210',
access_code: 'TGTCODE1',
serial_number: '01P00A391800001',
model: 'H2C',
is_active: true,
},
];
const twoVlanPrinter = () =>
createMockPrinter({
name: 'H2C',
mode: 'proxy',
model_name: 'H2C',
target_printer_id: 9,
// Both are populated only when Bambuddy and the printer sit on
// different subnets -- the reporter's case.
bind_ip: '192.168.20.175',
remote_interface_ip: '192.168.30.210',
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
vi.mocked(api.getPrinters).mockResolvedValue(printers as unknown as Awaited<ReturnType<typeof api.getPrinters>>);
});
it('keeps both IPs visible instead of truncating them away', async () => {
render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
await waitFor(() => {
expect(screen.getByText('192.168.20.175')).toBeInTheDocument();
});
// An operator opens this page to check exactly these two values, so the
// fix has to wrap them, not hide them.
expect(screen.getByText('192.168.30.210')).toBeInTheDocument();
});
it('puts the metadata in a shrinkable, wrapping group', async () => {
render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
const bindIp = await screen.findByText('192.168.20.175');
const group = bindIp.parentElement as HTMLElement;
expect(group.className).toContain('flex-wrap');
// Without min-w-0 a flex item cannot shrink below its content, which is
// what defeated the name's `truncate` before.
expect(group.className).toContain('min-w-0');
expect(group.className).toContain('flex-1');
});
it('has no header item that is both unshrinkable and truncating', async () => {
render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
const bindIp = await screen.findByText('192.168.20.175');
const group = bindIp.parentElement as HTMLElement;
const selfCancelling = Array.from(group.children).filter(
(el) => el.className.includes('flex-shrink-0') && el.className.includes('truncate')
);
expect(selfCancelling).toHaveLength(0);
});
it('clips at the card border as a backstop', async () => {
const { container } = render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
await screen.findByText('192.168.20.175');
// Scoped to this card on purpose: cards elsewhere render menus that paint
// outside their own bounds, so this must not migrate into `Card`.
const card = container.querySelector('.rounded-xl') as HTMLElement;
expect(card.className).toContain('overflow-hidden');
});
});
+46 -18
View File
@@ -255,7 +255,14 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
return (
<>
<Card>
{/*
Clip at the border as a backstop, so a value longer than anything
anticipated above lands inside the card instead of on the page
background (#2808). Scoped here rather than added to `Card` itself:
half the cards in the app render dropdowns and menus that deliberately
paint outside their bounds, and this one has none.
*/}
<Card className="overflow-hidden">
{/* Collapsed header - always visible, clickable to expand */}
<div
className="px-4 py-3 flex items-center gap-3 cursor-pointer select-none"
@@ -268,23 +275,44 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
}
</button>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
<span className="text-white font-medium truncate">{printer.name}</span>
<span className="text-xs text-bambu-gray flex-shrink-0">{modeLabel}</span>
{printer.model_name && (
<span className="text-xs text-bambu-gray flex-shrink-0">{printer.model_name}</span>
)}
{targetPrinterName && (
<span className="text-xs text-bambu-gray flex-shrink-0 truncate">
{localMode === 'proxy' && <ArrowRightLeft className="w-3 h-3 inline mr-1" />}
{targetPrinterName}
</span>
)}
{localBindIp && (
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localBindIp}</span>
)}
{localRemoteInterfaceIp && (
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
)}
{/*
Metadata wraps rather than overflowing (#2808). Every item in this
row used to be flex-shrink-0, so nothing could give and the row was
as wide as its contents -- which the Card doesn't clip, so the last
IP and the toggle were painted outside the card's border. The name's
`truncate` didn't save it either: a flex item defaults to
min-width:auto, so it couldn't shrink below its text and the
ellipsis never engaged (`flex-shrink-0 truncate` on the target name
was self-cancelling for the same reason).
It needs three things at once to overflow, which is why it took a
two-VLAN setup to surface: bind_ip and remote_interface_ip are both
set only when Bambuddy and the printer are on different subnets, a
printer adopted without a name is called "Printer at <ip>" (25
characters of unbreakable text), and the cards sit in a 3-column
grid. Wrapping keeps every value readable -- these are the addresses
an operator came to this page to check, so truncating them away
would trade one bug for a quieter one.
*/}
<div className="flex-1 min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-white font-medium truncate max-w-full">{printer.name}</span>
<span className="text-xs text-bambu-gray flex-shrink-0">{modeLabel}</span>
{printer.model_name && (
<span className="text-xs text-bambu-gray flex-shrink-0">{printer.model_name}</span>
)}
{targetPrinterName && (
<span className="text-xs text-bambu-gray truncate max-w-full">
{localMode === 'proxy' && <ArrowRightLeft className="w-3 h-3 inline mr-1" />}
{targetPrinterName}
</span>
)}
{localBindIp && (
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localBindIp}</span>
)}
{localRemoteInterfaceIp && (
<span className="text-[10px] text-bambu-gray flex-shrink-0 font-mono">{localRemoteInterfaceIp}</span>
)}
</div>
<div className="ml-auto flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={handleToggleEnabled}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-Bmu-wyBZ.js"></script>
<script type="module" crossorigin src="/assets/index-DhpDphGT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkuH4t27.css">
</head>
<body>