From 655aea6662b21cf8c1cb62692e5b74ba0a637ad9 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:54 +0200 Subject: [PATCH] Release govoplan-views v0.1.22: unify interface contracts and documentation --- pyproject.toml | 4 +- src/govoplan_views/__init__.py | 2 +- src/govoplan_views/backend/dsar_provider.py | 25 ++++++++ src/govoplan_views/backend/manifest.py | 17 +++++- src/govoplan_views/backend/service.py | 19 ++++++ tests/test_dsar_provider.py | 23 ++++++++ .../test_interface_documentation_contract.py | 4 ++ tests/test_views.py | 40 +++++++++++++ webui/package.json | 7 ++- webui/scripts/test-navigation-layout.mjs | 59 +++++++++++++++++++ webui/src/api/views.ts | 6 +- webui/src/features/views/ViewsAdminPanel.tsx | 38 ++++++++++-- webui/src/i18n/generatedTranslations.ts | 4 ++ 13 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 webui/scripts/test-navigation-layout.mjs diff --git a/pyproject.toml b/pyproject.toml index 544f6c5..1a0d4b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-views" -version = "0.1.21" +version = "0.1.22" description = "Governed task-focused interface projections for GovOPlaN." readme = "README.md" requires-python = ">=3.12" license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] -dependencies = ["govoplan-core>=0.1.18"] +dependencies = ["govoplan-core>=0.1.45"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_views/__init__.py b/src/govoplan_views/__init__.py index 54488de..89c6c7b 100644 --- a/src/govoplan_views/__init__.py +++ b/src/govoplan_views/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN Views module.""" -__version__ = "0.1.21" +__version__ = "0.1.22" diff --git a/src/govoplan_views/backend/dsar_provider.py b/src/govoplan_views/backend/dsar_provider.py index 0545c4d..6c57cfc 100644 --- a/src/govoplan_views/backend/dsar_provider.py +++ b/src/govoplan_views/backend/dsar_provider.py @@ -20,6 +20,7 @@ from govoplan_views.backend.db.models import ( ViewPreference, ViewRevision, ) +from govoplan_views.backend.service import ViewsValidationError, normalize_view_navigation VIEWS_DSAR_CAPABILITY = dsar_capability_name("views") @@ -742,6 +743,30 @@ def _presentation_projection(value: object) -> dict[str, object]: if not isinstance(value, Mapping): raise ValueError("View presentation must be an object.") projected: dict[str, object] = {} + if "navigation" in value: + navigation = value["navigation"] + if navigation is not None: + if not isinstance(navigation, Mapping): + raise ValueError("View navigation must be an object.") + navigation = { + key: navigation[key] + for key in ("contract_version", "order", "hidden", "separators") + if key in navigation + } + separators = navigation.get("separators") + if separators is not None: + if not isinstance(separators, list) or len(separators) > 256: + raise ValueError("View navigation separators exceed the DSAR bound.") + if any(not isinstance(item, Mapping) for item in separators): + raise ValueError("View navigation separators must be objects.") + navigation["separators"] = [ + {key: item[key] for key in ("id", "label") if key in item} + for item in separators + ] + try: + projected["navigation"] = normalize_view_navigation(navigation) + except ViewsValidationError as exc: + raise ValueError("View navigation exceeds the safe DSAR contract.") from exc mode = value.get("navigation_mode") if isinstance(mode, str): projected["navigation_mode"] = mode[:20] diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index ca58c5b..21faeee 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -39,7 +39,7 @@ from govoplan_views.backend.dsar_provider import ( MODULE_ID = "views" MODULE_NAME = "Views" -MODULE_VERSION = "0.1.21" +MODULE_VERSION = "0.1.22" DEFINITION_READ_SCOPE = "views:definition:read" DEFINITION_WRITE_SCOPE = "views:definition:write" @@ -398,6 +398,11 @@ manifest = ModuleManifest( body=( "Views are versioned presentation projections. Modules announce " "their selectable surfaces through the platform contract. System " + "and personal View editors reuse the shared navigation editor: drag or use keyboard " + "and move buttons to order modules, add named or unnamed separators, and remove " + "entries. Inherit restores tenant defaults. An explicit personal navigation layout " + "takes precedence; administrator-locked entries cannot be hidden. These controls " + "only rearrange already authorized, View-visible navigation, never grant access. System " "and tenant administrators can publish Views and make them " "available, default, or required at system, tenant, group, and " "user scope. Required Views retain administration escape surfaces " @@ -436,6 +441,7 @@ manifest = ModuleManifest( "summary": "Die sichtbare Oberfläche auf die für eine Aufgabe benötigten Module und Funktionen begrenzen, ohne Berechtigungen zu ändern.", "body": ( "Ansichten sind versionierte Darstellungsprojektionen. Module melden ihre wählbaren Oberflächen über den Plattformvertrag. System- und Mandantenadministrationen können Ansichten veröffentlichen und sie auf System-, Mandanten-, Gruppen- und Benutzerebene als verfügbar, voreingestellt oder verpflichtend zuordnen. Verpflichtende Ansichten behalten Auswahl- und Administrationsauswege, damit sie stets geprüft und geändert werden können. " + "Alle Ansichtseditoren verwenden den gemeinsamen Navigationseditor: Module per Ziehen, Tastatur oder Verschiebeschaltflächen anordnen, benannte oder unbenannte Trennlinien ergänzen und Einträge entfernen. Vererben stellt die Mandantenvorgaben wieder her. Eine ausdrücklich festgelegte persönliche Navigationsanordnung hat Vorrang; administrativ gesperrte Einträge können nicht ausgeblendet werden. Die Anordnung betrifft nur bereits berechtigte, in der Ansicht sichtbare Navigation und erteilt niemals Zugriff. " "Das Augensymbol in der Titelleiste öffnet die Auswahl und wird hervorgehoben, solange eine spezialisierte Ansicht aktiv ist. Ausgeblendete Funktionen bleiben durch ihre normalen Berechtigungsprüfungen geschützt. Eine Revision darf Schnellzugriffswerkzeuge empfehlen oder die Leiste auf eine aufgabenbezogene Teilmenge fokussieren. Das ändert nur die Darstellung: nicht verfügbare, unpassende oder unberechtigte Werkzeuge bleiben verborgen; ein unbrauchbarer Fokus fällt mit Erklärung auf die normale wirksame Leiste zurück. " "Alle verfügbaren Werkzeuge stellt vorübergehend dieselbe berechtigungsabgeleitete Leiste wieder her, ohne die Ansicht zu ändern oder eine Umgehung zu speichern. Workflow erhält dasselbe Verhalten durch Auflösung der genauen unveränderlichen Ansichtsrevision. Ist Policy aktiviert, liefert Views einen mandantenbezogenen und begrenzten Katalog von Definitionen, Aktionen und registrierten Oberflächen für Wirkungsvorschauen, ohne Zugriff auf Implementierungsdetails zu erteilen." ), @@ -490,6 +496,14 @@ manifest = ModuleManifest( "that system policy made unavailable or the tenant disabled. Saved references " "to such surfaces remain in immutable revisions and are reported as stale. " "Inherited definitions or assignments must be changed in their owning scope." + " The navigation field uses Core's shared, bounded preference contract. Module IDs and " + "separator IDs are interleaved in order; a missing or null separator list inherits grouping, " + "while an empty list explicitly removes separators. Save revision records these preferences " + "immutably; publish applies them to unpinned assignments. Views cannot create navigation " + "locks. While a custom navigation layout is configured, the old grouped/flat, area ordering, " + "and label controls are disabled to avoid competing layouts; surface visibility remains editable. " + "Personal data exports include only bounded navigation preferences and separator labels, never " + "arbitrary nested metadata." " Product-area grouping, ordering, and labels are presentation metadata in the same revision; " "they cannot expose a hidden surface or grant authority. Grouped navigation is the sensible default, " "while flat navigation preserves the complete authorized tool rail. Quick Access recommendation and " @@ -507,6 +521,7 @@ manifest = ModuleManifest( "body": ( "Eine Ansichtsdefinition enthält unveränderliche Revisionen sichtbarer Oberflächenkennungen und Darstellungsmetadaten. Durch Veröffentlichung wird die neueste Revision zuordenbar. Verfügbare Zuordnungen erlauben eine freiwillige Auswahl, Voreinstellungen gelten bis zu einer Änderung und verpflichtende Zuordnungen können nicht verlassen werden. Benutzer- und Gruppenzuordnungen haben Vorrang vor Mandanten- und Systemzuordnungen. " "Eine Fixierung bewahrt genau eine veröffentlichte Revision; eine nicht fixierte Zuordnung folgt späteren Veröffentlichungen. Verpflichtende Ansichten müssen Auswahl- und Administrationsauswege erhalten. Das Ausblenden einer Oberfläche erteilt oder entzieht niemals eine Berechtigung. Der Oberflächenkatalog ist durch die Modulfreigabe des aktiven Mandanten begrenzt; eine Ansicht kann daher kein systemseitig oder mandantenseitig deaktiviertes Modul freigeben. Gespeicherte Verweise bleiben in unveränderlichen Revisionen erhalten und werden als veraltet gemeldet. Geerbte Definitionen und Zuordnungen müssen in ihrer besitzenden Ebene geändert werden. " + "Das Navigationsfeld nutzt den gemeinsamen begrenzten Einstellungsvertrag von Core. Modul- und Trennlinienkennungen werden in einer Reihenfolge gespeichert; fehlende oder nullgesetzte Trennlinien erben die Gruppierung, eine leere Liste entfernt sie ausdrücklich. Revision speichern hält diese Einstellungen unveränderlich fest; Veröffentlichen wendet sie auf nicht fixierte Zuordnungen an. Ansichten können keine Navigationssperren erzeugen. Bei einer eigenen Anordnung sind die bisherigen Steuerelemente für gruppiert/flach, Bereichsreihenfolge und Beschriftung deaktiviert, damit keine widersprüchlichen Anordnungen entstehen; Oberflächensichtbarkeit bleibt einstellbar. Persönliche Datenauskünfte enthalten nur begrenzte Navigationseinstellungen und Trennlinienbeschriftungen, keine beliebigen verschachtelten Metadaten. " "Produktbereichsgruppen, Reihenfolge und Beschriftungen sind Darstellungsmetadaten derselben Revision und können weder verborgene Oberflächen freigeben noch Berechtigungen erteilen. Gruppierte Navigation ist die sinnvolle Voreinstellung; flache Navigation erhält die vollständige berechtigte Werkzeugleiste. Empfehlungen und Fokuskennungen für Schnellzugriff sind ebenfalls versionierte Darstellungsmetadaten. Der Ausweg Alle verfügbaren Werkzeuge kann nur Beiträge zeigen, die bereits Modulfreigabe, Richtlinie, Einstellung, Oberfläche, Kontext und Berechtigungsprüfung bestanden haben." ), } diff --git a/src/govoplan_views/backend/service.py b/src/govoplan_views/backend/service.py index 77f021b..6e135a4 100644 --- a/src/govoplan_views/backend/service.py +++ b/src/govoplan_views/backend/service.py @@ -8,10 +8,13 @@ from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Literal, Mapping +from pydantic import ValidationError from sqlalchemy import and_, or_ from sqlalchemy.orm import Session, joinedload from govoplan_core.core.access import PrincipalRef +from govoplan_core.api.v1.schemas import NavigationPreferencesPayload +from govoplan_core.core.navigation import navigation_preferences_from_mapping from govoplan_core.core.policy import ( DefinitionScopeRef, ViewGovernanceDecision, @@ -56,6 +59,7 @@ _QUICK_ACCESS_TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)+$") _PRESENTATION_KEYS = frozenset( { "navigation_mode", + "navigation", "product_area_order", "product_area_labels", "quick_access_recommended_tool_ids", @@ -361,6 +365,19 @@ def normalize_visible_surface_ids( ) +def normalize_view_navigation(value: object) -> dict[str, object] | None: + """Use the shared bounded preference contract without creating policy locks.""" + if value is None: + return None + try: + payload = NavigationPreferencesPayload.model_validate(value) + except ValidationError as exc: + raise ViewsValidationError("Invalid View navigation preferences") from exc + if payload.locked: + raise ViewsValidationError("Views cannot create navigation locks") + return navigation_preferences_from_mapping(payload.model_dump()).as_dict() + + def normalize_view_presentation( value: Mapping[str, Any] | None, *, @@ -377,6 +394,8 @@ def normalize_view_presentation( ) normalized: dict[str, object] = {} + if "navigation" in value: + normalized["navigation"] = normalize_view_navigation(value["navigation"]) mode = value.get("navigation_mode") if mode is not None: if mode not in {"grouped", "flat"}: diff --git a/tests/test_dsar_provider.py b/tests/test_dsar_provider.py index 51cfac1..adb5b70 100644 --- a/tests/test_dsar_provider.py +++ b/tests/test_dsar_provider.py @@ -26,6 +26,7 @@ from govoplan_views.backend.db.models import ( from govoplan_views.backend.dsar_provider import ( VIEWS_DSAR_CAPABILITY, ViewsDsarProvider, + _presentation_projection, ) from govoplan_views.backend.manifest import manifest @@ -118,6 +119,12 @@ class ViewsDsarProviderTests(unittest.TestCase): surface_contract_version="1.0.0", visible_surface_ids=["files.route.files"], presentation={ + "navigation": { + "order": ["separator:work", "files.nav.files"], + "hidden": ["mail.nav.mail"], + "separators": [{"id": "separator:work", "label": "Personal layout", "private": "separator-private-do-not-export"}], + "private": "navigation-private-do-not-export", + }, "navigation_mode": "flat", "quick_access_focused_tool_ids": ["files.recent"], "private_payload_do_not_export": f"private-{definition_id}", @@ -240,12 +247,28 @@ class ViewsDsarProviderTests(unittest.TestCase): exported = json.dumps([record.to_dict() for record in records]) self.assertIn("My personal work", exported) self.assertIn("files.recent", exported) + self.assertIn("Personal layout", exported) + self.assertIn("files.nav.files", exported) + self.assertNotIn("navigation-private-do-not-export", exported) + self.assertNotIn("separator-private-do-not-export", exported) self.assertNotIn("personal-metadata-do-not-export", exported) self.assertNotIn("tenant-metadata-do-not-export", exported) self.assertNotIn("private-tenant-definition-do-not-export", exported) self.assertNotIn("definition-other-account", exported) self.assertNotIn("definition-other-tenant", exported) + def test_navigation_projection_is_bounded_and_preserves_inheritance(self) -> None: + self.assertEqual({"navigation": None}, _presentation_projection({"navigation": None})) + self.assertNotIn("separators", _presentation_projection({"navigation": {"order": []}})["navigation"]) + self.assertEqual([], _presentation_projection({"navigation": {"separators": []}})["navigation"]["separators"]) + for navigation in ( + {"order": ["files.nav.files"] * 257}, + {"separators": [{"id": "separator:work"}] * 257}, + {"separators": ["invalid"]}, + ): + with self.subTest(navigation=navigation), self.assertRaises(ValueError): + _presentation_projection({"navigation": navigation}) + def test_resource_references_narrow_and_conflicts_fail_closed(self) -> None: definition = self.provider.search_subject( self.session, diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index 89bdebb..8920062 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -56,6 +56,10 @@ class ViewsInterfaceDocumentationContractTests(unittest.TestCase): ) ) self.assertEqual("reference", reference.metadata["kind"]) + self.assertIn("shared navigation editor", workflow.body) + self.assertIn("gemeinsamen Navigationseditor", workflow.translations["de"]["body"]) + self.assertIn("Views cannot create navigation", reference.body) + self.assertIn("keine Navigationssperren", reference.translations["de"]["body"]) if __name__ == "__main__": diff --git a/tests/test_views.py b/tests/test_views.py index 8712794..de9ff04 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -207,6 +207,12 @@ class ViewsServiceTests(unittest.TestCase): catalogue=self.catalogue, actor_id="account-admin", presentation={ + "navigation": { + "contract_version": "1", + "order": ["separator:work", "files.nav.files"], + "hidden": ["mail.nav.mail"], + "separators": [{"id": "separator:work", "label": "My work"}], + }, "navigation_mode": "grouped", "product_area_order": ["work", "records-documents"], "product_area_labels": {"work": "My work"}, @@ -254,6 +260,40 @@ class ViewsServiceTests(unittest.TestCase): catalogue=self.catalogue, ) self.assertEqual("My work", state.effective.presentation["product_area_labels"]["work"]) + self.assertEqual(revision.presentation["navigation"], state.effective.presentation["navigation"]) + changed = create_revision( + self.session, + definition, + visible_surface_ids=lockout_safe_surface_ids(), + catalogue=self.catalogue, + actor_id="account-admin", + presentation={**revision.presentation, "navigation": {"separators": []}}, + ) + self.assertNotEqual(revision.content_hash, changed.content_hash) + self.assertEqual([], changed.presentation["navigation"]["separators"]) + self.assertEqual("My work", revision.presentation["navigation"]["separators"][0]["label"]) + + def test_view_navigation_inheritance_flatness_and_bounds(self) -> None: + self.assertNotIn("navigation", normalize_view_presentation({})) + self.assertIsNone(normalize_view_presentation({"navigation": None})["navigation"]) + inherited = normalize_view_presentation({"navigation": {"order": [" files.nav.files ", "files.nav.files"]}})["navigation"] + self.assertEqual(["files.nav.files"], inherited["order"]) + self.assertNotIn("separators", inherited) + flat = normalize_view_presentation({"navigation": {"separators": []}})["navigation"] + self.assertEqual([], flat["separators"]) + for navigation in ( + {"locked": ["files.nav.files"]}, + {"grant": ["files.read"]}, + {"contract_version": "2"}, + {"order": ["files.nav.files"] * 257}, + {"hidden": ["files.nav.files"] * 257}, + {"separators": [{"id": "separator:work", "label": "a" * 121}]}, + {"separators": [{"id": "files.nav.files", "label": "Not a separator"}]}, + {"separators": [{"id": "separator:work", "label": "Unsafe\nlabel"}]}, + {"separators": [{"id": "separator:work", "private": "payload"}]}, + ): + with self.subTest(navigation=navigation), self.assertRaises(ViewsValidationError): + normalize_view_presentation({"navigation": navigation}) def test_view_presentation_rejects_unknown_or_unavailable_fields(self) -> None: with self.assertRaises(ViewsValidationError): diff --git a/webui/package.json b/webui/package.json index a20e179..66b729e 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,13 +1,14 @@ { "name": "@govoplan/views-webui", - "version": "0.1.21", + "version": "0.1.22", "private": true, "type": "module", "main": "src/index.ts", "module": "src/index.ts", "types": "src/index.ts", "scripts": { - "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs" + "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs", + "test:navigation-layout": "node scripts/test-navigation-layout.mjs" }, "exports": { ".": { @@ -17,7 +18,7 @@ "./styles/views.css": "./src/styles/views.css" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/scripts/test-navigation-layout.mjs b/webui/scripts/test-navigation-layout.mjs new file mode 100644 index 0000000..610dc51 --- /dev/null +++ b/webui/scripts/test-navigation-layout.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import vm from "node:vm"; + +const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url)); +const ts = require("typescript"); +const read = (path) => readFileSync(new URL(path, import.meta.url), "utf8"); +const plain = (value) => JSON.parse(JSON.stringify(value)); + +function evaluate(source, fileName) { + const exports = {}; + const compiled = ts.transpileModule(source, { + fileName, + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } + }).outputText; + vm.runInNewContext(compiled, { exports, require: () => ({}) }, { filename: fileName }); + return exports; +} + +const api = evaluate(read("../src/api/views.ts"), "views.ts"); +const admin = read("../src/features/views/ViewsAdminPanel.tsx"); +// Exercise the real private revision/dirty-state functions without mounting or making API calls. +const helpers = evaluate(`${admin}\nexport { revisionPresentation, presentationKey };`, "ViewsAdminPanel.tsx"); +const layout = { + contract_version: "1", + order: ["separator:work", "files.nav.files", "tasks.nav.tasks"], + hidden: ["mail.nav.mail"], + locked: [], + separators: [{ id: "separator:work", label: "Meine Arbeit" }] +}; +for (const navigation of [null, layout, { ...layout, separators: [] }, { contract_version: "1", order: [], hidden: [] }]) { + const presentation = { navigation, navigationMode: "grouped" }; + const wire = api.presentationToApi(presentation); + assert.deepEqual(plain(api.presentationFromApi(wire).navigation), navigation, "Effective API projection preserves null, inherited groups, explicit flat, labels and ordered entries"); + const revision = helpers.revisionPresentation(wire, []); + assert.deepEqual(plain(revision.navigation), navigation, "Opening a saved revision preserves its navigation preferences"); + assert.equal(helpers.presentationKey(revision), helpers.presentationKey(presentation), "Round-trip does not invent dirty state"); +} +assert.equal(helpers.revisionPresentation(undefined, []).navigation, null, "Legacy revisions inherit navigation"); +const saved = helpers.presentationKey({ navigation: layout }); +for (const changed of [ + { ...layout, order: [...layout.order].reverse() }, + { ...layout, hidden: [] }, + { ...layout, separators: [] }, + { ...layout, separators: [{ ...layout.separators[0], label: "Changed" }] }, + null +]) { + assert.notEqual(helpers.presentationKey({ navigation: changed }), saved, "Every navigation edit or inheritance reset participates in Save/dirty guards"); +} +assert.match(admin, /; @@ -260,10 +262,11 @@ export function createViewRevision( ); } -function presentationFromApi( +export function presentationFromApi( value: ViewRevision["presentation"] | undefined ): ViewPresentation { return { + navigation: value?.navigation ?? null, navigationMode: value?.navigation_mode, productAreaOrder: value?.product_area_order ?? [], productAreaLabels: value?.product_area_labels ?? {}, @@ -276,6 +279,7 @@ export function presentationToApi( value: ViewPresentation ): ViewRevision["presentation"] { return { + navigation: value.navigation ?? null, navigation_mode: value.navigationMode ?? "grouped", product_area_order: value.productAreaOrder ?? [], product_area_labels: value.productAreaLabels ?? {}, diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx index 313f84c..e82f4cc 100644 --- a/webui/src/features/views/ViewsAdminPanel.tsx +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -26,6 +26,7 @@ import { FormGrid, ExplorerTree, FormField, IconButton, + NavigationPreferenceEditor, SearchableSelect, SegmentedControl, SelectionList, @@ -33,6 +34,7 @@ import { FormGrid, StatusBadge, ToggleSwitch, adminErrorMessage, + configurableNavigationItemsForModules, dispatchPlatformViewChanged, i18nMessage, usePlatformLanguage, @@ -134,6 +136,8 @@ export default function ViewsAdminPanel({ }) { const surfaces = useViewSurfaces(); const modules = usePlatformModules(); + const navigationItems = useMemo(() => configurableNavigationItemsForModules(modules), [modules]); + const navigationProductAreas = useMemo(() => modules.flatMap((module) => module.productAreas ?? []), [modules]); const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]); const { requestDiscard } = useUnsavedChanges(); const { translateText } = usePlatformLanguage(); @@ -763,6 +767,26 @@ export default function ViewsAdminPanel({ +
+
+
+

i18n:govoplan-views.navigation_layout

+

i18n:govoplan-views.navigation_editor_help

+
+
+ setDraft({ + ...draft, + presentation: { ...draft.presentation, navigation } + })} + /> +
+ {productAreas.length > 0 && ( surface.required).map((surface) => surface.id) ); @@ -1518,14 +1543,14 @@ function ProductAreaEditor({

i18n:govoplan-views.product_areas

- i18n:govoplan-views.product_areas_help + {customNavigation ? "i18n:govoplan-views.product_areas_custom_navigation_help" : "i18n:govoplan-views.product_areas_help"}

ariaLabel={translateText("i18n:govoplan-views.navigation_layout")} role="group" value={draft.presentation.navigationMode ?? "grouped"} - disabled={disabled} + disabled={disabled || customNavigation} onChange={(navigationMode) => updatePresentation({ ...draft.presentation, navigationMode }) } @@ -1558,7 +1583,7 @@ function ProductAreaEditor({ { value0: translateText(area.label) } )} maxLength={200} - disabled={disabled} + disabled={disabled || customNavigation} onChange={(event) => setLabel(area.id, event.target.value)} /> } variant="ghost" - disabled={disabled || index === 0} + disabled={disabled || customNavigation || index === 0} onClick={() => move(area.id, -1)} /> } variant="ghost" - disabled={disabled || index === ordered.length - 1} + disabled={disabled || customNavigation || index === ordered.length - 1} onClick={() => move(area.id, 1)} /> @@ -1879,6 +1904,7 @@ function aggregateProductAreas(modules: PlatformWebModule[]): ViewProductArea[] function defaultPresentation(areas: ViewProductArea[]): ViewPresentation { return { + navigation: null, navigationMode: "grouped", productAreaOrder: areas.map((area) => area.id), productAreaLabels: {}, @@ -1917,6 +1943,7 @@ function revisionPresentation( ): ViewPresentation { const defaults = defaultPresentation(areas); return { + navigation: value?.navigation ?? null, navigationMode: value?.navigation_mode ?? defaults.navigationMode, productAreaOrder: value?.product_area_order?.length @@ -1945,6 +1972,7 @@ function orderedProductAreas( function presentationKey(value: ViewPresentation): string { return JSON.stringify({ + navigation: value.navigation ?? null, navigationMode: value.navigationMode ?? "grouped", productAreaOrder: value.productAreaOrder ?? [], productAreaLabels: Object.fromEntries( diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 3e2455a..bb4ac3f 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -133,6 +133,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-views.product_areas": "Product areas", "i18n:govoplan-views.product_areas_help": "Choose the outcome-based navigation groups, their order, and optional labels for this View.", "i18n:govoplan-views.navigation_layout": "Navigation layout", + "i18n:govoplan-views.navigation_editor_help": "Arrange modules and optional named separators with the shared navigation editor. Changes belong to this immutable View revision. Inherit restores tenant defaults; an explicit personal layout takes precedence. Layout changes never grant access or remove administrator locks.", + "i18n:govoplan-views.product_areas_custom_navigation_help": "The custom navigation layout above overrides legacy grouped/flat mode, area order, and labels. Area visibility still determines which surfaces this View allows. Inherit the navigation layout to enable the legacy controls again.", "i18n:govoplan-views.grouped": "Grouped", "i18n:govoplan-views.flat": "Flat", "i18n:govoplan-views.hidden": "Hidden", @@ -274,6 +276,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-views.product_areas": "Produktbereiche", "i18n:govoplan-views.product_areas_help": "Ergebnisorientierte Navigationsgruppen, ihre Reihenfolge und optionale Bezeichnungen für diese Ansicht festlegen.", "i18n:govoplan-views.navigation_layout": "Navigationsdarstellung", + "i18n:govoplan-views.navigation_editor_help": "Module und optional benannte Trennlinien mit dem gemeinsamen Navigationseditor anordnen. Änderungen gehören zu dieser unveränderlichen Ansichtsrevision. Vererben stellt die Mandantenvorgaben wieder her; eine ausdrücklich festgelegte persönliche Anordnung hat Vorrang. Die Darstellung erteilt weder Berechtigungen noch entfernt sie Administrationssperren.", + "i18n:govoplan-views.product_areas_custom_navigation_help": "Die eigene Navigationsanordnung oben ersetzt den bisherigen gruppierten/flachen Modus sowie Bereichsreihenfolge und -beschriftungen. Die Bereichssichtbarkeit bestimmt weiterhin, welche Oberflächen die Ansicht zulässt. Die Navigationsanordnung vererben, um die bisherigen Steuerelemente wieder zu aktivieren.", "i18n:govoplan-views.grouped": "Gruppiert", "i18n:govoplan-views.flat": "Flach", "i18n:govoplan-views.hidden": "Ausgeblendet",