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
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:
parent
fe1edb84f8
commit
11b7128204
|
|
@ -47,16 +47,29 @@ def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
|
def _seed_ui_profile(archetype_key: str) -> dict[str, Any]:
|
||||||
db_profile = _load_ui_profile_from_db(archetype_key)
|
|
||||||
if db_profile:
|
|
||||||
return db_profile
|
|
||||||
seed_profile = get_ui_profile_json(archetype_key)
|
seed_profile = get_ui_profile_json(archetype_key)
|
||||||
if seed_profile:
|
if seed_profile:
|
||||||
return seed_profile
|
return seed_profile
|
||||||
return resolve_ui_profile(archetype_key)
|
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]:
|
def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]:
|
||||||
caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or []
|
caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or []
|
||||||
return frozenset(caps)
|
return frozenset(caps)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from auth import AUTH_HEADER
|
from auth import AUTH_HEADER
|
||||||
|
from services import operating_context as operating_context_service
|
||||||
from tests.factories import provision_user_in_tenant
|
from tests.factories import provision_user_in_tenant
|
||||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||||
|
|
||||||
|
|
@ -182,3 +183,19 @@ def test_operating_context_not_found(client):
|
||||||
headers=_auth(token),
|
headers=_auth(token),
|
||||||
)
|
)
|
||||||
assert res.status_code == 404
|
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"]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { resolveEffectiveDataSlices } from '../registry/resolveOperatingProfile.js'
|
||||||
import {
|
import {
|
||||||
getInitiative,
|
getInitiative,
|
||||||
getInitiativeSteeringSnapshot,
|
getInitiativeSteeringSnapshot,
|
||||||
|
|
@ -190,7 +191,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
setOperatingContext(opContext)
|
setOperatingContext(opContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
const omSlices = canReadOm ? opContext?.data_slices || [] : []
|
const omSlices = canReadOm ? resolveEffectiveDataSlices(opContext) : []
|
||||||
const keysToLoad = sliceKeys || ['initiative', ...omSlices]
|
const keysToLoad = sliceKeys || ['initiative', ...omSlices]
|
||||||
|
|
||||||
if (keysToLoad.includes('steering_snapshot')) {
|
if (keysToLoad.includes('steering_snapshot')) {
|
||||||
|
|
@ -1030,7 +1031,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
||||||
steeringSnapshotError,
|
steeringSnapshotError,
|
||||||
steeringMethods,
|
steeringMethods,
|
||||||
operatingContext,
|
operatingContext,
|
||||||
dataSlices: operatingContext?.data_slices || [],
|
dataSlices: resolveEffectiveDataSlices(operatingContext),
|
||||||
uiFeatures,
|
uiFeatures,
|
||||||
steeringElements,
|
steeringElements,
|
||||||
operatingProfile,
|
operatingProfile,
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,23 @@ const EMPTY_PROFILE = {
|
||||||
steeringElements: [],
|
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) {
|
function resolveUiFeatures(context, profileFeatures) {
|
||||||
if (context?.ui_features && Object.keys(context.ui_features).length) {
|
if (context?.ui_features && Object.keys(context.ui_features).length) {
|
||||||
return { ...(context.ui_features || {}) }
|
return { ...(context.ui_features || {}) }
|
||||||
|
|
@ -85,7 +102,7 @@ export function resolveOperatingProfile(context, options = {}) {
|
||||||
profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute,
|
profile.controlDefaultRoute || EMPTY_PROFILE.controlDefaultRoute,
|
||||||
planOutlineKeys: profile.planOutlineKeys ?? null,
|
planOutlineKeys: profile.planOutlineKeys ?? null,
|
||||||
workNavKeys: profile.workNavKeys ?? null,
|
workNavKeys: profile.workNavKeys ?? null,
|
||||||
dataSlices: context.data_slices || profile.dataSlices || [],
|
dataSlices: resolveEffectiveDataSlices(context),
|
||||||
uiFeatures: resolveUiFeatures(context, profile.uiFeatures),
|
uiFeatures: resolveUiFeatures(context, profile.uiFeatures),
|
||||||
steeringElements: resolveSteeringElements(context),
|
steeringElements: resolveSteeringElements(context),
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +115,7 @@ export function resolveOperatingProfile(context, options = {}) {
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
...fallback,
|
...fallback,
|
||||||
dataSlices: context?.data_slices || [],
|
dataSlices: resolveEffectiveDataSlices(context),
|
||||||
uiFeatures: resolveUiFeatures(context, {}),
|
uiFeatures: resolveUiFeatures(context, {}),
|
||||||
steeringElements: resolveSteeringElements(context),
|
steeringElements: resolveSteeringElements(context),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
isRouteAllowedForProfile,
|
isRouteAllowedForProfile,
|
||||||
|
resolveEffectiveDataSlices,
|
||||||
resolveOperatingProfile,
|
resolveOperatingProfile,
|
||||||
resolveOperatingProfileFromInput,
|
resolveOperatingProfileFromInput,
|
||||||
resolveRedirectForDisallowedRoute,
|
resolveRedirectForDisallowedRoute,
|
||||||
|
|
@ -83,6 +84,26 @@ describe('resolveOperatingProfile', () => {
|
||||||
expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true)
|
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)', () => {
|
it('liefert uiFeatures aus Methoden-Vertrag (API)', () => {
|
||||||
const profile = resolveOperatingProfile(PRODUCT_CONTEXT)
|
const profile = resolveOperatingProfile(PRODUCT_CONTEXT)
|
||||||
expect(profile.uiFeatures.continuousProductWorkMode).toBe(true)
|
expect(profile.uiFeatures.continuousProductWorkMode).toBe(true)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user