fix(ui): Plan-Navigation nicht mehr durch leere data_slices blockieren
Some checks failed
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) Successful in 44s
Test Suite / pytest-backend (push) Has been cancelled

Archetyp-UI-Profil und Methoden-Schnittmenge werden korrekt gemerged; Frontend faellt bei leerem data_slices auf ui_profile.dataSlices zurueck, damit Eingang/Sprint/Gates erreichbar bleiben.
This commit is contained in:
Lars 2026-07-26 16:49:02 +02:00
parent fe1edb84f8
commit 11b7128204
5 changed files with 77 additions and 8 deletions

View File

@ -47,16 +47,29 @@ def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]:
conn.close()
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
db_profile = _load_ui_profile_from_db(archetype_key)
if db_profile:
return db_profile
def _seed_ui_profile(archetype_key: str) -> dict[str, Any]:
seed_profile = get_ui_profile_json(archetype_key)
if seed_profile:
return seed_profile
return resolve_ui_profile(archetype_key)
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
seed_profile = _seed_ui_profile(archetype_key)
db_profile = _load_ui_profile_from_db(archetype_key)
if not db_profile:
return seed_profile
merged = {**seed_profile, **db_profile}
if not (merged.get("omCapabilities") or merged.get("dataSlices")):
seed_slices = seed_profile.get("dataSlices") or []
merged["dataSlices"] = list(seed_slices)
merged["omCapabilities"] = list(
seed_profile.get("omCapabilities") or seed_slices
)
return merged
def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]:
caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or []
return frozenset(caps)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from auth import AUTH_HEADER
from services import operating_context as operating_context_service
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
@ -182,3 +183,19 @@ def test_operating_context_not_found(client):
headers=_auth(token),
)
assert res.status_code == 404
def test_resolve_ui_profile_merges_seed_slices_when_db_partial(monkeypatch):
monkeypatch.setattr(
operating_context_service,
"_load_ui_profile_from_db",
lambda _key: {
"planOutlineKeys": ["profile", "inbox", "sprint", "gates"],
"planDefaultRoute": "/plan/inbox",
},
)
profile = operating_context_service._resolve_ui_profile("initiative.product")
assert profile["planOutlineKeys"] == ["profile", "inbox", "sprint", "gates"]
assert "backlog" in profile["dataSlices"]
assert "work_cycles" in profile["dataSlices"]
assert profile["omCapabilities"]

View File

@ -7,6 +7,7 @@ import {
useState,
} from 'react'
import { useParams } from 'react-router-dom'
import { resolveEffectiveDataSlices } from '../registry/resolveOperatingProfile.js'
import {
getInitiative,
getInitiativeSteeringSnapshot,
@ -190,7 +191,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
setOperatingContext(opContext)
}
const omSlices = canReadOm ? opContext?.data_slices || [] : []
const omSlices = canReadOm ? resolveEffectiveDataSlices(opContext) : []
const keysToLoad = sliceKeys || ['initiative', ...omSlices]
if (keysToLoad.includes('steering_snapshot')) {
@ -1030,7 +1031,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
steeringSnapshotError,
steeringMethods,
operatingContext,
dataSlices: operatingContext?.data_slices || [],
dataSlices: resolveEffectiveDataSlices(operatingContext),
uiFeatures,
steeringElements,
operatingProfile,

View File

@ -48,6 +48,23 @@ const EMPTY_PROFILE = {
steeringElements: [],
}
/**
* API data_slices haben Vorrang; leeres Array gilt als fehlend (JS-Truthy-Falle).
* @param {OperatingContextResponse | null | undefined} context
* @returns {string[]}
*/
export function resolveEffectiveDataSlices(context) {
const apiSlices = context?.data_slices
if (Array.isArray(apiSlices) && apiSlices.length > 0) {
return apiSlices
}
const profileSlices = context?.ui_profile?.dataSlices
if (Array.isArray(profileSlices) && profileSlices.length > 0) {
return profileSlices
}
return Array.isArray(apiSlices) ? apiSlices : []
}
function resolveUiFeatures(context, profileFeatures) {
if (context?.ui_features && Object.keys(context.ui_features).length) {
return { ...(context.ui_features || {}) }
@ -85,7 +102,7 @@ export function resolveOperatingProfile(context, options = {}) {
profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute,
planOutlineKeys: profile.planOutlineKeys ?? null,
workNavKeys: profile.workNavKeys ?? null,
dataSlices: context.data_slices || profile.dataSlices || [],
dataSlices: resolveEffectiveDataSlices(context),
uiFeatures: resolveUiFeatures(context, profile.uiFeatures),
steeringElements: resolveSteeringElements(context),
}
@ -98,7 +115,7 @@ export function resolveOperatingProfile(context, options = {}) {
})
return {
...fallback,
dataSlices: context?.data_slices || [],
dataSlices: resolveEffectiveDataSlices(context),
uiFeatures: resolveUiFeatures(context, {}),
steeringElements: resolveSteeringElements(context),
}

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
isRouteAllowedForProfile,
resolveEffectiveDataSlices,
resolveOperatingProfile,
resolveOperatingProfileFromInput,
resolveRedirectForDisallowedRoute,
@ -83,6 +84,26 @@ describe('resolveOperatingProfile', () => {
expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true)
})
it('leeres data_slices fällt auf ui_profile.dataSlices zurück', () => {
const context = {
...PRODUCT_CONTEXT,
data_slices: [],
ui_profile: {
...PRODUCT_CONTEXT.ui_profile,
dataSlices: ['backlog', 'roadmap', 'work_cycles', 'actions'],
},
}
expect(resolveEffectiveDataSlices(context)).toEqual([
'backlog',
'roadmap',
'work_cycles',
'actions',
])
expect(isRouteAllowedForProfile('/plan/inbox', { operatingContext: context })).toBe(true)
expect(isRouteAllowedForProfile('/plan/gates', { operatingContext: context })).toBe(true)
expect(isRouteAllowedForProfile('/plan/sprint', { operatingContext: context })).toBe(true)
})
it('liefert uiFeatures aus Methoden-Vertrag (API)', () => {
const profile = resolveOperatingProfile(PRODUCT_CONTEXT)
expect(profile.uiFeatures.continuousProductWorkMode).toBe(true)