AP1.13b: Gate-Abhaengigkeiten auf der Detailseite pflegen.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s
DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7f03504c2d
commit
7cd8f4b90e
|
|
@ -98,6 +98,7 @@ app.include_router(backlog.router)
|
||||||
app.include_router(milestones.router)
|
app.include_router(milestones.router)
|
||||||
app.include_router(roadmap.initiative_router)
|
app.include_router(roadmap.initiative_router)
|
||||||
app.include_router(roadmap.items_router)
|
app.include_router(roadmap.items_router)
|
||||||
|
app.include_router(roadmap.deps_router)
|
||||||
app.include_router(roadmap.criteria_router)
|
app.include_router(roadmap.criteria_router)
|
||||||
app.include_router(evidence.router)
|
app.include_router(evidence.router)
|
||||||
app.include_router(decisions.router)
|
app.include_router(decisions.router)
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class ReopenRequest(BaseModel):
|
||||||
|
|
||||||
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
|
||||||
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
|
||||||
|
deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"])
|
||||||
|
|
||||||
|
|
||||||
@initiative_router.get("/{initiative_id}/roadmap")
|
@initiative_router.get("/{initiative_id}/roadmap")
|
||||||
|
|
@ -270,6 +271,21 @@ def create_roadmap_item_criterion(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@deps_router.delete("/{dependency_id}", status_code=204)
|
||||||
|
def delete_roadmap_dependency(
|
||||||
|
dependency_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
roadmap_service.delete_dependency(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
dependency_id=dependency_id,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
|
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,17 @@ def add_dependency(
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM roadmap_item_dependencies
|
||||||
|
WHERE tenant_id = %s AND from_item_id = %s AND to_item_id = %s
|
||||||
|
AND dependency_type = %s
|
||||||
|
""",
|
||||||
|
(tenant_id, from_item_id, to_item_id, dependency_type),
|
||||||
|
)
|
||||||
|
if cur.fetchone():
|
||||||
|
raise ValueError("Abhängigkeit existiert bereits")
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO roadmap_item_dependencies (
|
INSERT INTO roadmap_item_dependencies (
|
||||||
|
|
@ -573,6 +584,49 @@ def add_dependency(
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def delete_dependency(
|
||||||
|
*, tenant_id: str, dependency_id: str, user_id: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, from_item_id, to_item_id, dependency_type
|
||||||
|
FROM roadmap_item_dependencies
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(dependency_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("Abhängigkeit nicht gefunden")
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM roadmap_item_dependencies
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(dependency_id, tenant_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"roadmap_item.dependency_removed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"dependency_id": dependency_id,
|
||||||
|
"from_item_id": str(row["from_item_id"]),
|
||||||
|
"to_item_id": str(row["to_item_id"]),
|
||||||
|
"dependency_type": row["dependency_type"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def verify_reached(
|
def verify_reached(
|
||||||
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
*, tenant_id: str, item_id: str, user_id: Optional[str] = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -146,3 +146,53 @@ def test_initiative_roadmap_dependencies_list(client):
|
||||||
assert deps[0]["from_item_id"] == gate_a["id"]
|
assert deps[0]["from_item_id"] == gate_a["id"]
|
||||||
assert deps[0]["to_item_id"] == gate_b["id"]
|
assert deps[0]["to_item_id"] == gate_b["id"]
|
||||||
assert deps[0]["dependency_type"] == "requires"
|
assert deps[0]["dependency_type"] == "requires"
|
||||||
|
|
||||||
|
|
||||||
|
def test_roadmap_dependency_delete(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||||
|
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||||
|
json={"to_item_id": gate_b["id"], "dependency_type": "requires"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
dep_id = created.json()["id"]
|
||||||
|
|
||||||
|
deleted = client.delete(
|
||||||
|
f"/api/roadmap-dependencies/{dep_id}",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert deleted.status_code == 204
|
||||||
|
|
||||||
|
listed = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/dependencies",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert listed.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_roadmap_dependency_duplicate_rejected(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json()
|
||||||
|
gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json()
|
||||||
|
body = {"to_item_id": gate_b["id"], "dependency_type": "requires"}
|
||||||
|
|
||||||
|
assert client.post(
|
||||||
|
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
).status_code == 201
|
||||||
|
|
||||||
|
dup = client.post(
|
||||||
|
f"/api/roadmap-items/{gate_a['id']}/dependencies",
|
||||||
|
json=body,
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert dup.status_code == 400
|
||||||
|
assert "existiert bereits" in dup.json()["detail"]
|
||||||
|
|
|
||||||
|
|
@ -96,3 +96,7 @@ export function addRoadmapItemDependency(itemId, body) {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteRoadmapDependency(dependencyId) {
|
||||||
|
return apiFetch(`/api/roadmap-dependencies/${dependencyId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
|
||||||
201
frontend/src/components/GateDependenciesSection.jsx
Normal file
201
frontend/src/components/GateDependenciesSection.jsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { gatePath } from '../utils/routes.js'
|
||||||
|
import { gateTitleById } from './GateSelect.jsx'
|
||||||
|
|
||||||
|
const DEPENDENCY_TYPE_LABELS = {
|
||||||
|
requires: 'Voraussetzung',
|
||||||
|
blocks: 'Blockiert',
|
||||||
|
related: 'Bezug',
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTGOING_PHRASES = {
|
||||||
|
requires: 'benötigt',
|
||||||
|
blocks: 'blockiert',
|
||||||
|
related: 'Bezug zu',
|
||||||
|
}
|
||||||
|
|
||||||
|
const INCOMING_PHRASES = {
|
||||||
|
requires: 'Voraussetzung für',
|
||||||
|
blocks: 'Wird blockiert von',
|
||||||
|
related: 'Bezug von',
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupDependencies(itemId, dependencies) {
|
||||||
|
/** @type {{ outgoing: typeof dependencies, incoming: typeof dependencies }} */
|
||||||
|
const groups = { outgoing: [], incoming: [] }
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
if (dep.from_item_id === itemId) groups.outgoing.push(dep)
|
||||||
|
else if (dep.to_item_id === itemId) groups.incoming.push(dep)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy, onDelete }) {
|
||||||
|
const otherId = direction === 'outgoing' ? dep.to_item_id : dep.from_item_id
|
||||||
|
const otherTitle = gateTitleById(siblingItems, otherId) || otherId
|
||||||
|
const phrase =
|
||||||
|
direction === 'outgoing'
|
||||||
|
? OUTGOING_PHRASES[dep.dependency_type] || dep.dependency_type
|
||||||
|
: INCOMING_PHRASES[dep.dependency_type] || dep.dependency_type
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="list-item card-list-item gate-dependency-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<p className="gate-dependency-item__phrase">
|
||||||
|
{direction === 'outgoing' ? (
|
||||||
|
<>
|
||||||
|
Dieses Gate <strong>{phrase}</strong>{' '}
|
||||||
|
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{phrase}</strong>{' '}
|
||||||
|
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="muted list-item-sub">
|
||||||
|
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<div className="list-item-meta action-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onDelete(dep.id)}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GateDependenciesSection({
|
||||||
|
itemId,
|
||||||
|
siblingItems = [],
|
||||||
|
dependencies = [],
|
||||||
|
canManage,
|
||||||
|
busy,
|
||||||
|
onAdd,
|
||||||
|
onDelete,
|
||||||
|
}) {
|
||||||
|
const candidates = siblingItems.filter((item) => item.id !== itemId)
|
||||||
|
const { outgoing, incoming } = groupDependencies(itemId, dependencies)
|
||||||
|
const hasAny = outgoing.length > 0 || incoming.length > 0
|
||||||
|
|
||||||
|
async function handleAddSubmit(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const form = event.target
|
||||||
|
const toItemId = form.to_item_id.value
|
||||||
|
const dependencyType = form.dependency_type.value
|
||||||
|
if (!toItemId) return
|
||||||
|
await onAdd({
|
||||||
|
to_item_id: toItemId,
|
||||||
|
dependency_type: dependencyType,
|
||||||
|
})
|
||||||
|
form.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="gate-dependencies-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h3>Abhängigkeiten</h3>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Kanten für den Gate-Graph — ausgehende Abhängigkeiten kannst du hier anlegen und
|
||||||
|
entfernen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasAny && (
|
||||||
|
<p className="muted gate-dependencies-section__empty">
|
||||||
|
Noch keine Kanten — im Graph erscheinen Gates sonst nach Reihenfolge.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outgoing.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4 className="gate-dependencies-section__group-title">Ausgehend</h4>
|
||||||
|
<ul className="item-list gate-dependency-list">
|
||||||
|
{outgoing.map((dep) => (
|
||||||
|
<DependencyRow
|
||||||
|
key={dep.id}
|
||||||
|
dep={dep}
|
||||||
|
itemId={itemId}
|
||||||
|
siblingItems={siblingItems}
|
||||||
|
direction="outgoing"
|
||||||
|
canManage={canManage}
|
||||||
|
busy={busy}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incoming.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h4 className="gate-dependencies-section__group-title">Eingehend</h4>
|
||||||
|
<ul className="item-list gate-dependency-list">
|
||||||
|
{incoming.map((dep) => (
|
||||||
|
<DependencyRow
|
||||||
|
key={dep.id}
|
||||||
|
dep={dep}
|
||||||
|
itemId={itemId}
|
||||||
|
siblingItems={siblingItems}
|
||||||
|
direction="incoming"
|
||||||
|
canManage={canManage}
|
||||||
|
busy={busy}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && candidates.length > 0 && (
|
||||||
|
<form className="inline-form-block gate-dependency-form" onSubmit={handleAddSubmit}>
|
||||||
|
<h4>Kante hinzufügen</h4>
|
||||||
|
<p className="muted form-hint gate-dependency-form__hint">
|
||||||
|
Dieses Gate …
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
Beziehung
|
||||||
|
<select name="dependency_type" defaultValue="requires">
|
||||||
|
<option value="requires">benötigt (Voraussetzung)</option>
|
||||||
|
<option value="blocks">blockiert</option>
|
||||||
|
<option value="related">steht in Bezug zu</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Anderes Gate
|
||||||
|
<select name="to_item_id" required defaultValue="">
|
||||||
|
<option value="" disabled>
|
||||||
|
Gate wählen …
|
||||||
|
</option>
|
||||||
|
{candidates.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
|
Kante anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && candidates.length === 0 && (
|
||||||
|
<p className="muted gate-dependencies-section__empty">
|
||||||
|
Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -73,8 +73,8 @@ export function GateMapView({ initiativeId, items }) {
|
||||||
<div>
|
<div>
|
||||||
<h2>Zielzustände (Graph)</h2>
|
<h2>Zielzustände (Graph)</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite
|
Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen,
|
||||||
(AP1.13b).
|
Verify und Kriterien dort ebenfalls.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -177,8 +177,8 @@ export function GateMapView({ initiativeId, items }) {
|
||||||
|
|
||||||
{dependencies.length === 0 && (
|
{dependencies.length === 0 && (
|
||||||
<p className="muted gate-map-view__hint">
|
<p className="muted gate-map-view__hint">
|
||||||
Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den
|
Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten
|
||||||
Gate-Detailseiten gepflegt werden (AP1.13b).
|
gelten Gates nach Reihenfolge.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ import {
|
||||||
verifyRoadmapItemReached,
|
verifyRoadmapItemReached,
|
||||||
reopenRoadmapItem,
|
reopenRoadmapItem,
|
||||||
updateRoadmapItem,
|
updateRoadmapItem,
|
||||||
|
listRoadmapItemDependencies,
|
||||||
|
addRoadmapItemDependency,
|
||||||
|
deleteRoadmapDependency,
|
||||||
|
listInitiativeRoadmapItems,
|
||||||
} from '../../api/roadmap.js'
|
} from '../../api/roadmap.js'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
|
|
@ -31,6 +35,7 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte
|
||||||
import { scopedPath } from '../../utils/routes.js'
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
import { listGateContributions } from '../../api/journey.js'
|
import { listGateContributions } from '../../api/journey.js'
|
||||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||||
|
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
||||||
import { Modal } from '../../components/Modal.jsx'
|
import { Modal } from '../../components/Modal.jsx'
|
||||||
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
|
||||||
|
|
||||||
|
|
@ -100,6 +105,8 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
const [contributionsLoading, setContributionsLoading] = useState(true)
|
const [contributionsLoading, setContributionsLoading] = useState(true)
|
||||||
const [contributionsError, setContributionsError] = useState(null)
|
const [contributionsError, setContributionsError] = useState(null)
|
||||||
const [editingMeta, setEditingMeta] = useState(false)
|
const [editingMeta, setEditingMeta] = useState(false)
|
||||||
|
const [dependencies, setDependencies] = useState([])
|
||||||
|
const [siblingItems, setSiblingItems] = useState([])
|
||||||
|
|
||||||
const loadContributions = useCallback(async () => {
|
const loadContributions = useCallback(async () => {
|
||||||
setContributionsLoading(true)
|
setContributionsLoading(true)
|
||||||
|
|
@ -124,12 +131,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
setItem(itemData)
|
setItem(itemData)
|
||||||
setCriteria(criteriaData.items || [])
|
setCriteria(criteriaData.items || [])
|
||||||
setProgress(criteriaData.progress || null)
|
setProgress(criteriaData.progress || null)
|
||||||
|
|
||||||
|
const resolvedInitiativeId = initiativeId || itemData.initiative_id
|
||||||
|
if (resolvedInitiativeId) {
|
||||||
|
const [depsData, siblings] = await Promise.all([
|
||||||
|
listRoadmapItemDependencies(itemId),
|
||||||
|
listInitiativeRoadmapItems(resolvedInitiativeId),
|
||||||
|
])
|
||||||
|
setDependencies(Array.isArray(depsData) ? depsData : [])
|
||||||
|
setSiblingItems(Array.isArray(siblings) ? siblings : [])
|
||||||
|
} else {
|
||||||
|
setDependencies([])
|
||||||
|
setSiblingItems([])
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [itemId])
|
}, [itemId, initiativeId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
|
|
@ -207,6 +227,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
||||||
error={contributionsError}
|
error={contributionsError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<GateDependenciesSection
|
||||||
|
itemId={itemId}
|
||||||
|
siblingItems={siblingItems}
|
||||||
|
dependencies={dependencies}
|
||||||
|
canManage={canManage}
|
||||||
|
busy={busy}
|
||||||
|
onAdd={(body) => runAction(() => addRoadmapItemDependency(itemId, body))}
|
||||||
|
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
||||||
|
/>
|
||||||
|
|
||||||
<ul className="item-list criterion-list">
|
<ul className="item-list criterion-list">
|
||||||
{criteria.map((crit) => (
|
{criteria.map((crit) => (
|
||||||
<li key={crit.id} className="list-item card-list-item">
|
<li key={crit.id} className="list-item card-list-item">
|
||||||
|
|
|
||||||
|
|
@ -1760,3 +1760,27 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gate-dependencies-section {
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--jk-border, #dde3ea);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-dependencies-section__group-title {
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-dependencies-section__empty {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-dependency-item__phrase {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-dependency-form__hint {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user