Add product-area presentation to Views

This commit is contained in:
2026-08-06 19:02:54 +02:00
parent 013f05839b
commit b9a92c79f9
12 changed files with 650 additions and 20 deletions
+3
View File
@@ -100,6 +100,9 @@ class ViewRevision(Base, TimestampMixin):
visible_surface_ids: Mapped[list[str]] = mapped_column( visible_surface_ids: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False 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) content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_by: Mapped[str | None] = mapped_column( created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True String(255), nullable=True, index=True
+6 -1
View File
@@ -344,7 +344,7 @@ manifest = ModuleManifest(
"View safeguards, and the difference between visibility and access." "View safeguards, and the difference between visibility and access."
), ),
body=( 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 " "Publishing makes the latest revision assignable. Available assignments "
"let users opt in, defaults apply until changed, and required assignments " "let users opt in, defaults apply until changed, and required assignments "
"cannot be left. User and group assignments take precedence over tenant " "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 " "that system policy made unavailable or the tenant disabled. Saved references "
"to such surfaces remain in immutable revisions and are reported as stale. " "to such surfaces remain in immutable revisions and are reported as stale. "
"Inherited definitions or assignments must be changed in their owning scope." "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",), documentation_types=("admin",),
audience=("administrator", "power_user", "workflow_designer"), audience=("administrator", "power_user", "workflow_designer"),
@@ -378,6 +381,8 @@ manifest = ModuleManifest(
"views.field.name", "views.field.name",
"views.field.description", "views.field.description",
"views.field.surfaces", "views.field.surfaces",
"views.field.product-areas",
"views.field.navigation-layout",
"views.field.assignment-target", "views.field.assignment-target",
"views.field.assignment-mode", "views.field.assignment-mode",
"views.field.assignment-priority", "views.field.assignment-priority",
@@ -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")
+20
View File
@@ -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(): def _view_governance_policy():
return view_governance_policy(get_registry()) return view_governance_policy(get_registry())
@@ -507,6 +522,7 @@ def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
active_revision_id=effective.revision_id, active_revision_id=effective.revision_id,
active_view_name=effective.name, active_view_name=effective.name,
visible_surface_ids=sorted(effective.visible_surface_ids), visible_surface_ids=sorted(effective.visible_surface_ids),
presentation=dict(effective.presentation),
projection_active=effective.projection_active, projection_active=effective.projection_active,
locked=effective.locked, locked=effective.locked,
available_views=[ available_views=[
@@ -753,6 +769,8 @@ def api_create_definition(
visible_surface_ids=payload.visible_surface_ids, visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(session, principal), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
presentation=payload.presentation,
available_product_area_ids=_product_area_ids(session, principal),
) )
_audit( _audit(
session, session,
@@ -892,6 +910,8 @@ def api_create_revision(
visible_surface_ids=payload.visible_surface_ids, visible_surface_ids=payload.visible_surface_ids,
catalogue=_catalogue(session, principal), catalogue=_catalogue(session, principal),
actor_id=_actor_id(principal), actor_id=_actor_id(principal),
presentation=payload.presentation,
available_product_area_ids=_product_area_ids(session, principal),
) )
_audit( _audit(
session, session,
+4
View File
@@ -37,6 +37,7 @@ class ViewRevisionResponse(BaseModel):
revision: int revision: int
surface_contract_version: str surface_contract_version: str
visible_surface_ids: list[str] visible_surface_ids: list[str]
presentation: dict[str, Any] = Field(default_factory=dict)
content_hash: str content_hash: str
created_by: str | None = None created_by: str | None = None
created_at: datetime created_at: datetime
@@ -73,6 +74,7 @@ class ViewDefinitionCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=200) name: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=4000) description: str | None = Field(default=None, max_length=4000)
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000) visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
presentation: dict[str, Any] = Field(default_factory=dict)
class ViewDefinitionUpdateRequest(BaseModel): class ViewDefinitionUpdateRequest(BaseModel):
@@ -82,6 +84,7 @@ class ViewDefinitionUpdateRequest(BaseModel):
class ViewRevisionCreateRequest(BaseModel): class ViewRevisionCreateRequest(BaseModel):
visible_surface_ids: list[str] = Field(min_length=1, max_length=1000) visible_surface_ids: list[str] = Field(min_length=1, max_length=1000)
presentation: dict[str, Any] | None = None
class ViewAssignmentResponse(BaseModel): class ViewAssignmentResponse(BaseModel):
@@ -179,6 +182,7 @@ class EffectiveViewResponse(BaseModel):
active_revision_id: str | None = None active_revision_id: str | None = None
active_view_name: str | None = None active_view_name: str | None = None
visible_surface_ids: list[str] = Field(default_factory=list) visible_surface_ids: list[str] = Field(default_factory=list)
presentation: dict[str, Any] = Field(default_factory=dict)
projection_active: bool = False projection_active: bool = False
locked: bool = False locked: bool = False
available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list) available_views: list[EffectiveViewOptionResponse] = Field(default_factory=list)
+107 -5
View File
@@ -6,7 +6,7 @@ import re
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Literal from typing import Any, Literal, Mapping
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -51,6 +51,10 @@ LOCKOUT_ADMIN_SURFACE_IDS = {
"user": "views.admin.tenant", "user": "views.admin.tenant",
} }
_KEY_RE = re.compile(r"[^a-z0-9]+") _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): 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 = { payload = {
"surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION, "surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"visible_surface_ids": surface_ids, "visible_surface_ids": surface_ids,
"presentation": dict(presentation or {}),
} }
return hashlib.sha256( return hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -372,6 +458,8 @@ def create_definition(
visible_surface_ids: Iterable[str], visible_surface_ids: Iterable[str],
catalogue: Iterable[ViewSurface], catalogue: Iterable[ViewSurface],
actor_id: str | None, actor_id: str | None,
presentation: Mapping[str, Any] | None = None,
available_product_area_ids: Iterable[str] | None = None,
) -> ViewDefinition: ) -> ViewDefinition:
if scope_type not in DEFINITION_SCOPES: if scope_type not in DEFINITION_SCOPES:
raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}") raise ViewsValidationError(f"Unsupported View definition scope: {scope_type}")
@@ -401,6 +489,10 @@ def create_definition(
visible_surface_ids, visible_surface_ids,
catalogue=catalogue, catalogue=catalogue,
) )
normalized_presentation = normalize_view_presentation(
presentation,
available_product_area_ids=available_product_area_ids,
)
definition = ViewDefinition( definition = ViewDefinition(
tenant_id=row_tenant_id, tenant_id=row_tenant_id,
scope_type=scope_type, scope_type=scope_type,
@@ -422,7 +514,8 @@ def create_definition(
revision=1, revision=1,
surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION,
visible_surface_ids=normalized_surfaces, 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, created_by=actor_id,
) )
session.add(revision) session.add(revision)
@@ -461,15 +554,21 @@ def create_revision(
visible_surface_ids: Iterable[str], visible_surface_ids: Iterable[str],
catalogue: Iterable[ViewSurface], catalogue: Iterable[ViewSurface],
actor_id: str | None, actor_id: str | None,
presentation: Mapping[str, Any] | None = None,
available_product_area_ids: Iterable[str] | None = None,
) -> ViewRevision: ) -> ViewRevision:
if definition.status == "archived": if definition.status == "archived":
raise ViewsConflictError("Archived Views cannot be revised") raise ViewsConflictError("Archived Views cannot be revised")
latest = get_revision(session, definition_id=definition.id)
normalized_surfaces = normalize_visible_surface_ids( normalized_surfaces = normalize_visible_surface_ids(
visible_surface_ids, visible_surface_ids,
catalogue=catalogue, catalogue=catalogue,
) )
content_hash = _revision_hash(normalized_surfaces) normalized_presentation = normalize_view_presentation(
latest = get_revision(session, definition_id=definition.id) 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: if latest.content_hash == content_hash:
return latest return latest
next_number = definition.current_revision + 1 next_number = definition.current_revision + 1
@@ -479,6 +578,7 @@ def create_revision(
revision=next_number, revision=next_number,
surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION, surface_contract_version=VIEW_SURFACE_CONTRACT_VERSION,
visible_surface_ids=normalized_surfaces, visible_surface_ids=normalized_surfaces,
presentation=normalized_presentation,
content_hash=content_hash, content_hash=content_hash,
created_by=actor_id, created_by=actor_id,
) )
@@ -1441,6 +1541,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
revision_id=None, revision_id=None,
name=None, name=None,
visible_surface_ids=selection.visible_surface_ids or frozenset(), visible_surface_ids=selection.visible_surface_ids or frozenset(),
presentation={},
locked=False, locked=False,
projection_active=selection.visible_surface_ids is not None, projection_active=selection.visible_surface_ids is not None,
provenance=selection.provenance, provenance=selection.provenance,
@@ -1455,6 +1556,7 @@ def _effective_view_from_selection(selection: ViewSelection) -> EffectiveView:
if selection.visible_surface_ids is not None if selection.visible_surface_ids is not None
else frozenset(revision.visible_surface_ids) else frozenset(revision.visible_surface_ids)
), ),
presentation=dict(revision.presentation or {}),
locked=selection.locked, locked=selection.locked,
projection_active=True, projection_active=True,
provenance=selection.provenance, provenance=selection.provenance,
+10 -1
View File
@@ -26,7 +26,7 @@ class ViewsMigrationTests(unittest.TestCase):
try: try:
with engine.connect() as connection: with engine.connect() as connection:
self.assertIn( self.assertIn(
"b8e4c1f7a2d9", "c6f2a9d4e7b1",
set(MigrationContext.configure(connection).get_current_heads()), set(MigrationContext.configure(connection).get_current_heads()),
) )
self.assertEqual( self.assertEqual(
@@ -42,6 +42,15 @@ class ViewsMigrationTests(unittest.TestCase):
if name.startswith("view_") if name.startswith("view_")
}, },
) )
self.assertIn(
"presentation",
{
column["name"]
for column in inspect(connection).get_columns(
"view_revisions"
)
},
)
finally: finally:
engine.dispose() engine.dispose()
+65
View File
@@ -20,6 +20,7 @@ from govoplan_views.backend.service import (
create_revision, create_revision,
get_revision, get_revision,
list_definitions, list_definitions,
normalize_view_presentation,
normalize_visible_surface_ids, normalize_visible_surface_ids,
publish_revision, publish_revision,
resolve_effective_view, resolve_effective_view,
@@ -193,6 +194,70 @@ class ViewsServiceTests(unittest.TestCase):
self.session.close() self.session.close()
self.engine.dispose() 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( def create_published_definition(
self, self,
*, *,
+36 -3
View File
@@ -2,7 +2,8 @@ import {
apiFetch, apiFetch,
apiPath, apiPath,
type ApiSettings, type ApiSettings,
type EffectiveViewProjection type EffectiveViewProjection,
type ViewPresentation
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
export type ViewScopeType = "system" | "tenant" | "group" | "user"; export type ViewScopeType = "system" | "tenant" | "group" | "user";
@@ -15,6 +16,11 @@ export type ViewRevision = {
revision: number; revision: number;
surface_contract_version: string; surface_contract_version: string;
visible_surface_ids: string[]; visible_surface_ids: string[];
presentation: {
navigation_mode?: "grouped" | "flat";
product_area_order?: string[];
product_area_labels?: Record<string, string>;
};
content_hash: string; content_hash: string;
created_by?: string | null; created_by?: string | null;
created_at: string; created_at: string;
@@ -79,6 +85,7 @@ type EffectiveViewApiResponse = {
active_revision_id?: string | null; active_revision_id?: string | null;
active_view_name?: string | null; active_view_name?: string | null;
visible_surface_ids: string[]; visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
locked: boolean; locked: boolean;
available_views: Array<{ available_views: Array<{
id: string; id: string;
@@ -113,6 +120,7 @@ function projection(response: EffectiveViewApiResponse): EffectiveViewProjection
activeRevisionId: response.active_revision_id ?? null, activeRevisionId: response.active_revision_id ?? null,
activeViewName: response.active_view_name ?? null, activeViewName: response.active_view_name ?? null,
visibleSurfaceIds: response.visible_surface_ids, visibleSurfaceIds: response.visible_surface_ids,
presentation: presentationFromApi(response.presentation),
locked: response.locked, locked: response.locked,
availableViews: response.available_views.map((view) => ({ availableViews: response.available_views.map((view) => ({
id: view.id, id: view.id,
@@ -211,6 +219,7 @@ export function createViewDefinition(
name: string; name: string;
description?: string | null; description?: string | null;
visible_surface_ids: string[]; visible_surface_ids: string[];
presentation?: ViewRevision["presentation"];
} }
): Promise<ViewDefinition> { ): Promise<ViewDefinition> {
return apiFetch(settings, "/api/v1/views/definitions", { return apiFetch(settings, "/api/v1/views/definitions", {
@@ -233,18 +242,42 @@ export function updateViewDefinition(
export function createViewRevision( export function createViewRevision(
settings: ApiSettings, settings: ApiSettings,
definitionId: string, definitionId: string,
visibleSurfaceIds: string[] visibleSurfaceIds: string[],
presentation: ViewPresentation
): Promise<ViewDefinition> { ): Promise<ViewDefinition> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/views/definitions/${definitionId}/revisions`, `/api/v1/views/definitions/${definitionId}/revisions`,
{ {
method: "POST", 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( export function publishViewRevision(
settings: ApiSettings, settings: ApiSettings,
definitionId: string, definitionId: string,
+286 -8
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Archive, Archive,
ArrowDown,
ArrowUp,
CheckSquare2, CheckSquare2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -25,6 +27,7 @@ import {
FormField, FormField,
IconButton, IconButton,
SearchableSelect, SearchableSelect,
SegmentedControl,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
StatusBadge, StatusBadge,
@@ -33,12 +36,15 @@ import {
dispatchPlatformViewChanged, dispatchPlatformViewChanged,
i18nMessage, i18nMessage,
usePlatformLanguage, usePlatformLanguage,
usePlatformModules,
useUnsavedChanges, useUnsavedChanges,
useUnsavedDraftGuard, useUnsavedDraftGuard,
useViewSurfaces, useViewSurfaces,
type ApiSettings, type ApiSettings,
type PlatformWebModule,
type PlatformViewSurface, type PlatformViewSurface,
type SearchableSelectOption type SearchableSelectOption,
type ViewPresentation
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
archiveViewDefinition, archiveViewDefinition,
@@ -50,6 +56,7 @@ import {
fetchViewAssignments, fetchViewAssignments,
fetchViewDefinitions, fetchViewDefinitions,
publishViewRevision, publishViewRevision,
presentationToApi,
updateViewAssignment, updateViewAssignment,
updateViewDefinition, updateViewDefinition,
type ViewAssignment, type ViewAssignment,
@@ -70,6 +77,15 @@ type DefinitionDraft = {
name: string; name: string;
description: string; description: string;
surfaceIds: string[]; surfaceIds: string[];
presentation: ViewPresentation;
};
type ViewProductArea = {
id: string;
label: string;
description?: string | null;
order: number;
surfaceIds: string[];
}; };
type AssignmentDraft = { type AssignmentDraft = {
@@ -117,6 +133,8 @@ export default function ViewsAdminPanel({
description?: string; description?: string;
}) { }) {
const surfaces = useViewSurfaces(); const surfaces = useViewSurfaces();
const modules = usePlatformModules();
const productAreas = useMemo(() => aggregateProductAreas(modules), [modules]);
const { requestDiscard } = useUnsavedChanges(); const { requestDiscard } = useUnsavedChanges();
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const [definitions, setDefinitions] = useState<ViewDefinition[]>([]); const [definitions, setDefinitions] = useState<ViewDefinition[]>([]);
@@ -125,7 +143,8 @@ export default function ViewsAdminPanel({
const [draft, setDraft] = useState<DefinitionDraft>({ const [draft, setDraft] = useState<DefinitionDraft>({
name: "", name: "",
description: "", description: "",
surfaceIds: [] surfaceIds: [],
presentation: defaultPresentation([])
}); });
const [savedDraftKey, setSavedDraftKey] = useState(""); const [savedDraftKey, setSavedDraftKey] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -225,9 +244,18 @@ export default function ViewsAdminPanel({
? { ? {
name: definition.name, name: definition.name,
description: definition.description ?? "", 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); setDraft(next);
setSavedDraftKey(definitionDraftKey(next)); setSavedDraftKey(definitionDraftKey(next));
} }
@@ -267,12 +295,17 @@ export default function ViewsAdminPanel({
} }
if ( if (
surfaceSetKey(draft.surfaceIds) !== 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( next = await createViewRevision(
settings, settings,
selected.id, selected.id,
draft.surfaceIds draft.surfaceIds,
draft.presentation
); );
} }
await load(selected.id); await load(selected.id);
@@ -328,7 +361,8 @@ export default function ViewsAdminPanel({
scope_id: scopeId || null, scope_id: scopeId || null,
name: createDraft.name.trim(), name: createDraft.name.trim(),
description: createDraft.description.trim() || null, description: createDraft.description.trim() || null,
visible_surface_ids: visibleSurfaceIds visible_surface_ids: visibleSurfaceIds,
presentation: presentationToApi(defaultPresentation(productAreas))
}); });
closeCreate(); closeCreate();
setSuccess("i18n:govoplan-views.draft_created"); setSuccess("i18n:govoplan-views.draft_created");
@@ -727,6 +761,16 @@ export default function ViewsAdminPanel({
</FormField> </FormField>
</div> </div>
{productAreas.length > 0 && (
<ProductAreaEditor
areas={productAreas}
surfaces={surfaces}
draft={draft}
disabled={!definitionEditable || busy}
onChange={setDraft}
/>
)}
<section className="views-surface-section"> <section className="views-surface-section">
<div className="views-section-heading"> <div className="views-section-heading">
<div> <div>
@@ -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 (
<section className="views-product-area-section">
<div className="views-section-heading">
<div>
<h4>i18n:govoplan-views.product_areas</h4>
<p className="muted small-note">
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}
onChange={(navigationMode) =>
updatePresentation({ ...draft.presentation, navigationMode })
}
options={[
{ id: "grouped", label: "i18n:govoplan-views.grouped" },
{ id: "flat", label: "i18n:govoplan-views.flat" }
]}
/>
</div>
<div className="views-product-area-list">
{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 (
<div className="views-product-area-row" key={area.id}>
<div className="views-product-area-copy">
<strong>{translateText(area.label)}</strong>
<small>{translateText(area.description ?? area.id)}</small>
</div>
<input
value={draft.presentation.productAreaLabels?.[area.id] ?? ""}
placeholder={translateText(area.label)}
aria-label={i18nMessage(
"i18n:govoplan-views.custom_area_label_value",
{ value0: translateText(area.label) }
)}
maxLength={200}
disabled={disabled}
onChange={(event) => setLabel(area.id, event.target.value)}
/>
<ToggleSwitch
label={area.label}
inactiveLabel="i18n:govoplan-views.hidden"
activeLabel="i18n:govoplan-views.visible"
checked={visible}
disabled={disabled || required}
help={required ? "i18n:govoplan-views.required_area_help" : undefined}
onChange={(checked) => setAreaVisible(area, checked)}
/>
<div className="views-product-area-order">
<IconButton
label="i18n:govoplan-views.move_up"
icon={<ArrowUp size={16} />}
variant="ghost"
disabled={disabled || 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}
onClick={() => move(area.id, 1)}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
function SurfaceSelector({ function SurfaceSelector({
surfaces, surfaces,
selected, selected,
@@ -1627,7 +1801,111 @@ function definitionDraftKey(draft: DefinitionDraft): string {
return JSON.stringify({ return JSON.stringify({
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description.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<string, ViewProductArea>();
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))
)
}); });
} }
+24 -2
View File
@@ -129,7 +129,18 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-views.filter_view_surfaces": "Filter View surfaces", "i18n:govoplan-views.filter_view_surfaces": "Filter View surfaces",
"i18n:govoplan-views.surface_count": "{value0}/{value1} surfaces", "i18n:govoplan-views.surface_count": "{value0}/{value1} surfaces",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}", "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: { de: {
"i18n:govoplan-views.views": "Ansichten", "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.filter_view_surfaces": "Ansichtsoberflächen filtern",
"i18n:govoplan-views.surface_count": "{value0}/{value1} Oberflächen", "i18n:govoplan-views.surface_count": "{value0}/{value1} Oberflächen",
"i18n:govoplan-views.surface_detail": "{value0} · {value1}", "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"
} }
}; };
+56
View File
@@ -175,12 +175,59 @@
resize: vertical; resize: vertical;
} }
.views-product-area-section,
.views-surface-section { .views-surface-section {
margin-top: 22px; margin-top: 22px;
padding-top: 18px; padding-top: 18px;
border-top: var(--border-line); 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 { .views-assignments-section {
min-width: 0; min-width: 0;
padding-top: 18px; padding-top: 18px;
@@ -337,6 +384,15 @@
grid-template-columns: 1fr; 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-editor-heading,
.views-section-heading, .views-section-heading,
.views-stale-surface-warning { .views-stale-surface-warning {