mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user