feat(queue): drag-reorder grouped queue items; collapsed batches no longer block

Batches were stuck where they were created. The parent row had no drag
      handle and wasn't registered with dnd-kit's SortableContext, so the
      only way to move a grouped item was to ungroup, drag, and re-group.
      Collapsed batches also acted as unmovable obstacles for adjacent items.

      The batch parent now registers under a synthetic "batch-<id>" string
      id and carries a GripVertical handle in the header (gated by
      queue:reorder). handleDragEnd resolves both endpoints: dragging a
      batch moves all its children as one block, dropping onto a batch
      anchors at the batch's first child so the group lands immediately
      before it. Direction-aware insert uses the first moving id's index
      instead of the previously single dragged id, so multi-row drags and
      batch drags share the same insert math. Within-batch child reorder
      is unchanged. DragOverlay gained a batch ghost showing
      "<name> (<N> copies)".
This commit is contained in:
maziggy
2026-06-28 12:41:10 +02:00
15 changed files with 199 additions and 58 deletions
+2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -164,6 +164,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
- **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
- Dual external spool support for H2D (Ext-L / Ext-R)
- HMS error monitoring with history and clear errors
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
+1
View File
@@ -194,6 +194,7 @@ const FR_COGNATES = [
'{{count}} filament', '{{count}} filaments', '{{count}} permissions',
'{{count}} downloads', '{{count}} item', '{{count}} selected',
'({{count}} item)', 'Provisioning...', 'Pressure Advance',
'{{name}} ({{count}} copies)', // FR plural of "copie" is also "copies"
'Box label (62 × 29 mm)',
'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Gruppierung aufheben',
ungroupConfirmTitle: 'Stapel auflösen?',
ungroupConfirmMessage: 'Die Einträge bleiben in der Warteschlange, sind aber nicht mehr gruppiert.',
dragGroup: 'Gruppe ziehen',
},
tabs: {
queue: 'Warteschlange',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} Einträge',
batch: '{{name}} ({{count}} Kopie)',
batch_plural: '{{name}} ({{count}} Kopien)',
},
// Sections
sections: {
+3
View File
@@ -1067,6 +1067,7 @@ export default {
ungroup: 'Ungroup',
ungroupConfirmTitle: 'Ungroup batch?',
ungroupConfirmMessage: 'The items will stay in the queue but no longer be grouped together.',
dragGroup: 'Drag group',
},
// Tabs
tabs: {
@@ -1090,6 +1091,8 @@ export default {
// Drag ghost label when multi-dragging
dragGhost: {
multiCount: '{{count}} items',
batch: '{{name}} ({{count}} copy)',
batch_plural: '{{name}} ({{count}} copies)',
},
// Sections
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Desagrupar',
ungroupConfirmTitle: '¿Desagrupar lote?',
ungroupConfirmMessage: 'Los elementos permanecerán en la cola pero ya no estarán agrupados.',
dragGroup: 'Arrastrar grupo',
},
tabs: {
queue: 'Cola',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} elementos',
batch: '{{name}} ({{count}} copia)',
batch_plural: '{{name}} ({{count}} copias)',
},
// Sections
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Dégrouper',
ungroupConfirmTitle: 'Dégrouper le lot ?',
ungroupConfirmMessage: 'Les éléments resteront dans la file mais ne seront plus groupés.',
dragGroup: 'Faire glisser le groupe',
},
tabs: {
queue: 'File',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} éléments',
batch: '{{name}} ({{count}} copie)',
batch_plural: '{{name}} ({{count}} copies)',
},
// Sections
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Separa',
ungroupConfirmTitle: 'Separare il lotto?',
ungroupConfirmMessage: 'Gli elementi resteranno in coda ma non saranno più raggruppati.',
dragGroup: 'Trascina gruppo',
},
tabs: {
queue: 'Coda',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} elementi',
batch: '{{name}} ({{count}} copia)',
batch_plural: '{{name}} ({{count}} copie)',
},
// Sections
sections: {
+3
View File
@@ -1062,6 +1062,7 @@ export default {
ungroup: 'グループ解除',
ungroupConfirmTitle: 'バッチのグループを解除しますか?',
ungroupConfirmMessage: 'アイテムはキューに残りますが、グループ化されなくなります。',
dragGroup: 'グループをドラッグ',
},
tabs: {
queue: 'キュー',
@@ -1079,6 +1080,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}}件',
batch: '{{name}}({{count}}部)',
batch_plural: '{{name}}({{count}}部)',
},
// Sections
sections: {
+3
View File
@@ -1007,6 +1007,7 @@ export default {
ungroup: '그룹 해제',
ungroupConfirmTitle: '배치 그룹을 해제하시겠습니까?',
ungroupConfirmMessage: '항목은 큐에 남아 있지만 더 이상 함께 그룹화되지 않습니다.',
dragGroup: '그룹 드래그',
},
tabs: {
queue: '큐',
@@ -1024,6 +1025,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}}개 항목',
batch: '{{name}} ({{count}}부)',
batch_plural: '{{name}} ({{count}}부)',
},
sections: {
currentlyPrinting: '현재 인쇄 중',
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Desagrupar',
ungroupConfirmTitle: 'Desagrupar lote?',
ungroupConfirmMessage: 'Os itens permanecerão na fila mas não estarão mais agrupados.',
dragGroup: 'Arrastar grupo',
},
tabs: {
queue: 'Fila',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} itens',
batch: '{{name}} ({{count}} cópia)',
batch_plural: '{{name}} ({{count}} cópias)',
},
// Sections
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: 'Gruptan çıkar',
ungroupConfirmTitle: 'Yığını gruptan çıkar?',
ungroupConfirmMessage: 'Öğeler kuyrukta kalacak ancak artık birlikte gruplanmayacak.',
dragGroup: 'Grubu sürükle',
},
tabs: {
queue: 'Kuyruk',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} öğe',
batch: '{{name}} ({{count}} kopya)',
batch_plural: '{{name}} ({{count}} kopya)',
},
// Bölümler
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: '取消分组',
ungroupConfirmTitle: '取消批次分组?',
ungroupConfirmMessage: '项目将保留在队列中,但不再分组在一起。',
dragGroup: '拖动批次',
},
tabs: {
queue: '队列',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} 项',
batch: '{{name}}({{count}} 份)',
batch_plural: '{{name}}({{count}} 份)',
},
// Sections
sections: {
+3
View File
@@ -1063,6 +1063,7 @@ export default {
ungroup: '取消分組',
ungroupConfirmTitle: '取消批次分組?',
ungroupConfirmMessage: '項目將保留在佇列中,但不再分組在一起。',
dragGroup: '拖曳批次',
},
tabs: {
queue: '佇列',
@@ -1080,6 +1081,8 @@ export default {
},
dragGhost: {
multiCount: '{{count}} 項',
batch: '{{name}}({{count}} 份)',
batch_plural: '{{name}}({{count}} 份)',
},
// Sections
sections: {
+162 -58
View File
@@ -771,22 +771,20 @@ interface QueueRowRenderProps {
/** Renders either a single item or a collapsible batch group containing N
* sibling items. The batch parent shows aggregate stats; children render
* with the existing SortableQueueItem (only draggable inside the batch). */
function QueueRowRender({
row,
collapsed,
onToggleBatch,
onUngroup,
setEditItem,
setConfirmAction,
startMutation,
selectedItems,
handleToggleSelect,
timeFormat,
hasPermission,
canModify,
t,
aggregateForRows,
}: QueueRowRenderProps) {
function QueueRowRender(props: QueueRowRenderProps) {
const {
row,
setEditItem,
setConfirmAction,
startMutation,
selectedItems,
handleToggleSelect,
timeFormat,
hasPermission,
canModify,
t,
} = props;
if (row.kind === 'item') {
return (
<SortableQueueItem
@@ -807,22 +805,67 @@ function QueueRowRender({
);
}
// Batch group
const agg = aggregateForRows([row]);
const allChildIds = row.items.map((i) => i.id);
return <SortableBatchRow {...props} />;
}
/** Batch parent header registered with dnd-kit so the whole group can be
* reordered as one unit. Drag handle lives in the header itself; children
* remain individually draggable while expanded for within-batch reorder. */
function SortableBatchRow({
row,
collapsed,
onToggleBatch,
onUngroup,
setEditItem,
setConfirmAction,
startMutation,
selectedItems,
handleToggleSelect,
timeFormat,
hasPermission,
canModify,
t,
aggregateForRows,
}: QueueRowRenderProps) {
// Dispatcher (QueueRowRender) only mounts this with row.kind === 'batch';
// narrow up-front so the hook below can reference batchId unconditionally.
const batchRow = row as Extract<QueueRow, { kind: 'batch' }>;
const canReorder = hasPermission('queue:reorder');
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: `batch-${batchRow.batchId}`, disabled: !canReorder });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const agg = aggregateForRows([batchRow]);
const allChildIds = batchRow.items.map((i) => i.id);
const allSelected = allChildIds.length > 0 && allChildIds.every((id) => selectedItems.includes(id));
// Status rollup: worst-of-children (failed > printing > pending).
const childStatuses = new Set(row.items.map((i) => i.status));
const childStatuses = new Set(batchRow.items.map((i) => i.status));
// We never put non-pending into a batch grouping but render defensively.
const rollupStatus: PrintQueueItem['status'] = childStatuses.has('failed')
? 'failed'
: childStatuses.has('printing')
? 'printing'
: 'pending';
const pendingChildren = row.items.filter((i) => i.status === 'pending').length;
const pendingChildren = batchRow.items.filter((i) => i.status === 'pending').length;
return (
<div className="bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden">
<div
ref={setNodeRef}
style={style}
className={`bg-bambu-dark-secondary rounded-xl border border-l-[3px] border-l-cyan-400 border-bambu-dark-tertiary overflow-hidden ${
isDragging ? 'opacity-50 scale-[1.01] shadow-xl z-50' : ''
}`}
>
{/* Parent header */}
<div className="flex items-center gap-2 sm:gap-3 p-3 sm:p-4">
<button
@@ -845,6 +888,16 @@ function QueueRowRender({
>
{allSelected && <Check className="w-4 h-4" />}
</button>
{canReorder && (
<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"
title={t('queue.batch.dragGroup', { defaultValue: 'Drag group' })}
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</div>
)}
<button
onClick={onToggleBatch}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors shrink-0"
@@ -865,7 +918,7 @@ function QueueRowRender({
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="text-sm sm:text-base text-white font-medium truncate">{row.batchName}</p>
<p className="text-sm sm:text-base text-white font-medium truncate">{batchRow.batchName}</p>
<span className="flex-shrink-0 px-1.5 py-0.5 text-[10px] sm:text-xs bg-cyan-500/15 text-cyan-300 rounded border border-cyan-500/30">
{t('queue.batch.label', { count: agg.count })}
</span>
@@ -909,7 +962,7 @@ function QueueRowRender({
{/* Children (only when expanded) */}
{!collapsed && (
<div className="border-t border-bambu-dark-tertiary bg-black/20 p-2 sm:p-3 space-y-2">
{row.items.map((child) => (
{batchRow.items.map((child) => (
<SortableQueueItem
key={child.id}
item={child}
@@ -1196,8 +1249,9 @@ export function QueuePage() {
return {};
}
});
// Multi-drag bookkeeping for DragOverlay.
const [activeDragId, setActiveDragId] = useState<number | null>(null);
// Multi-drag bookkeeping for DragOverlay. Numeric for single items, string
// `batch-<id>` when a whole group is being dragged.
const [activeDragId, setActiveDragId] = useState<number | string | null>(null);
// "Group as batch" modal.
const [groupBatchModal, setGroupBatchModal] = useState(false);
// Ungroup confirm.
@@ -1582,7 +1636,8 @@ export function QueuePage() {
}, [pendingItems]);
const handleDragStart = (event: DragStartEvent) => {
setActiveDragId(typeof event.active.id === 'number' ? event.active.id : null);
const id = event.active.id;
setActiveDragId(typeof id === 'number' || typeof id === 'string' ? id : null);
};
const handleDragEnd = (event: DragEndEvent) => {
@@ -1590,29 +1645,49 @@ export function QueuePage() {
setActiveDragId(null);
if (!over || active.id === over.id) return;
// Multi-drag: when the dragged row is part of a multi-selection, move
// all selected items as a contiguous block, preserving their original
// relative order. Otherwise, single-row drag (today's behavior).
const draggedId = active.id as number;
const movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
? selectedItems.slice().sort((a, b) => {
const ai = pendingItems.findIndex((i) => i.id === a);
const bi = pendingItems.findIndex((i) => i.id === b);
return ai - bi;
})
: [draggedId];
// Resolve dragged source → movingIds (preserving order from pendingItems).
// - `batch-<id>`: every child of that batch, in their current order
// - selected + dragged is one of them: contiguous multi-drag block
// - otherwise: single row
let movingIds: number[];
const activeId = active.id;
if (typeof activeId === 'string' && activeId.startsWith('batch-')) {
const batchId = Number(activeId.slice('batch-'.length));
movingIds = pendingItems.filter((i) => i.batch_id === batchId).map((i) => i.id);
} else {
const draggedId = activeId as number;
movingIds = selectedItems.includes(draggedId) && selectedItems.length > 1
? selectedItems.slice().sort((a, b) => {
const ai = pendingItems.findIndex((i) => i.id === a);
const bi = pendingItems.findIndex((i) => i.id === b);
return ai - bi;
})
: [draggedId];
}
if (movingIds.length === 0) return;
const overIndex = pendingItems.findIndex(i => i.id === over.id);
// Resolve drop target → index inside pendingItems. A `batch-<id>` drop
// target anchors at the batch's first child, so dropping above another
// batch lands the moving block immediately before it.
let overIndex: number;
const overId = over.id;
if (typeof overId === 'string' && overId.startsWith('batch-')) {
const overBatchId = Number(overId.slice('batch-'.length));
overIndex = pendingItems.findIndex((i) => i.batch_id === overBatchId);
} else {
overIndex = pendingItems.findIndex((i) => i.id === overId);
}
if (overIndex === -1) return;
// Remove the moving items, then re-insert at overIndex (adjusted).
const overAnchor = pendingItems[overIndex];
const remaining = pendingItems.filter((i) => !movingIds.includes(i.id));
let insertAt = remaining.findIndex((i) => i.id === over.id);
let insertAt = remaining.findIndex((i) => i.id === overAnchor.id);
if (insertAt === -1) insertAt = overIndex;
// If dragging downward across the drop target, insert AFTER it; upward
// = before. dnd-kit's `over` is the row under the pointer, not the gap.
const draggedOriginalIndex = pendingItems.findIndex((i) => i.id === draggedId);
if (draggedOriginalIndex < overIndex) insertAt += 1;
const firstMovingIndex = pendingItems.findIndex((i) => i.id === movingIds[0]);
if (firstMovingIndex < overIndex) insertAt += 1;
const reordered = [
...remaining.slice(0, insertAt),
...movingIds
@@ -1653,18 +1728,21 @@ export function QueuePage() {
return rows;
}, [pendingItems, t]);
// SortableContext ID list. Only include IDs whose DOM node actually
// renders — collapsed batch children are detached from the DOM, so
// registering them with dnd-kit confuses the collision resolver when
// dragging past a collapsed batch row. v1 batch parent isn't itself
// draggable (within-batch reorder only); collapsed batches just act as
// unmovable obstacles.
const sortableIds = useMemo<number[]>(() => {
const ids: number[] = [];
// SortableContext ID list.
// - Standalone pending items: their numeric id.
// - Batch parents: the synthetic `batch-<id>` string, always present so the
// group itself is draggable and acts as a drop target whether collapsed
// or expanded.
// - Expanded batch children: their numeric id, so within-batch reorder
// keeps working. Collapsed children are detached from the DOM and
// intentionally omitted to keep dnd-kit's collision resolver clean.
const sortableIds = useMemo<(number | string)[]>(() => {
const ids: (number | string)[] = [];
for (const row of groupedRows) {
if (row.kind === 'item') {
ids.push(row.item.id);
} else {
ids.push(`batch-${row.batchId}`);
const collapsed = batchCollapsed[row.batchId] ?? true;
if (!collapsed) {
for (const child of row.items) ids.push(child.id);
@@ -2166,14 +2244,40 @@ export function QueuePage() {
)}
</SortableContext>
<DragOverlay>
{activeDragId !== null && selectedItems.includes(activeDragId) && selectedItems.length > 1 ? (
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
<Package className="w-4 h-4 text-cyan-300" />
<span className="text-sm text-white font-medium">
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
</span>
</div>
) : null}
{(() => {
if (activeDragId === null) return null;
// Batch drag — show the group ghost with copy count.
if (typeof activeDragId === 'string' && activeDragId.startsWith('batch-')) {
const batchId = Number(activeDragId.slice('batch-'.length));
const siblings = pendingItems.filter((i) => i.batch_id === batchId);
if (siblings.length === 0) return null;
const name = siblings[0].batch_name || t('queue.batch.defaultName');
return (
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
<Package className="w-4 h-4 text-cyan-300" />
<span className="text-sm text-white font-medium">
{t('queue.dragGhost.batch', {
defaultValue: '{{name}} ({{count}} copies)',
name,
count: siblings.length,
})}
</span>
</div>
);
}
// Multi-row drag — show the N-item ghost.
if (typeof activeDragId === 'number' && selectedItems.includes(activeDragId) && selectedItems.length > 1) {
return (
<div className="flex items-center gap-3 px-3 py-2 bg-bambu-dark-secondary border-2 border-cyan-400 rounded-lg shadow-2xl">
<Package className="w-4 h-4 text-cyan-300" />
<span className="text-sm text-white font-medium">
{t('queue.dragGhost.multiCount', { count: selectedItems.length })}
</span>
</div>
);
}
return null;
})()}
</DragOverlay>
</DndContext>
</div>