From b9a92c79f96412acc43acb3d93403d3fc8750c0f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 19:02:54 +0200 Subject: [PATCH] Add product-area presentation to Views --- src/govoplan_views/backend/db/models.py | 3 + src/govoplan_views/backend/manifest.py | 7 +- .../c6f2a9d4e7b1_v0118_view_presentation.py | 33 ++ src/govoplan_views/backend/router.py | 20 ++ src/govoplan_views/backend/schemas.py | 4 + src/govoplan_views/backend/service.py | 112 ++++++- tests/test_migrations.py | 11 +- tests/test_views.py | 65 ++++ webui/src/api/views.ts | 39 ++- webui/src/features/views/ViewsAdminPanel.tsx | 294 +++++++++++++++++- webui/src/i18n/generatedTranslations.ts | 26 +- webui/src/styles/views.css | 56 ++++ 12 files changed, 650 insertions(+), 20 deletions(-) create mode 100644 src/govoplan_views/backend/migrations/versions/c6f2a9d4e7b1_v0118_view_presentation.py diff --git a/src/govoplan_views/backend/db/models.py b/src/govoplan_views/backend/db/models.py index d38ba52..e300716 100644 --- a/src/govoplan_views/backend/db/models.py +++ b/src/govoplan_views/backend/db/models.py @@ -100,6 +100,9 @@ class ViewRevision(Base, TimestampMixin): visible_surface_ids: Mapped[list[str]] = mapped_column( JSON, default=list, nullable=False ) + presentation: Mapped[dict[str, Any]] = mapped_column( + JSON, default=dict, nullable=False + ) content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) created_by: Mapped[str | None] = mapped_column( String(255), nullable=True, index=True diff --git a/src/govoplan_views/backend/manifest.py b/src/govoplan_views/backend/manifest.py index 7913ec6..71ad1bc 100644 --- a/src/govoplan_views/backend/manifest.py +++ b/src/govoplan_views/backend/manifest.py @@ -344,7 +344,7 @@ manifest = ModuleManifest( "View safeguards, and the difference between visibility and access." ), body=( - "A View definition owns immutable revisions of visible surface IDs. " + "A View definition owns immutable revisions of visible surface IDs and presentation metadata. " "Publishing makes the latest revision assignable. Available assignments " "let users opt in, defaults apply until changed, and required assignments " "cannot be left. User and group assignments take precedence over tenant " @@ -356,6 +356,9 @@ 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." + " 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." ), documentation_types=("admin",), audience=("administrator", "power_user", "workflow_designer"), @@ -378,6 +381,8 @@ manifest = ModuleManifest( "views.field.name", "views.field.description", "views.field.surfaces", + "views.field.product-areas", + "views.field.navigation-layout", "views.field.assignment-target", "views.field.assignment-mode", "views.field.assignment-priority", diff --git a/src/govoplan_views/backend/migrations/versions/c6f2a9d4e7b1_v0118_view_presentation.py b/src/govoplan_views/backend/migrations/versions/c6f2a9d4e7b1_v0118_view_presentation.py new file mode 100644 index 0000000..42e9edd --- /dev/null +++ b/src/govoplan_views/backend/migrations/versions/c6f2a9d4e7b1_v0118_view_presentation.py @@ -0,0 +1,33 @@ +"""v0.1.18 immutable View presentation metadata. + +Revision ID: c6f2a9d4e7b1 +Revises: b8e4c1f7a2d9 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "c6f2a9d4e7b1" +down_revision = "b8e4c1f7a2d9" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("view_revisions") as batch_op: + batch_op.add_column( + sa.Column( + "presentation", + sa.JSON(), + nullable=False, + server_default=sa.text("'{}'"), + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("view_revisions") as batch_op: + batch_op.drop_column("presentation") diff --git a/src/govoplan_views/backend/router.py b/src/govoplan_views/backend/router.py index 3e8290e..e86ce4c 100644 --- a/src/govoplan_views/backend/router.py +++ b/src/govoplan_views/backend/router.py @@ -111,6 +111,21 @@ def _catalogue( ) +def _product_area_ids( + session: Session, + principal: ApiPrincipal, +) -> frozenset[str]: + active_module_ids = { + surface.module_id for surface in _catalogue(session, principal) + } + return frozenset( + area.id + for manifest in get_registry().manifests() + if manifest.id in active_module_ids and manifest.frontend is not None + for area in manifest.frontend.product_areas + ) + + def _view_governance_policy(): return view_governance_policy(get_registry()) @@ -507,6 +522,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse: active_revision_id=effective.revision_id, active_view_name=effective.name, visible_surface_ids=sorted(effective.visible_surface_ids), + presentation=dict(effective.presentation), projection_active=effective.projection_active, locked=effective.locked, available_views=[ @@ -753,6 +769,8 @@ def api_create_definition( visible_surface_ids=payload.visible_surface_ids, catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), + presentation=payload.presentation, + available_product_area_ids=_product_area_ids(session, principal), ) _audit( session, @@ -892,6 +910,8 @@ def api_create_revision( visible_surface_ids=payload.visible_surface_ids, catalogue=_catalogue(session, principal), actor_id=_actor_id(principal), + presentation=payload.presentation, + available_product_area_ids=_product_area_ids(session, principal), ) _audit( session, diff --git a/src/govoplan_views/backend/schemas.py b/src/govoplan_views/backend/schemas.py index b430570..2468af5 100644 --- a/src/govoplan_views/backend/schemas.py +++ b/src/govoplan_views/backend/schemas.py @@ -37,6 +37,7 @@ class ViewRevisionResponse(BaseModel): revision: int surface_contract_version: str visible_surface_ids: list[str] + presentation: dict[str, Any] = Field(default_factory=dict) content_hash: str created_by: str | None = None created_at: datetime @@ -73,6 +74,7 @@ class ViewDefinitionCreateRequest(BaseModel): name: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=4000) visible_surface_ids: list[str] = Field(min_length=1, max_length=1000) + presentation: dict[str, Any] = Field(default_factory=dict) class ViewDefinitionUpdateRequest(BaseModel): @@ -82,6 +84,7 @@ class ViewDefinitionUpdateRequest(BaseModel): class ViewRevisionCreateRequest(BaseModel): visible_surface_ids: list[str] = Field(min_length=1, max_length=1000) + presentation: dict[str, Any] | None = None class ViewAssignmentResponse(BaseModel): @@ -179,6 +182,7 @@ class EffectiveViewResponse(BaseModel): active_revision_id: str | None = None active_view_name: str | None = None visible_surface_ids: list[str] = Field(default_factory=list) + presentation: dict[str, Any] = Field(default_factory=dict) projection_active: bool = False locked: bool = False available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list) diff --git a/src/govoplan_views/backend/service.py b/src/govoplan_views/backend/service.py index 8a2b2ae..4574915 100644 --- a/src/govoplan_views/backend/service.py +++ b/src/govoplan_views/backend/service.py @@ -6,7 +6,7 @@ import re from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timezone -from typing import Literal +from typing import Any, Literal, Mapping from sqlalchemy import and_, or_ from sqlalchemy.orm import Session, joinedload @@ -51,6 +51,10 @@ LOCKOUT_ADMIN_SURFACE_IDS = { "user": "views.admin.tenant", } _KEY_RE = re.compile(r"[^a-z0-9]+") +_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9-]{1,79}$") +_PRESENTATION_KEYS = frozenset( + {"navigation_mode", "product_area_order", "product_area_labels"} +) class ViewsError(RuntimeError): @@ -350,10 +354,92 @@ def normalize_visible_surface_ids( ) -def _revision_hash(surface_ids: list[str]) -> str: +def normalize_view_presentation( + value: Mapping[str, Any] | None, + *, + available_product_area_ids: Iterable[str] | None = None, +) -> dict[str, object]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ViewsValidationError("View presentation must be an object") + unknown_keys = sorted(set(value) - _PRESENTATION_KEYS) + if unknown_keys: + raise ViewsValidationError( + "Unsupported View presentation fields: " + ", ".join(unknown_keys) + ) + + normalized: dict[str, object] = {} + mode = value.get("navigation_mode") + if mode is not None: + if mode not in {"grouped", "flat"}: + raise ViewsValidationError( + "View navigation mode must be 'grouped' or 'flat'" + ) + normalized["navigation_mode"] = mode + + raw_order = value.get("product_area_order") + if raw_order is not None: + if not isinstance(raw_order, list) or len(raw_order) > 100: + raise ViewsValidationError( + "View product area order must be a list of at most 100 ids" + ) + order: list[str] = [] + for raw_id in raw_order: + area_id = str(raw_id).strip() + if not _PRESENTATION_ID_RE.fullmatch(area_id): + raise ViewsValidationError( + f"Invalid product area id in View presentation: {area_id!r}" + ) + if area_id in order: + raise ViewsValidationError( + f"Duplicate product area id in View presentation: {area_id}" + ) + order.append(area_id) + normalized["product_area_order"] = order + + raw_labels = value.get("product_area_labels") + if raw_labels is not None: + if not isinstance(raw_labels, Mapping) or len(raw_labels) > 100: + raise ViewsValidationError( + "View product area labels must be an object with at most 100 entries" + ) + labels: dict[str, str] = {} + for raw_id, raw_label in raw_labels.items(): + area_id = str(raw_id).strip() + label = str(raw_label).strip() + if not _PRESENTATION_ID_RE.fullmatch(area_id): + raise ViewsValidationError( + f"Invalid product area id in View presentation: {area_id!r}" + ) + if not label or len(label) > 200: + raise ViewsValidationError( + f"Product area label for {area_id} must contain 1 to 200 characters" + ) + labels[area_id] = label + normalized["product_area_labels"] = labels + + if available_product_area_ids is not None: + available = {str(item) for item in available_product_area_ids} + referenced = set(normalized.get("product_area_order", ())) | set( + normalized.get("product_area_labels", {}) + ) + unavailable = sorted(referenced - available) + if unavailable: + raise ViewsValidationError( + "View presentation references unavailable product areas: " + + ", ".join(unavailable) + ) + return normalized + + +def _revision_hash( + surface_ids: list[str], presentation: Mapping[str, object] | None = None +) -> str: payload = { "surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION, "visible_surface_ids": surface_ids, + "presentation": dict(presentation or {}), } return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") @@ -372,6 +458,8 @@ def create_definition( visible_surface_ids: Iterable[str], catalogue: Iterable[ViewSurface], actor_id: str | None, + presentation: Mapping[str, Any] | None = None, + available_product_area_ids: Iterable[str] | None = None, ) -> ViewDefinition: if scope_type not in DEFINITION_SCOPES: raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") @@ -401,6 +489,10 @@ def create_definition( visible_surface_ids, catalogue=catalogue, ) + normalized_presentation = normalize_view_presentation( + presentation, + available_product_area_ids=available_product_area_ids, + ) definition = ViewDefinition( tenant_id=row_tenant_id, scope_type=scope_type, @@ -422,7 +514,8 @@ def create_definition( revision=1, surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, visible_surface_ids=normalized_surfaces, - content_hash=_revision_hash(normalized_surfaces), + presentation=normalized_presentation, + content_hash=_revision_hash(normalized_surfaces, normalized_presentation), created_by=actor_id, ) session.add(revision) @@ -461,15 +554,21 @@ def create_revision( visible_surface_ids: Iterable[str], catalogue: Iterable[ViewSurface], actor_id: str | None, + presentation: Mapping[str, Any] | None = None, + available_product_area_ids: Iterable[str] | None = None, ) -> ViewRevision: if definition.status == "archived": raise ViewsConflictError("Archived Views cannot be revised") + latest = get_revision(session, definition_id=definition.id) normalized_surfaces = normalize_visible_surface_ids( visible_surface_ids, catalogue=catalogue, ) - content_hash = _revision_hash(normalized_surfaces) - latest = get_revision(session, definition_id=definition.id) + normalized_presentation = normalize_view_presentation( + latest.presentation if presentation is None else presentation, + available_product_area_ids=available_product_area_ids, + ) + content_hash = _revision_hash(normalized_surfaces, normalized_presentation) if latest.content_hash == content_hash: return latest next_number = definition.current_revision + 1 @@ -479,6 +578,7 @@ def create_revision( revision=next_number, surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, visible_surface_ids=normalized_surfaces, + presentation=normalized_presentation, content_hash=content_hash, created_by=actor_id, ) @@ -1441,6 +1541,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView: revision_id=None, name=None, visible_surface_ids=selection.visible_surface_ids or frozenset(), + presentation={}, locked=False, projection_active=selection.visible_surface_ids is not None, provenance=selection.provenance, @@ -1455,6 +1556,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView: if selection.visible_surface_ids is not None else frozenset(revision.visible_surface_ids) ), + presentation=dict(revision.presentation or {}), locked=selection.locked, projection_active=True, provenance=selection.provenance, diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 667f539..8271d40 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ class ViewsMigrationTests(unittest.TestCase): try: with engine.connect() as connection: self.assertIn( - "b8e4c1f7a2d9", + "c6f2a9d4e7b1", set(MigrationContext.configure(connection).get_current_heads()), ) self.assertEqual( @@ -42,6 +42,15 @@ class ViewsMigrationTests(unittest.TestCase): if name.startswith("view_") }, ) + self.assertIn( + "presentation", + { + column["name"] + for column in inspect(connection).get_columns( + "view_revisions" + ) + }, + ) finally: engine.dispose() diff --git a/tests/test_views.py b/tests/test_views.py index be48a94..ba136da 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -20,6 +20,7 @@ from govoplan_views.backend.service import ( create_revision, get_revision, list_definitions, + normalize_view_presentation, normalize_visible_surface_ids, publish_revision, resolve_effective_view, @@ -193,6 +194,70 @@ class ViewsServiceTests(unittest.TestCase): self.session.close() self.engine.dispose() + def test_view_presentation_is_normalized_and_versioned(self) -> None: + definition = create_definition( + self.session, + tenant_id="tenant-1", + scope_type="tenant", + scope_id=None, + definition_key=None, + name="Product navigation", + description=None, + visible_surface_ids=ordinary_surface_ids(), + catalogue=self.catalogue, + actor_id="account-admin", + presentation={ + "navigation_mode": "grouped", + "product_area_order": ["work", "records-documents"], + "product_area_labels": {"work": "My work"}, + }, + available_product_area_ids=("work", "records-documents"), + ) + revision = get_revision(self.session, definition_id=definition.id) + self.assertEqual("grouped", revision.presentation["navigation_mode"]) + compatible_revision = create_revision( + self.session, + definition, + visible_surface_ids=lockout_safe_surface_ids(), + catalogue=self.catalogue, + actor_id="legacy-client", + available_product_area_ids=("work", "records-documents"), + ) + self.assertEqual( + revision.presentation, + compatible_revision.presentation, + "omitting presentation must preserve the previous revision contract", + ) + revision = compatible_revision + publish_revision( + self.session, + definition, + revision, + catalogue=self.catalogue, + actor_id="account-admin", + ) + self.assign( + definition, + scope_type="tenant", + scope_id=None, + ) + state = resolve_effective_view( + self.session, + tenant_id="tenant-1", + account_id="account-1", + catalogue=self.catalogue, + ) + self.assertEqual("My work", state.effective.presentation["product_area_labels"]["work"]) + + def test_view_presentation_rejects_unknown_or_unavailable_fields(self) -> None: + with self.assertRaises(ViewsValidationError): + normalize_view_presentation({"unknown": True}) + with self.assertRaises(ViewsValidationError): + normalize_view_presentation( + {"product_area_order": ["unavailable"]}, + available_product_area_ids=("work",), + ) + def create_published_definition( self, *, diff --git a/webui/src/api/views.ts b/webui/src/api/views.ts index 842e686..3686bb7 100644 --- a/webui/src/api/views.ts +++ b/webui/src/api/views.ts @@ -2,7 +2,8 @@ import { apiFetch, apiPath, type ApiSettings, - type EffectiveViewProjection + type EffectiveViewProjection, + type ViewPresentation } from "@govoplan/core-webui"; export type ViewScopeType = "system" | "tenant" | "group" | "user"; @@ -15,6 +16,11 @@ export type ViewRevision = { revision: number; surface_contract_version: string; visible_surface_ids: string[]; + presentation: { + navigation_mode?: "grouped" | "flat"; + product_area_order?: string[]; + product_area_labels?: Record; + }; content_hash: string; created_by?: string | null; created_at: string; @@ -79,6 +85,7 @@ type EffectiveViewApiResponse = { active_revision_id?: string | null; active_view_name?: string | null; visible_surface_ids: string[]; + presentation?: ViewRevision["presentation"]; locked: boolean; available_views: Array<{ id: string; @@ -113,6 +120,7 @@ function projection(response: EffectiveViewApiResponse): EffectiveViewProjection activeRevisionId: response.active_revision_id ?? null, activeViewName: response.active_view_name ?? null, visibleSurfaceIds: response.visible_surface_ids, + presentation: presentationFromApi(response.presentation), locked: response.locked, availableViews: response.available_views.map((view) => ({ id: view.id, @@ -211,6 +219,7 @@ export function createViewDefinition( name: string; description?: string | null; visible_surface_ids: string[]; + presentation?: ViewRevision["presentation"]; } ): Promise { return apiFetch(settings, "/api/v1/views/definitions", { @@ -233,18 +242,42 @@ export function updateViewDefinition( export function createViewRevision( settings: ApiSettings, definitionId: string, - visibleSurfaceIds: string[] + visibleSurfaceIds: string[], + presentation: ViewPresentation ): Promise { return apiFetch( settings, `/api/v1/views/definitions/${definitionId}/revisions`, { method: "POST", - ...jsonBody({ visible_surface_ids: visibleSurfaceIds }) + ...jsonBody({ + visible_surface_ids: visibleSurfaceIds, + presentation: presentationToApi(presentation) + }) } ); } +function presentationFromApi( + value: ViewRevision["presentation"] | undefined +): ViewPresentation { + return { + navigationMode: value?.navigation_mode, + productAreaOrder: value?.product_area_order ?? [], + productAreaLabels: value?.product_area_labels ?? {} + }; +} + +export function presentationToApi( + value: ViewPresentation +): ViewRevision["presentation"] { + return { + navigation_mode: value.navigationMode ?? "grouped", + product_area_order: value.productAreaOrder ?? [], + product_area_labels: value.productAreaLabels ?? {} + }; +} + export function publishViewRevision( settings: ApiSettings, definitionId: string, diff --git a/webui/src/features/views/ViewsAdminPanel.tsx b/webui/src/features/views/ViewsAdminPanel.tsx index fa015e7..32219b9 100644 --- a/webui/src/features/views/ViewsAdminPanel.tsx +++ b/webui/src/features/views/ViewsAdminPanel.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Archive, + ArrowDown, + ArrowUp, CheckSquare2, ChevronDown, ChevronRight, @@ -25,6 +27,7 @@ import { FormField, IconButton, SearchableSelect, + SegmentedControl, SelectionList, SelectionListItem, StatusBadge, @@ -33,12 +36,15 @@ import { dispatchPlatformViewChanged, i18nMessage, usePlatformLanguage, + usePlatformModules, useUnsavedChanges, useUnsavedDraftGuard, useViewSurfaces, type ApiSettings, + type PlatformWebModule, type PlatformViewSurface, - type SearchableSelectOption + type SearchableSelectOption, + type ViewPresentation } from "@govoplan/core-webui"; import { archiveViewDefinition, @@ -50,6 +56,7 @@ import { fetchViewAssignments, fetchViewDefinitions, publishViewRevision, + presentationToApi, updateViewAssignment, updateViewDefinition, type ViewAssignment, @@ -70,6 +77,15 @@ type DefinitionDraft = { name: string; description: string; surfaceIds: string[]; + presentation: ViewPresentation; +}; + +type ViewProductArea = { + id: string; + label: string; + description?: string | null; + order: number; + surfaceIds: string[]; }; type AssignmentDraft = { @@ -117,6 +133,8 @@ export default function ViewsAdminPanel({ description?: string; }) { const surfaces = useViewSurfaces(); + const modules = usePlatformModules(); + const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]); const { requestDiscard } = useUnsavedChanges(); const { translateText } = usePlatformLanguage(); const [definitions, setDefinitions] = useState([]); @@ -125,7 +143,8 @@ export default function ViewsAdminPanel({ const [draft, setDraft] = useState({ name: "", description: "", - surfaceIds: [] + surfaceIds: [], + presentation: defaultPresentation([]) }); const [savedDraftKey, setSavedDraftKey] = useState(""); const [loading, setLoading] = useState(true); @@ -225,9 +244,18 @@ export default function ViewsAdminPanel({ ? { name: definition.name, description: definition.description ?? "", - surfaceIds: definition.latest_revision.visible_surface_ids + surfaceIds: definition.latest_revision.visible_surface_ids, + presentation: revisionPresentation( + definition.latest_revision.presentation, + productAreas + ) } - : { name: "", description: "", surfaceIds: [] }; + : { + name: "", + description: "", + surfaceIds: [], + presentation: defaultPresentation(productAreas) + }; setDraft(next); setSavedDraftKey(definitionDraftKey(next)); } @@ -267,12 +295,17 @@ export default function ViewsAdminPanel({ } if ( surfaceSetKey(draft.surfaceIds) !== - surfaceSetKey(next.latest_revision.visible_surface_ids) + surfaceSetKey(next.latest_revision.visible_surface_ids) || + presentationKey(draft.presentation) !== + presentationKey( + revisionPresentation(next.latest_revision.presentation, productAreas) + ) ) { next = await createViewRevision( settings, selected.id, - draft.surfaceIds + draft.surfaceIds, + draft.presentation ); } await load(selected.id); @@ -328,7 +361,8 @@ export default function ViewsAdminPanel({ scope_id: scopeId || null, name: createDraft.name.trim(), description: createDraft.description.trim() || null, - visible_surface_ids: visibleSurfaceIds + visible_surface_ids: visibleSurfaceIds, + presentation: presentationToApi(defaultPresentation(productAreas)) }); closeCreate(); setSuccess("i18n:govoplan-views.draft_created"); @@ -727,6 +761,16 @@ export default function ViewsAdminPanel({ + {productAreas.length > 0 && ( + + )} +
@@ -1379,6 +1423,136 @@ function AssignmentDialog({ } +function ProductAreaEditor({ + areas, + surfaces, + draft, + disabled, + onChange +}: { + areas: ViewProductArea[]; + surfaces: PlatformViewSurface[]; + draft: DefinitionDraft; + disabled: boolean; + onChange: (draft: DefinitionDraft) => void; +}) { + const { translateText } = usePlatformLanguage(); + const ordered = orderedProductAreas(areas, draft.presentation.productAreaOrder); + const requiredSurfaceIds = new Set( + surfaces.filter((surface) => surface.required).map((surface) => surface.id) + ); + + function updatePresentation(presentation: ViewPresentation) { + onChange({ ...draft, presentation }); + } + + function move(areaId: string, direction: -1 | 1) { + const order = ordered.map((area) => area.id); + const index = order.indexOf(areaId); + const target = index + direction; + if (index < 0 || target < 0 || target >= order.length) return; + [order[index], order[target]] = [order[target], order[index]]; + updatePresentation({ ...draft.presentation, productAreaOrder: order }); + } + + function setLabel(areaId: string, label: string) { + const labels = { ...(draft.presentation.productAreaLabels ?? {}) }; + if (label.trim()) labels[areaId] = label; + else delete labels[areaId]; + updatePresentation({ ...draft.presentation, productAreaLabels: labels }); + } + + function setAreaVisible(area: ViewProductArea, visible: boolean) { + const selected = new Set(draft.surfaceIds); + const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces); + if (visible) affectedSurfaceIds.forEach((id) => selected.add(id)); + else affectedSurfaceIds.forEach((id) => selected.delete(id)); + onChange({ ...draft, surfaceIds: [...selected] }); + } + + return ( +
+
+
+

i18n:govoplan-views.product_areas

+

+ i18n:govoplan-views.product_areas_help +

+
+ + ariaLabel={translateText("i18n:govoplan-views.navigation_layout")} + role="group" + value={draft.presentation.navigationMode ?? "grouped"} + disabled={disabled} + onChange={(navigationMode) => + updatePresentation({ ...draft.presentation, navigationMode }) + } + options={[ + { id: "grouped", label: "i18n:govoplan-views.grouped" }, + { id: "flat", label: "i18n:govoplan-views.flat" } + ]} + /> +
+
+ {ordered.map((area, index) => { + const affectedSurfaceIds = productAreaSurfaceIds(area, surfaces); + const visible = affectedSurfaceIds.some((id) => + draft.surfaceIds.includes(id) + ); + const required = affectedSurfaceIds.some((id) => + requiredSurfaceIds.has(id) + ); + return ( +
+
+ {translateText(area.label)} + {translateText(area.description ?? area.id)} +
+ setLabel(area.id, event.target.value)} + /> + setAreaVisible(area, checked)} + /> +
+ } + variant="ghost" + disabled={disabled || index === 0} + onClick={() => move(area.id, -1)} + /> + } + variant="ghost" + disabled={disabled || index === ordered.length - 1} + onClick={() => move(area.id, 1)} + /> +
+
+ ); + })} +
+
+ ); +} + + function SurfaceSelector({ surfaces, selected, @@ -1627,7 +1801,111 @@ function definitionDraftKey(draft: DefinitionDraft): string { return JSON.stringify({ name: draft.name.trim(), description: draft.description.trim(), - surfaces: [...new Set(draft.surfaceIds)].sort() + surfaces: [...new Set(draft.surfaceIds)].sort(), + presentation: presentationKey(draft.presentation) + }); +} + + +function aggregateProductAreas(modules: PlatformWebModule[]): ViewProductArea[] { + const result = new Map(); + for (const contribution of modules.flatMap( + (module) => module.productAreas ?? [] + )) { + const existing = result.get(contribution.id); + if (existing) { + existing.surfaceIds = [ + ...new Set([...existing.surfaceIds, ...contribution.surfaceIds]) + ]; + existing.order = Math.min(existing.order, contribution.order ?? 100); + continue; + } + result.set(contribution.id, { + id: contribution.id, + label: contribution.label, + description: contribution.description, + order: contribution.order ?? 100, + surfaceIds: [...new Set(contribution.surfaceIds)] + }); + } + return [...result.values()].sort( + (left, right) => + left.order - right.order || left.label.localeCompare(right.label) + ); +} + + +function defaultPresentation(areas: ViewProductArea[]): ViewPresentation { + return { + navigationMode: "grouped", + productAreaOrder: areas.map((area) => area.id), + productAreaLabels: {} + }; +} + + +function productAreaSurfaceIds( + area: ViewProductArea, + surfaces: PlatformViewSurface[] +): string[] { + const affected = new Set(area.surfaceIds); + let changed = true; + while (changed) { + changed = false; + for (const surface of surfaces) { + if ( + surface.parentId && + affected.has(surface.parentId) && + !affected.has(surface.id) + ) { + affected.add(surface.id); + changed = true; + } + } + } + return [...affected]; +} + + +function revisionPresentation( + value: ViewDefinition["latest_revision"]["presentation"] | undefined, + areas: ViewProductArea[] +): ViewPresentation { + const defaults = defaultPresentation(areas); + return { + navigationMode: value?.navigation_mode ?? defaults.navigationMode, + productAreaOrder: + value?.product_area_order?.length + ? value.product_area_order + : defaults.productAreaOrder, + productAreaLabels: value?.product_area_labels ?? {} + }; +} + + +function orderedProductAreas( + areas: ViewProductArea[], + configuredOrder: string[] | undefined +): ViewProductArea[] { + const rank = new Map((configuredOrder ?? []).map((id, index) => [id, index])); + return [...areas].sort( + (left, right) => + (rank.get(left.id) ?? 10_000) - (rank.get(right.id) ?? 10_000) || + left.order - right.order || + left.label.localeCompare(right.label) + ); +} + + +function presentationKey(value: ViewPresentation): string { + return JSON.stringify({ + navigationMode: value.navigationMode ?? "grouped", + productAreaOrder: value.productAreaOrder ?? [], + productAreaLabels: Object.fromEntries( + Object.entries(value.productAreaLabels ?? {}) + .filter(([, label]) => label.trim()) + .sort(([left], [right]) => left.localeCompare(right)) + ) }); } diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index f7ed6e6..3e2455a 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -129,7 +129,18 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-views.filter_view_surfaces": "Filter View surfaces", "i18n:govoplan-views.surface_count": "{value0}/{value1} surfaces", "i18n:govoplan-views.surface_detail": "{value0} · {value1}", - "i18n:govoplan-views.no_matching_surfaces": "No matching surfaces." + "i18n:govoplan-views.no_matching_surfaces": "No matching surfaces.", + "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.grouped": "Grouped", + "i18n:govoplan-views.flat": "Flat", + "i18n:govoplan-views.hidden": "Hidden", + "i18n:govoplan-views.visible": "Visible", + "i18n:govoplan-views.required_area_help": "This area contains a required surface and cannot be hidden.", + "i18n:govoplan-views.custom_area_label_value": "Custom label for {value0}", + "i18n:govoplan-views.move_up": "Move up", + "i18n:govoplan-views.move_down": "Move down" }, de: { "i18n:govoplan-views.views": "Ansichten", @@ -259,6 +270,17 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-views.filter_view_surfaces": "Ansichtsoberflächen filtern", "i18n:govoplan-views.surface_count": "{value0}/{value1} Oberflächen", "i18n:govoplan-views.surface_detail": "{value0} · {value1}", - "i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen." + "i18n:govoplan-views.no_matching_surfaces": "Keine passenden Oberflächen.", + "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.grouped": "Gruppiert", + "i18n:govoplan-views.flat": "Flach", + "i18n:govoplan-views.hidden": "Ausgeblendet", + "i18n:govoplan-views.visible": "Sichtbar", + "i18n:govoplan-views.required_area_help": "Dieser Bereich enthält eine vorgeschriebene Oberfläche und kann nicht ausgeblendet werden.", + "i18n:govoplan-views.custom_area_label_value": "Eigene Bezeichnung für {value0}", + "i18n:govoplan-views.move_up": "Nach oben", + "i18n:govoplan-views.move_down": "Nach unten" } }; diff --git a/webui/src/styles/views.css b/webui/src/styles/views.css index 670e135..9578178 100644 --- a/webui/src/styles/views.css +++ b/webui/src/styles/views.css @@ -175,12 +175,59 @@ resize: vertical; } +.views-product-area-section, .views-surface-section { margin-top: 22px; padding-top: 18px; border-top: var(--border-line); } +.views-product-area-list { + overflow: hidden; + border: var(--border-line); + border-radius: var(--radius-sm); +} + +.views-product-area-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(180px, .8fr) auto auto; + align-items: center; + gap: 12px; + min-height: 62px; + padding: 8px 10px; + border-bottom: var(--border-line); +} + +.views-product-area-row:last-child { + border-bottom: 0; +} + +.views-product-area-row:hover { + background: var(--hover-tint-soft); +} + +.views-product-area-copy { + min-width: 0; +} + +.views-product-area-copy strong, +.views-product-area-copy small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.views-product-area-copy small { + margin-top: 3px; + color: var(--muted); +} + +.views-product-area-order { + display: flex; + align-items: center; +} + .views-assignments-section { min-width: 0; padding-top: 18px; @@ -337,6 +384,15 @@ grid-template-columns: 1fr; } + .views-product-area-row { + grid-template-columns: minmax(0, 1fr) auto; + } + + .views-product-area-row > input { + grid-column: 1 / -1; + grid-row: 2; + } + .views-editor-heading, .views-section-heading, .views-stale-surface-warning {