feat(backlog): Epic-Hierarchie mit parent_backlog_id (ADP P2)
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 3m54s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 15s

Epics als Backlog-Container mit Baum-UI, Convert-Guard und pytest-Abdeckung — Roll-up bleibt für P4 offen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 09:22:20 +02:00
parent dc6f25b673
commit f33135b87f
12 changed files with 473 additions and 27 deletions

View File

@ -0,0 +1,14 @@
-- AP2.2f / ADP Backlog Epic-Hierarchie — parent_backlog_id + item_kind epic
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS parent_backlog_id UUID NULL
REFERENCES backlog_items (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_backlog_parent_backlog
ON backlog_items (tenant_id, initiative_id, parent_backlog_id)
WHERE parent_backlog_id IS NOT NULL;
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
ALTER TABLE backlog_items
ADD CONSTRAINT backlog_items_item_kind_check
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic'));

View File

@ -20,8 +20,9 @@ class BacklogCreateRequest(BaseModel):
priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
sort_order: Optional[int] = None
item_kind: Literal["story", "bug", "issue"] = "story"
item_kind: Literal["story", "bug", "issue", "epic"] = "story"
parent_action_id: Optional[str] = None
parent_backlog_id: Optional[str] = None
class BacklogUpdateRequest(BaseModel):
@ -32,9 +33,11 @@ class BacklogUpdateRequest(BaseModel):
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
item_kind: Optional[Literal["story", "bug", "issue"]] = None
item_kind: Optional[Literal["story", "bug", "issue", "epic"]] = None
parent_action_id: Optional[str] = None
clear_parent_action: bool = False
parent_backlog_id: Optional[str] = None
clear_parent_backlog: bool = False
class BacklogConvertRequest(BaseModel):
@ -77,6 +80,8 @@ def update_backlog_item(
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
clear_parent_action=body.clear_parent_action,
parent_backlog_id=body.parent_backlog_id,
clear_parent_backlog=body.clear_parent_backlog,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@ -90,11 +95,15 @@ def delete_backlog_item(
backlog_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
if not backlog_service.delete_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
):
try:
deleted = backlog_service.delete_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")

View File

@ -89,8 +89,9 @@ class BacklogCreateRequest(BaseModel):
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
item_kind: Literal["story", "bug", "issue"] = "story"
item_kind: Literal["story", "bug", "issue", "epic"] = "story"
parent_action_id: Optional[str] = None
parent_backlog_id: Optional[str] = None
class MilestoneCreateRequest(BaseModel):
@ -438,6 +439,7 @@ def create_initiative_backlog_item(
roadmap_item_id=body.roadmap_item_id,
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
parent_backlog_id=body.parent_backlog_id,
user_id=ctx.user_id,
)
except ValueError as exc:

View File

@ -13,15 +13,16 @@ from services.initiatives import PRIORITIES, get_initiative
from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BacklogItemKind = Literal["story", "bug", "issue"]
BacklogItemKind = Literal["story", "bug", "issue", "epic"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"})
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic"})
CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue"})
_BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order, item_kind,
parent_action_id, created_at, updated_at
parent_action_id, parent_backlog_id, created_at, updated_at
"""
@ -34,6 +35,7 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
"converted_action_id",
"roadmap_item_id",
"parent_action_id",
"parent_backlog_id",
):
if result.get(key):
result[key] = str(result[key])
@ -92,6 +94,48 @@ def _validate_parent_action_in_initiative(
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue")
def _validate_backlog_hierarchy(
*,
cur,
tenant_id: str,
initiative_id: str,
item_kind: str,
parent_backlog_id: Optional[str],
parent_action_id: Optional[str],
backlog_item_id: Optional[str] = None,
) -> None:
if item_kind == "epic":
if parent_backlog_id:
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
if parent_action_id:
raise ValueError("Epic kann keinem Arbeitspaket zugeordnet werden")
return
if not parent_backlog_id:
return
if backlog_item_id and parent_backlog_id == backlog_item_id:
raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
cur.execute(
"""
SELECT id, item_kind, initiative_id
FROM backlog_items
WHERE id = %s AND tenant_id = %s
""",
(parent_backlog_id, tenant_id),
)
parent = cur.fetchone()
if not parent:
raise ValueError("Epic-Referenz nicht gefunden")
parent_initiative = parent["initiative_id"] if isinstance(parent, dict) else parent[2]
parent_kind = parent["item_kind"] if isinstance(parent, dict) else parent[1]
if str(parent_initiative) != initiative_id:
raise ValueError("Epic gehört zu einem anderen Vorhaben")
if parent_kind != "epic":
raise ValueError("Übergeordnetes Item muss ein Epic sein")
def create_backlog_item(
*,
tenant_id: str,
@ -104,6 +148,7 @@ def create_backlog_item(
sort_order: Optional[int] = None,
item_kind: BacklogItemKind = "story",
parent_action_id: Optional[str] = None,
parent_backlog_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
@ -130,6 +175,14 @@ def create_backlog_item(
initiative_id=initiative_id,
parent_action_id=parent_action_id,
)
_validate_backlog_hierarchy(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item_kind=item_kind,
parent_backlog_id=parent_backlog_id,
parent_action_id=parent_action_id,
)
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
@ -143,9 +196,9 @@ def create_backlog_item(
f"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id, sort_order, item_kind, parent_action_id
roadmap_item_id, sort_order, item_kind, parent_action_id, parent_backlog_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_BACKLOG_COLUMNS}
""",
(
@ -159,6 +212,7 @@ def create_backlog_item(
sort_order if sort_order is not None else next_order,
item_kind,
parent_action_id,
parent_backlog_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
@ -229,6 +283,8 @@ def update_backlog_item(
item_kind: Optional[BacklogItemKind] = None,
parent_action_id: Optional[str] = None,
clear_parent_action: bool = False,
parent_backlog_id: Optional[str] = None,
clear_parent_backlog: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
@ -285,16 +341,46 @@ def update_backlog_item(
elif parent_action_id is not None:
updates.append("parent_action_id = %s")
params.append(parent_action_id)
if clear_parent_backlog:
updates.append("parent_backlog_id = NULL")
elif parent_backlog_id is not None:
updates.append("parent_backlog_id = %s")
params.append(parent_backlog_id)
if not updates:
return existing
effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story"
effective_parent_backlog = (
None
if clear_parent_backlog
else parent_backlog_id
if parent_backlog_id is not None
else existing.get("parent_backlog_id")
)
effective_parent_action = (
None
if clear_parent_action
else parent_action_id
if parent_action_id is not None
else existing.get("parent_action_id")
)
updates.append("updated_at = NOW()")
params.extend([backlog_item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_validate_backlog_hierarchy(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item_kind=effective_kind,
parent_backlog_id=effective_parent_backlog,
parent_action_id=effective_parent_action,
backlog_item_id=backlog_item_id,
)
if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative(
cur=cur,
@ -348,6 +434,18 @@ def delete_backlog_item(
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id FROM backlog_items
WHERE parent_backlog_id = %s AND tenant_id = %s AND status != 'converted'
LIMIT 1
""",
(backlog_item_id, tenant_id),
)
if cur.fetchone():
raise ValueError(
"Epic mit untergeordneten Items kann nicht gelöscht werden"
)
cur.execute(
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
(backlog_item_id, tenant_id),
@ -384,6 +482,10 @@ def convert_backlog_to_action(
if existing["status"] not in ("accepted", "triaged", "new"):
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
item_kind = existing.get("item_kind") or "story"
if item_kind not in CONVERTIBLE_ITEM_KINDS:
raise ValueError("Epic kann nicht direkt in ein Arbeitspaket umgewandelt werden")
resolved_cycle_id = work_cycle_id
if not resolved_cycle_id and assign_active_sprint:
from services.work_cycle import get_active_work_cycle
@ -394,7 +496,6 @@ def convert_backlog_to_action(
if active:
resolved_cycle_id = active["id"]
item_kind = existing.get("item_kind") or "story"
action = create_action(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],

View File

@ -0,0 +1,161 @@
"""AP2.2f — Backlog Epic-Hierarchie (parent_backlog_id + item_kind epic)."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def _create_product_initiative(client, token, title="Epic Product"):
created = _create_initiative(
client,
token,
title=title,
archetype_key="initiative.product",
)
assert created.status_code == 201
return created.json()["id"]
def test_create_epic_and_story_under_epic(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token)
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Checkout Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
assert epic.status_code == 201
epic_id = epic.json()["id"]
assert epic.json()["item_kind"] == "epic"
assert epic.json().get("parent_backlog_id") is None
story = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Login Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "accepted",
},
headers=_auth(token),
)
assert story.status_code == 201
assert story.json()["parent_backlog_id"] == epic_id
listed = client.get(
f"/api/initiatives/{initiative_id}/backlog",
headers=_auth(token),
)
assert listed.status_code == 200
by_id = {item["id"]: item for item in listed.json()}
assert by_id[story.json()["id"]]["parent_backlog_id"] == epic_id
def test_epic_cannot_convert_to_action(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token, title="Epic Convert Block")
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Big Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_id = epic.json()["id"]
converted = client.post(
f"/api/backlog/{epic_id}/convert-to-action",
json={},
headers=_auth(token),
)
assert converted.status_code == 400
assert "Epic" in converted.json()["detail"]
def test_story_under_epic_converts_to_action(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token, title="Story Convert")
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_id = epic.json()["id"]
story = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "accepted",
},
headers=_auth(token),
)
story_id = story.json()["id"]
converted = client.post(
f"/api/backlog/{story_id}/convert-to-action",
json={},
headers=_auth(token),
)
assert converted.status_code == 201
assert converted.json()["action"]["title"] == "Story"
def test_cannot_delete_epic_with_children(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token, title="Epic Delete Guard")
epic = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Epic", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_id = epic.json()["id"]
client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Child Story",
"item_kind": "story",
"parent_backlog_id": epic_id,
"status": "new",
},
headers=_auth(token),
)
deleted = client.delete(f"/api/backlog/{epic_id}", headers=_auth(token))
assert deleted.status_code == 400
assert "untergeordneten" in deleted.json()["detail"]
def test_epic_cannot_have_parent_backlog(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
initiative_id = _create_product_initiative(client, token, title="Epic Parent Guard")
epic_a = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Epic A", "item_kind": "epic", "status": "accepted"},
headers=_auth(token),
)
epic_a_id = epic_a.json()["id"]
epic_b = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Epic B",
"item_kind": "epic",
"parent_backlog_id": epic_a_id,
"status": "accepted",
},
headers=_auth(token),
)
assert epic_b.status_code == 400

View File

@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| `actions.sort_order` | ◐ | Migration 023 |
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
| BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden |
| Backlog Epic-Hierarchie | ✗ | ADP P2: `epic` + `parent_backlog_id` — dokumentiert 2026-07-26 |
| Backlog Epic-Hierarchie | ◐ | AP2.2f: `epic` + `parent_backlog_id`, Baum-UI, Convert-Guard — Roll-up (P4) offen |
| Dual Commit (Direct AP) | ◐ | ADP P1: Plan→Arbeit im Product-Outline (2026-07-26) |
| Profil-Vokabular Backlog | ✗ | ADP P3: Continuous vs. Agile labels via Operating Context |
| Blocker | ✓ | `action_id` optional |

View File

@ -1,45 +1,59 @@
import { useMemo } from 'react'
import {
BACKLOG_STATUSES,
BACKLOG_STATUS_LABELS,
BACKLOG_ITEM_KINDS,
BACKLOG_ITEM_KINDS_WITH_EPIC,
BACKLOG_ITEM_KIND_LABELS,
PRIORITIES,
PRIORITY_LABELS,
} from '../constants/status.js'
import { GateSelect } from './GateSelect.jsx'
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
import { EpicParentSelect } from './EpicParentSelect.jsx'
export function BacklogItemForm({
initial = {},
roadmapItems = [],
featureActions = [],
epicItems = [],
epicHierarchyEnabled = false,
onSubmit,
onCancel,
busy = false,
submitLabel = 'Speichern',
allowStatus = true,
}) {
const itemKinds = epicHierarchyEnabled ? BACKLOG_ITEM_KINDS_WITH_EPIC : BACKLOG_ITEM_KINDS
const initialKind = initial.item_kind || 'story'
const isEpic = initialKind === 'epic'
async function handleSubmit(e) {
e.preventDefault()
const form = e.target
const gateValue = form.roadmap_item_id?.value ?? ''
const parentValue = form.parent_action_id?.value ?? ''
const epicValue = form.parent_backlog_id?.value ?? ''
const itemKind = form.item_kind?.value || 'story'
await onSubmit({
title: form.title.value.trim(),
description: form.description.value,
status: form.status?.value,
priority: form.priority.value,
item_kind: form.item_kind?.value || 'story',
item_kind: itemKind,
roadmap_item_id: gateValue || undefined,
clear_roadmap_item: gateValue === '',
parent_action_id: parentValue || undefined,
clear_parent_action: parentValue === '',
parent_action_id: itemKind === 'epic' ? undefined : parentValue || undefined,
clear_parent_action: itemKind === 'epic' ? true : parentValue === '',
parent_backlog_id: itemKind === 'epic' ? undefined : epicValue || undefined,
clear_parent_backlog: itemKind === 'epic' ? true : epicValue === '',
})
}
const statusLocked = initial.status === 'converted'
const showFeatureRef =
(initial.item_kind || 'story') !== 'story' || featureActions.length > 0
!isEpic &&
((initial.item_kind || 'story') !== 'story' || featureActions.length > 0)
return (
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
@ -67,10 +81,10 @@ export function BacklogItemForm({
Typ
<select
name="item_kind"
defaultValue={initial.item_kind || 'story'}
defaultValue={initialKind}
disabled={statusLocked}
>
{BACKLOG_ITEM_KINDS.map((kind) => (
{itemKinds.map((kind) => (
<option key={kind} value={kind}>
{BACKLOG_ITEM_KIND_LABELS[kind]}
</option>
@ -113,6 +127,13 @@ export function BacklogItemForm({
disabled={statusLocked}
/>
)}
{epicHierarchyEnabled && !isEpic && (
<EpicParentSelect
epicItems={epicItems}
defaultValue={initial.parent_backlog_id || ''}
disabled={statusLocked}
/>
)}
{showFeatureRef && (
<FeatureParentSelect
featureActions={featureActions}

View File

@ -7,6 +7,7 @@ import { BacklogItemForm } from './BacklogItemForm.jsx'
import { ReorderControls } from './ReorderControls.jsx'
import { gateTitleById } from './GateSelect.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
import { useMinWidth } from '../hooks/useMinWidth.js'
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js'
import { actionTitleById } from '../utils/actionReferences.js'
@ -82,6 +83,18 @@ export function BacklogSection({
[items],
)
const epicItems = useMemo(() => listEpicBacklogItems(items), [items])
const displayRows = useMemo(() => buildBacklogDisplayRows(items), [items])
const epicTitleById = useMemo(() => {
const map = new Map()
for (const epic of epicItems) {
map.set(epic.id, epic.title)
}
return map
}, [epicItems])
const epicHierarchyEnabled = sprintPlanningEnabled
function closeModal() {
setModalMode(null)
}
@ -94,6 +107,7 @@ export function BacklogSection({
roadmap_item_id: payload.roadmap_item_id,
item_kind: payload.item_kind,
parent_action_id: payload.parent_action_id,
parent_backlog_id: payload.parent_backlog_id,
status: payload.status || 'new',
})
closeModal()
@ -111,6 +125,8 @@ export function BacklogSection({
item_kind: payload.item_kind,
parent_action_id: payload.parent_action_id,
clear_parent_action: payload.clear_parent_action,
parent_backlog_id: payload.parent_backlog_id,
clear_parent_backlog: payload.clear_parent_backlog,
})
if (ok !== false) closeModal()
}
@ -158,7 +174,9 @@ export function BacklogSection({
const modalTitle =
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
const committableItems = sortedItems.filter((item) => COMMITTABLE_STATUSES.has(item.status))
const committableItems = sortedItems.filter(
(item) => COMMITTABLE_STATUSES.has(item.status) && item.item_kind !== 'epic',
)
const selectedCommittable = committableItems.filter((item) => selectedIds.has(item.id))
function toggleSelected(itemId) {
@ -267,20 +285,24 @@ export function BacklogSection({
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
<ul className="item-list backlog-reorder-list">
{sortedItems.map((item, index) => {
{displayRows.map(({ item, depth }) => {
const isDragging = dragItemId === item.id
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
const reorderable = canReorder && item.status !== 'converted'
const isEpic = item.item_kind === 'epic'
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
return (
<li
key={item.id}
className={
'list-item card-list-item backlog-reorder-item' +
(depth > 0 ? ' backlog-reorder-item--child' : '') +
(isDragging ? ' backlog-reorder-item--dragging' : '') +
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
}
style={depth > 0 ? { marginLeft: `${depth * 1.25}rem` } : undefined}
draggable={reorderable && isDesktop}
onDragStart={(event) => handleDragStart(event, item.id)}
onDragEnd={handleDragEnd}
@ -292,7 +314,7 @@ export function BacklogSection({
</span>
)}
{showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && (
{showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
<label className="backlog-select-checkbox">
<input
type="checkbox"
@ -318,6 +340,11 @@ export function BacklogSection({
{item.description && (
<p className="list-item-desc">{item.description}</p>
)}
{item.parent_backlog_id && (
<p className="list-item-sub muted">
Epic: {epicTitleById.get(item.parent_backlog_id) || '…'}
</p>
)}
{item.parent_action_id && (
<p className="list-item-sub muted">
Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'}
@ -334,8 +361,8 @@ export function BacklogSection({
{reorderable && !isDesktop && (
<ReorderControls
itemId={item.id}
canMoveUp={index > 0}
canMoveDown={index < sortedItems.length - 1}
canMoveUp={flatIndex > 0}
canMoveDown={flatIndex < sortedItems.length - 1}
onMove={(direction) => handleMove(item.id, direction)}
busy={busy}
/>
@ -344,7 +371,7 @@ export function BacklogSection({
<PriorityBadge priority={item.priority} />
{canManage && item.status !== 'converted' && (
<>
{COMMITTABLE_STATUSES.has(item.status) && (
{COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
<button
type="button"
className="btn btn-primary btn-sm"
@ -393,6 +420,8 @@ export function BacklogSection({
<BacklogItemForm
roadmapItems={roadmapItems}
featureActions={featureActions}
epicItems={epicItems}
epicHierarchyEnabled={epicHierarchyEnabled}
onSubmit={handleCreateSubmit}
onCancel={closeModal}
busy={busy}
@ -404,6 +433,8 @@ export function BacklogSection({
initial={modalMode.item}
roadmapItems={roadmapItems}
featureActions={featureActions}
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
epicHierarchyEnabled={epicHierarchyEnabled}
onSubmit={handleEditSubmit}
onCancel={closeModal}
busy={busy}

View File

@ -0,0 +1,24 @@
export function EpicParentSelect({ epicItems = [], defaultValue = '', disabled = false }) {
if (!epicItems.length) {
return (
<label>
Epic (optional)
<p className="muted form-hint">Lege zuerst ein Epic an, um Stories zuzuordnen.</p>
</label>
)
}
return (
<label>
Epic (optional)
<select name="parent_backlog_id" defaultValue={defaultValue || ''} disabled={disabled}>
<option value=""> kein Epic </option>
{epicItems.map((epic) => (
<option key={epic.id} value={epic.id}>
{epic.title}
</option>
))}
</select>
</label>
)
}

View File

@ -62,7 +62,9 @@ export const BACKLOG_STATUS_LABELS = {
}
export const BACKLOG_ITEM_KINDS = ['story', 'bug', 'issue']
export const BACKLOG_ITEM_KINDS_WITH_EPIC = ['epic', 'story', 'bug', 'issue']
export const BACKLOG_ITEM_KIND_LABELS = {
epic: 'Epic',
story: 'Story',
bug: 'Bug',
issue: 'Issue',

View File

@ -0,0 +1,53 @@
import { sortByOrder } from './reorder.js'
/**
* Flache Anzeige-Reihenfolge: Epics an Wurzel, Stories/Bugs/Issues eingerückt darunter.
* @param {Array<{ id: string, item_kind?: string, parent_backlog_id?: string | null, status?: string }>} items
* @returns {Array<{ item: object, depth: number }>}
*/
export function buildBacklogDisplayRows(items) {
const active = sortByOrder((items || []).filter((item) => item.status !== 'converted'))
const childrenByEpic = new Map()
const roots = []
for (const item of active) {
if (item.parent_backlog_id) {
const list = childrenByEpic.get(item.parent_backlog_id) || []
list.push(item)
childrenByEpic.set(item.parent_backlog_id, list)
} else {
roots.push(item)
}
}
const rows = []
const linkedChildIds = new Set()
for (const root of roots) {
rows.push({ item: root, depth: 0 })
if (root.item_kind === 'epic') {
const children = sortByOrder(childrenByEpic.get(root.id) || [])
for (const child of children) {
linkedChildIds.add(child.id)
rows.push({ item: child, depth: 1 })
}
}
}
for (const item of active) {
if (item.parent_backlog_id && !linkedChildIds.has(item.id)) {
rows.push({ item, depth: 0 })
}
}
return rows
}
/**
* @param {Array<{ id: string, item_kind?: string, status?: string }>} items
*/
export function listEpicBacklogItems(items) {
return (items || []).filter(
(item) => item.item_kind === 'epic' && item.status !== 'converted',
)
}

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { buildBacklogDisplayRows, listEpicBacklogItems } from './backlogTree.js'
describe('backlogTree', () => {
it('gruppiert Stories unter Epic', () => {
const items = [
{ id: 'e1', title: 'Epic A', item_kind: 'epic', sort_order: 0, status: 'accepted' },
{ id: 's1', title: 'Story 1', item_kind: 'story', parent_backlog_id: 'e1', sort_order: 0, status: 'new' },
{ id: 's2', title: 'Story 2', item_kind: 'story', parent_backlog_id: 'e1', sort_order: 10, status: 'new' },
{ id: 'x1', title: 'Los', item_kind: 'story', sort_order: 20, status: 'new' },
]
const rows = buildBacklogDisplayRows(items)
expect(rows.map((r) => [r.item.id, r.depth])).toEqual([
['e1', 0],
['s1', 1],
['s2', 1],
['x1', 0],
])
})
it('listEpicBacklogItems filtert konvertierte Epics', () => {
const items = [
{ id: 'e1', item_kind: 'epic', status: 'accepted' },
{ id: 'e2', item_kind: 'epic', status: 'converted' },
]
expect(listEpicBacklogItems(items).map((i) => i.id)).toEqual(['e1'])
})
})