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
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:
parent
dc6f25b673
commit
f33135b87f
14
backend/migrations/028_backlog_epic_hierarchy.sql
Normal file
14
backend/migrations/028_backlog_epic_hierarchy.sql
Normal 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'));
|
||||||
|
|
@ -20,8 +20,9 @@ class BacklogCreateRequest(BaseModel):
|
||||||
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
|
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_action_id: Optional[str] = None
|
||||||
|
parent_backlog_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BacklogUpdateRequest(BaseModel):
|
class BacklogUpdateRequest(BaseModel):
|
||||||
|
|
@ -32,9 +33,11 @@ class BacklogUpdateRequest(BaseModel):
|
||||||
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
|
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
|
parent_action_id: Optional[str] = None
|
||||||
clear_parent_action: bool = False
|
clear_parent_action: bool = False
|
||||||
|
parent_backlog_id: Optional[str] = None
|
||||||
|
clear_parent_backlog: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BacklogConvertRequest(BaseModel):
|
class BacklogConvertRequest(BaseModel):
|
||||||
|
|
@ -77,6 +80,8 @@ def update_backlog_item(
|
||||||
item_kind=body.item_kind,
|
item_kind=body.item_kind,
|
||||||
parent_action_id=body.parent_action_id,
|
parent_action_id=body.parent_action_id,
|
||||||
clear_parent_action=body.clear_parent_action,
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
@ -90,11 +95,15 @@ def delete_backlog_item(
|
||||||
backlog_item_id: str,
|
backlog_item_id: str,
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
|
||||||
):
|
):
|
||||||
if not backlog_service.delete_backlog_item(
|
try:
|
||||||
tenant_id=ctx.tenant_id,
|
deleted = backlog_service.delete_backlog_item(
|
||||||
backlog_item_id=backlog_item_id,
|
tenant_id=ctx.tenant_id,
|
||||||
user_id=ctx.user_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")
|
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,9 @@ 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
|
||||||
item_kind: Literal["story", "bug", "issue"] = "story"
|
item_kind: Literal["story", "bug", "issue", "epic"] = "story"
|
||||||
parent_action_id: Optional[str] = None
|
parent_action_id: Optional[str] = None
|
||||||
|
parent_backlog_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class MilestoneCreateRequest(BaseModel):
|
class MilestoneCreateRequest(BaseModel):
|
||||||
|
|
@ -438,6 +439,7 @@ def create_initiative_backlog_item(
|
||||||
roadmap_item_id=body.roadmap_item_id,
|
roadmap_item_id=body.roadmap_item_id,
|
||||||
item_kind=body.item_kind,
|
item_kind=body.item_kind,
|
||||||
parent_action_id=body.parent_action_id,
|
parent_action_id=body.parent_action_id,
|
||||||
|
parent_backlog_id=body.parent_backlog_id,
|
||||||
user_id=ctx.user_id,
|
user_id=ctx.user_id,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,16 @@ from services.initiatives import PRIORITIES, get_initiative
|
||||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||||
|
|
||||||
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
|
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_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 = """
|
_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, sort_order, item_kind,
|
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",
|
"converted_action_id",
|
||||||
"roadmap_item_id",
|
"roadmap_item_id",
|
||||||
"parent_action_id",
|
"parent_action_id",
|
||||||
|
"parent_backlog_id",
|
||||||
):
|
):
|
||||||
if result.get(key):
|
if result.get(key):
|
||||||
result[key] = str(result[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")
|
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(
|
def create_backlog_item(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -104,6 +148,7 @@ def create_backlog_item(
|
||||||
sort_order: Optional[int] = None,
|
sort_order: Optional[int] = None,
|
||||||
item_kind: BacklogItemKind = "story",
|
item_kind: BacklogItemKind = "story",
|
||||||
parent_action_id: Optional[str] = None,
|
parent_action_id: Optional[str] = None,
|
||||||
|
parent_backlog_id: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
|
|
@ -130,6 +175,14 @@ def create_backlog_item(
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
parent_action_id=parent_action_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(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
|
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
|
||||||
|
|
@ -143,9 +196,9 @@ def create_backlog_item(
|
||||||
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, 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}
|
RETURNING {_BACKLOG_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -159,6 +212,7 @@ def create_backlog_item(
|
||||||
sort_order if sort_order is not None else next_order,
|
sort_order if sort_order is not None else next_order,
|
||||||
item_kind,
|
item_kind,
|
||||||
parent_action_id,
|
parent_action_id,
|
||||||
|
parent_backlog_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
|
|
@ -229,6 +283,8 @@ def update_backlog_item(
|
||||||
item_kind: Optional[BacklogItemKind] = None,
|
item_kind: Optional[BacklogItemKind] = None,
|
||||||
parent_action_id: Optional[str] = None,
|
parent_action_id: Optional[str] = None,
|
||||||
clear_parent_action: bool = False,
|
clear_parent_action: bool = False,
|
||||||
|
parent_backlog_id: Optional[str] = None,
|
||||||
|
clear_parent_backlog: bool = False,
|
||||||
) -> 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:
|
||||||
|
|
@ -285,16 +341,46 @@ def update_backlog_item(
|
||||||
elif parent_action_id is not None:
|
elif parent_action_id is not None:
|
||||||
updates.append("parent_action_id = %s")
|
updates.append("parent_action_id = %s")
|
||||||
params.append(parent_action_id)
|
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:
|
if not updates:
|
||||||
return existing
|
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()")
|
updates.append("updated_at = NOW()")
|
||||||
params.extend([backlog_item_id, tenant_id])
|
params.extend([backlog_item_id, tenant_id])
|
||||||
|
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
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:
|
if parent_action_id is not None and not clear_parent_action:
|
||||||
_validate_parent_action_in_initiative(
|
_validate_parent_action_in_initiative(
|
||||||
cur=cur,
|
cur=cur,
|
||||||
|
|
@ -348,6 +434,18 @@ def delete_backlog_item(
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
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(
|
cur.execute(
|
||||||
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
|
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
|
||||||
(backlog_item_id, tenant_id),
|
(backlog_item_id, tenant_id),
|
||||||
|
|
@ -384,6 +482,10 @@ def convert_backlog_to_action(
|
||||||
if existing["status"] not in ("accepted", "triaged", "new"):
|
if existing["status"] not in ("accepted", "triaged", "new"):
|
||||||
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
|
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
|
resolved_cycle_id = work_cycle_id
|
||||||
if not resolved_cycle_id and assign_active_sprint:
|
if not resolved_cycle_id and assign_active_sprint:
|
||||||
from services.work_cycle import get_active_work_cycle
|
from services.work_cycle import get_active_work_cycle
|
||||||
|
|
@ -394,7 +496,6 @@ def convert_backlog_to_action(
|
||||||
if active:
|
if active:
|
||||||
resolved_cycle_id = active["id"]
|
resolved_cycle_id = active["id"]
|
||||||
|
|
||||||
item_kind = existing.get("item_kind") or "story"
|
|
||||||
action = create_action(
|
action = create_action(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=existing["initiative_id"],
|
initiative_id=existing["initiative_id"],
|
||||||
|
|
|
||||||
161
backend/tests/test_ap22f_backlog_epic_hierarchy.py
Normal file
161
backend/tests/test_ap22f_backlog_epic_hierarchy.py
Normal 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
|
||||||
|
|
@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||||
| `actions.sort_order` | ◐ | Migration 023 |
|
| `actions.sort_order` | ◐ | Migration 023 |
|
||||||
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
|
| BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` |
|
||||||
| BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden |
|
| 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) |
|
| 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 |
|
| Profil-Vokabular Backlog | ✗ | ADP P3: Continuous vs. Agile labels via Operating Context |
|
||||||
| Blocker | ✓ | `action_id` optional |
|
| Blocker | ✓ | `action_id` optional |
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,59 @@
|
||||||
|
import { useMemo } from 'react'
|
||||||
import {
|
import {
|
||||||
BACKLOG_STATUSES,
|
BACKLOG_STATUSES,
|
||||||
BACKLOG_STATUS_LABELS,
|
BACKLOG_STATUS_LABELS,
|
||||||
BACKLOG_ITEM_KINDS,
|
BACKLOG_ITEM_KINDS,
|
||||||
|
BACKLOG_ITEM_KINDS_WITH_EPIC,
|
||||||
BACKLOG_ITEM_KIND_LABELS,
|
BACKLOG_ITEM_KIND_LABELS,
|
||||||
PRIORITIES,
|
PRIORITIES,
|
||||||
PRIORITY_LABELS,
|
PRIORITY_LABELS,
|
||||||
} from '../constants/status.js'
|
} from '../constants/status.js'
|
||||||
import { GateSelect } from './GateSelect.jsx'
|
import { GateSelect } from './GateSelect.jsx'
|
||||||
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
|
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
|
||||||
|
import { EpicParentSelect } from './EpicParentSelect.jsx'
|
||||||
|
|
||||||
export function BacklogItemForm({
|
export function BacklogItemForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
featureActions = [],
|
featureActions = [],
|
||||||
|
epicItems = [],
|
||||||
|
epicHierarchyEnabled = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
submitLabel = 'Speichern',
|
submitLabel = 'Speichern',
|
||||||
allowStatus = true,
|
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) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const form = e.target
|
const form = e.target
|
||||||
const gateValue = form.roadmap_item_id?.value ?? ''
|
const gateValue = form.roadmap_item_id?.value ?? ''
|
||||||
const parentValue = form.parent_action_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({
|
await onSubmit({
|
||||||
title: form.title.value.trim(),
|
title: form.title.value.trim(),
|
||||||
description: form.description.value,
|
description: form.description.value,
|
||||||
status: form.status?.value,
|
status: form.status?.value,
|
||||||
priority: form.priority.value,
|
priority: form.priority.value,
|
||||||
item_kind: form.item_kind?.value || 'story',
|
item_kind: itemKind,
|
||||||
roadmap_item_id: gateValue || undefined,
|
roadmap_item_id: gateValue || undefined,
|
||||||
clear_roadmap_item: gateValue === '',
|
clear_roadmap_item: gateValue === '',
|
||||||
parent_action_id: parentValue || undefined,
|
parent_action_id: itemKind === 'epic' ? undefined : parentValue || undefined,
|
||||||
clear_parent_action: parentValue === '',
|
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 statusLocked = initial.status === 'converted'
|
||||||
const showFeatureRef =
|
const showFeatureRef =
|
||||||
(initial.item_kind || 'story') !== 'story' || featureActions.length > 0
|
!isEpic &&
|
||||||
|
((initial.item_kind || 'story') !== 'story' || featureActions.length > 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
||||||
|
|
@ -67,10 +81,10 @@ export function BacklogItemForm({
|
||||||
Typ
|
Typ
|
||||||
<select
|
<select
|
||||||
name="item_kind"
|
name="item_kind"
|
||||||
defaultValue={initial.item_kind || 'story'}
|
defaultValue={initialKind}
|
||||||
disabled={statusLocked}
|
disabled={statusLocked}
|
||||||
>
|
>
|
||||||
{BACKLOG_ITEM_KINDS.map((kind) => (
|
{itemKinds.map((kind) => (
|
||||||
<option key={kind} value={kind}>
|
<option key={kind} value={kind}>
|
||||||
{BACKLOG_ITEM_KIND_LABELS[kind]}
|
{BACKLOG_ITEM_KIND_LABELS[kind]}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -113,6 +127,13 @@ export function BacklogItemForm({
|
||||||
disabled={statusLocked}
|
disabled={statusLocked}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{epicHierarchyEnabled && !isEpic && (
|
||||||
|
<EpicParentSelect
|
||||||
|
epicItems={epicItems}
|
||||||
|
defaultValue={initial.parent_backlog_id || ''}
|
||||||
|
disabled={statusLocked}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{showFeatureRef && (
|
{showFeatureRef && (
|
||||||
<FeatureParentSelect
|
<FeatureParentSelect
|
||||||
featureActions={featureActions}
|
featureActions={featureActions}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { BacklogItemForm } from './BacklogItemForm.jsx'
|
||||||
import { ReorderControls } from './ReorderControls.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 { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
|
||||||
|
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
|
||||||
import { useMinWidth } from '../hooks/useMinWidth.js'
|
import { useMinWidth } from '../hooks/useMinWidth.js'
|
||||||
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js'
|
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js'
|
||||||
import { actionTitleById } from '../utils/actionReferences.js'
|
import { actionTitleById } from '../utils/actionReferences.js'
|
||||||
|
|
@ -82,6 +83,18 @@ export function BacklogSection({
|
||||||
[items],
|
[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() {
|
function closeModal() {
|
||||||
setModalMode(null)
|
setModalMode(null)
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +107,7 @@ export function BacklogSection({
|
||||||
roadmap_item_id: payload.roadmap_item_id,
|
roadmap_item_id: payload.roadmap_item_id,
|
||||||
item_kind: payload.item_kind,
|
item_kind: payload.item_kind,
|
||||||
parent_action_id: payload.parent_action_id,
|
parent_action_id: payload.parent_action_id,
|
||||||
|
parent_backlog_id: payload.parent_backlog_id,
|
||||||
status: payload.status || 'new',
|
status: payload.status || 'new',
|
||||||
})
|
})
|
||||||
closeModal()
|
closeModal()
|
||||||
|
|
@ -111,6 +125,8 @@ export function BacklogSection({
|
||||||
item_kind: payload.item_kind,
|
item_kind: payload.item_kind,
|
||||||
parent_action_id: payload.parent_action_id,
|
parent_action_id: payload.parent_action_id,
|
||||||
clear_parent_action: payload.clear_parent_action,
|
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()
|
if (ok !== false) closeModal()
|
||||||
}
|
}
|
||||||
|
|
@ -158,7 +174,9 @@ export function BacklogSection({
|
||||||
const modalTitle =
|
const modalTitle =
|
||||||
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
|
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))
|
const selectedCommittable = committableItems.filter((item) => selectedIds.has(item.id))
|
||||||
|
|
||||||
function toggleSelected(itemId) {
|
function toggleSelected(itemId) {
|
||||||
|
|
@ -267,20 +285,24 @@ export function BacklogSection({
|
||||||
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
||||||
|
|
||||||
<ul className="item-list backlog-reorder-list">
|
<ul className="item-list backlog-reorder-list">
|
||||||
{sortedItems.map((item, index) => {
|
{displayRows.map(({ item, depth }) => {
|
||||||
const isDragging = dragItemId === item.id
|
const isDragging = dragItemId === item.id
|
||||||
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
|
||||||
const reorderable = canReorder && item.status !== 'converted'
|
const reorderable = canReorder && item.status !== 'converted'
|
||||||
|
const isEpic = item.item_kind === 'epic'
|
||||||
|
const flatIndex = sortedItems.findIndex((row) => row.id === item.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={
|
className={
|
||||||
'list-item card-list-item backlog-reorder-item' +
|
'list-item card-list-item backlog-reorder-item' +
|
||||||
|
(depth > 0 ? ' backlog-reorder-item--child' : '') +
|
||||||
(isDragging ? ' backlog-reorder-item--dragging' : '') +
|
(isDragging ? ' backlog-reorder-item--dragging' : '') +
|
||||||
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
|
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
|
||||||
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
|
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
|
||||||
}
|
}
|
||||||
|
style={depth > 0 ? { marginLeft: `${depth * 1.25}rem` } : undefined}
|
||||||
draggable={reorderable && isDesktop}
|
draggable={reorderable && isDesktop}
|
||||||
onDragStart={(event) => handleDragStart(event, item.id)}
|
onDragStart={(event) => handleDragStart(event, item.id)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
|
@ -292,7 +314,7 @@ export function BacklogSection({
|
||||||
⋮⋮
|
⋮⋮
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && (
|
{showSprintPlanning && canManage && COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
|
||||||
<label className="backlog-select-checkbox">
|
<label className="backlog-select-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -318,6 +340,11 @@ export function BacklogSection({
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<p className="list-item-desc">{item.description}</p>
|
<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 && (
|
{item.parent_action_id && (
|
||||||
<p className="list-item-sub muted">
|
<p className="list-item-sub muted">
|
||||||
Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'}
|
Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'}
|
||||||
|
|
@ -334,8 +361,8 @@ export function BacklogSection({
|
||||||
{reorderable && !isDesktop && (
|
{reorderable && !isDesktop && (
|
||||||
<ReorderControls
|
<ReorderControls
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
canMoveUp={index > 0}
|
canMoveUp={flatIndex > 0}
|
||||||
canMoveDown={index < sortedItems.length - 1}
|
canMoveDown={flatIndex < sortedItems.length - 1}
|
||||||
onMove={(direction) => handleMove(item.id, direction)}
|
onMove={(direction) => handleMove(item.id, direction)}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
/>
|
/>
|
||||||
|
|
@ -344,7 +371,7 @@ export function BacklogSection({
|
||||||
<PriorityBadge priority={item.priority} />
|
<PriorityBadge priority={item.priority} />
|
||||||
{canManage && item.status !== 'converted' && (
|
{canManage && item.status !== 'converted' && (
|
||||||
<>
|
<>
|
||||||
{COMMITTABLE_STATUSES.has(item.status) && (
|
{COMMITTABLE_STATUSES.has(item.status) && !isEpic && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary btn-sm"
|
className="btn btn-primary btn-sm"
|
||||||
|
|
@ -393,6 +420,8 @@ export function BacklogSection({
|
||||||
<BacklogItemForm
|
<BacklogItemForm
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
featureActions={featureActions}
|
featureActions={featureActions}
|
||||||
|
epicItems={epicItems}
|
||||||
|
epicHierarchyEnabled={epicHierarchyEnabled}
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|
@ -404,6 +433,8 @@ export function BacklogSection({
|
||||||
initial={modalMode.item}
|
initial={modalMode.item}
|
||||||
roadmapItems={roadmapItems}
|
roadmapItems={roadmapItems}
|
||||||
featureActions={featureActions}
|
featureActions={featureActions}
|
||||||
|
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
|
||||||
|
epicHierarchyEnabled={epicHierarchyEnabled}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
|
|
||||||
24
frontend/src/components/EpicParentSelect.jsx
Normal file
24
frontend/src/components/EpicParentSelect.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,9 @@ export const BACKLOG_STATUS_LABELS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BACKLOG_ITEM_KINDS = ['story', 'bug', 'issue']
|
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 = {
|
export const BACKLOG_ITEM_KIND_LABELS = {
|
||||||
|
epic: 'Epic',
|
||||||
story: 'Story',
|
story: 'Story',
|
||||||
bug: 'Bug',
|
bug: 'Bug',
|
||||||
issue: 'Issue',
|
issue: 'Issue',
|
||||||
|
|
|
||||||
53
frontend/src/utils/backlogTree.js
Normal file
53
frontend/src/utils/backlogTree.js
Normal 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',
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/src/utils/backlogTree.test.js
Normal file
28
frontend/src/utils/backlogTree.test.js
Normal 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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user