feat(web): add Move left/right to kanban card context menu (TASK-1908) (#777)

Add adjacent-column moves to the per-card kebab: left/right set the item's
group field to the neighbouring column value (left->right order) and land the
card at the top of the destination lane, reusing the drag status-change +
sort_order commit path (extracted commitColumnMove) with optimistic
source-lane removal so the card never double-renders. Left/right are a
separate optional onMove callback wired only by BoardView, leaving the vertical
onReorder type (and List/Table/Child hosts) untouched.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
This commit is contained in:
xarmian
2026-06-30 23:55:13 -04:00
committed by GitHub
parent 915f7e66c5
commit 645ba00bf7
5 changed files with 188 additions and 35 deletions
+30 -1
View File
@@ -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();
});
});
+21
View File
@@ -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
@@ -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<DndEvent<Item>>) {
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<ReorderDirection | 'left' | 'right'> {
const disabled: Set<ReorderDirection | 'left' | 'right'> = 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}
/>
</div>
{/each}
@@ -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<ReorderDirection>;
/**
* 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<MenuDirection>;
/** 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<HTMLButtonElement>();
@@ -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) {
@@ -26,10 +26,19 @@
* stays dumb: it forwards these, it has no ordering context.
*/
onReorderItem?: (item: Item, dir: ReorderDirection) => void;
reorderDisabledDirs?: Set<ReorderDirection>;
reorderDisabledDirs?: Set<ReorderDirection | 'left' | 'right'>;
/**
* 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}
<ItemActionsMenu
{item}
{horizontal}
disabledDirs={reorderDisabledDirs}
label={item.title}
onReorder={(dir) => onReorderItem?.(item, dir)}
onMove={onMoveItem ? (dir) => onMoveItem?.(item, dir) : undefined}
/>
{/if}
</div>