AP2.3b: Frontend Profile Resolver — Operating Context statt methodUiDefaults.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4ad908bfbf
commit
90934453e6
|
|
@ -12,6 +12,10 @@ export function getInitiativeSteeringSnapshot(id) {
|
|||
return apiFetch(`/api/initiatives/${id}/steering-snapshot`)
|
||||
}
|
||||
|
||||
export function getInitiativeOperatingContext(id) {
|
||||
return apiFetch(`/api/initiatives/${id}/operating-context`)
|
||||
}
|
||||
|
||||
export function createInitiative(payload) {
|
||||
return apiFetch('/api/initiatives', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||
import { useMethodUiContext } from '../hooks/useMethodUiContext.js'
|
||||
import { resolveActiveProcessStepKey } from '../config/methodUiDefaults.js'
|
||||
import { resolveActiveProcessStepKey } from '../registry/resolveOperatingProfile.js'
|
||||
|
||||
export function MethodProcessBar() {
|
||||
const location = useLocation()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* AP1.9d — Methoden-Default-UI: Archetyp/Methode steuert Prozessleiste,
|
||||
* Modus-Defaults und sichtbare Nav-Knoten (Anti-Todo-Wand).
|
||||
* AP1.9d — Methoden-Default-UI (DEPRECATED AP2.3b — Fallback bis Resolver live).
|
||||
* Bevorzugt: GET /api/initiatives/:id/operating-context + resolveOperatingProfile.js
|
||||
*
|
||||
* @typedef {{ key: string, to: string, label: string, mode: 'plan'|'work'|'control' }} ProcessStep
|
||||
* @typedef {{
|
||||
|
|
|
|||
|
|
@ -1,24 +1,21 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getInitiative } from '../api/initiatives.js'
|
||||
import { getInitiativeOperatingContext } from '../api/initiatives.js'
|
||||
import { getActiveWorkCycle } from '../api/workCycles.js'
|
||||
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
|
||||
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
|
||||
import { resolveMethodUiDefaults } from '../config/methodUiDefaults.js'
|
||||
import { resolveOperatingProfileFromInput } from '../registry/resolveOperatingProfile.js'
|
||||
|
||||
/**
|
||||
* Lädt Archetyp/Methode für methodengebundene UI (AP1.9d).
|
||||
* Lädt Operating Context für methodengebundene UI (AP2.3b).
|
||||
*/
|
||||
export function useMethodUiContext() {
|
||||
const { initiativeId } = useProgramScope()
|
||||
const [archetypeKey, setArchetypeKey] = useState(null)
|
||||
const [methodKey, setMethodKey] = useState(null)
|
||||
const [operatingContext, setOperatingContext] = useState(null)
|
||||
const [hasActiveSprint, setHasActiveSprint] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setArchetypeKey(null)
|
||||
setMethodKey(null)
|
||||
setOperatingContext(null)
|
||||
setHasActiveSprint(false)
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -27,20 +24,17 @@ export function useMethodUiContext() {
|
|||
setLoading(true)
|
||||
|
||||
Promise.all([
|
||||
getInitiative(initiativeId),
|
||||
getInitiativeOperatingContext(initiativeId).catch(() => null),
|
||||
getActiveWorkCycle(initiativeId).catch(() => null),
|
||||
getInitiativeSteeringSnapshot(initiativeId).catch(() => null),
|
||||
])
|
||||
.then(([initiative, activeCycle, snapshot]) => {
|
||||
.then(([context, activeCycle]) => {
|
||||
if (cancelled) return
|
||||
setArchetypeKey(initiative?.archetype_key || null)
|
||||
setMethodKey(snapshot?.method_key || null)
|
||||
setOperatingContext(context)
|
||||
setHasActiveSprint(Boolean(activeCycle?.id))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setArchetypeKey(null)
|
||||
setMethodKey(null)
|
||||
setOperatingContext(null)
|
||||
setHasActiveSprint(false)
|
||||
}
|
||||
})
|
||||
|
|
@ -53,19 +47,25 @@ export function useMethodUiContext() {
|
|||
}
|
||||
}, [initiativeId])
|
||||
|
||||
const input = useMemo(
|
||||
() => ({ archetypeKey, methodKey, hasActiveSprint }),
|
||||
[archetypeKey, methodKey, hasActiveSprint],
|
||||
const profileInput = useMemo(
|
||||
() => ({ operatingContext, hasActiveSprint }),
|
||||
[operatingContext, hasActiveSprint],
|
||||
)
|
||||
|
||||
const defaults = useMemo(() => resolveMethodUiDefaults(input), [input])
|
||||
const profile = useMemo(
|
||||
() => resolveOperatingProfileFromInput(profileInput),
|
||||
[profileInput],
|
||||
)
|
||||
|
||||
return {
|
||||
initiativeId,
|
||||
archetypeKey,
|
||||
methodKey,
|
||||
archetypeKey: operatingContext?.archetype_key || null,
|
||||
methodKey: operatingContext?.method_key || null,
|
||||
methodProfileKey: operatingContext?.method_profile_key || null,
|
||||
operatingContext,
|
||||
hasActiveSprint,
|
||||
loading,
|
||||
...defaults,
|
||||
dataSlices: profile.dataSlices,
|
||||
...profile,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Navigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
|
||||
import { getInitiative } from '../../api/initiatives.js'
|
||||
import { getInitiativeOperatingContext } from '../../api/initiatives.js'
|
||||
import { getActiveWorkCycle } from '../../api/workCycles.js'
|
||||
import { resolveMethodUiDefaults } from '../../config/methodUiDefaults.js'
|
||||
import { resolveOperatingProfileFromInput } from '../../registry/resolveOperatingProfile.js'
|
||||
|
||||
/** Portfolio ohne Scope → Übersicht; mit Vorhaben → methoden-Default (AP1.9d). */
|
||||
export function PlanIndexRedirect() {
|
||||
|
|
@ -17,13 +17,13 @@ export function PlanIndexRedirect() {
|
|||
}
|
||||
let cancelled = false
|
||||
Promise.all([
|
||||
getInitiative(initiativeId),
|
||||
getInitiativeOperatingContext(initiativeId),
|
||||
getActiveWorkCycle(initiativeId).catch(() => null),
|
||||
])
|
||||
.then(([initiative, activeCycle]) => {
|
||||
.then(([context, activeCycle]) => {
|
||||
if (cancelled) return
|
||||
const defaults = resolveMethodUiDefaults({
|
||||
archetypeKey: initiative?.archetype_key,
|
||||
const defaults = resolveOperatingProfileFromInput({
|
||||
operatingContext: context,
|
||||
hasActiveSprint: Boolean(activeCycle?.id),
|
||||
})
|
||||
setTarget(hrefWithScope(defaults.planDefaultRoute))
|
||||
|
|
|
|||
170
frontend/src/registry/resolveOperatingProfile.js
Normal file
170
frontend/src/registry/resolveOperatingProfile.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* AP2.3b — Operating Profile Resolver (API-first, methodUiDefaults als Fallback).
|
||||
*/
|
||||
import { PLAN_OUTLINE_NODES } from '../plan/planOutlineNodes.js'
|
||||
import { WORK_NAV_ITEMS } from '../config/workNav.js'
|
||||
import {
|
||||
resolveMethodUiDefaults,
|
||||
resolveActiveProcessStepKey as resolveActiveProcessStepKeyFallback,
|
||||
} from '../config/methodUiDefaults.js'
|
||||
|
||||
/**
|
||||
* @typedef {import('../config/methodUiDefaults.js').ProcessStep} ProcessStep
|
||||
* @typedef {import('../config/methodUiDefaults.js').MethodUiDefaults} MethodUiDefaults
|
||||
* @typedef {{
|
||||
* initiative_id?: string,
|
||||
* archetype_key?: string | null,
|
||||
* method_key?: string | null,
|
||||
* method_profile_key?: string | null,
|
||||
* ui_profile?: Partial<MethodUiDefaults> | null,
|
||||
* data_slices?: string[],
|
||||
* method_capabilities?: object,
|
||||
* }} OperatingContextResponse
|
||||
* @typedef {{
|
||||
* archetypeKey?: string | null,
|
||||
* methodKey?: string | null,
|
||||
* hasActiveSprint?: boolean,
|
||||
* operatingContext?: OperatingContextResponse | null,
|
||||
* }} ResolveOperatingProfileInput
|
||||
*/
|
||||
|
||||
const EMPTY_PROFILE = {
|
||||
processSteps: [],
|
||||
planDefaultRoute: '/plan/profile',
|
||||
workDefaultRoute: '/work/today',
|
||||
controlDefaultRoute: '/control/status',
|
||||
planOutlineKeys: null,
|
||||
workNavKeys: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {OperatingContextResponse | null | undefined} context
|
||||
* @param {{ hasActiveSprint?: boolean }} [options]
|
||||
* @returns {MethodUiDefaults & { dataSlices: string[] }}
|
||||
*/
|
||||
export function resolveOperatingProfile(context, options = {}) {
|
||||
if (context?.ui_profile) {
|
||||
const profile = context.ui_profile
|
||||
let workDefaultRoute = profile.workDefaultRoute || EMPTY_PROFILE.workDefaultRoute
|
||||
if (
|
||||
context.archetype_key === 'initiative.product' &&
|
||||
!options.hasActiveSprint
|
||||
) {
|
||||
workDefaultRoute = '/work/today'
|
||||
}
|
||||
return {
|
||||
processSteps: profile.processSteps || [],
|
||||
planDefaultRoute: profile.planDefaultRoute || EMPTY_PROFILE.planDefaultRoute,
|
||||
workDefaultRoute,
|
||||
controlDefaultRoute:
|
||||
profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute,
|
||||
planOutlineKeys: profile.planOutlineKeys ?? null,
|
||||
workNavKeys: profile.workNavKeys ?? null,
|
||||
dataSlices: context.data_slices || profile.dataSlices || [],
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = resolveMethodUiDefaults({
|
||||
archetypeKey: context?.archetype_key || null,
|
||||
methodKey: context?.method_key || null,
|
||||
hasActiveSprint: options.hasActiveSprint,
|
||||
})
|
||||
return {
|
||||
...fallback,
|
||||
dataSlices: context?.data_slices || [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ResolveOperatingProfileInput} input
|
||||
* @returns {MethodUiDefaults & { dataSlices: string[] }}
|
||||
*/
|
||||
export function resolveOperatingProfileFromInput(input = {}) {
|
||||
if (input.operatingContext) {
|
||||
return resolveOperatingProfile(input.operatingContext, {
|
||||
hasActiveSprint: input.hasActiveSprint,
|
||||
})
|
||||
}
|
||||
const fallback = resolveMethodUiDefaults({
|
||||
archetypeKey: input.archetypeKey || null,
|
||||
methodKey: input.methodKey || null,
|
||||
hasActiveSprint: input.hasActiveSprint,
|
||||
})
|
||||
return { ...fallback, dataSlices: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ResolveOperatingProfileInput} input
|
||||
* @returns {typeof PLAN_OUTLINE_NODES}
|
||||
*/
|
||||
export function resolvePlanOutlineNodesFromProfile(input = {}) {
|
||||
const { planOutlineKeys } = resolveOperatingProfileFromInput(input)
|
||||
if (!planOutlineKeys) return PLAN_OUTLINE_NODES
|
||||
const allowed = new Set(planOutlineKeys)
|
||||
return PLAN_OUTLINE_NODES.filter((node) => allowed.has(node.key))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ResolveOperatingProfileInput} input
|
||||
* @returns {typeof WORK_NAV_ITEMS}
|
||||
*/
|
||||
export function resolveWorkNavItemsFromProfile(input = {}) {
|
||||
const { workNavKeys } = resolveOperatingProfileFromInput(input)
|
||||
if (!workNavKeys) return WORK_NAV_ITEMS
|
||||
const order = new Map(workNavKeys.map((key, index) => [key, index]))
|
||||
return WORK_NAV_ITEMS.filter((item) => order.has(item.key)).sort(
|
||||
(a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {ProcessStep[]} steps
|
||||
*/
|
||||
export function resolveActiveProcessStepKey(pathname, steps) {
|
||||
return resolveActiveProcessStepKeyFallback(pathname, steps)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} routePath
|
||||
* @param {ResolveOperatingProfileInput} input
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRouteAllowedForProfile(routePath, input = {}) {
|
||||
const profile = resolveOperatingProfileFromInput(input)
|
||||
const normalized = (routePath || '').replace(/\/$/, '')
|
||||
|
||||
if (normalized.startsWith('/plan/sprint') || normalized.startsWith('/work/sprint')) {
|
||||
return profile.dataSlices.includes('work_cycles')
|
||||
}
|
||||
if (normalized.startsWith('/plan/structure')) {
|
||||
return profile.dataSlices.includes('projects')
|
||||
}
|
||||
if (normalized.startsWith('/plan/inbox')) {
|
||||
return profile.dataSlices.includes('backlog')
|
||||
}
|
||||
if (normalized.startsWith('/control/journey')) {
|
||||
const steps = profile.processSteps || []
|
||||
return steps.some((s) => s.key === 'journey')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} routePath
|
||||
* @param {ResolveOperatingProfileInput} input
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveRedirectForDisallowedRoute(routePath, input = {}) {
|
||||
const profile = resolveOperatingProfileFromInput(input)
|
||||
const normalized = (routePath || '').replace(/\/$/, '')
|
||||
|
||||
if (normalized.startsWith('/work/')) {
|
||||
return profile.workDefaultRoute
|
||||
}
|
||||
if (normalized.startsWith('/control/')) {
|
||||
return profile.controlDefaultRoute
|
||||
}
|
||||
return profile.planDefaultRoute
|
||||
}
|
||||
76
frontend/src/registry/resolveOperatingProfile.test.js
Normal file
76
frontend/src/registry/resolveOperatingProfile.test.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isRouteAllowedForProfile,
|
||||
resolveOperatingProfile,
|
||||
resolveOperatingProfileFromInput,
|
||||
resolveRedirectForDisallowedRoute,
|
||||
} from './resolveOperatingProfile.js'
|
||||
|
||||
const PRODUCT_CONTEXT = {
|
||||
initiative_id: 'test-id',
|
||||
archetype_key: 'initiative.product',
|
||||
method_key: 'continuous_product',
|
||||
ui_profile: {
|
||||
processSteps: [
|
||||
{ key: 'inbox', to: '/plan/inbox', label: 'Eingang', mode: 'plan' },
|
||||
{ key: 'sprint-plan', to: '/plan/sprint', label: 'Sprint planen', mode: 'plan' },
|
||||
],
|
||||
planDefaultRoute: '/plan/inbox',
|
||||
workDefaultRoute: '/work/sprint',
|
||||
controlDefaultRoute: '/control/status',
|
||||
planOutlineKeys: ['profile', 'inbox', 'sprint', 'gates'],
|
||||
workNavKeys: ['sprint', 'today', 'mine'],
|
||||
},
|
||||
data_slices: ['backlog', 'actions', 'roadmap', 'work_cycles', 'projects'],
|
||||
}
|
||||
|
||||
const SUPPORT_QUEUE_CONTEXT = {
|
||||
archetype_key: 'initiative.support_queue',
|
||||
method_key: 'queue_pull',
|
||||
ui_profile: {
|
||||
processSteps: [],
|
||||
planDefaultRoute: '/plan/inbox',
|
||||
workDefaultRoute: '/work/today',
|
||||
controlDefaultRoute: '/control/status',
|
||||
planOutlineKeys: ['profile', 'inbox', 'work'],
|
||||
workNavKeys: ['today', 'mine'],
|
||||
},
|
||||
data_slices: ['backlog', 'actions', 'blockers'],
|
||||
}
|
||||
|
||||
describe('resolveOperatingProfile', () => {
|
||||
it('liefert UI-Profil aus Operating Context', () => {
|
||||
const profile = resolveOperatingProfile(PRODUCT_CONTEXT)
|
||||
expect(profile.planDefaultRoute).toBe('/plan/inbox')
|
||||
expect(profile.processSteps).toHaveLength(2)
|
||||
expect(profile.dataSlices).toContain('work_cycles')
|
||||
})
|
||||
|
||||
it('Product ohne aktiven Sprint → work/today', () => {
|
||||
const profile = resolveOperatingProfile(PRODUCT_CONTEXT, { hasActiveSprint: false })
|
||||
expect(profile.workDefaultRoute).toBe('/work/today')
|
||||
})
|
||||
|
||||
it('Product mit aktivem Sprint → work/sprint', () => {
|
||||
const profile = resolveOperatingProfile(PRODUCT_CONTEXT, { hasActiveSprint: true })
|
||||
expect(profile.workDefaultRoute).toBe('/work/sprint')
|
||||
})
|
||||
|
||||
it('Fallback ohne Operating Context', () => {
|
||||
const profile = resolveOperatingProfileFromInput({
|
||||
archetypeKey: 'initiative.linear_project',
|
||||
})
|
||||
expect(profile.planDefaultRoute).toBe('/plan/gates')
|
||||
})
|
||||
|
||||
it('support_queue erlaubt kein /plan/sprint', () => {
|
||||
const input = { operatingContext: SUPPORT_QUEUE_CONTEXT }
|
||||
expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(false)
|
||||
expect(resolveRedirectForDisallowedRoute('/plan/sprint', input)).toBe('/plan/inbox')
|
||||
})
|
||||
|
||||
it('product erlaubt /plan/sprint', () => {
|
||||
const input = { operatingContext: PRODUCT_CONTEXT }
|
||||
expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user