5 Commits
Author SHA1 Message Date
zemion 970af124d4 fix(ui): clarify inherited navigation and heading help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:04:23 +02:00
zemion 37e7e9786c fix(packaging): expose immutable WebUI Git package for v0.1.22
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:11 +02:00
zemion 655aea6662 Release govoplan-views v0.1.22: unify interface contracts and documentation 2026-09-08 01:32:54 +02:00
zemion 9a53898388 fix(webui): bind view publication to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:48 +02:00
zemion 4e574f3bdd docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
16 changed files with 376 additions and 15 deletions
+18
View File
@@ -85,3 +85,21 @@ already effective, authorized catalogue. The rail identifies the active View,
explains a focus whose tools are no longer available, and exposes **All
available tools** as a temporary permission-derived escape; that action neither
changes the View nor stores an override.
## Git-source WebUI package
The repository root exposes `@govoplan/views-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/views-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/views-webui",
"version": "0.1.22",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/views.css": "./webui/src/styles/views.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-views"
version = "0.1.19"
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"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Views module."""
__version__ = "0.1.19"
__version__ = "0.1.22"
@@ -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]
@@ -0,0 +1,71 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'views.data-subject-requests': {'consequence_classes': {'delete_assignment': 'Entfernt nur die '
'persönliche '
'Zuordnung, nicht '
'die '
'View-Definition.',
'delete_personal_definition': 'Entfernt '
'die '
'persönliche '
'Definition '
'und '
'Überarbeitungen '
'nur, wenn '
'kein '
'anderes '
'Konto '
'davon '
'abhängt.',
'delete_selection': 'Entfernt die '
'Kontoauswahl; '
'effektive Ausfälle '
'gelten erneut.',
'retain_attribution': 'Reserviert '
'minimierte '
'Mandant- / '
'Gruppenkonfigurationsverantwortung.'}},
'views.interface-projections': {'steps': ['Öffnen Sie den View-Selektor und überprüfen Sie den '
'effektiven View und seine Quelle.',
'Wählen Sie eine verfügbare View oder kehren Sie zum '
'berechtigungsabgeleiteten Standard zurück.',
'Verwenden Sie die Administrationsflucht, wenn eine '
'erforderliche View inspiziert oder geändert werden '
'muss.']},
'views.reference.fields-and-consequences': {'consequence_classes': {'archive': 'Deaktiviert '
'optionale '
'Zuweisungen; '
'erforderliche '
'Zuweisungen '
'müssen zuerst '
'entfernt werden.',
'publish': 'Erstellt die '
'zuordenbare '
'unveränderliche '
'Revision, die von '
'nicht gepinnten '
'Zuweisungen '
'verwendet wird.',
'remove_assignment': 'Stoppt, '
'dass '
'das '
'Ziel '
'diese '
'Zuweisung '
'erbt, '
'ohne '
'View zu '
'löschen.',
'required': 'Schränkt '
'betroffene '
'Benutzer ein, '
'während '
'Selektor- und '
'Administrations-Escape-Oberflächen '
'beibehalten '
'werden.'}}}
+31 -1
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_views.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
@@ -36,7 +39,7 @@ from govoplan_views.backend.dsar_provider import (
MODULE_ID = "views"
MODULE_NAME = "Views"
MODULE_VERSION = "0.1.19"
MODULE_VERSION = "0.1.22"
DEFINITION_READ_SCOPE = "views:definition:read"
DEFINITION_WRITE_SCOPE = "views:definition:write"
@@ -393,8 +396,15 @@ manifest = ModuleManifest(
"for a task without changing authorization."
),
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Views, not among operational action buttons. Field help remains beside its label. "
"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 "
@@ -432,7 +442,11 @@ manifest = ModuleManifest(
"title": "Aufgabenbezogene Ansichten",
"summary": "Die sichtbare Oberfläche auf die für eine Aufgabe benötigten Module und Funktionen begrenzen, ohne Berechtigungen zu ändern.",
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Ansichten, nicht zwischen ausführbaren Aktionsschaltflächen. "
"Feldhilfe bleibt neben der Feldbezeichnung. "
"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."
),
@@ -487,12 +501,21 @@ 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 "
"focus ids are likewise revisioned presentation metadata and never authorize a contribution. The runtime's "
"All available tools escape can only reveal contributions that already survived entitlement, policy, preference, "
"surface, context, and permission checks."
" No setup is needed for the standard product-area groups: Work; Services and cases; Records and documents; Communication; Meetings and decisions; Data and assurance; People and responsibility. Only available, authorized and View-visible groups appear. The inherited navigation baseline is personal over tenant over system over standard; a personal navigation override takes precedence over View presentation and locked entries cannot be hidden. Expanded rails show group headings without dividers; collapsed rails show horizontal group boundaries. The shared editor identifies inherited, custom grouped and explicitly flat layouts. Use inherited layout removes only this View's custom navigation draft, not personal, tenant or system settings; Save revision and publication apply that revision according to assignment policy. Explicit flat navigation remains flat until its responsible setting is changed."
),
documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"),
@@ -504,7 +527,9 @@ 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."
" Die Standardgruppen benötigen keine Einrichtung: Arbeit; Leistungen und Vorgänge; Akten und Dokumente; Kommunikation; Termine und Entscheidungen; Daten und Qualitätssicherung; Personen und Verantwortung. Nur verfügbare, berechtigte und in der View sichtbare Gruppen erscheinen. Die geerbte Navigationsgrundlage folgt persönlich vor Mandant vor System vor Standard; eine persönliche Navigationsanpassung hat Vorrang vor der View-Darstellung, gesperrte Einträge können nicht ausgeblendet werden. Ausgeklappte Leisten zeigen Gruppenüberschriften ohne Trennlinien, eingeklappte Leisten horizontale Gruppengrenzen. Der gemeinsame Editor kennzeichnet geerbte, eigene gruppierte und ausdrücklich ungegliederte Anordnungen. Geerbte Anordnung verwenden entfernt nur den eigenen Navigationsentwurf dieser View, nicht persönliche, Mandanten- oder Systemeinstellungen. Revision speichern und Veröffentlichen wenden diese Revision gemäß Zuordnungsrichtlinie an. Eine ausdrücklich flache Navigation bleibt erhalten, bis ihre verantwortliche Einstellung geändert wird."
),
}
},
@@ -571,6 +596,11 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
+19
View File
@@ -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"}:
+23
View File
@@ -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,
@@ -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__":
+40
View File
@@ -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):
+4 -3
View File
@@ -1,13 +1,14 @@
{
"name": "@govoplan/views-webui",
"version": "0.1.19",
"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",
+59
View File
@@ -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, /<NavigationPreferenceEditor\s+scope="view"/);
assert.ok(admin.includes("configurableNavigationItemsForModules(modules)"));
assert.ok(admin.includes("productAreas={navigationProductAreas}"));
assert.ok(admin.includes("disabled={disabled || customNavigation}"), "Conflicting legacy layout controls disable while custom navigation is configured");
assert.ok(admin.includes("disabled={disabled || required}"), "Surface visibility remains separate from navigation ordering");
for (const key of ["navigation_editor_help", "product_areas_custom_navigation_help"]) {
assert.equal(read("../src/i18n/generatedTranslations.ts").split(`"i18n:govoplan-views.${key}":`).length - 1, 2, "Shared layout guidance has English and German text");
}
console.log("Views shared navigation revision, inheritance, API and dirty-state contract passed.");
+5 -1
View File
@@ -3,6 +3,7 @@ import {
apiPath,
type ApiSettings,
type EffectiveViewProjection,
type NavigationPreferences,
type ViewPresentation
} from "@govoplan/core-webui";
@@ -17,6 +18,7 @@ export type ViewRevision = {
surface_contract_version: string;
visible_surface_ids: string[];
presentation: {
navigation?: NavigationPreferences | null;
navigation_mode?: "grouped" | "flat";
product_area_order?: string[];
product_area_labels?: Record<string, string>;
@@ -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 ?? {},
+37 -7
View File
@@ -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();
@@ -580,7 +584,7 @@ export default function ViewsAdminPanel({
return (
<>
<AdminPageLayout
title={title}
title={title} titleHelp={<DocumentationHelpLink reference={VIEWS_DOCUMENTATION} />}
description={description}
className={embedded ? "views-admin-page embedded" : "views-admin-page"}
loading={loading}
@@ -588,7 +592,7 @@ export default function ViewsAdminPanel({
success={success}
actions={
<>
<DocumentationHelpLink reference={VIEWS_DOCUMENTATION} />
<IconButton
label="i18n:govoplan-views.reload_views"
icon={<RefreshCw />}
@@ -711,6 +715,8 @@ export default function ViewsAdminPanel({
</Button>
<Button
variant="primary"
helpContextId="views.action.publish"
helpModuleId="views"
onClick={() => void publishDraft()}
disabled={Boolean(editDisabledReason) || !draft.name.trim()}
disabledReason={editDisabledReason ?? (!draft.name.trim() ? VIEWS_INTERFACE_I18N.nameRequiredText : undefined)}
@@ -761,6 +767,26 @@ export default function ViewsAdminPanel({
</FormField>
</div>
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>i18n:govoplan-views.navigation_layout</h4>
<p className="muted small-note">i18n:govoplan-views.navigation_editor_help</p>
</div>
</div>
<NavigationPreferenceEditor
scope="view"
items={navigationItems}
productAreas={navigationProductAreas}
value={draft.presentation.navigation ?? null}
disabled={!definitionEditable || busy}
onChange={(navigation) => setDraft({
...draft,
presentation: { ...draft.presentation, navigation }
})}
/>
</section>
{productAreas.length > 0 && (
<ProductAreaEditor
areas={productAreas}
@@ -1478,6 +1504,7 @@ function ProductAreaEditor({
}) {
const { translateText } = usePlatformLanguage();
const ordered = orderedProductAreas(areas, draft.presentation.productAreaOrder);
const customNavigation = draft.presentation.navigation != null;
const requiredSurfaceIds = new Set(
surfaces.filter((surface) => surface.required).map((surface) => surface.id)
);
@@ -1516,14 +1543,14 @@ function ProductAreaEditor({
<div>
<h4>i18n:govoplan-views.product_areas</h4>
<p className="muted small-note">
i18n:govoplan-views.product_areas_help
{customNavigation ? "i18n:govoplan-views.product_areas_custom_navigation_help" : "i18n:govoplan-views.product_areas_help"}
</p>
</div>
<SegmentedControl<"grouped" | "flat">
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 })
}
@@ -1556,7 +1583,7 @@ function ProductAreaEditor({
{ value0: translateText(area.label) }
)}
maxLength={200}
disabled={disabled}
disabled={disabled || customNavigation}
onChange={(event) => setLabel(area.id, event.target.value)}
/>
<ToggleSwitch
@@ -1573,14 +1600,14 @@ function ProductAreaEditor({
label="i18n:govoplan-views.move_up"
icon={<ArrowUp size={16} />}
variant="ghost"
disabled={disabled || index === 0}
disabled={disabled || customNavigation || index === 0}
onClick={() => move(area.id, -1)}
/>
<IconButton
label="i18n:govoplan-views.move_down"
icon={<ArrowDown size={16} />}
variant="ghost"
disabled={disabled || index === ordered.length - 1}
disabled={disabled || customNavigation || index === ordered.length - 1}
onClick={() => move(area.id, 1)}
/>
</div>
@@ -1877,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: {},
@@ -1915,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
@@ -1943,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(
+4
View File
@@ -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",