Add product-area presentation to Views
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+33
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user