diff --git a/CHANGELOG.md b/CHANGELOG.md index e79402bb6..25a4b7a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [1.2.6b1] - Unreleased ### Fixed +- **The print queue couldn't be reordered on a phone, and the reorder controls were invisible in portrait (#2667, reporter @aporlebeke)** — On mobile there was no way to reorder the queue: in portrait the reorder controls simply weren't visible, and even in landscape (where the desktop drag handle appears) touch-dragging didn't move anything. **Root cause.** The drag grip and selection checkbox on every pending row are `hidden sm:flex`, so below the 640px breakpoint (phone portrait) they disappear entirely — there's no affordance to grab. Above it (landscape phone/tablet) the grip shows, but it carried `touch-action: manipulation` and the only drag sensor is dnd-kit's `PointerSensor` with an 8px activation distance, so on touch the browser claimed the vertical gesture as a scroll before the drag ever started. The whole reorder mechanism was effectively mouse-only. **Fix.** Pending rows now get tap-friendly **up/down arrow buttons** on mobile (the "arrow select" the reporter asked for), shown below `sm` where the drag handle is hidden. They move a row one step among its siblings — standalone items, whole batches, and items within a batch, in both the flat and per-printer layouts — and persist through the same `POST /queue/reorder` path as drag, so arrows and drag agree. Arrows appear only in the manual "position" sort (with shortest-job-first off), where a position actually has meaning, and are gated on the same `queue:reorder` permission; the up arrow on the first row and the down arrow on the last are shown disabled. Separately, the desktop drag handle's `touch-action` is now `none`, so mouse-style drag also works on touch (landscape phones, tablets). Reuses the existing `queue.moveUp` / `queue.moveDown` translations (already present in all locales). Covered by tests: the controls render for pending items, moving the first item down persists the swapped order, and the boundary arrows are disabled. - **3D Preview plate thumbnails were broken (401) in File Manager when login was enabled (#2661, reporter @fbordonaro)** — Opening a multi-plate 3MF via **File Manager → 3D Preview** showed broken-image icons for every plate thumbnail, and the network tab showed `GET /api/v1/library/files//plate-thumbnail/` returning **401 "Valid camera stream token required."** The Slice dialog displayed the same file's thumbnails correctly, which is what made it look inconsistent. **Root cause.** The plate-thumbnail endpoints (both archive and library) are gated behind a **camera stream token** passed as a `?token=` query param, because an `` tag can't send an `Authorization: Bearer` header. Every place that renders these thumbnails is supposed to append the token via the `withStreamToken()` helper — `PlatePickerModal` (the Slice dialog's multi-plate picker) and the Print modal's `PlateSelector` both do — but the **3D Preview dialog** (`ModelViewerModal`) rendered the raw `thumbnail_url` with no token, so with auth enabled the browser fetched without one and got a 401. **Fix.** `ModelViewerModal` now wraps the plate thumbnail `src` in `withStreamToken()`, matching the two existing call sites. The token is already synced app-wide (the same global the working pickers read), and `withStreamToken()` is a no-op when auth is off, so nothing changes for non-auth setups. Covered by a component test asserting the plate thumbnail `` carries the `?token=` query param. - **Force color match dispatched a print onto the wrong PLA variant — Matte jobs went to Basic and Silk printers alike, and the wrong AMS slot on a printer holding two same-colour variants (#2650, reporter @MartinNYHC)** — With **Force color match** on, a job sliced for **White PLA Matte** was dispatched to every printer that had *any* white PLA loaded — the ones holding White PLA **Basic** and White PLA **Silk+** included — so a matte model came out glossy on the wrong machine. **Root cause.** Bambu's MQTT status reports every PLA sub-variant as `tray_type == "PLA"`; the Basic/Matte/Silk distinction is carried only in `tray_info_idx` (`GFA00` = Basic, `GFA01` = Matte, `GFA06` = Silk, …), which the 3MF's `slice_info.config` also records per filament. Three places dropped it: the Virtual-Printer queue built each force override as `{slot_id, type, color, force_color_match}` without the parsed `tray_info_idx`; the scheduler's eligibility check (`_get_missing_force_color_slots`) compared loaded trays on `(type, colour)` only — so `(PLA, #FFFFFF)` matched Basic, Matte and Silk indiscriminately and all three printers looked eligible; and the AMS slot mapper cleared `tray_info_idx` when applying the override, so even on the correct printer it could pick a different-variant tray of the same colour. **Fix.** The force override now carries the 3MF's `tray_info_idx`; a slot counts as satisfied only when a loaded tray matches type **and** colour **and** the variant (identical `tray_info_idx`, *or* either side lacks one); and the slot mapper now keeps the variant for force-colour overrides so it pins the matching tray. A blank idx on either side (custom/third-party spools report none, and older 3MFs carry none) falls back to the historical type+colour behaviour, so those setups are unaffected, and a manual filament *swap* (a preference override) still clears the idx so it matches the swapped-in spool rather than the old one. A job sliced for GFA01 now goes only to a printer with GFA01 loaded, and lands on that printer's GFA01 tray. The printer-card queue-compatibility hint (which printers show a pending job as runnable) now applies the same variant rule. Covered by scheduler tests (Matte requirement rejects Basic/Silk, accepts Matte, blank loaded idx falls back, requirement without an idx unchanged; the mapper pins the GFA01 tray over a same-colour GFA00 on both the 3MF and no-3MF paths; a preference swap still matches by colour), a Virtual-Printer test asserting the override carries `tray_info_idx`, and frontend tests for the variant-aware queue hint (rejects other variants, accepts the match, blank-idx and no-variant-data fall back). diff --git a/frontend/src/__tests__/pages/QueuePage.test.tsx b/frontend/src/__tests__/pages/QueuePage.test.tsx index f7b744a86..b86d523ac 100644 --- a/frontend/src/__tests__/pages/QueuePage.test.tsx +++ b/frontend/src/__tests__/pages/QueuePage.test.tsx @@ -534,4 +534,65 @@ describe('QueuePage', () => { expect(attempts).toBe(2); }); }); + + // #2667: mobile can't drag-reorder the queue, so pending rows get up/down + // arrows. They persist via the same POST /queue/reorder as drag. The + // buttons live in the DOM at every width (Tailwind `sm:hidden` is CSS-only), + // so they're clickable in jsdom. + describe('mobile reorder arrows (#2667)', () => { + const threePending = [1, 2, 3].map((n) => ({ + ...mockQueueItems[0], + id: n, + archive_id: n, + position: n, + status: 'pending', + archive_name: `Pending ${n}`, + })); + + it('renders Move Up / Move Down controls for pending items', async () => { + server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending))); + render(); + + await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument()); + + // One pair per pending row. + expect(screen.getAllByTitle('Move Up')).toHaveLength(3); + expect(screen.getAllByTitle('Move Down')).toHaveLength(3); + }); + + it('moving the first item down persists the swapped order', async () => { + let reorderBody: { items: { id: number; position: number }[] } | null = null; + server.use( + http.get('/api/v1/queue/', () => HttpResponse.json(threePending)), + http.post('/api/v1/queue/reorder', async ({ request }) => { + reorderBody = (await request.json()) as typeof reorderBody; + return HttpResponse.json({ message: 'ok' }); + }), + ); + render(); + + await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument()); + + // First row's "Move Down": item 1 drops below item 2 → [2, 1, 3]. + await userEvent.click(screen.getAllByTitle('Move Down')[0]); + + await waitFor(() => expect(reorderBody).not.toBeNull()); + expect(reorderBody!.items).toEqual([ + { id: 2, position: 1 }, + { id: 1, position: 2 }, + { id: 3, position: 3 }, + ]); + }); + + it('disables Move Up on the first row and Move Down on the last', async () => { + server.use(http.get('/api/v1/queue/', () => HttpResponse.json(threePending))); + render(); + + await waitFor(() => expect(screen.getByText('Pending 1')).toBeInTheDocument()); + + // Rows render top-to-bottom in position order. + expect(screen.getAllByTitle('Move Up')[0]).toBeDisabled(); + expect(screen.getAllByTitle('Move Down')[2]).toBeDisabled(); + }); + }); }); diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 65df68c12..95b9b3129 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -50,6 +50,7 @@ import { Weight, ChevronDown, ChevronRight, + ChevronUp, List, GanttChart, Code, @@ -347,6 +348,8 @@ function SortableQueueItem({ onStop, onRequeue, onStart, + onMoveUp, + onMoveDown, timeFormat = 'system', isSelected = false, onToggleSelect, @@ -363,6 +366,11 @@ function SortableQueueItem({ onStop: () => void; onRequeue: () => void; onStart: () => void; + // Mobile tap-to-reorder (#2667). Undefined = at a list boundary (button + // shown disabled) or reordering isn't available; the desktop drag handle + // is unaffected. Move one step among siblings, then persist via reorder. + onMoveUp?: () => void; + onMoveDown?: () => void; timeFormat?: TimeFormat; isSelected?: boolean; onToggleSelect?: () => void; @@ -451,6 +459,37 @@ function SortableQueueItem({ )}
+ {/* Mobile reorder arrows (#2667). The desktop drag handle is hidden on + phones and touch-drag is unreliable there, so pending rows get + tap-to-move up/down controls instead. Shown only below `sm`. */} + {isPending && (onMoveUp || onMoveDown) && ( +
e.stopPropagation()} + > + + +
+ )} + {/* Mobile selection indicator — left accent bar only, no tick */} {/* Selection checkbox for pending items - hidden on mobile, tap card instead */} @@ -475,7 +514,7 @@ function SortableQueueItem({
@@ -806,6 +845,13 @@ interface QueueRowRenderProps { canModify: (resource: any, action: any, createdById?: number | null) => boolean; t: (key: string, options?: Record) => string; aggregateForRows: (rows: QueueRow[]) => { count: number; time: number; weight: number }; + // Mobile tap-to-reorder (#2667). onMoveUp/onMoveDown move this whole row + // (single item or batch) one step among its siblings; onMoveBlock is the + // low-level primitive SortableBatchRow uses to move a child within the + // batch. All undefined when reordering isn't available (non-manual sort). + onMoveUp?: () => void; + onMoveDown?: () => void; + onMoveBlock?: (movingIds: number[], anchorId: number, placeAfter: boolean) => void; } /** Renders either a single item or a collapsible batch group containing N @@ -823,6 +869,8 @@ function QueueRowRender(props: QueueRowRenderProps) { hasPermission, canModify, t, + onMoveUp, + onMoveDown, } = props; if (row.kind === 'item') { @@ -835,6 +883,8 @@ function QueueRowRender(props: QueueRowRenderProps) { onStop={() => {}} onRequeue={() => {}} onStart={() => startMutation.mutate({ id: row.item.id })} + onMoveUp={onMoveUp} + onMoveDown={onMoveDown} timeFormat={timeFormat} isSelected={selectedItems.includes(row.item.id)} onToggleSelect={() => handleToggleSelect(row.item.id)} @@ -866,6 +916,9 @@ function SortableBatchRow({ canModify, t, aggregateForRows, + onMoveUp, + onMoveDown, + onMoveBlock, }: QueueRowRenderProps) { // Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch'; // narrow up-front so the hook below can reference batchId unconditionally. @@ -908,6 +961,32 @@ function SortableBatchRow({ > {/* Parent header */}
+ {/* Mobile reorder arrows for the whole group (#2667), mirroring the + desktop drag handle which is hidden on phones. */} + {canReorder && (onMoveUp || onMoveDown) && ( +
+ + +
+ )}