From a99d4a8974b8721a7590ad9234fc4682b2e15a14 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 9 Jul 2026 11:35:08 +0200 Subject: [PATCH] =?UTF-8?q?AP1.12c:=20Reorder=20f=C3=BCr=20Projekte=20und?= =?UTF-8?q?=20Backlog=20per=20sort=5Forder.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 015 für Backlog sort_order; DnD auf Desktop, Pfeile auf Mobile; PATCH-Batches für Geschwister. Co-authored-by: Cursor --- backend/migrations/015_backlog_sort_order.sql | 21 ++ backend/routers/backlog.py | 3 + backend/services/backlog.py | 33 ++- backend/tests/test_ap08_operating_model.py | 23 ++ backend/version.py | 4 +- frontend/src/components/BacklogSection.jsx | 203 ++++++++++---- frontend/src/components/ProjectsSection.jsx | 261 +++++++++++++----- frontend/src/components/ReorderControls.jsx | 32 +++ .../context/InitiativeOperationsContext.jsx | 38 +++ frontend/src/hooks/useMinWidth.js | 22 ++ .../pages/initiative/InitiativeInboxPage.jsx | 1 + .../src/pages/modes/PlanStructurePage.jsx | 1 + frontend/src/styles/components.css | 78 ++++++ frontend/src/utils/reorder.js | 71 +++++ frontend/src/utils/reorder.test.js | 48 ++++ 15 files changed, 709 insertions(+), 130 deletions(-) create mode 100644 backend/migrations/015_backlog_sort_order.sql create mode 100644 frontend/src/components/ReorderControls.jsx create mode 100644 frontend/src/hooks/useMinWidth.js create mode 100644 frontend/src/utils/reorder.js create mode 100644 frontend/src/utils/reorder.test.js diff --git a/backend/migrations/015_backlog_sort_order.sql b/backend/migrations/015_backlog_sort_order.sql new file mode 100644 index 0000000..9974540 --- /dev/null +++ b/backend/migrations/015_backlog_sort_order.sql @@ -0,0 +1,21 @@ +-- AP1.12c: Backlog sort_order für Reorder im Plan-Modus + +ALTER TABLE backlog_items + ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0; + +WITH ranked AS ( + SELECT + id, + (ROW_NUMBER() OVER ( + PARTITION BY tenant_id, initiative_id + ORDER BY created_at ASC, id ASC + ) - 1) * 10 AS next_order + FROM backlog_items +) +UPDATE backlog_items bi +SET sort_order = ranked.next_order +FROM ranked +WHERE bi.id = ranked.id; + +CREATE INDEX IF NOT EXISTS idx_backlog_items_sort + ON backlog_items (tenant_id, initiative_id, sort_order); diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index 87ecb7f..e2d806c 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -19,6 +19,7 @@ class BacklogCreateRequest(BaseModel): status: Literal["new", "triaged", "accepted", "rejected"] = "new" priority: Literal["low", "normal", "high"] = "normal" roadmap_item_id: Optional[str] = None + sort_order: Optional[int] = None class BacklogUpdateRequest(BaseModel): @@ -28,6 +29,7 @@ class BacklogUpdateRequest(BaseModel): priority: Optional[Literal["low", "normal", "high"]] = None roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False + sort_order: Optional[int] = None class BacklogConvertRequest(BaseModel): @@ -64,6 +66,7 @@ def update_backlog_item( priority=body.priority, roadmap_item_id=body.roadmap_item_id, clear_roadmap_item=body.clear_roadmap_item, + sort_order=body.sort_order, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/services/backlog.py b/backend/services/backlog.py index 1c65ef3..ccb0f78 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -18,7 +18,7 @@ BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "convert _BACKLOG_COLUMNS = """ id, tenant_id, initiative_id, title, description, status, - priority, roadmap_item_id, converted_action_id, created_at, updated_at + priority, roadmap_item_id, converted_action_id, sort_order, created_at, updated_at """ @@ -53,6 +53,7 @@ def create_backlog_item( status: BacklogStatus = "new", priority: str = "normal", roadmap_item_id: Optional[str] = None, + sort_order: Optional[int] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -72,16 +73,34 @@ def create_backlog_item( initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) + cur.execute( + """ + SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + next_order = int(cur.fetchone()["next_order"]) cur.execute( f""" INSERT INTO backlog_items ( tenant_id, initiative_id, title, description, status, priority, - roadmap_item_id + roadmap_item_id, sort_order ) - VALUES (%s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_BACKLOG_COLUMNS} """, - (tenant_id, initiative_id, title, description, status, priority, roadmap_item_id), + ( + tenant_id, + initiative_id, + title, + description, + status, + priority, + roadmap_item_id, + sort_order if sort_order is not None else next_order, + ), ) row = _serialize_row(dict(cur.fetchone())) conn.commit() @@ -109,7 +128,7 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d SELECT {_BACKLOG_COLUMNS} FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s - ORDER BY updated_at DESC, title + ORDER BY sort_order ASC, created_at ASC, title """, (tenant_id, initiative_id), ) @@ -147,6 +166,7 @@ def update_backlog_item( priority: Optional[str] = None, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, + sort_order: Optional[int] = None, ) -> Optional[dict[str, Any]]: existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) if not existing: @@ -191,6 +211,9 @@ def update_backlog_item( conn.close() updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) + if sort_order is not None: + updates.append("sort_order = %s") + params.append(sort_order) if not updates: return existing diff --git a/backend/tests/test_ap08_operating_model.py b/backend/tests/test_ap08_operating_model.py index 160eb86..649574a 100644 --- a/backend/tests/test_ap08_operating_model.py +++ b/backend/tests/test_ap08_operating_model.py @@ -247,6 +247,29 @@ def test_backlog_crud(client): assert deleted.status_code == 204 +def test_backlog_sort_order(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + first = _create_backlog(client, token, initiative_id, title="First").json() + second = _create_backlog(client, token, initiative_id, title="Second").json() + assert first["sort_order"] < second["sort_order"] + + patched = client.patch( + f"/api/backlog/{second['id']}", + json={"sort_order": 0}, + headers=_auth(token), + ) + assert patched.status_code == 200 + assert patched.json()["sort_order"] == 0 + + listed = client.get( + f"/api/initiatives/{initiative_id}/backlog", + headers=_auth(token), + ).json() + assert [row["title"] for row in listed[:2]] == ["Second", "First"] + + def test_milestone_crud(client): user = provision_user_in_tenant(tenant_role="member") token = _login(client, user) diff --git a/backend/version.py b/backend/version.py index 825c34d..ca9baec 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.16.6-ap1.12b" -DB_SCHEMA_VERSION = "014" +APP_VERSION = "0.16.7-ap1.12c" +DB_SCHEMA_VERSION = "015" APP_NAME = "jinkendo-kairo" diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index aca310d..83967da 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -1,10 +1,13 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { StatusBadge } from './StatusBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx' import { EmptyState } from './EmptyState.jsx' import { Modal } from './Modal.jsx' import { BacklogItemForm } from './BacklogItemForm.jsx' +import { ReorderControls } from './ReorderControls.jsx' import { gateTitleById } from './GateSelect.jsx' +import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js' +import { useMinWidth } from '../hooks/useMinWidth.js' export function BacklogSection({ items, @@ -12,11 +15,21 @@ export function BacklogSection({ canManage, onCreate, onUpdate, + onReorder, onConvert, onDelete, busy, }) { const [modalMode, setModalMode] = useState(null) + const [dragItemId, setDragItemId] = useState('') + const [dropTargetId, setDropTargetId] = useState('') + const isDesktop = useMinWidth(1024) + const canReorder = canManage && typeof onReorder === 'function' + + const sortedItems = useMemo( + () => sortByOrder(items.filter((item) => item.status !== 'converted')), + [items], + ) function closeModal() { setModalMode(null) @@ -46,13 +59,58 @@ export function BacklogSection({ if (ok !== false) closeModal() } + async function applyPatches(patches) { + if (!patches.length) return + await onReorder(patches) + } + + async function handleMove(itemId, direction) { + const patches = computeMovePatches(sortedItems, itemId, direction) + await applyPatches(patches) + } + + function handleDragStart(event, itemId) { + if (!canReorder || !isDesktop) return + setDragItemId(itemId) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', itemId) + } + + function handleDragEnd() { + setDragItemId('') + setDropTargetId('') + } + + function handleDragOver(event, itemId) { + if (!canReorder || !isDesktop || !dragItemId) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDropTargetId(itemId) + } + + async function handleDrop(event, targetId) { + event.preventDefault() + if (!canReorder || !isDesktop || !dragItemId) { + handleDragEnd() + return + } + const patches = computeDropPatches(sortedItems, dragItemId, targetId) + handleDragEnd() + await applyPatches(patches) + } + const modalTitle = modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten' return ( -
+
-

Backlog

+
+

Backlog

+

+ Reihenfolge per Drag & Drop (Desktop) oder ↑/↓ (Mobile). +

+
{canManage && ( -
- - - {canManage && item.status !== 'converted' && ( - <> - {item.status === 'accepted' && ( + +
+ {reorderable && !isDesktop && ( + 0} + canMoveDown={index < sortedItems.length - 1} + onMove={(direction) => handleMove(item.id, direction)} + busy={busy} + /> + )} + + + {canManage && item.status !== 'converted' && ( + <> + {item.status === 'accepted' && ( + + )} - )} - - - - )} -
- - ))} + + + )} +
+ + ) + })} + {items + .filter((item) => item.status === 'converted') + .map((item) => ( +
  • +
    + {item.title} +

    Konvertiert — nicht mehr sortierbar

    +
    + +
  • + ))} diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx index 68393fa..9af025c 100644 --- a/frontend/src/components/ProjectsSection.jsx +++ b/frontend/src/components/ProjectsSection.jsx @@ -4,10 +4,13 @@ import { EmptyState } from './EmptyState.jsx' import { StatusBadge } from './StatusBadge.jsx' import { Modal } from './Modal.jsx' import { ProjectForm } from './ProjectForm.jsx' +import { ReorderControls } from './ReorderControls.jsx' import { buildProjectsByParent, containerKindLabel, } from '../utils/projectTree.js' +import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js' +import { useMinWidth } from '../hooks/useMinWidth.js' import { gateTitleById } from './GateSelect.jsx' import { projectPath } from '../utils/routes.js' @@ -18,6 +21,15 @@ function ProjectTreeNodes({ selectedProjectId, onSelectProject, canManage, + canReorder, + isDesktop, + dragProjectId, + dropTargetId, + onDragStart, + onDragEnd, + onDragOver, + onDrop, + onMove, onEdit, onDelete, busy, @@ -29,80 +41,120 @@ function ProjectTreeNodes({ return (
      - {nodes.map((project) => ( -
    • -
      -
      - - {project.title} - - {project.container_kind && ( - - {containerKindLabel(project.container_kind)} - - )} - {project.roadmap_item_id && ( - - Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)} + {nodes.map((project, index) => { + const isDragging = dragProjectId === project.id + const isDropTarget = dropTargetId === project.id && dragProjectId && dragProjectId !== project.id + + return ( +
    • +
      onDragStart(event, project.id, parentId || '')} + onDragEnd={onDragEnd} + onDragOver={(event) => onDragOver(event, project.id)} + onDrop={(event) => onDrop(event, project.id, parentId || '')} + > + {canReorder && isDesktop && ( + )} +
      + + {project.title} + + {project.container_kind && ( + + {containerKindLabel(project.container_kind)} + + )} + {project.roadmap_item_id && ( + + Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)} + + )} +
      +
      + {canReorder && !isDesktop && ( + 0} + canMoveDown={index < nodes.length - 1} + onMove={(direction) => onMove(project.id, parentId || '', direction)} + busy={busy} + /> + )} + + + {canManage && ( + <> + + + + )} +
      -
      - - - {canManage && ( - <> - - - - )} -
      -
    - - - ))} + + + ) + })} ) } @@ -116,9 +168,21 @@ export function ProjectsSection({ onCreate, onUpdate, onDelete, + onReorder, busy, }) { const [modalMode, setModalMode] = useState(null) + const [dragProjectId, setDragProjectId] = useState('') + const [dragParentId, setDragParentId] = useState('') + const [dropTargetId, setDropTargetId] = useState('') + const isDesktop = useMinWidth(1024) + const canReorder = canManage && typeof onReorder === 'function' + + const byParent = useMemo(() => buildProjectsByParent(projects), [projects]) + + function getSiblings(parentKey) { + return sortByOrder(byParent.get(parentKey || '') || []) + } function closeModal() { setModalMode(null) @@ -135,6 +199,48 @@ export function ProjectsSection({ if (ok) closeModal() } + async function applyPatches(patches) { + if (!patches.length) return + await onReorder(patches) + } + + async function handleMove(projectId, parentKey, direction) { + const patches = computeMovePatches(getSiblings(parentKey), projectId, direction) + await applyPatches(patches) + } + + function handleDragStart(event, projectId, parentKey) { + if (!canReorder || !isDesktop) return + setDragProjectId(projectId) + setDragParentId(parentKey) + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', projectId) + } + + function handleDragEnd() { + setDragProjectId('') + setDragParentId('') + setDropTargetId('') + } + + function handleDragOver(event, projectId) { + if (!canReorder || !isDesktop || !dragProjectId) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + setDropTargetId(projectId) + } + + async function handleDrop(event, targetId, parentKey) { + event.preventDefault() + if (!canReorder || !isDesktop || !dragProjectId || dragParentId !== parentKey) { + handleDragEnd() + return + } + const patches = computeDropPatches(getSiblings(parentKey), dragProjectId, targetId) + handleDragEnd() + await applyPatches(patches) + } + const modalTitle = modalMode?.kind === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten' @@ -144,8 +250,8 @@ export function ProjectsSection({

    Struktur

    - Projekte, Streams und Phasen — Bearbeitung im Modal, Detailseite über den Titel. - Filter schränkt Arbeitspakete ein. + Projekte per Drag & Drop (Desktop) oder ↑/↓ (Mobile) sortieren — Bearbeitung im + Modal.

    {canManage && ( @@ -183,6 +289,15 @@ export function ProjectsSection({ selectedProjectId={selectedProjectId} onSelectProject={onSelectProject} canManage={canManage} + canReorder={canReorder} + isDesktop={isDesktop} + dragProjectId={dragProjectId} + dropTargetId={dropTargetId} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onDragOver={handleDragOver} + onDrop={handleDrop} + onMove={handleMove} onEdit={(project) => setModalMode({ kind: 'edit', project })} onDelete={onDelete} busy={busy} diff --git a/frontend/src/components/ReorderControls.jsx b/frontend/src/components/ReorderControls.jsx new file mode 100644 index 0000000..5feb1f4 --- /dev/null +++ b/frontend/src/components/ReorderControls.jsx @@ -0,0 +1,32 @@ +/** + * ↑/↓ Reorder für Touch / schmale Viewports (AP1.12c). + * + * @param {{ itemId: string, canMoveUp: boolean, canMoveDown: boolean, onMove: (direction: 'up'|'down') => void, busy?: boolean }} props + */ +export function ReorderControls({ itemId, canMoveUp, canMoveDown, onMove, busy = false }) { + return ( +
    + + + + Reihenfolge ändern + +
    + ) +} diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 9cb8033..ae557eb 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -510,6 +510,42 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ } } + async function handleReorderProjects(patches) { + if (!patches?.length) return true + setFormBusy(true) + setError(null) + try { + await Promise.all( + patches.map((patch) => updateProject(patch.id, { sort_order: patch.sort_order })), + ) + setProjects(await listInitiativeProjects(id)) + return true + } catch (err) { + setError(err.message) + return false + } finally { + setFormBusy(false) + } + } + + async function handleReorderBacklog(patches) { + if (!patches?.length) return true + setFormBusy(true) + setError(null) + try { + await Promise.all( + patches.map((patch) => updateBacklogItem(patch.id, { sort_order: patch.sort_order })), + ) + await load() + return true + } catch (err) { + setError(err.message) + return false + } finally { + setFormBusy(false) + } + } + const milestones = useMemo( () => roadmapItems.filter((item) => item.item_type === 'milestone'), [roadmapItems] @@ -701,6 +737,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ handleCreateProject, handleUpdateProject, handleDeleteProject, + handleReorderProjects, + handleReorderBacklog, handleCreateEvidence, handleEvidenceStatus, handleDeleteEvidence, diff --git a/frontend/src/hooks/useMinWidth.js b/frontend/src/hooks/useMinWidth.js new file mode 100644 index 0000000..7851c71 --- /dev/null +++ b/frontend/src/hooks/useMinWidth.js @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react' + +/** + * @param {number} minWidthPx + */ +export function useMinWidth(minWidthPx) { + const query = `(min-width: ${minWidthPx}px)` + const [matches, setMatches] = useState(() => { + if (typeof window === 'undefined') return true + return window.matchMedia(query).matches + }) + + useEffect(() => { + const mq = window.matchMedia(query) + const onChange = () => setMatches(mq.matches) + onChange() + mq.addEventListener('change', onChange) + return () => mq.removeEventListener('change', onChange) + }, [query]) + + return matches +} diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx index 3a97c8a..80cb830 100644 --- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx +++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx @@ -28,6 +28,7 @@ export function InitiativeInboxPage() { canManage={capabilities.has('kairo.backlog.manage')} onCreate={handleCreateBacklog} onUpdate={handleUpdateBacklog} + onReorder={handleReorderBacklog} onConvert={handleConvertBacklog} onDelete={handleDeleteBacklog} busy={formBusy} diff --git a/frontend/src/pages/modes/PlanStructurePage.jsx b/frontend/src/pages/modes/PlanStructurePage.jsx index 70b29ea..7ea7037 100644 --- a/frontend/src/pages/modes/PlanStructurePage.jsx +++ b/frontend/src/pages/modes/PlanStructurePage.jsx @@ -33,6 +33,7 @@ function PlanStructureInner() { onCreate={ops.handleCreateProject} onUpdate={ops.handleUpdateProject} onDelete={ops.handleDeleteProject} + onReorder={ops.handleReorderProjects} busy={ops.formBusy} /> diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5ad9cc4..f4ff176 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -5,6 +5,18 @@ font-size: 0.92rem; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .error { color: var(--jk-danger); font-size: 0.92rem; @@ -1435,3 +1447,69 @@ cursor: default; } +.reorder-controls { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +@media (min-width: 1024px) { + .reorder-controls { + display: none; + } +} + +.reorder-controls__btn { + min-width: 2rem; + padding: 2px 6px; + border-radius: 6px; + border: 1px solid var(--jk-border); + background: var(--jk-surface-raised); + color: var(--jk-text); + font-size: 12px; + line-height: 1.2; + cursor: pointer; +} + +.reorder-controls__btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.project-tree-item--draggable, +.backlog-reorder-item--draggable { + cursor: grab; +} + +.project-tree-item--dragging, +.backlog-reorder-item--dragging { + opacity: 0.55; + cursor: grabbing; +} + +.project-tree-item--drop-target, +.backlog-reorder-item--drop-target { + outline: 2px dashed var(--jk-primary); + outline-offset: 2px; +} + +.project-tree-item__drag-hint, +.backlog-reorder-item__drag-hint { + flex: 0 0 auto; + margin-right: 8px; + color: var(--jk-text-muted); + font-size: 12px; + user-select: none; +} + +.project-tree-item, +.backlog-reorder-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.backlog-reorder-item--converted { + opacity: 0.75; +} + diff --git a/frontend/src/utils/reorder.js b/frontend/src/utils/reorder.js new file mode 100644 index 0000000..d305a86 --- /dev/null +++ b/frontend/src/utils/reorder.js @@ -0,0 +1,71 @@ +/** + * Reorder-Helfer (AP1.12c) — sort_order-Patches für Geschwister-Listen. + */ + +/** + * @template {{ id: string, sort_order?: number }} T + * @param {T[]} items + */ +export function sortByOrder(items) { + return [...items].sort( + (a, b) => + (a.sort_order ?? 0) - (b.sort_order ?? 0) || + String(a.id).localeCompare(String(b.id)), + ) +} + +/** + * @template {{ id: string, sort_order?: number }} T + * @param {T[]} items — bereits sortierte Geschwister + * @param {number} fromIndex + * @param {number} toIndex + * @returns {{ id: string, sort_order: number }[]} + */ +export function computeReorderPatches(items, fromIndex, toIndex) { + if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length) { + return [] + } + const next = [...items] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + + /** @type {{ id: string, sort_order: number }[]} */ + const patches = [] + next.forEach((item, index) => { + const order = index * 10 + if ((item.sort_order ?? 0) !== order) { + patches.push({ id: item.id, sort_order: order }) + } + }) + return patches +} + +/** + * @template {{ id: string, sort_order?: number }} T + * @param {T[]} items + * @param {string} itemId + * @param {'up'|'down'} direction + */ +export function computeMovePatches(items, itemId, direction) { + const sorted = sortByOrder(items) + const fromIndex = sorted.findIndex((item) => item.id === itemId) + if (fromIndex < 0) return [] + const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1 + if (toIndex < 0 || toIndex >= sorted.length) return [] + return computeReorderPatches(sorted, fromIndex, toIndex) +} + +/** + * @template {{ id: string, sort_order?: number }} T + * @param {T[]} items + * @param {string} dragId + * @param {string} targetId + */ +export function computeDropPatches(items, dragId, targetId) { + if (!dragId || !targetId || dragId === targetId) return [] + const sorted = sortByOrder(items) + const fromIndex = sorted.findIndex((item) => item.id === dragId) + const toIndex = sorted.findIndex((item) => item.id === targetId) + if (fromIndex < 0 || toIndex < 0) return [] + return computeReorderPatches(sorted, fromIndex, toIndex) +} diff --git a/frontend/src/utils/reorder.test.js b/frontend/src/utils/reorder.test.js new file mode 100644 index 0000000..c8db333 --- /dev/null +++ b/frontend/src/utils/reorder.test.js @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + computeDropPatches, + computeMovePatches, + computeReorderPatches, + sortByOrder, +} from './reorder.js' + +const items = [ + { id: 'a', sort_order: 0 }, + { id: 'b', sort_order: 10 }, + { id: 'c', sort_order: 20 }, +] + +describe('reorder', () => { + it('sorts by sort_order then id', () => { + const unsorted = [ + { id: 'c', sort_order: 20 }, + { id: 'a', sort_order: 0 }, + { id: 'b', sort_order: 10 }, + ] + expect(sortByOrder(unsorted).map((i) => i.id)).toEqual(['a', 'b', 'c']) + }) + + it('computes patches when moving down', () => { + const patches = computeMovePatches(items, 'a', 'down') + expect(patches).toEqual( + expect.arrayContaining([ + { id: 'a', sort_order: 10 }, + { id: 'b', sort_order: 0 }, + ]), + ) + expect(patches).toHaveLength(2) + }) + + it('computes patches for drag drop', () => { + const patches = computeDropPatches(items, 'c', 'a') + expect(patches).toEqual([ + { id: 'c', sort_order: 0 }, + { id: 'a', sort_order: 10 }, + { id: 'b', sort_order: 20 }, + ]) + }) + + it('returns empty when indices unchanged', () => { + expect(computeReorderPatches(items, 1, 1)).toEqual([]) + }) +})