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 { 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 { listInitiativeActions } from '../api/initiatives.js'
|
||||||
import { listInitiativeBacklog } from '../api/backlog.js'
|
import { listInitiativeBacklog } from '../api/backlog.js'
|
||||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||||
|
import { buildProjectsByParent } from '../utils/projectTree.js'
|
||||||
|
import { projectPath } from '../utils/routes.js'
|
||||||
import {
|
import {
|
||||||
PLAN_OUTLINE_NODES,
|
PLAN_OUTLINE_NODES,
|
||||||
resolvePlanOutlineActiveKey,
|
resolvePlanOutlineActiveKey,
|
||||||
|
resolvePlanOutlineProjectId,
|
||||||
} from '../plan/planOutlineNodes.js'
|
} 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() {
|
export function PlanOutlineNav() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
const { initiativeId, initiativeTitle, hrefWithScope } = useProgramScope()
|
||||||
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
const activeKey = resolvePlanOutlineActiveKey(location.pathname)
|
||||||
|
const activeProjectId = resolvePlanOutlineProjectId(location.pathname)
|
||||||
const [counts, setCounts] = useState({ inbox: null, work: null })
|
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(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) {
|
if (!initiativeId) {
|
||||||
|
|
@ -44,6 +110,9 @@ export function PlanOutlineNav() {
|
||||||
if (node.key === 'work' && counts.work != null) {
|
if (node.key === 'work' && counts.work != null) {
|
||||||
return `${node.label} (${counts.work})`
|
return `${node.label} (${counts.work})`
|
||||||
}
|
}
|
||||||
|
if (node.key === 'structure' && projects.length > 0) {
|
||||||
|
return `${node.label} (${projects.length})`
|
||||||
|
}
|
||||||
return node.label
|
return node.label
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,6 +135,8 @@ export function PlanOutlineNav() {
|
||||||
{PLAN_OUTLINE_NODES.map((node) => {
|
{PLAN_OUTLINE_NODES.map((node) => {
|
||||||
const needsInitiative = node.requiresInitiative && !initiativeId
|
const needsInitiative = node.requiresInitiative && !initiativeId
|
||||||
const isDisabled = node.disabled || needsInitiative
|
const isDisabled = node.disabled || needsInitiative
|
||||||
|
const isStructure = node.key === 'structure'
|
||||||
|
const hasProjectTree = isStructure && projects.length > 0 && initiativeId
|
||||||
|
|
||||||
if (isDisabled) {
|
if (isDisabled) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -86,16 +157,38 @@ export function PlanOutlineNav() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={node.key}>
|
<li key={node.key}>
|
||||||
<NavLink
|
<div className="analysis-split__nav-row">
|
||||||
to={hrefWithScope(node.to)}
|
{hasProjectTree && (
|
||||||
end
|
<button
|
||||||
className={({ isActive }) =>
|
type="button"
|
||||||
'analysis-split__nav-link' +
|
className="analysis-split__nav-toggle"
|
||||||
(isActive ? ' analysis-split__nav-link--active' : '')
|
aria-expanded={structureOpen}
|
||||||
}
|
aria-label={structureOpen ? 'Struktur einklappen' : 'Struktur ausklappen'}
|
||||||
>
|
onClick={() => setStructureOpen((open) => !open)}
|
||||||
{nodeLabel(node)}
|
>
|
||||||
</NavLink>
|
{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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,20 @@ export const PLAN_OUTLINE_NODES = [
|
||||||
*/
|
*/
|
||||||
export function resolvePlanOutlineActiveKey(pathname) {
|
export function resolvePlanOutlineActiveKey(pathname) {
|
||||||
const path = pathname.replace(/\/$/, '') || pathname
|
const path = pathname.replace(/\/$/, '') || pathname
|
||||||
|
if (path.startsWith('/projects/')) {
|
||||||
|
return 'structure'
|
||||||
|
}
|
||||||
const match = PLAN_OUTLINE_NODES.find(
|
const match = PLAN_OUTLINE_NODES.find(
|
||||||
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
(node) => path === node.to || path.startsWith(`${node.to}/`),
|
||||||
)
|
)
|
||||||
return match?.key ?? null
|
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/inbox')).toBe('inbox')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
|
||||||
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
|
||||||
|
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,48 @@
|
||||||
cursor: not-allowed;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.analysis-split {
|
.analysis-split {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user