fix(queue): mobile tap-to-reorder with up/down arrows (#2667)

The print queue couldn't be reordered on a phone. In portrait the drag
grip and selection checkbox are hidden below the sm breakpoint, so there
was no reorder affordance at all; in landscape the grip shows but it
carried touch-action: manipulation while the only dnd-kit sensor is a
PointerSensor with an 8px distance, so touch gestures scrolled instead of
starting a drag. Reordering was effectively mouse-only.

Add tap-friendly up/down arrow buttons to pending rows on mobile (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. Arrows appear only in the manual
"position" sort (shortest-job-first off) where position has meaning, are
gated on queue:reorder, and the first row's up / last row's down render
disabled. Also switch the desktop drag handle's touch-action to none so
mouse-style drag works on touch (landscape phones, tablets). Reuses the
existing queue.moveUp / queue.moveDown translations.
This commit is contained in:
maziggy
2026-07-27 09:02:36 +02:00
parent 6530a6af06
commit db6d306b35
7 changed files with 234 additions and 17 deletions
+1
View File
@@ -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/<id>/plate-thumbnail/<plate>` 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 `<img>` 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 `<img>` 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).
@@ -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(<QueuePage />);
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(<QueuePage />);
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(<QueuePage />);
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();
});
});
});
+160 -5
View File
@@ -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({
)}
<div className="flex items-start sm:items-center gap-2 sm:gap-4 p-3 sm:p-4">
{/* 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) && (
<div
className="flex sm:hidden flex-col shrink-0 -my-1"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onMoveUp}
disabled={!onMoveUp}
title={t('queue.moveUp')}
aria-label={t('queue.moveUp')}
className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
>
<ChevronUp className="w-5 h-5" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={!onMoveDown}
title={t('queue.moveDown')}
aria-label={t('queue.moveDown')}
className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
>
<ChevronDown className="w-5 h-5" />
</button>
</div>
)}
{/* 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({
<div
{...attributes}
{...listeners}
className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</div>
@@ -806,6 +845,13 @@ interface QueueRowRenderProps {
canModify: (resource: any, action: any, createdById?: number | null) => boolean;
t: (key: string, options?: Record<string, unknown>) => 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 */}
<div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
{/* Mobile reorder arrows for the whole group (#2667), mirroring the
desktop drag handle which is hidden on phones. */}
{canReorder && (onMoveUp || onMoveDown) && (
<div className="flex sm:hidden flex-col shrink-0 -my-1">
<button
type="button"
onClick={onMoveUp}
disabled={!onMoveUp}
title={t('queue.moveUp')}
aria-label={t('queue.moveUp')}
className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
>
<ChevronUp className="w-5 h-5" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={!onMoveDown}
title={t('queue.moveDown')}
aria-label={t('queue.moveDown')}
className="flex items-center justify-center w-8 h-7 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark disabled:opacity-25 disabled:hover:bg-transparent transition-colors"
>
<ChevronDown className="w-5 h-5" />
</button>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -932,7 +1011,7 @@ function SortableBatchRow({
<div
{...attributes}
{...listeners}
className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-manipulation shrink-0"
className="hidden sm:flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors touch-none shrink-0"
title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
@@ -1002,7 +1081,7 @@ function SortableBatchRow({
{/* Children (only when expanded) */}
{!collapsed && (
<div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
{batchRow.items.map((child) => (
{batchRow.items.map((child, ci) => (
<SortableQueueItem
key={child.id}
item={child}
@@ -1012,6 +1091,16 @@ function SortableBatchRow({
onStop={() => {}}
onRequeue={() => {}}
onStart={() => startMutation.mutate({ id: child.id })}
onMoveUp={
onMoveBlock && ci > 0
? () => onMoveBlock([child.id], batchRow.items[ci - 1].id, false)
: undefined
}
onMoveDown={
onMoveBlock && ci < batchRow.items.length - 1
? () => onMoveBlock([child.id], batchRow.items[ci + 1].id, true)
: undefined
}
timeFormat={timeFormat}
isSelected={selectedItems.includes(child.id)}
onToggleSelect={() => handleToggleSelect(child.id)}
@@ -1797,6 +1886,68 @@ export function QueuePage() {
return rows;
}, [pendingItems, t]);
// Mobile tap-to-reorder (#2667). The desktop drag handle is hidden on
// phones and touch-drag is unreliable, so pending rows get up/down arrows.
// Reordering only has a defined meaning in the manual "position" sort with
// SJF off — any other sort re-orders the list itself, so we don't offer it.
const canReorderManually =
hasPermission('queue:reorder') &&
pendingSortBy === 'position' &&
!settings?.queue_shortest_first;
const rowItemIds = (row: QueueRow): number[] =>
row.kind === 'item' ? [row.item.id] : row.items.map((i) => i.id);
// Move a block of items to sit immediately before (or after) an anchor item
// in the global pending order, then persist — the same remove-and-reinsert
// shape as handleDragEnd, so arrows and drag agree. Anchoring to a real item
// id keeps it correct in the printer-grouped layout, where a bucket's rows
// aren't contiguous in the global order.
const moveBlockRelativeTo = (
movingIds: number[],
anchorId: number,
placeAfter: boolean,
) => {
const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
let insertAt = remaining.findIndex((i) => i.id === anchorId);
if (insertAt === -1) return;
if (placeAfter) insertAt += 1;
const movingItems = movingIds
.map((id) => pendingItems.find((i) => i.id === id))
.filter((x): x is PrintQueueItem => !!x);
const reordered = [
...remaining.slice(0, insertAt),
...movingItems,
...remaining.slice(insertAt),
];
reorderMutation.mutate(
reordered.map((item, index) => ({ id: item.id, position: index + 1 })),
);
};
// Build up/down thunks for the row at `idx` within its displayed sibling
// list (the flat list, or a single printer bucket). Undefined at a boundary
// (button rendered disabled) or when manual reorder isn't available.
const rowMovers = (
rows: QueueRow[],
idx: number,
): { onMoveUp?: () => void; onMoveDown?: () => void } => {
if (!canReorderManually) return {};
const moving = rowItemIds(rows[idx]);
const onMoveUp =
idx > 0
? () => moveBlockRelativeTo(moving, rowItemIds(rows[idx - 1])[0], false)
: undefined;
const onMoveDown =
idx < rows.length - 1
? () => {
const next = rowItemIds(rows[idx + 1]);
moveBlockRelativeTo(moving, next[next.length - 1], true);
}
: undefined;
return { onMoveUp, onMoveDown };
};
// SortableContext ID list.
// - Standalone pending items: their numeric id.
// - Batch parents: the synthetic `batch-<id>` string, always present so the
@@ -2335,7 +2486,7 @@ export function QueuePage() {
>
{activeLayout === 'position' ? (
<div className="space-y-2 sm:space-y-3">
{groupedRows.map((row) => (
{groupedRows.map((row, idx) => (
<QueueRowRender
key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
row={row}
@@ -2352,6 +2503,8 @@ export function QueuePage() {
canModify={canModify}
t={t}
aggregateForRows={aggregateForRows}
{...rowMovers(groupedRows, idx)}
onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
/>
))}
</div>
@@ -2371,7 +2524,7 @@ export function QueuePage() {
</span>
</div>
<div className="bg-bambu-dark/40 border border-t-0 border-bambu-dark-tertiary rounded-b-lg p-2 space-y-2">
{bucket.rows.map((row) => (
{bucket.rows.map((row, idx) => (
<QueueRowRender
key={row.kind === 'item' ? `item-${row.item.id}` : `batch-${row.batchId}`}
row={row}
@@ -2388,6 +2541,8 @@ export function QueuePage() {
canModify={canModify}
t={t}
aggregateForRows={aggregateForRows}
{...rowMovers(bucket.rows, idx)}
onMoveBlock={canReorderManually ? moveBlockRelativeTo : undefined}
/>
))}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DYQO8a-5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-kl51qImb.css">
<script type="module" crossorigin src="/assets/index-CZQ6os90.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-whrCxRGI.css">
</head>
<body>
<div id="root"></div>