AP1.12a: Plan-Outline mit Projektbaum unter Struktur.
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 2m7s
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 51s
Test Suite / pytest-backend (push) Successful in 2m7s
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
Linke Outline zeigt rekursive Projects im Scope; Deep-Links zu /projects/:id markieren Struktur aktiv. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5e2ae9de31
commit
bcf6f83813
|
|
@ -1,18 +1,84 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { listInitiativeProjects } from '../api/projects.js'
|
||||
import { listInitiativeActions } from '../api/initiatives.js'
|
||||
import { listInitiativeBacklog } from '../api/backlog.js'
|
||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||
import { buildProjectsByParent } from '../utils/projectTree.js'
|
||||
import { projectPath } from '../utils/routes.js'
|
||||
import {
|
||||
PLAN_OUTLINE_NODES,
|
||||
resolvePlanOutlineActiveKey,
|
||||
resolvePlanOutlineProjectId,
|
||||
} from '../plan/planOutlineNodes.js'
|
||||
|
||||
function PlanOutlineProjectTree({ projects, parentId, activeProjectId, depth = 0 }) {
|
||||
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
|
||||
const nodes = byParent.get(parentId || '') || []
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="analysis-split__nav-sublist">
|
||||
{nodes.map((project) => (
|
||||
<li key={project.id}>
|
||||
<NavLink
|
||||
to={projectPath(project.id)}
|
||||
className={({ isActive }) =>
|
||||
'analysis-split__nav-link analysis-split__nav-link--nested' +
|
||||
(isActive || activeProjectId === project.id
|
||||
? ' analysis-split__nav-link--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
{project.title}
|
||||
</NavLink>
|
||||
<PlanOutlineProjectTree
|
||||
projects={projects}
|
||||
parentId={project.id}
|
||||
activeProjectId={activeProjectId}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlanOutlineNav() {
|
||||
const location = useLocation()
|
||||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
||||
const [counts, setCounts] = useState({ inbox: null, work: null })
|
||||
const [projects, setProjects] = useState([])
|
||||
const [structureOpen, setStructureOpen] = useState(
|
||||
activeKey === 'structure' || Boolean(activeProjectId),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setStructureOpen(activeKey === 'structure' || Boolean(activeProjectId))
|
||||
}, [activeKey, activeProjectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setProjects([])
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
listInitiativeProjects(initiativeId)
|
||||
.then((items) => {
|
||||
if (!cancelled) setProjects(Array.isArray(items) ? items : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProjects([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
|
|
@ -44,6 +110,9 @@ export function PlanOutlineNav() {
|
|||
if (node.key === 'work' && counts.work != null) {
|
||||
return `${node.label} (${counts.work})`
|
||||
}
|
||||
if (node.key === 'structure' && projects.length > 0) {
|
||||
return `${node.label} (${projects.length})`
|
||||
}
|
||||
return node.label
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +135,8 @@ export function PlanOutlineNav() {
|
|||
{PLAN_OUTLINE_NODES.map((node) => {
|
||||
const needsInitiative = node.requiresInitiative && !initiativeId
|
||||
const isDisabled = node.disabled || needsInitiative
|
||||
const isStructure = node.key === 'structure'
|
||||
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
|
|
@ -86,16 +157,38 @@ export function PlanOutlineNav() {
|
|||
|
||||
return (
|
||||
<li key={node.key}>
|
||||
<NavLink
|
||||
to={hrefWithScope(node.to)}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
'analysis-split__nav-link' +
|
||||
(isActive ? ' analysis-split__nav-link--active' : '')
|
||||
}
|
||||
>
|
||||
{nodeLabel(node)}
|
||||
</NavLink>
|
||||
<div className="analysis-split__nav-row">
|
||||
{hasProjectTree && (
|
||||
<button
|
||||
type="button"
|
||||
className="analysis-split__nav-toggle"
|
||||
aria-expanded={structureOpen}
|
||||
aria-label={structureOpen ? 'Struktur einklappen' : 'Struktur ausklappen'}
|
||||
onClick={() => setStructureOpen((open) => !open)}
|
||||
>
|
||||
{structureOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
)}
|
||||
<NavLink
|
||||
to={hrefWithScope(node.to)}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
'analysis-split__nav-link' +
|
||||
(isActive || (isStructure && activeProjectId)
|
||||
? ' analysis-split__nav-link--active'
|
||||
: '')
|
||||
}
|
||||
>
|
||||
{nodeLabel(node)}
|
||||
</NavLink>
|
||||
</div>
|
||||
{hasProjectTree && structureOpen && (
|
||||
<PlanOutlineProjectTree
|
||||
projects={projects}
|
||||
parentId=""
|
||||
activeProjectId={activeProjectId}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,20 @@ export const PLAN_OUTLINE_NODES = [
|
|||
*/
|
||||
export function resolvePlanOutlineActiveKey(pathname) {
|
||||
const path = pathname.replace(/\/$/, '') || pathname
|
||||
if (path.startsWith('/projects/')) {
|
||||
return 'structure'
|
||||
}
|
||||
const match = PLAN_OUTLINE_NODES.find(
|
||||
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
||||
)
|
||||
return match?.key ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolvePlanOutlineProjectId(pathname) {
|
||||
const match = (pathname || '').match(/^\/projects\/([^/?]+)/)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ describe('planOutlineNodes', () => {
|
|||
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
|
||||
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
||||
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
||||
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -306,6 +306,48 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.analysis-split__nav-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.analysis-split__nav-row .analysis-split__nav-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analysis-split__nav-toggle {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--jk-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.analysis-split__nav-toggle:hover {
|
||||
background: var(--jk-primary-soft);
|
||||
color: var(--jk-text);
|
||||
}
|
||||
|
||||
.analysis-split__nav-sublist {
|
||||
list-style: none;
|
||||
margin: 2px 0 4px;
|
||||
padding: 0 0 0 12px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
border-left: 1px solid var(--jk-border);
|
||||
}
|
||||
|
||||
.analysis-split__nav-link--nested {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.analysis-split {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user