AP1.12c: Reorder für Projekte und Backlog per sort_order.
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Migration 015 für Backlog sort_order; DnD auf Desktop, Pfeile auf Mobile; PATCH-Batches für Geschwister. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e50c04c96f
commit
a99d4a8974
21
backend/migrations/015_backlog_sort_order.sql
Normal file
21
backend/migrations/015_backlog_sort_order.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -19,6 +19,7 @@ class BacklogCreateRequest(BaseModel):
|
||||||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||||
priority: Literal["low", "normal", "high"] = "normal"
|
priority: Literal["low", "normal", "high"] = "normal"
|
||||||
roadmap_item_id: Optional[str] = None
|
roadmap_item_id: Optional[str] = None
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class BacklogUpdateRequest(BaseModel):
|
class BacklogUpdateRequest(BaseModel):
|
||||||
|
|
@ -28,6 +29,7 @@ class BacklogUpdateRequest(BaseModel):
|
||||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||||
roadmap_item_id: Optional[str] = None
|
roadmap_item_id: Optional[str] = None
|
||||||
clear_roadmap_item: bool = False
|
clear_roadmap_item: bool = False
|
||||||
|
sort_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class BacklogConvertRequest(BaseModel):
|
class BacklogConvertRequest(BaseModel):
|
||||||
|
|
@ -64,6 +66,7 @@ def update_backlog_item(
|
||||||
priority=body.priority,
|
priority=body.priority,
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
clear_roadmap_item=body.clear_roadmap_item,
|
clear_roadmap_item=body.clear_roadmap_item,
|
||||||
|
sort_order=body.sort_order,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "convert
|
||||||
|
|
||||||
_BACKLOG_COLUMNS = """
|
_BACKLOG_COLUMNS = """
|
||||||
id, tenant_id, initiative_id, title, description, status,
|
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",
|
status: BacklogStatus = "new",
|
||||||
priority: str = "normal",
|
priority: str = "normal",
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
|
sort_order: Optional[int] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
|
|
@ -72,16 +73,34 @@ def create_backlog_item(
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
roadmap_item_id=roadmap_item_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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO backlog_items (
|
INSERT INTO backlog_items (
|
||||||
tenant_id, initiative_id, title, description, status, priority,
|
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}
|
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()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -109,7 +128,7 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d
|
||||||
SELECT {_BACKLOG_COLUMNS}
|
SELECT {_BACKLOG_COLUMNS}
|
||||||
FROM backlog_items
|
FROM backlog_items
|
||||||
WHERE tenant_id = %s AND initiative_id = %s
|
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),
|
(tenant_id, initiative_id),
|
||||||
)
|
)
|
||||||
|
|
@ -147,6 +166,7 @@ def update_backlog_item(
|
||||||
priority: Optional[str] = None,
|
priority: Optional[str] = None,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
clear_roadmap_item: bool = False,
|
clear_roadmap_item: bool = False,
|
||||||
|
sort_order: Optional[int] = None,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -191,6 +211,9 @@ def update_backlog_item(
|
||||||
conn.close()
|
conn.close()
|
||||||
updates.append("roadmap_item_id = %s")
|
updates.append("roadmap_item_id = %s")
|
||||||
params.append(roadmap_item_id)
|
params.append(roadmap_item_id)
|
||||||
|
if sort_order is not None:
|
||||||
|
updates.append("sort_order = %s")
|
||||||
|
params.append(sort_order)
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,29 @@ def test_backlog_crud(client):
|
||||||
assert deleted.status_code == 204
|
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):
|
def test_milestone_crud(client):
|
||||||
user = provision_user_in_tenant(tenant_role="member")
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
token = _login(client, user)
|
token = _login(client, user)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.16.6-ap1.12b"
|
APP_VERSION = "0.16.7-ap1.12c"
|
||||||
DB_SCHEMA_VERSION = "014"
|
DB_SCHEMA_VERSION = "015"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { PriorityBadge } from './PriorityBadge.jsx'
|
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||||
import { EmptyState } from './EmptyState.jsx'
|
import { EmptyState } from './EmptyState.jsx'
|
||||||
import { Modal } from './Modal.jsx'
|
import { Modal } from './Modal.jsx'
|
||||||
import { BacklogItemForm } from './BacklogItemForm.jsx'
|
import { BacklogItemForm } from './BacklogItemForm.jsx'
|
||||||
|
import { ReorderControls } from './ReorderControls.jsx'
|
||||||
import { gateTitleById } from './GateSelect.jsx'
|
import { gateTitleById } from './GateSelect.jsx'
|
||||||
|
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
||||||
|
import { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
|
|
||||||
export function BacklogSection({
|
export function BacklogSection({
|
||||||
items,
|
items,
|
||||||
|
|
@ -12,11 +15,21 @@ export function BacklogSection({
|
||||||
canManage,
|
canManage,
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onReorder,
|
||||||
onConvert,
|
onConvert,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
const [modalMode, setModalMode] = useState(null)
|
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() {
|
function closeModal() {
|
||||||
setModalMode(null)
|
setModalMode(null)
|
||||||
|
|
@ -46,13 +59,58 @@ export function BacklogSection({
|
||||||
if (ok !== false) closeModal()
|
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 =
|
const modalTitle =
|
||||||
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<section className="card backlog-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
<h2>Backlog</h2>
|
<h2>Backlog</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Reihenfolge per Drag & Drop (Desktop) oder ↑/↓ (Mobile).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -66,9 +124,32 @@ export function BacklogSection({
|
||||||
|
|
||||||
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
||||||
|
|
||||||
<ul className="item-list">
|
<ul className="item-list backlog-reorder-list">
|
||||||
{items.map((item) => (
|
{sortedItems.map((item, index) => {
|
||||||
<li key={item.id} className="list-item card-list-item">
|
const isDragging = dragItemId === item.id
|
||||||
|
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||||
|
const reorderable = canReorder && item.status !== 'converted'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className={
|
||||||
|
'list-item card-list-item backlog-reorder-item' +
|
||||||
|
(isDragging ? ' backlog-reorder-item--dragging' : '') +
|
||||||
|
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
|
||||||
|
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
|
||||||
|
}
|
||||||
|
draggable={reorderable && isDesktop}
|
||||||
|
onDragStart={(event) => handleDragStart(event, item.id)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragOver={(event) => handleDragOver(event, item.id)}
|
||||||
|
onDrop={(event) => handleDrop(event, item.id)}
|
||||||
|
>
|
||||||
|
{reorderable && isDesktop && (
|
||||||
|
<span className="backlog-reorder-item__drag-hint" aria-hidden="true">
|
||||||
|
⋮⋮
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="list-item-main list-item-main--clickable"
|
className="list-item-main list-item-main--clickable"
|
||||||
|
|
@ -81,11 +162,21 @@ export function BacklogSection({
|
||||||
)}
|
)}
|
||||||
{item.roadmap_item_id && (
|
{item.roadmap_item_id && (
|
||||||
<p className="list-item-sub muted">
|
<p className="list-item-sub muted">
|
||||||
Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
|
Gate:{' '}
|
||||||
|
{gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="list-item-meta action-controls">
|
<div className="list-item-meta action-controls">
|
||||||
|
{reorderable && !isDesktop && (
|
||||||
|
<ReorderControls
|
||||||
|
itemId={item.id}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < sortedItems.length - 1}
|
||||||
|
onMove={(direction) => handleMove(item.id, direction)}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StatusBadge kind="backlog" status={item.status} />
|
<StatusBadge kind="backlog" status={item.status} />
|
||||||
<PriorityBadge priority={item.priority} />
|
<PriorityBadge priority={item.priority} />
|
||||||
{canManage && item.status !== 'converted' && (
|
{canManage && item.status !== 'converted' && (
|
||||||
|
|
@ -119,6 +210,18 @@ export function BacklogSection({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{items
|
||||||
|
.filter((item) => item.status === 'converted')
|
||||||
|
.map((item) => (
|
||||||
|
<li key={item.id} className="list-item card-list-item backlog-reorder-item--converted">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<p className="list-item-sub muted">Konvertiert — nicht mehr sortierbar</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge kind="backlog" status={item.status} />
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ import { EmptyState } from './EmptyState.jsx'
|
||||||
import { StatusBadge } from './StatusBadge.jsx'
|
import { StatusBadge } from './StatusBadge.jsx'
|
||||||
import { Modal } from './Modal.jsx'
|
import { Modal } from './Modal.jsx'
|
||||||
import { ProjectForm } from './ProjectForm.jsx'
|
import { ProjectForm } from './ProjectForm.jsx'
|
||||||
|
import { ReorderControls } from './ReorderControls.jsx'
|
||||||
import {
|
import {
|
||||||
buildProjectsByParent,
|
buildProjectsByParent,
|
||||||
containerKindLabel,
|
containerKindLabel,
|
||||||
} from '../utils/projectTree.js'
|
} from '../utils/projectTree.js'
|
||||||
|
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
||||||
|
import { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
import { gateTitleById } from './GateSelect.jsx'
|
import { gateTitleById } from './GateSelect.jsx'
|
||||||
import { projectPath } from '../utils/routes.js'
|
import { projectPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
|
@ -18,6 +21,15 @@ function ProjectTreeNodes({
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
onSelectProject,
|
onSelectProject,
|
||||||
canManage,
|
canManage,
|
||||||
|
canReorder,
|
||||||
|
isDesktop,
|
||||||
|
dragProjectId,
|
||||||
|
dropTargetId,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onDragOver,
|
||||||
|
onDrop,
|
||||||
|
onMove,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
busy,
|
busy,
|
||||||
|
|
@ -29,15 +41,36 @@ function ProjectTreeNodes({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="project-tree-children">
|
<ul className="project-tree-children">
|
||||||
{nodes.map((project) => (
|
{nodes.map((project, index) => {
|
||||||
|
const isDragging = dragProjectId === project.id
|
||||||
|
const isDropTarget = dropTargetId === project.id && dragProjectId && dragProjectId !== project.id
|
||||||
|
|
||||||
|
return (
|
||||||
<li key={project.id} className="project-tree-branch">
|
<li key={project.id} className="project-tree-branch">
|
||||||
<div
|
<div
|
||||||
className={`list-item card-list-item project-tree-item${selectedProjectId === project.id ? ' project-tree-item--selected' : ''}`}
|
className={
|
||||||
|
'list-item card-list-item project-tree-item' +
|
||||||
|
(selectedProjectId === project.id ? ' project-tree-item--selected' : '') +
|
||||||
|
(isDragging ? ' project-tree-item--dragging' : '') +
|
||||||
|
(isDropTarget ? ' project-tree-item--drop-target' : '') +
|
||||||
|
(canReorder && isDesktop ? ' project-tree-item--draggable' : '')
|
||||||
|
}
|
||||||
|
draggable={canReorder && isDesktop}
|
||||||
|
onDragStart={(event) => onDragStart(event, project.id, parentId || '')}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={(event) => onDragOver(event, project.id)}
|
||||||
|
onDrop={(event) => onDrop(event, project.id, parentId || '')}
|
||||||
>
|
>
|
||||||
|
{canReorder && isDesktop && (
|
||||||
|
<span className="project-tree-item__drag-hint" aria-hidden="true">
|
||||||
|
⋮⋮
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="list-item-main project-tree-item-main">
|
<div className="list-item-main project-tree-item-main">
|
||||||
<Link
|
<Link
|
||||||
to={projectPath(project.id)}
|
to={projectPath(project.id)}
|
||||||
className="project-tree-title-link"
|
className="project-tree-title-link"
|
||||||
|
draggable={false}
|
||||||
>
|
>
|
||||||
<strong>{project.title}</strong>
|
<strong>{project.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -53,6 +86,15 @@ function ProjectTreeNodes({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="list-item-meta action-controls project-tree-item-actions">
|
<div className="list-item-meta action-controls project-tree-item-actions">
|
||||||
|
{canReorder && !isDesktop && (
|
||||||
|
<ReorderControls
|
||||||
|
itemId={project.id}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < nodes.length - 1}
|
||||||
|
onMove={(direction) => onMove(project.id, parentId || '', direction)}
|
||||||
|
busy={busy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StatusBadge kind="project" status={project.status} />
|
<StatusBadge kind="project" status={project.status} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -97,12 +139,22 @@ function ProjectTreeNodes({
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
onSelectProject={onSelectProject}
|
onSelectProject={onSelectProject}
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
|
canReorder={canReorder}
|
||||||
|
isDesktop={isDesktop}
|
||||||
|
dragProjectId={dragProjectId}
|
||||||
|
dropTargetId={dropTargetId}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onMove={onMove}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -116,9 +168,21 @@ export function ProjectsSection({
|
||||||
onCreate,
|
onCreate,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onReorder,
|
||||||
busy,
|
busy,
|
||||||
}) {
|
}) {
|
||||||
const [modalMode, setModalMode] = useState(null)
|
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() {
|
function closeModal() {
|
||||||
setModalMode(null)
|
setModalMode(null)
|
||||||
|
|
@ -135,6 +199,48 @@ export function ProjectsSection({
|
||||||
if (ok) closeModal()
|
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 =
|
const modalTitle =
|
||||||
modalMode?.kind === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten'
|
modalMode?.kind === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten'
|
||||||
|
|
||||||
|
|
@ -144,8 +250,8 @@ export function ProjectsSection({
|
||||||
<div>
|
<div>
|
||||||
<h2>Struktur</h2>
|
<h2>Struktur</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Projekte, Streams und Phasen — Bearbeitung im Modal, Detailseite über den Titel.
|
Projekte per Drag & Drop (Desktop) oder ↑/↓ (Mobile) sortieren — Bearbeitung im
|
||||||
Filter schränkt Arbeitspakete ein.
|
Modal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage && (
|
{canManage && (
|
||||||
|
|
@ -183,6 +289,15 @@ export function ProjectsSection({
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
onSelectProject={onSelectProject}
|
onSelectProject={onSelectProject}
|
||||||
canManage={canManage}
|
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 })}
|
onEdit={(project) => setModalMode({ kind: 'edit', project })}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|
|
||||||
32
frontend/src/components/ReorderControls.jsx
Normal file
32
frontend/src/components/ReorderControls.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="reorder-controls" aria-label="Reihenfolge">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reorder-controls__btn"
|
||||||
|
aria-label="Nach oben"
|
||||||
|
disabled={!canMoveUp || busy}
|
||||||
|
onClick={() => onMove('up')}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="reorder-controls__btn"
|
||||||
|
aria-label="Nach unten"
|
||||||
|
disabled={!canMoveDown || busy}
|
||||||
|
onClick={() => onMove('down')}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<span className="sr-only" id={`reorder-${itemId}`}>
|
||||||
|
Reihenfolge ändern
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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(
|
const milestones = useMemo(
|
||||||
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
|
||||||
[roadmapItems]
|
[roadmapItems]
|
||||||
|
|
@ -701,6 +737,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
handleCreateProject,
|
handleCreateProject,
|
||||||
handleUpdateProject,
|
handleUpdateProject,
|
||||||
handleDeleteProject,
|
handleDeleteProject,
|
||||||
|
handleReorderProjects,
|
||||||
|
handleReorderBacklog,
|
||||||
handleCreateEvidence,
|
handleCreateEvidence,
|
||||||
handleEvidenceStatus,
|
handleEvidenceStatus,
|
||||||
handleDeleteEvidence,
|
handleDeleteEvidence,
|
||||||
|
|
|
||||||
22
frontend/src/hooks/useMinWidth.js
Normal file
22
frontend/src/hooks/useMinWidth.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@ export function InitiativeInboxPage() {
|
||||||
canManage={capabilities.has('kairo.backlog.manage')}
|
canManage={capabilities.has('kairo.backlog.manage')}
|
||||||
onCreate={handleCreateBacklog}
|
onCreate={handleCreateBacklog}
|
||||||
onUpdate={handleUpdateBacklog}
|
onUpdate={handleUpdateBacklog}
|
||||||
|
onReorder={handleReorderBacklog}
|
||||||
onConvert={handleConvertBacklog}
|
onConvert={handleConvertBacklog}
|
||||||
onDelete={handleDeleteBacklog}
|
onDelete={handleDeleteBacklog}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ function PlanStructureInner() {
|
||||||
onCreate={ops.handleCreateProject}
|
onCreate={ops.handleCreateProject}
|
||||||
onUpdate={ops.handleUpdateProject}
|
onUpdate={ops.handleUpdateProject}
|
||||||
onDelete={ops.handleDeleteProject}
|
onDelete={ops.handleDeleteProject}
|
||||||
|
onReorder={ops.handleReorderProjects}
|
||||||
busy={ops.formBusy}
|
busy={ops.formBusy}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,18 @@
|
||||||
font-size: 0.92rem;
|
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 {
|
.error {
|
||||||
color: var(--jk-danger);
|
color: var(--jk-danger);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
@ -1435,3 +1447,69 @@
|
||||||
cursor: default;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
71
frontend/src/utils/reorder.js
Normal file
71
frontend/src/utils/reorder.js
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
48
frontend/src/utils/reorder.test.js
Normal file
48
frontend/src/utils/reorder.test.js
Normal file
|
|
@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user