diff --git a/web/src/lib/collections/reorder.test.ts b/web/src/lib/collections/reorder.test.ts index c6058df0..59063f10 100644 --- a/web/src/lib/collections/reorder.test.ts +++ b/web/src/lib/collections/reorder.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { Item } from '$lib/types'; -import { reorderGroup, reorderedList, disabledDirections } from './reorder'; +import { reorderGroup, reorderedList, disabledDirections, adjacentColumn } from './reorder'; // Minimal Item stand-in — reorder only touches `id` and `sort_order`. function item(id: string, sort_order: number): Item { @@ -104,3 +104,32 @@ describe('disabledDirections', () => { expect(disabledDirections(1, 4).size).toBe(0); }); }); + +describe('adjacentColumn', () => { + const cols = ['open', 'in_progress', 'done']; + + it('returns both neighbours for a middle column', () => { + expect(adjacentColumn(cols, 'in_progress', 'left')).toBe('open'); + expect(adjacentColumn(cols, 'in_progress', 'right')).toBe('done'); + }); + + it('returns null moving left from the first column', () => { + expect(adjacentColumn(cols, 'open', 'left')).toBeNull(); + expect(adjacentColumn(cols, 'open', 'right')).toBe('in_progress'); + }); + + it('returns null moving right from the last column', () => { + expect(adjacentColumn(cols, 'done', 'right')).toBeNull(); + expect(adjacentColumn(cols, 'done', 'left')).toBe('in_progress'); + }); + + it('returns null for a single-column board in either direction', () => { + expect(adjacentColumn(['only'], 'only', 'left')).toBeNull(); + expect(adjacentColumn(['only'], 'only', 'right')).toBeNull(); + }); + + it('returns null when the current value is not in the order', () => { + expect(adjacentColumn(cols, 'archived', 'left')).toBeNull(); + expect(adjacentColumn(cols, 'archived', 'right')).toBeNull(); + }); +}); diff --git a/web/src/lib/collections/reorder.ts b/web/src/lib/collections/reorder.ts index 2af234b0..5ce4002c 100644 --- a/web/src/lib/collections/reorder.ts +++ b/web/src/lib/collections/reorder.ts @@ -87,6 +87,27 @@ export function reorderGroup( return updates; } +/** + * Return the value of the column immediately to the left/right of `current` + * in `columnOrder` (the board's left→right display order), or `null` when + * there is no neighbour in that direction: `current` is at the relevant edge + * (first + `left`, last + `right`), isn't present in `columnOrder`, or the + * board has a single column. Pure — used by the kanban card menu's + * Move left / Move right (TASK-1908) to pick the adjacent column and to + * decide when the edge option should be hidden. + */ +export function adjacentColumn( + columnOrder: string[], + current: string, + dir: 'left' | 'right' +): string | null { + const idx = columnOrder.indexOf(current); + if (idx === -1) return null; + const target = dir === 'left' ? idx - 1 : idx + 1; + if (target < 0 || target >= columnOrder.length) return null; + return columnOrder[target]; +} + /** * Which directions are unavailable for the item at `index` in a group of * `length` items — used to hide no-op menu entries (first item can't move diff --git a/web/src/lib/components/collections/BoardView.svelte b/web/src/lib/components/collections/BoardView.svelte index 6b9feddd..a3e3d209 100644 --- a/web/src/lib/components/collections/BoardView.svelte +++ b/web/src/lib/components/collections/BoardView.svelte @@ -2,7 +2,7 @@ import type { Item, Collection } from '$lib/types'; import { parseSchema, parseFields } from '$lib/types'; import { itemComparator, type SortMode } from '$lib/collections/itemSort'; - import { reorderGroup, disabledDirections, type ReorderDirection } from '$lib/collections/reorder'; + import { reorderGroup, disabledDirections, adjacentColumn, type ReorderDirection } from '$lib/collections/reorder'; import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'; import type { DndEvent } from 'svelte-dnd-action'; import ItemCard from './ItemCard.svelte'; @@ -286,40 +286,66 @@ async function handleFinalize(columnValue: string, e: CustomEvent>) { columnData[columnValue] = e.detail.items; - const reorderUpdates = columnData[columnValue] - .filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]) - .map((item, index) => ({ slug: item.id, sort_order: index })); - const { id: itemId, trigger } = e.detail.info; - isDragging = false; - dropCooldown = true; - - let moveSucceeded = true; + // The zone that RECEIVED the card owns the group-value change + the + // target-lane reindex; delegate that to the shared commit path, + // passing the dnd-provided drop order as the placement. if (trigger === TRIGGERS.DROPPED_INTO_ZONE) { const originalItem = items.find((i) => i.id === itemId); if (originalItem) { - const fields = parseFields(originalItem); - if (fields[groupField] !== columnValue) { - try { - await onStatusChange(originalItem, columnValue); - } catch { - moveSucceeded = false; - } - } + await commitColumnMove(originalItem, columnValue, e.detail.items); + return; + } + } + + // Source/other zone (the card left this lane) or the item is gone: + // no status change — just re-densify this lane's remaining order. + dropCooldown = true; + if (onReorder) { + const reorderUpdates = e.detail.items + .filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]) + .map((item, index) => ({ slug: item.id, sort_order: index })); + if (reorderUpdates.length > 0) onReorder(reorderUpdates); + } + setTimeout(() => { dropCooldown = false; }, 2000); + } + + // Shared commit tail for a card that changes columns — used by both drag + // (handleFinalize's receiving zone) and the menu's Move left/right + // (moveItem). Guards with the drop cooldown, changes the group value via + // the page's status handler, persists the target lane's sort_order, then + // releases the cooldown after SSE settles — or reverts the optimistic + // order on failure. `placement` stays parametric: drag passes the + // dnd-provided target order (e.detail.items); the menu passes 'top' (the + // card has already been inserted at the target lane's head). DR-7. + async function commitColumnMove(item: Item, targetColumn: string, placement: Item[] | 'top') { + dropCooldown = true; + + let moveSucceeded = true; + const fields = parseFields(item); + if (fields[groupField] !== targetColumn) { + try { + await onStatusChange(item, targetColumn); + } catch { + moveSucceeded = false; } } if (moveSucceeded) { - // Only persist reorder after a successful move - if (onReorder && reorderUpdates.length > 0) { - onReorder(reorderUpdates); + // Only persist reorder after a successful move. + if (onReorder) { + const order = placement === 'top' ? (columnData[targetColumn] ?? []) : placement; + const reorderUpdates = order + .filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]) + .map((it, index) => ({ slug: it.id, sort_order: index })); + if (reorderUpdates.length > 0) onReorder(reorderUpdates); } - // Let SSE events settle before re-syncing from props + // Let SSE events settle before re-syncing from props. setTimeout(() => { dropCooldown = false; }, 2000); } else { - // Move failed — immediately restore original positions + // Move failed — immediately restore original positions. dropCooldown = false; } } @@ -342,6 +368,26 @@ } } + // Menu-driven adjacent-column move (TASK-1908) — the horizontal + // counterpart to reorderItem. Sets the item's group field to the + // neighbouring column's value and lands the card at the TOP of that lane, + // reusing the drag commit path (commitColumnMove). Because a menu move — + // unlike a drag — doesn't get source-lane removal for free from the dnd + // library, we optimistically pull the card out of the source lane and + // insert it at the target lane's head BEFORE committing; otherwise the + // card would render in BOTH lanes until the cooldown/SSE settle (DR-7). + function moveItem(columnValue: string, item: Item, dir: 'left' | 'right') { + const target = adjacentColumn(columnOrder, columnValue, dir); + if (!target) return; + + const source = (columnData[columnValue] ?? []).filter((i) => i.id !== item.id); + const dest = [item, ...(columnData[target] ?? []).filter((i) => i.id !== item.id)]; + columnData[columnValue] = source; + columnData[target] = dest; + + commitColumnMove(item, target, 'top'); + } + // Per-lane gate: edit permission, not search-preserving order, and the // lane is in manual sort (its override, else page sort). Deliberately // NOT gated on isMobile — unlike drag (disabled on mobile), the menu IS @@ -350,6 +396,25 @@ return canEdit && !preserveOrder && laneSortFor(columnValue) === 'manual'; } + // Combined hidden-direction set for a card's reorder menu: the vertical + // edges (disabledDirections) plus the horizontal edges — hide `left` in + // the first column, `right` in the last, so the edge option simply isn't + // rendered (DR-1). Pure derivation from columnOrder — read in the + // template, never a $state an $effect writes (CONVE-1688). + function moveDisabledDirs( + columnValue: string, + index: number, + length: number + ): Set { + const disabled: Set = new Set( + disabledDirections(index, length) + ); + const colIdx = columnOrder.indexOf(columnValue); + if (colIdx <= 0) disabled.add('left'); + if (colIdx >= columnOrder.length - 1) disabled.add('right'); + return disabled; + } + function columnCssClass(value: string): string { switch (value) { case 'in_progress': @@ -516,7 +581,9 @@ progress={itemProgress?.[item.id] ?? null} {progressLabel} onReorderItem={canReorderLane(colValue) ? (it, dir) => reorderItem(colValue, it, dir) : undefined} - reorderDisabledDirs={canReorderLane(colValue) ? disabledDirections(i, colItems.length) : undefined} + onMoveItem={canReorderLane(colValue) ? (it, dir) => moveItem(colValue, it, dir) : undefined} + horizontal={canReorderLane(colValue)} + reorderDisabledDirs={canReorderLane(colValue) ? moveDisabledDirs(colValue, i, colItems.length) : undefined} /> {/each} diff --git a/web/src/lib/components/collections/ItemActionsMenu.svelte b/web/src/lib/components/collections/ItemActionsMenu.svelte index 58fd8a39..f68c9273 100644 --- a/web/src/lib/components/collections/ItemActionsMenu.svelte +++ b/web/src/lib/components/collections/ItemActionsMenu.svelte @@ -22,30 +22,51 @@ import type { Item } from '$lib/types'; import type { ReorderDirection } from '$lib/collections/reorder'; + // Horizontal (adjacent-column) moves ride a SEPARATE optional callback so + // the shared vertical `onReorder` type stays 'top'|'bottom'|'up'|'down' + // for the List/Table/Child hosts (TASK-1908 / DR-6). MenuDirection is + // internal to this menu — it never appears on a prop those hosts consume. + type MenuDirection = ReorderDirection | 'left' | 'right'; + interface Props { item: Item; - /** Fire the reorder. The host has the item + group context bound. */ + /** Fire the vertical reorder. The host has the item + group context bound. */ onReorder: (dir: ReorderDirection) => void; - /** Directions to hide (edge of group) — see disabledDirections(). */ - disabledDirs?: Set; + /** + * Fire an adjacent-column move (board only). Only wired by BoardView; + * omitted by every other host, so left/right never fire elsewhere. + */ + onMove?: (dir: 'left' | 'right') => void; + /** Render the Move left / Move right entries (BoardView passes true). */ + horizontal?: boolean; + /** Directions to hide (edge of group / board) — see disabledDirections(). */ + disabledDirs?: Set; /** Accessible label suffix, e.g. the item title. */ label?: string; } - let { item, onReorder, disabledDirs, label }: Props = $props(); + let { item, onReorder, onMove, horizontal = false, disabledDirs, label }: Props = $props(); interface Action { - dir: ReorderDirection; + dir: MenuDirection; icon: string; text: string; } - const ALL_ACTIONS: Action[] = [ + const VERTICAL_ACTIONS: Action[] = [ { dir: 'top', icon: '⤒', text: 'Move to top' }, { dir: 'up', icon: '↑', text: 'Move up' }, { dir: 'down', icon: '↓', text: 'Move down' }, { dir: 'bottom', icon: '⤓', text: 'Move to bottom' } ]; - let actions = $derived(ALL_ACTIONS.filter((a) => !disabledDirs?.has(a.dir))); + const HORIZONTAL_ACTIONS: Action[] = [ + { dir: 'left', icon: '←', text: 'Move left' }, + { dir: 'right', icon: '→', text: 'Move right' } + ]; + let actions = $derived( + [...VERTICAL_ACTIONS, ...(horizontal ? HORIZONTAL_ACTIONS : [])].filter( + (a) => !disabledDirs?.has(a.dir) + ) + ); let open = $state(false); let triggerEl = $state(); @@ -102,14 +123,18 @@ if (returnFocus) triggerEl?.focus(); } - function pick(dir: ReorderDirection) { + function pick(dir: MenuDirection) { if (disabledDirs?.has(dir)) return; // Close before firing so the menu can't be machine-gunned against // the optimistic reorder state — a second move needs a reopen, by // which point the new order has settled. (Drag is naturally // debounced; menu clicks are not.) closeMenu(false); - onReorder(dir); + if (dir === 'left' || dir === 'right') { + onMove?.(dir); + } else { + onReorder(dir); + } } function onTriggerClick(e: MouseEvent) { diff --git a/web/src/lib/components/collections/ItemCard.svelte b/web/src/lib/components/collections/ItemCard.svelte index bd92d999..7ffaa856 100644 --- a/web/src/lib/components/collections/ItemCard.svelte +++ b/web/src/lib/components/collections/ItemCard.svelte @@ -26,10 +26,19 @@ * stays dumb: it forwards these, it has no ordering context. */ onReorderItem?: (item: Item, dir: ReorderDirection) => void; - reorderDisabledDirs?: Set; + reorderDisabledDirs?: Set; + /** + * Board-only adjacent-column move (TASK-1908). Pass-through to the + * menu's `onMove`; only BoardView wires it, so left/right never + * appear on List/Table/Child cards. The vertical `onReorderItem` + * type is deliberately left untouched (DR-6). + */ + onMoveItem?: (item: Item, dir: 'left' | 'right') => void; + /** Render the Move left / Move right menu entries (BoardView only). */ + horizontal?: boolean; } - let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', onReorderItem, reorderDisabledDirs }: Props = $props(); + let { item, collection, compact = false, focused = false, showCollection = false, statusOptions, onStatusClick, progress = null, progressLabel = 'tasks', onReorderItem, reorderDisabledDirs, onMoveItem, horizontal = false }: Props = $props(); let wsSlug = $derived(page.params.workspace ?? ''); let username = $derived(page.params.username ?? ''); @@ -156,9 +165,11 @@ {#if onReorderItem} onReorderItem?.(item, dir)} + onMove={onMoveItem ? (dir) => onMoveItem?.(item, dir) : undefined} /> {/if}