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-21 12:59:00 +02:00
parent 368ee1bdd1
commit d6e0c2b1d1
18 changed files with 346 additions and 206 deletions
+2
View File
File diff suppressed because one or more lines are too long
+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>
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-D7H6KGaQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aPHrdXng.css">
<script type="module" crossorigin src="/assets/index-DWkuyGzA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPDrjcz-.css">
</head>
<body>
<div id="root"></div>