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(
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
+6 -1
View File
@@ -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",
@@ -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():
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,
+4
View File
@@ -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)
+107 -5
View File
@@ -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,
+10 -1
View File
@@ -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()
+65
View File
@@ -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,
*,
+36 -3
View File
@@ -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<string, string>;
};
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<ViewDefinition> {
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<ViewDefinition> {
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,
+286 -8
View File
@@ -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<ViewDefinition[]>([]);
@@ -125,7 +143,8 @@ export default function ViewsAdminPanel({
const [draft, setDraft] = useState<DefinitionDraft>({
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({
</FormField>
</div>
{productAreas.length > 0 && (
<ProductAreaEditor
areas={productAreas}
surfaces={surfaces}
draft={draft}
disabled={!definitionEditable || busy}
onChange={setDraft}
/>
)}
<section className="views-surface-section">
<div className="views-section-heading">
<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({
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<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.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"
}
};
+56
View File
@@ -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 {