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(
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+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():
|
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user