diff --git a/README.md b/README.md index 9f2ffad..428301c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,28 @@ The module owns the `/dashboard` route when installed. Core keeps only a minimal fallback home for installations where this module is absent. Modules contribute widgets through the `dashboard.widgets` WebUI capability. -The first implementation stores personal widget visibility in browser storage; -server-side layout persistence can be added later without changing the widget -contract. +Personal widget layouts are stored by the backend for each tenant, account, and +active View. Configure mode supports adding, removing, ordering, sizing, and +widget-specific settings. Existing browser-only layouts are used as a migration +fallback until the first server save. + +Widget providers can declare supported sizes and a small configuration schema: + +```ts +{ + id: "example.summary", + surfaceId: "example.dashboard.summary", + title: "Example summary", + supportedSizes: ["small", "medium", "wide"], + defaultConfiguration: { limit: 10, showDetails: true }, + configurationFields: [ + { id: "limit", label: "Rows", kind: "number", min: 1, max: 50 }, + { id: "showDetails", label: "Show details", kind: "boolean" } + ], + render: ({ configuration }) => +} +``` + +Every widget needs a matching View surface. The Dashboard catalogue filters +contributions through the effective View before rendering them, while saved +placements remain intact when a View or module temporarily hides a widget. diff --git a/src/govoplan_dashboard/backend/db/__init__.py b/src/govoplan_dashboard/backend/db/__init__.py new file mode 100644 index 0000000..93ac4ce --- /dev/null +++ b/src/govoplan_dashboard/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_dashboard.backend.db.models import DashboardLayout + +__all__ = ["DashboardLayout"] diff --git a/src/govoplan_dashboard/backend/db/models.py b/src/govoplan_dashboard/backend/db/models.py new file mode 100644 index 0000000..607d0bd --- /dev/null +++ b/src/govoplan_dashboard/backend/db/models.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import Index, Integer, JSON, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class DashboardLayout(Base, TimestampMixin): + __tablename__ = "dashboard_layouts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "account_id", + "context_key", + name="uq_dashboard_layouts_actor_context", + ), + Index( + "ix_dashboard_layouts_actor", + "tenant_id", + "account_id", + ), + ) + + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=new_uuid, + ) + tenant_id: Mapped[str] = mapped_column( + String(36), + nullable=False, + index=True, + ) + account_id: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + ) + context_key: Mapped[str] = mapped_column( + String(300), + nullable=False, + index=True, + ) + view_id: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) + layout_version: Mapped[int] = mapped_column( + Integer, + default=1, + nullable=False, + ) + revision: Mapped[int] = mapped_column( + Integer, + default=1, + nullable=False, + ) + placements: Mapped[list[dict[str, Any]]] = mapped_column( + JSON, + default=list, + nullable=False, + ) + known_widget_ids: Mapped[list[str]] = mapped_column( + JSON, + default=list, + nullable=False, + ) + + +__all__ = ["DashboardLayout", "new_uuid"] diff --git a/src/govoplan_dashboard/backend/manifest.py b/src/govoplan_dashboard/backend/manifest.py index 4e3e41d..48f0c40 100644 --- a/src/govoplan_dashboard/backend/manifest.py +++ b/src/govoplan_dashboard/backend/manifest.py @@ -1,8 +1,39 @@ from __future__ import annotations +from pathlib import Path + from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem +from govoplan_core.core.module_guards import ( + drop_table_retirement_provider, + persistent_table_uninstall_guard, +) +from govoplan_core.core.modules import ( + DocumentationTopic, + FrontendModule, + FrontendRoute, + MigrationSpec, + ModuleManifest, + NavItem, +) from govoplan_core.core.views import ViewSurface +from govoplan_core.db.base import Base +from govoplan_dashboard.backend.db.models import DashboardLayout + + +def _dashboard_router(_context): + from govoplan_dashboard.backend.router import router + + return router + + +def _tenant_summary(session, tenant_id: str) -> dict[str, int]: + return { + "dashboard_layouts": ( + session.query(DashboardLayout) + .filter(DashboardLayout.tenant_id == tenant_id) + .count() + ) + } manifest = ModuleManifest( @@ -11,6 +42,28 @@ manifest = ModuleManifest( version="0.1.8", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"), + route_factory=_dashboard_router, + tenant_summary_providers=(_tenant_summary,), + migration_spec=MigrationSpec( + module_id="dashboard", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider( + DashboardLayout, + label="Dashboard", + ), + retirement_notes=( + "Destructive retirement removes personal Dashboard layouts after " + "the installer captures a database snapshot." + ), + ), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + DashboardLayout, + label="Dashboard", + ), + ), nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), frontend=FrontendModule( module_id="dashboard", @@ -34,7 +87,8 @@ manifest = ModuleManifest( summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.", body=( "Core only provides a minimal fallback home when the dashboard module is absent. " - "Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly." + "Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. " + "Personal layouts are stored per tenant, account, and active View." ), layer="configured", documentation_types=("admin", "user"), diff --git a/src/govoplan_dashboard/backend/migrations/__init__.py b/src/govoplan_dashboard/backend/migrations/__init__.py new file mode 100644 index 0000000..700805c --- /dev/null +++ b/src/govoplan_dashboard/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Dashboard database migrations.""" diff --git a/src/govoplan_dashboard/backend/migrations/versions/7b9d2f4a6c8e_v019_dashboard_layouts.py b/src/govoplan_dashboard/backend/migrations/versions/7b9d2f4a6c8e_v019_dashboard_layouts.py new file mode 100644 index 0000000..3aaa71f --- /dev/null +++ b/src/govoplan_dashboard/backend/migrations/versions/7b9d2f4a6c8e_v019_dashboard_layouts.py @@ -0,0 +1,63 @@ +"""add personal and View-scoped dashboard layouts + +Revision ID: 7b9d2f4a6c8e +Revises: None +Create Date: 2026-07-29 10:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "7b9d2f4a6c8e" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "dashboard_layouts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("account_id", sa.String(length=255), nullable=False), + sa.Column("context_key", sa.String(length=300), nullable=False), + sa.Column("view_id", sa.String(length=255), nullable=True), + sa.Column("layout_version", sa.Integer(), nullable=False), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("placements", sa.JSON(), nullable=False), + sa.Column("known_widget_ids", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_dashboard_layouts")), + sa.UniqueConstraint( + "tenant_id", + "account_id", + "context_key", + name="uq_dashboard_layouts_actor_context", + ), + ) + for column in ( + "tenant_id", + "account_id", + "context_key", + "view_id", + ): + op.create_index( + op.f(f"ix_dashboard_layouts_{column}"), + "dashboard_layouts", + [column], + unique=False, + ) + op.create_index( + "ix_dashboard_layouts_actor", + "dashboard_layouts", + ["tenant_id", "account_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("dashboard_layouts") diff --git a/src/govoplan_dashboard/backend/migrations/versions/__init__.py b/src/govoplan_dashboard/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..af86c54 --- /dev/null +++ b/src/govoplan_dashboard/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Dashboard migration revisions.""" diff --git a/src/govoplan_dashboard/backend/router.py b/src/govoplan_dashboard/backend/router.py new file mode 100644 index 0000000..7374f6d --- /dev/null +++ b/src/govoplan_dashboard/backend/router.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.db.session import get_session +from govoplan_dashboard.backend.db.models import DashboardLayout +from govoplan_dashboard.backend.schemas import ( + DashboardLayoutResponse, + DashboardLayoutUpdateRequest, +) +from govoplan_dashboard.backend.service import ( + DashboardLayoutConflict, + DashboardLayoutLimitExceeded, + delete_dashboard_layout, + get_dashboard_layout, + normalize_view_id, + save_dashboard_layout, +) + + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +def _response( + layout: DashboardLayout | None, + *, + view_id: str | None, +) -> DashboardLayoutResponse: + if layout is None: + return DashboardLayoutResponse( + exists=False, + view_id=normalize_view_id(view_id), + ) + return DashboardLayoutResponse( + exists=True, + view_id=layout.view_id, + layout_version=layout.layout_version, + revision=layout.revision, + placements=layout.placements, + known_widget_ids=layout.known_widget_ids, + updated_at=layout.updated_at, + ) + + +@router.get("/layout", response_model=DashboardLayoutResponse) +def api_get_dashboard_layout( + view_id: str | None = Query(default=None, max_length=255), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> DashboardLayoutResponse: + return _response( + get_dashboard_layout( + session, + tenant_id=principal.tenant_id, + account_id=principal.account_id, + view_id=view_id, + ), + view_id=view_id, + ) + + +@router.put("/layout", response_model=DashboardLayoutResponse) +def api_save_dashboard_layout( + payload: DashboardLayoutUpdateRequest, + view_id: str | None = Query(default=None, max_length=255), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> DashboardLayoutResponse: + try: + layout = save_dashboard_layout( + session, + tenant_id=principal.tenant_id, + account_id=principal.account_id, + view_id=view_id, + expected_revision=payload.expected_revision, + layout_version=payload.layout_version, + placements=payload.placements, + known_widget_ids=payload.known_widget_ids, + ) + session.commit() + except DashboardLayoutConflict as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=str(exc), + ) from exc + except DashboardLayoutLimitExceeded as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + except IntegrityError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + "The dashboard layout changed in another session. " + "Reload it before saving." + ), + ) from exc + return _response(layout, view_id=view_id) + + +@router.delete("/layout", status_code=status.HTTP_204_NO_CONTENT) +def api_delete_dashboard_layout( + view_id: str | None = Query(default=None, max_length=255), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> None: + delete_dashboard_layout( + session, + tenant_id=principal.tenant_id, + account_id=principal.account_id, + view_id=view_id, + ) + session.commit() + + +__all__ = ["router"] diff --git a/src/govoplan_dashboard/backend/schemas.py b/src/govoplan_dashboard/backend/schemas.py new file mode 100644 index 0000000..c6c6a79 --- /dev/null +++ b/src/govoplan_dashboard/backend/schemas.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import math +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + + +WidgetId = Annotated[str, Field(min_length=1, max_length=160)] +ConfigurationValue = str | int | float | bool | None + + +class DashboardWidgetPlacementPayload(BaseModel): + instance_id: str = Field(min_length=1, max_length=120) + widget_id: WidgetId + size: Literal["small", "medium", "wide", "full"] = "medium" + configuration: dict[str, ConfigurationValue] = Field( + default_factory=dict, + max_length=40, + ) + + @field_validator("configuration") + @classmethod + def validate_configuration( + cls, + value: dict[str, ConfigurationValue], + ) -> dict[str, ConfigurationValue]: + approximate_size = 0 + for key, item in value.items(): + if not key or len(key) > 120: + raise ValueError( + "Widget configuration keys must contain 1 to 120 characters." + ) + approximate_size += len(key) + if isinstance(item, str): + if len(item) > 4_000: + raise ValueError( + "Widget configuration text values may contain at most " + "4,000 characters." + ) + approximate_size += len(item) + else: + approximate_size += len(str(item)) + if isinstance(item, float) and not math.isfinite(item): + raise ValueError( + "Widget configuration numbers must be finite." + ) + if approximate_size > 32_000: + raise ValueError( + "A widget configuration may contain at most 32,000 characters." + ) + return value + + +class DashboardLayoutUpdateRequest(BaseModel): + expected_revision: int | None = Field(default=None, ge=0) + layout_version: Literal[1] = 1 + placements: list[DashboardWidgetPlacementPayload] = Field( + default_factory=list, + max_length=100, + ) + known_widget_ids: list[WidgetId] = Field( + default_factory=list, + max_length=500, + ) + + @model_validator(mode="after") + def validate_unique_ids(self) -> "DashboardLayoutUpdateRequest": + instance_ids = [item.instance_id for item in self.placements] + if len(instance_ids) != len(set(instance_ids)): + raise ValueError("Dashboard widget instance ids must be unique.") + return self + + +class DashboardLayoutResponse(BaseModel): + exists: bool + view_id: str | None = None + layout_version: int = 1 + revision: int = 0 + placements: list[DashboardWidgetPlacementPayload] = Field( + default_factory=list + ) + known_widget_ids: list[str] = Field(default_factory=list) + updated_at: datetime | None = None + + +__all__ = [ + "DashboardLayoutResponse", + "DashboardLayoutUpdateRequest", + "DashboardWidgetPlacementPayload", +] diff --git a/src/govoplan_dashboard/backend/service.py b/src/govoplan_dashboard/backend/service.py new file mode 100644 index 0000000..80cad76 --- /dev/null +++ b/src/govoplan_dashboard/backend/service.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from sqlalchemy.orm import Session + +from govoplan_dashboard.backend.db.models import DashboardLayout +from govoplan_dashboard.backend.schemas import DashboardWidgetPlacementPayload + + +MAX_LAYOUT_CONTEXTS_PER_ACCOUNT = 100 + + +class DashboardLayoutConflict(ValueError): + pass + + +class DashboardLayoutLimitExceeded(ValueError): + pass + + +def normalize_view_id(view_id: str | None) -> str | None: + normalized = (view_id or "").strip() + if not normalized: + return None + if len(normalized) > 255: + raise ValueError("Dashboard View ids may contain at most 255 characters.") + return normalized + + +def layout_context_key(view_id: str | None) -> str: + normalized = normalize_view_id(view_id) + return f"view:{normalized}" if normalized else "full" + + +def get_dashboard_layout( + session: Session, + *, + tenant_id: str, + account_id: str, + view_id: str | None, +) -> DashboardLayout | None: + return ( + session.query(DashboardLayout) + .filter( + DashboardLayout.tenant_id == tenant_id, + DashboardLayout.account_id == account_id, + DashboardLayout.context_key == layout_context_key(view_id), + ) + .one_or_none() + ) + + +def save_dashboard_layout( + session: Session, + *, + tenant_id: str, + account_id: str, + view_id: str | None, + expected_revision: int | None, + layout_version: int, + placements: Sequence[DashboardWidgetPlacementPayload], + known_widget_ids: Sequence[str], +) -> DashboardLayout: + normalized_view_id = normalize_view_id(view_id) + layout = get_dashboard_layout( + session, + tenant_id=tenant_id, + account_id=account_id, + view_id=normalized_view_id, + ) + current_revision = layout.revision if layout is not None else 0 + if ( + expected_revision is not None + and expected_revision != current_revision + ): + raise DashboardLayoutConflict( + "The dashboard layout changed in another session. Reload it before " + "saving." + ) + if layout is None: + layout_count = ( + session.query(DashboardLayout) + .filter( + DashboardLayout.tenant_id == tenant_id, + DashboardLayout.account_id == account_id, + ) + .count() + ) + if layout_count >= MAX_LAYOUT_CONTEXTS_PER_ACCOUNT: + raise DashboardLayoutLimitExceeded( + "At most 100 Dashboard layouts may be stored per account and tenant." + ) + layout = DashboardLayout( + tenant_id=tenant_id, + account_id=account_id, + context_key=layout_context_key(normalized_view_id), + view_id=normalized_view_id, + layout_version=layout_version, + revision=1, + placements=[], + known_widget_ids=[], + ) + session.add(layout) + else: + layout.layout_version = layout_version + layout.revision += 1 + layout.placements = [ + placement.model_dump(mode="json") + for placement in placements + ] + layout.known_widget_ids = list(dict.fromkeys(known_widget_ids)) + session.flush() + return layout + + +def delete_dashboard_layout( + session: Session, + *, + tenant_id: str, + account_id: str, + view_id: str | None, +) -> bool: + layout = get_dashboard_layout( + session, + tenant_id=tenant_id, + account_id=account_id, + view_id=view_id, + ) + if layout is None: + return False + session.delete(layout) + session.flush() + return True + + +__all__ = [ + "DashboardLayoutConflict", + "DashboardLayoutLimitExceeded", + "MAX_LAYOUT_CONTEXTS_PER_ACCOUNT", + "delete_dashboard_layout", + "get_dashboard_layout", + "layout_context_key", + "normalize_view_id", + "save_dashboard_layout", +] diff --git a/tests/test_dashboard_layouts.py b/tests/test_dashboard_layouts.py new file mode 100644 index 0000000..12dfaa0 --- /dev/null +++ b/tests/test_dashboard_layouts.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import unittest + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.db.session import get_session +from govoplan_dashboard.backend.db.models import DashboardLayout +from govoplan_dashboard.backend.router import router + + +def principal( + *, + tenant_id: str = "tenant-1", + account_id: str = "account-1", +) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id=f"membership:{tenant_id}:{account_id}", + tenant_id=tenant_id, + scopes=frozenset(), + group_ids=frozenset(), + ), + account=object(), + user=object(), + ) + + +class DashboardLayoutApiTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + DashboardLayout.__table__.create(self.engine) + self.session_factory = sessionmaker( + bind=self.engine, + autoflush=False, + expire_on_commit=False, + ) + self.active_principal = principal() + app = FastAPI() + app.include_router(router, prefix="/api/v1") + + def session_dependency(): + session = self.session_factory() + try: + yield session + finally: + session.close() + + app.dependency_overrides[get_session] = session_dependency + app.dependency_overrides[get_api_principal] = ( + lambda: self.active_principal + ) + self.client = TestClient(app) + + def tearDown(self) -> None: + self.client.close() + self.engine.dispose() + + def test_layouts_are_isolated_by_account_and_view(self) -> None: + initial = self.client.get("/api/v1/dashboard/layout") + self.assertEqual(200, initial.status_code) + self.assertFalse(initial.json()["exists"]) + self.assertEqual(0, initial.json()["revision"]) + + saved = self.client.put( + "/api/v1/dashboard/layout", + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [ + { + "instance_id": "instance-1", + "widget_id": "dashboard.installed-modules", + "size": "wide", + "configuration": { + "maxItems": 5, + "showVersions": False, + }, + } + ], + "known_widget_ids": ["dashboard.installed-modules"], + }, + ) + self.assertEqual(200, saved.status_code) + self.assertEqual(1, saved.json()["revision"]) + + view_layout = self.client.get( + "/api/v1/dashboard/layout", + params={"view_id": "view-files"}, + ) + self.assertEqual(200, view_layout.status_code) + self.assertFalse(view_layout.json()["exists"]) + + self.active_principal = principal(account_id="account-2") + other_account = self.client.get("/api/v1/dashboard/layout") + self.assertEqual(200, other_account.status_code) + self.assertFalse(other_account.json()["exists"]) + + self.active_principal = principal() + original = self.client.get("/api/v1/dashboard/layout") + self.assertTrue(original.json()["exists"]) + self.assertEqual( + "dashboard.installed-modules", + original.json()["placements"][0]["widget_id"], + ) + self.assertIs( + False, + original.json()["placements"][0]["configuration"]["showVersions"], + ) + + def test_stale_revision_is_rejected(self) -> None: + payload = { + "expected_revision": 0, + "layout_version": 1, + "placements": [], + "known_widget_ids": [], + } + first = self.client.put("/api/v1/dashboard/layout", json=payload) + self.assertEqual(200, first.status_code) + + stale = self.client.put("/api/v1/dashboard/layout", json=payload) + self.assertEqual(409, stale.status_code) + self.assertIn("another session", stale.json()["detail"]) + + def test_duplicate_instance_ids_are_rejected(self) -> None: + placement = { + "instance_id": "duplicate", + "widget_id": "example.widget", + "size": "medium", + "configuration": {}, + } + response = self.client.put( + "/api/v1/dashboard/layout", + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [placement, placement], + "known_widget_ids": ["example.widget"], + }, + ) + + self.assertEqual(422, response.status_code) + with Session(self.engine) as session: + self.assertEqual(0, session.query(DashboardLayout).count()) + + def test_oversized_widget_configuration_is_rejected(self) -> None: + response = self.client.put( + "/api/v1/dashboard/layout", + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [ + { + "instance_id": "instance-1", + "widget_id": "example.widget", + "size": "medium", + "configuration": {"query": "x" * 4_001}, + } + ], + "known_widget_ids": ["example.widget"], + }, + ) + + self.assertEqual(422, response.status_code) + + def test_account_layout_context_limit_is_enforced(self) -> None: + with self.session_factory() as session: + session.add_all( + DashboardLayout( + tenant_id="tenant-1", + account_id="account-1", + context_key=f"view:view-{index}", + view_id=f"view-{index}", + placements=[], + known_widget_ids=[], + ) + for index in range(100) + ) + session.commit() + + response = self.client.put( + "/api/v1/dashboard/layout", + params={"view_id": "one-too-many"}, + json={ + "expected_revision": 0, + "layout_version": 1, + "placements": [], + "known_widget_ids": [], + }, + ) + + self.assertEqual(422, response.status_code) + self.assertIn("At most 100", response.json()["detail"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..5793d93 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from alembic.runtime.migration import MigrationContext +from sqlalchemy import create_engine, inspect + +from govoplan_core.db.migrations import migrate_database +from govoplan_dashboard.backend.manifest import get_manifest + + +class DashboardMigrationTests(unittest.TestCase): + def test_migration_creates_dashboard_layouts_and_head(self) -> None: + with tempfile.TemporaryDirectory( + prefix="govoplan-dashboard-migration-" + ) as directory: + url = f"sqlite:///{Path(directory) / 'dashboard.db'}" + migrate_database( + database_url=url, + enabled_modules=("dashboard",), + manifest_factories=(get_manifest,), + ) + engine = create_engine(url) + try: + with engine.connect() as connection: + self.assertIn( + "7b9d2f4a6c8e", + set( + MigrationContext.configure( + connection + ).get_current_heads() + ), + ) + self.assertEqual( + {"dashboard_layouts"}, + { + name + for name in inspect(connection).get_table_names() + if name.startswith("dashboard_") + }, + ) + finally: + engine.dispose() + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 66a2e14..02148f8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -13,12 +13,15 @@ }, "./styles/dashboard.css": "./src/styles/dashboard.css" }, + "scripts": { + "test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts" + }, "peerDependencies": { "@govoplan/core-webui": "^0.1.8", "lucide-react": "^1.23.0", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.1.1" + "react-router-dom": ">=7.18.2 <8" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/api/dashboard.ts b/webui/src/api/dashboard.ts new file mode 100644 index 0000000..a9373bd --- /dev/null +++ b/webui/src/api/dashboard.ts @@ -0,0 +1,53 @@ +import { + apiFetch, + apiPath, + type ApiSettings, + type DashboardWidgetConfiguration, + type DashboardWidgetSize +} from "@govoplan/core-webui"; + +export type DashboardWidgetPlacementResponse = { + instance_id: string; + widget_id: string; + size: DashboardWidgetSize; + configuration: DashboardWidgetConfiguration; +}; + +export type DashboardLayoutResponse = { + exists: boolean; + view_id: string | null; + layout_version: number; + revision: number; + placements: DashboardWidgetPlacementResponse[]; + known_widget_ids: string[]; + updated_at: string | null; +}; + +export type DashboardLayoutUpdate = { + expected_revision: number | null; + layout_version: 1; + placements: DashboardWidgetPlacementResponse[]; + known_widget_ids: string[]; +}; + +function layoutPath(viewId: string | null): string { + return apiPath("/api/v1/dashboard/layout", { view_id: viewId }); +} + +export function fetchDashboardLayout( + settings: ApiSettings, + viewId: string | null +): Promise { + return apiFetch(settings, layoutPath(viewId)); +} + +export function saveDashboardLayout( + settings: ApiSettings, + viewId: string | null, + payload: DashboardLayoutUpdate +): Promise { + return apiFetch(settings, layoutPath(viewId), { + method: "PUT", + body: JSON.stringify(payload) + }); +} diff --git a/webui/src/features/dashboard/DashboardGrid.tsx b/webui/src/features/dashboard/DashboardGrid.tsx new file mode 100644 index 0000000..4ca2cb9 --- /dev/null +++ b/webui/src/features/dashboard/DashboardGrid.tsx @@ -0,0 +1,368 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type HTMLAttributes, + type DragEvent as ReactDragEvent, + type ReactNode +} from "react"; +import { + GripVertical, + Settings2, + Trash2 +} from "lucide-react"; +import { + Card, + IconButton, + type ApiSettings, + type AuthInfo, + type DashboardWidgetContribution, + type EffectiveViewProjection, + type PlatformWebModule +} from "@govoplan/core-webui"; +import { + dashboardMasonryRowSpan, + defaultWidgetSize, + widgetIsConfigurable, + type DashboardWidgetPlacement +} from "./dashboardLayout"; +import { + dashboardGridPreview, + type DashboardDragItem, + type DashboardDropTarget +} from "./dashboardEditorTypes"; + +type DashboardGridProps = { + placements: DashboardWidgetPlacement[]; + widgetById: Map; + settings: ApiSettings; + auth: AuthInfo; + modules: PlatformWebModule[]; + effectiveView: EffectiveViewProjection | null; + refreshKey: number; + configuring: boolean; + dragItem: DashboardDragItem | null; + dropTarget: DashboardDropTarget | null; + onDragStart: (event: ReactDragEvent, item: DashboardDragItem) => void; + onDragEnd: () => void; + onDragOver: (event: ReactDragEvent, instanceId: string) => void; + onDragOverEnd: (event: ReactDragEvent) => void; + onDrop: ( + event: ReactDragEvent, + placement: DashboardWidgetPlacement + ) => void; + onDropPreview: (event: ReactDragEvent) => void; + onDropAtEnd: (event: ReactDragEvent) => void; + onRemove: (instanceId: string) => void; + onConfigure: (instanceId: string) => void; +}; + +export default function DashboardGrid({ + placements, + widgetById, + settings, + auth, + modules, + effectiveView, + refreshKey, + configuring, + dragItem, + dropTarget, + onDragStart, + onDragEnd, + onDragOver, + onDragOverEnd, + onDrop, + onDropPreview, + onDropAtEnd, + onRemove, + onConfigure +}: DashboardGridProps) { + const showingEmptyPreview = + configuring + && dragItem?.kind === "catalogue" + && dropTarget !== null; + if (!placements.length && !showingEmptyPreview) { + return ( +
{ + if (!configuring) return; + onDragOverEnd(event); + }} + onDrop={configuring ? onDropAtEnd : undefined} + > +

No widgets on this Dashboard

+

+ {configuring + ? "Drag a widget here from the library." + : "Open Configure to add widgets provided by active modules."} +

+
+ ); + } + + const catalogueWidget = + dragItem?.kind === "catalogue" + ? widgetById.get(dragItem.widgetId) + : null; + const previewItems = dashboardGridPreview( + placements, + configuring ? dragItem : null, + configuring ? dropTarget : null, + catalogueWidget ? defaultWidgetSize(catalogueWidget) : "medium" + ); + + return ( +
{ + if (!configuring || event.target !== event.currentTarget) return; + onDragOverEnd(event); + }} + onDrop={configuring ? onDropAtEnd : undefined} + > + {previewItems.map((item) => { + if (item.kind === "catalogue") { + const widget = widgetById.get(item.widgetId); + return ( + event.preventDefault()} + onDrop={onDropPreview} + /> + ); + } + const placement = item.placement; + const widget = widgetById.get(placement.widgetId); + if (!widget) return null; + if (item.placeholder) { + return ( + event.preventDefault()} + onDrop={onDropPreview} + > + + + + + ); + } + return ( + onDragOver(event, placement.instanceId) + : undefined + } + onDrop={ + configuring + ? (event) => onDrop(event, placement) + : undefined + } + > + + } + variant="ghost" + className="dashboard-widget-drag-handle" + draggable + onDragStart={(event) => + onDragStart(event, { + kind: "placement", + instanceId: placement.instanceId + }) + } + onDragEnd={onDragEnd} + /> + {widgetIsConfigurable(widget) && ( + } + variant="ghost" + onClick={() => onConfigure(placement.instanceId)} + /> + )} + } + variant="ghost" + onClick={() => onRemove(placement.instanceId)} + /> +
+ ) : undefined + } + > + + + + ); + })} + {configuring && ( +
+ Drop here to place the widget last +
+ )} + + ); +} + +function DashboardDropPlaceholder({ + title, + size, + preserveHeight = false, + onDragOver, + onDrop, + children +}: { + title: string; + size: DashboardWidgetPlacement["size"]; + preserveHeight?: boolean; + onDragOver: (event: ReactDragEvent) => void; + onDrop: (event: ReactDragEvent) => void; + children?: ReactNode; +}) { + return ( + + {children && ( +
+ {children} +
+ )} +
+
+
+ ); +} + +function DashboardMasonryItem({ + children, + ...props +}: HTMLAttributes) { + const itemRef = useRef(null); + + useLayoutEffect(() => { + const item = itemRef.current; + const grid = item?.parentElement; + if (!item || !grid) return undefined; + + const updateSpan = () => { + const gridStyles = window.getComputedStyle(grid); + const itemStyles = window.getComputedStyle(item); + const rowHeight = Number.parseFloat(gridStyles.gridAutoRows); + const bottomGap = Number.parseFloat(itemStyles.marginBottom); + const nextSpan = dashboardMasonryRowSpan( + item.getBoundingClientRect().height, + rowHeight, + bottomGap + ); + const gridRowEnd = `span ${nextSpan}`; + if (item.style.gridRowEnd !== gridRowEnd) { + item.style.gridRowEnd = gridRowEnd; + } + }; + + updateSpan(); + if (typeof ResizeObserver === "undefined") return undefined; + const observer = new ResizeObserver(updateSpan); + observer.observe(item); + return () => observer.disconnect(); + }, []); + + return ( +
+ {children} +
+ ); +} + +function DashboardWidgetContent({ + widget, + placement, + settings, + auth, + modules, + effectiveView, + refreshKey +}: { + widget: DashboardWidgetContribution; + placement: DashboardWidgetPlacement; + settings: ApiSettings; + auth: AuthInfo; + modules: PlatformWebModule[]; + effectiveView: EffectiveViewProjection | null; + refreshKey: number; +}) { + const [scheduledRefreshKey, setScheduledRefreshKey] = useState(0); + + useEffect(() => { + if (!widget.refreshIntervalMs) return undefined; + const interval = window.setInterval( + () => setScheduledRefreshKey((value) => value + 1), + Math.max(widget.refreshIntervalMs, 5_000) + ); + return () => window.clearInterval(interval); + }, [widget.refreshIntervalMs]); + + return widget.render({ + settings, + auth, + modules, + effectiveView, + instanceId: placement.instanceId, + widgetId: widget.id, + refreshKey: refreshKey + scheduledRefreshKey, + size: placement.size, + configuration: placement.configuration + }); +} diff --git a/webui/src/features/dashboard/DashboardPage.tsx b/webui/src/features/dashboard/DashboardPage.tsx index 0975c54..9b8794b 100644 --- a/webui/src/features/dashboard/DashboardPage.tsx +++ b/webui/src/features/dashboard/DashboardPage.tsx @@ -1,29 +1,72 @@ -import { useEffect, useMemo, useState } from "react"; -import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react"; import { - AdminSelectionList, + useEffect, + useMemo, + useState, + type DragEvent as ReactDragEvent +} from "react"; +import { + RefreshCw, + Save, + SlidersHorizontal, + X +} from "lucide-react"; +import { Button, - Card, + DismissibleAlert, + LoadingFrame, MetricCard, PageScrollViewport, PageTitle, dashboardWidgetsForModules, hasAnyScope, hasScope, + isApiError, useEffectiveView, usePlatformModules, + useUnsavedDraftGuard, type ApiSettings, type AuthInfo, - type DashboardWidgetContribution, - type DashboardWidgetSize + type DashboardWidgetContribution } from "@govoplan/core-webui"; +import { + fetchDashboardLayout, + saveDashboardLayout +} from "../../api/dashboard"; +import DashboardGrid from "./DashboardGrid"; +import WidgetConfigurationDialog from "./WidgetConfigurationDialog"; +import WidgetLibrary from "./WidgetLibrary"; +import { + appendPlacement, + defaultDashboardLayout, + insertPlacement, + layoutFromResponse, + layoutUpdatePayload, + layoutsEqual, + localLayoutKey, + MAX_DASHBOARD_WIDGETS, + readLocalLayout, + reconcileDashboardLayout, + removePlacement, + reorderPlacement, + updatePlacement, + writeLocalLayout, + type DashboardLayoutState, + type DashboardWidgetPlacement +} from "./dashboardLayout"; +import type { + DashboardDragItem, + DashboardDropTarget +} from "./dashboardEditorTypes"; -type DashboardLayout = { - visible: string[]; - known: string[]; -}; +type LayoutSource = "server" | "browser" | "default"; -export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { +export default function DashboardPage({ + settings, + auth +}: { + settings: ApiSettings; + auth: AuthInfo; +}) { const modules = usePlatformModules(); const effectiveView = useEffectiveView(); const widgets = useMemo( @@ -33,150 +76,485 @@ export default function DashboardPage({ settings, auth }: { settings: ApiSetting ), [auth, effectiveView, modules] ); - const widgetSignature = widgets.map((widget) => widget.id).join("|"); - const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`; - const [layout, setLayout] = useState(() => defaultLayout(widgets)); + const widgetSignature = JSON.stringify( + widgets.map((widget) => ({ + id: widget.id, + allowMultiple: widget.allowMultiple ?? false, + defaultVisible: widget.defaultVisible ?? true, + defaultSize: widget.defaultSize ?? "medium", + supportedSizes: widget.supportedSizes ?? [], + defaultConfiguration: widget.defaultConfiguration ?? {} + })) + ); + const tenantId = auth.active_tenant?.id ?? auth.tenant.id; + const accountId = auth.user.account_id || auth.user.id; + const viewId = effectiveView?.activeViewId ?? null; + const storageKey = localLayoutKey(tenantId, accountId, viewId); + const legacyStorageKey = viewId === null + ? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}` + : null; + const initialLayout = useMemo( + () => defaultDashboardLayout(widgets), + [widgets] + ); + const [savedLayout, setSavedLayout] = useState(initialLayout); + const [draftLayout, setDraftLayout] = useState(initialLayout); + const [layoutSource, setLayoutSource] = useState("default"); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [configuring, setConfiguring] = useState(false); const [refreshKey, setRefreshKey] = useState(0); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [libraryQuery, setLibraryQuery] = useState(""); + const [dragItem, setDragItem] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [editingInstanceId, setEditingInstanceId] = useState(null); useEffect(() => { - setLayout(readLayout(storageKey) ?? defaultLayout(widgets)); - }, [storageKey, widgetSignature]); + let active = true; + setLoading(true); + setError(""); + setNotice(""); + setConfiguring(false); + setEditingInstanceId(null); - const visibleWidgetIds = useMemo(() => { - const known = new Set(layout.known); - const visible = new Set(layout.visible); - for (const widget of widgets) { - if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id); + void fetchDashboardLayout(settings, viewId) + .then((response) => { + if (!active) return; + const local = readLocalLayout(storageKey, legacyStorageKey, widgets); + const source: LayoutSource = response.exists + ? "server" + : local + ? "browser" + : "default"; + const base = response.exists + ? layoutFromResponse(response) + : local + ? { ...local, revision: 0 } + : defaultDashboardLayout(widgets); + const next = reconcileDashboardLayout(base, widgets); + setSavedLayout(next); + setDraftLayout(next); + setLayoutSource(source); + }) + .catch((reason) => { + if (!active) return; + const local = readLocalLayout(storageKey, legacyStorageKey, widgets); + const next = reconcileDashboardLayout( + local ?? defaultDashboardLayout(widgets), + widgets + ); + setSavedLayout(next); + setDraftLayout(next); + setLayoutSource(local ? "browser" : "default"); + setError( + `The saved Dashboard layout could not be loaded. ${ + local + ? "The browser fallback is shown." + : "The module defaults are shown." + } ${errorMessage(reason)}` + ); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, [ + legacyStorageKey, + settings.accessToken, + settings.apiBaseUrl, + settings.apiKey, + storageKey, + viewId, + widgetSignature + ]); + + const dirty = configuring && !layoutsEqual(savedLayout, draftLayout); + useUnsavedDraftGuard({ + dirty, + onSave: persistLayout, + onDiscard: discardChanges, + title: "Unsaved Dashboard layout", + message: "Save or discard the Dashboard arrangement before leaving this page." + }); + + const activeLayout = configuring ? draftLayout : savedLayout; + const widgetById = useMemo( + () => new Map(widgets.map((widget) => [widget.id, widget])), + [widgets] + ); + const renderedPlacements = activeLayout.placements.filter((placement) => + widgetById.has(placement.widgetId) + ); + const placedWidgetIds = new Set( + draftLayout.placements.map((placement) => placement.widgetId) + ); + const libraryWidgets = widgets.filter((widget) => { + if (!widget.allowMultiple && placedWidgetIds.has(widget.id)) return false; + if (!libraryQuery.trim()) return true; + const query = libraryQuery.trim().toLocaleLowerCase(); + return [widget.title, widget.description, widget.category, widget.moduleId, widget.id] + .filter(Boolean) + .some((value) => String(value).toLocaleLowerCase().includes(query)); + }); + const hiddenCount = widgets.filter( + (widget) => !placedWidgetIds.has(widget.id) + ).length; + const editingPlacement = editingInstanceId + ? draftLayout.placements.find( + (placement) => placement.instanceId === editingInstanceId + ) ?? null + : null; + const editingWidget = editingPlacement + ? widgetById.get(editingPlacement.widgetId) ?? null + : null; + const viewLabel = effectiveView?.activeViewName ?? "Full interface"; + + async function persistLayout(): Promise { + setSaving(true); + setError(""); + setNotice(""); + try { + const response = await saveDashboardLayout( + settings, + viewId, + layoutUpdatePayload(draftLayout) + ); + const next = reconcileDashboardLayout(layoutFromResponse(response), widgets); + setSavedLayout(next); + setDraftLayout(next); + setLayoutSource("server"); + setConfiguring(false); + writeLocalLayout(storageKey, next); + setNotice(`Dashboard layout saved for ${viewLabel}.`); + return true; + } catch (reason) { + setError(errorMessage(reason)); + return false; + } finally { + setSaving(false); } - const availableIds = new Set(widgets.map((widget) => widget.id)); - return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id)); - }, [layout, widgets]); - const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id)); - const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0); - - function saveLayout(next: DashboardLayout) { - setLayout(next); - writeLayout(storageKey, next); } - function setVisibleWidgets(selected: string[]) { - const allIds = widgets.map((widget) => widget.id); - const nextVisible = new Set(selected); - saveLayout({ - known: allIds, - visible: allIds.filter((id) => nextVisible.has(id)) - }); + function discardChanges() { + setDraftLayout(savedLayout); + setConfiguring(false); + setEditingInstanceId(null); + setDragItem(null); + setDropTarget(null); } - function resetLayout() { - saveLayout(defaultLayout(widgets)); + function beginConfiguration() { + setDraftLayout(savedLayout); + setConfiguring(true); + setError(""); + setNotice(""); + } + + function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) { + setDraftLayout((current) => updater(current)); + } + + function addWidget(widget: DashboardWidgetContribution) { + updateDraft((layout) => appendPlacement(layout, widget)); + } + + function startDrag(event: ReactDragEvent, item: DashboardDragItem) { + event.dataTransfer.effectAllowed = item.kind === "catalogue" ? "copy" : "move"; + event.dataTransfer.setData("application/x-govoplan-dashboard", JSON.stringify(item)); + setDragItem(item); + setDropTarget(null); + } + + function finishDrag() { + setDragItem(null); + setDropTarget(null); + } + + function markDropTarget( + event: ReactDragEvent, + instanceId: string + ) { + if (!dragItem) return; + event.preventDefault(); + event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; + const bounds = event.currentTarget.getBoundingClientRect(); + const verticalPosition = (event.clientY - bounds.top) / bounds.height; + const edge = + verticalPosition < 0.3 + || ( + verticalPosition <= 0.7 + && event.clientX < bounds.left + bounds.width / 2 + ) + ? "before" + : "after"; + setDropTarget((current) => + current?.kind === "placement" + && current.instanceId === instanceId + && current.edge === edge + ? current + : { kind: "placement", instanceId, edge } + ); + } + + function markEndDropTarget(event: ReactDragEvent) { + if (!dragItem) return; + event.preventDefault(); + event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move"; + setDropTarget((current) => + current?.kind === "end" ? current : { kind: "end" } + ); + } + + function dropOnPlacement( + event: ReactDragEvent, + target: DashboardWidgetPlacement + ) { + event.preventDefault(); + event.stopPropagation(); + if (!dragItem) return; + const edge = dropTarget?.kind === "placement" + && dropTarget.instanceId === target.instanceId + ? dropTarget.edge + : "after"; + if (dragItem.kind === "placement") { + updateDraft((layout) => + reorderPlacement(layout, dragItem.instanceId, target.instanceId, edge) + ); + } else { + const widget = widgetById.get(dragItem.widgetId); + if (widget) { + updateDraft((layout) => + insertPlacement(layout, widget, target.instanceId, edge) + ); + } + } + finishDrag(); + } + + function dropOnPreview(event: ReactDragEvent) { + if (dropTarget?.kind === "placement") { + const target = renderedPlacements.find( + (placement) => placement.instanceId === dropTarget.instanceId + ); + if (target) { + dropOnPlacement(event, target); + return; + } + } + dropAtEnd(event); + } + + function dropAtEnd(event: ReactDragEvent) { + event.preventDefault(); + event.stopPropagation(); + if (!dragItem) return; + if (dragItem.kind === "placement") { + const last = draftLayout.placements.at(-1); + if (last) { + updateDraft((layout) => + reorderPlacement(layout, dragItem.instanceId, last.instanceId, "after") + ); + } + } else { + const widget = widgetById.get(dragItem.widgetId); + if (widget) addWidget(widget); + } + finishDrag(); } return (
-
-
- i18n:govoplan-dashboard.dashboard.3f8b4df2 -

Personal workspace assembled from installed module widgets.

+
+
+ i18n:govoplan-dashboard.dashboard.3f8b4df2 +

Personal workspace assembled from installed module widgets.

+
+
+ {!configuring && ( + <> + + + + )} + {configuring && ( + <> + + + + )} +
-
- - -
-
-
- - - - -
- - {configuring && - Reset}> - {widgets.length === 0 ?

No installed module exposes dashboard widgets yet.

: - ({ id: widget.id, label: widget.title, description: widget.description ?? widget.id }))} - selected={visibleWidgetIds} - onChange={setVisibleWidgets} - /> - } -
- } - - {visibleWidgets.length === 0 ? - -

Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.

-
: -
- {visibleWidgets.map((widget) => -
- - {widget.render({ - settings, - auth, - modules, - widgetId: widget.id, - refreshKey, - size: widgetSize(widget) - })} - -
+ {error && ( + + {error} + )} + {notice && !error && ( + + {notice} + + )} + +
+ + + +
- } + + + {configuring ? ( +
+ = MAX_DASHBOARD_WIDGETS} + query={libraryQuery} + onQueryChange={setLibraryQuery} + onReset={() => + setDraftLayout({ + ...defaultDashboardLayout(widgets), + revision: savedLayout.revision + }) + } + onAdd={addWidget} + onDragStart={startDrag} + onDragEnd={finishDrag} + /> + + + updateDraft((layout) => removePlacement(layout, instanceId)) + } + onConfigure={setEditingInstanceId} + /> +
+ ) : ( + undefined} + onConfigure={() => undefined} + /> + )} +
- ); + + setEditingInstanceId(null)} + onSave={(placement) => { + updateDraft((layout) => updatePlacement(layout, placement)); + setEditingInstanceId(null); + }} + /> + + ); } -function canUseWidget(auth: AuthInfo, widget: DashboardWidgetContribution): boolean { - if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false; +function canUseWidget( + auth: AuthInfo, + widget: DashboardWidgetContribution +): boolean { + if ( + widget.allOf?.length + && !widget.allOf.every((scope) => hasScope(auth, scope)) + ) { + return false; + } if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false; return true; } -function widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize { - return widget.defaultSize ?? "medium"; -} - -function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout { - const ids = widgets.map((widget) => widget.id); - return { - known: ids, - visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id) - }; -} - -function readLayout(storageKey: string): DashboardLayout | null { - if (typeof window === "undefined") return null; - try { - const raw = window.localStorage.getItem(storageKey); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null; - return { - visible: parsed.visible.filter((id): id is string => typeof id === "string"), - known: parsed.known.filter((id): id is string => typeof id === "string") - }; - } catch { - return null; - } -} - -function writeLayout(storageKey: string, layout: DashboardLayout): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(storageKey, JSON.stringify(layout)); - } catch { - // localStorage may be unavailable in restricted browsing contexts. - } -} - function moduleLabels(modules: Array<{ id: string }>): string { if (!modules.length) return "Core shell only"; return modules.slice(0, 4).map((module) => module.id).join(", "); } + +function layoutSourceLabel(source: LayoutSource): string { + if (source === "server") return "Saved for your account"; + if (source === "browser") return "Browser layout; save to synchronize"; + return "Module defaults"; +} + +function errorMessage(error: unknown): string { + if (isApiError(error)) { + if (error.status === 409) { + return "This layout changed in another session. Reload the page before saving again."; + } + return error.message; + } + return error instanceof Error ? error.message : "The Dashboard request failed."; +} diff --git a/webui/src/features/dashboard/WidgetConfigurationDialog.tsx b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx new file mode 100644 index 0000000..2977dc0 --- /dev/null +++ b/webui/src/features/dashboard/WidgetConfigurationDialog.tsx @@ -0,0 +1,201 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Button, + Dialog, + FormField, + SegmentedControl, + ToggleSwitch, + type DashboardWidgetConfiguration, + type DashboardWidgetConfigurationField, + type DashboardWidgetConfigurationValue, + type DashboardWidgetContribution, + type DashboardWidgetSize +} from "@govoplan/core-webui"; +import { + defaultWidgetSize, + supportedWidgetSizes, + type DashboardWidgetPlacement +} from "./dashboardLayout"; + +type WidgetConfigurationDialogProps = { + open: boolean; + widget: DashboardWidgetContribution | null; + placement: DashboardWidgetPlacement | null; + onClose: () => void; + onSave: (placement: DashboardWidgetPlacement) => void; +}; + +export default function WidgetConfigurationDialog({ + open, + widget, + placement, + onClose, + onSave +}: WidgetConfigurationDialogProps) { + const [size, setSize] = useState("medium"); + const [configuration, setConfiguration] = useState({}); + + useEffect(() => { + if (!open || !widget || !placement) return; + setSize(placement.size); + setConfiguration({ + ...(widget.defaultConfiguration ?? {}), + ...placement.configuration + }); + }, [open, placement, widget]); + + const invalidConfiguration = useMemo( + () => (widget?.configurationFields ?? []).some((field) => { + const value = configuration[field.id]; + if (field.required && valueIsEmpty(value)) return true; + if (field.kind !== "number" || value === null || value === undefined) { + return false; + } + if (typeof value !== "number" || !Number.isFinite(value)) return true; + if (field.min !== undefined && value < field.min) return true; + return field.max !== undefined && value > field.max; + }), + [configuration, widget] + ); + + if (!widget || !placement) return null; + const supportedSizes = supportedWidgetSizes(widget); + + function setValue(id: string, value: DashboardWidgetConfigurationValue) { + setConfiguration((current) => ({ ...current, [id]: value })); + } + + function reset() { + setSize(defaultWidgetSize(widget)); + setConfiguration({ ...(widget.defaultConfiguration ?? {}) }); + } + + return ( + + + + + + + } + > + {supportedSizes.length > 1 && ( + + ({ + id: candidate, + label: sizeLabel(candidate) + }))} + value={size} + onChange={setSize} + ariaLabel="Widget size" + role="group" + size="equal" + width="fill" + /> + + )} + {(widget.configurationFields ?? []).map((field) => ( + setValue(field.id, value)} + /> + ))} + + ); +} + +function ConfigurationField({ + field, + value, + onChange +}: { + field: DashboardWidgetConfigurationField; + value: DashboardWidgetConfigurationValue; + onChange: (value: DashboardWidgetConfigurationValue) => void; +}) { + if (field.kind === "boolean") { + return ( + + ); + } + + if (field.kind === "select") { + return ( + + + + ); + } + + if (field.kind === "number") { + return ( + + { + const next = event.target.value; + onChange(next === "" ? null : Number(next)); + }} + /> + + ); + } + + return ( + + onChange(event.target.value)} + /> + + ); +} + +function valueIsEmpty(value: DashboardWidgetConfigurationValue | undefined): boolean { + return value === null || value === undefined || value === ""; +} + +function sizeLabel(size: DashboardWidgetSize): string { + return size.charAt(0).toUpperCase() + size.slice(1); +} diff --git a/webui/src/features/dashboard/WidgetLibrary.tsx b/webui/src/features/dashboard/WidgetLibrary.tsx new file mode 100644 index 0000000..17c9396 --- /dev/null +++ b/webui/src/features/dashboard/WidgetLibrary.tsx @@ -0,0 +1,99 @@ +import type { DragEvent as ReactDragEvent } from "react"; +import { GripVertical, Plus, RotateCcw, Search } from "lucide-react"; +import { + IconButton, + type DashboardWidgetContribution +} from "@govoplan/core-webui"; +import type { DashboardDragItem } from "./dashboardEditorTypes"; + +type WidgetLibraryProps = { + widgets: DashboardWidgetContribution[]; + atCapacity: boolean; + query: string; + onQueryChange: (query: string) => void; + onReset: () => void; + onAdd: (widget: DashboardWidgetContribution) => void; + onDragStart: ( + event: ReactDragEvent, + item: DashboardDragItem + ) => void; + onDragEnd: () => void; +}; + +export default function WidgetLibrary({ + widgets, + atCapacity, + query, + onQueryChange, + onReset, + onAdd, + onDragStart, + onDragEnd +}: WidgetLibraryProps) { + return ( + + ); +} diff --git a/webui/src/features/dashboard/dashboardEditorTypes.ts b/webui/src/features/dashboard/dashboardEditorTypes.ts new file mode 100644 index 0000000..c327de9 --- /dev/null +++ b/webui/src/features/dashboard/dashboardEditorTypes.ts @@ -0,0 +1,96 @@ +import type { DashboardWidgetSize } from "@govoplan/core-webui"; +import type { DashboardWidgetPlacement } from "./dashboardLayout"; + +export type DashboardDragItem = + | { kind: "placement"; instanceId: string } + | { kind: "catalogue"; widgetId: string }; + +export type DashboardDropTarget = + | { + kind: "placement"; + instanceId: string; + edge: "before" | "after"; + } + | { kind: "end" }; + +export type DashboardGridPreviewItem = + | { + kind: "placement"; + placement: DashboardWidgetPlacement; + placeholder: boolean; + } + | { + kind: "catalogue"; + widgetId: string; + size: DashboardWidgetSize; + }; + +export function dashboardGridPreview( + placements: DashboardWidgetPlacement[], + dragItem: DashboardDragItem | null, + dropTarget: DashboardDropTarget | null, + catalogueSize: DashboardWidgetSize = "medium" +): DashboardGridPreviewItem[] { + const items: DashboardGridPreviewItem[] = placements.map((placement) => ({ + kind: "placement", + placement, + placeholder: false + })); + if (!dragItem || !dropTarget) return items; + + if (dragItem.kind === "catalogue") { + const insertionIndex = previewInsertionIndex( + placements, + dropTarget, + null + ); + items.splice(insertionIndex, 0, { + kind: "catalogue", + widgetId: dragItem.widgetId, + size: catalogueSize + }); + return items; + } + + const sourceIndex = placements.findIndex( + (placement) => placement.instanceId === dragItem.instanceId + ); + if (sourceIndex < 0) return items; + const source = placements[sourceIndex]; + const remaining = placements.filter( + (placement) => placement.instanceId !== dragItem.instanceId + ); + const insertionIndex = previewInsertionIndex( + remaining, + dropTarget, + sourceIndex + ); + const preview: DashboardGridPreviewItem[] = remaining.map((placement) => ({ + kind: "placement", + placement, + placeholder: false + })); + preview.splice(insertionIndex, 0, { + kind: "placement", + placement: source, + placeholder: true + }); + return preview; +} + +function previewInsertionIndex( + placements: DashboardWidgetPlacement[], + target: DashboardDropTarget, + fallbackIndex: number | null +): number { + if (target.kind === "end") return placements.length; + const targetIndex = placements.findIndex( + (placement) => placement.instanceId === target.instanceId + ); + if (targetIndex < 0) { + return fallbackIndex === null + ? placements.length + : Math.min(fallbackIndex, placements.length); + } + return targetIndex + (target.edge === "after" ? 1 : 0); +} diff --git a/webui/src/features/dashboard/dashboardLayout.ts b/webui/src/features/dashboard/dashboardLayout.ts new file mode 100644 index 0000000..2384d56 --- /dev/null +++ b/webui/src/features/dashboard/dashboardLayout.ts @@ -0,0 +1,445 @@ +import type { + DashboardWidgetConfiguration, + DashboardWidgetContribution, + DashboardWidgetSize +} from "@govoplan/core-webui"; +import type { + DashboardLayoutResponse, + DashboardLayoutUpdate, + DashboardWidgetPlacementResponse +} from "../../api/dashboard"; + +export type DashboardWidgetPlacement = { + instanceId: string; + widgetId: string; + size: DashboardWidgetSize; + configuration: DashboardWidgetConfiguration; +}; + +export type DashboardLayoutState = { + layoutVersion: 1; + revision: number; + knownWidgetIds: string[]; + placements: DashboardWidgetPlacement[]; +}; + +type LegacyDashboardLayout = { + visible: string[]; + known: string[]; +}; + +const SIZES: DashboardWidgetSize[] = ["small", "medium", "wide", "full"]; +export const MAX_DASHBOARD_WIDGETS = 100; + +export function createWidgetPlacement( + widget: DashboardWidgetContribution +): DashboardWidgetPlacement { + return { + instanceId: newInstanceId(), + widgetId: widget.id, + size: defaultWidgetSize(widget), + configuration: { ...(widget.defaultConfiguration ?? {}) } + }; +} + +export function defaultDashboardLayout( + widgets: DashboardWidgetContribution[] +): DashboardLayoutState { + return { + layoutVersion: 1, + revision: 0, + knownWidgetIds: widgets.map((widget) => widget.id), + placements: widgets + .filter((widget) => widget.defaultVisible !== false) + .slice(0, MAX_DASHBOARD_WIDGETS) + .map(createWidgetPlacement) + }; +} + +export function reconcileDashboardLayout( + layout: DashboardLayoutState, + widgets: DashboardWidgetContribution[] +): DashboardLayoutState { + const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + const seenInstances = new Set(); + const seenWidgets = new Set(); + const placements = layout.placements.flatMap((placement) => { + if (!placement.instanceId || seenInstances.has(placement.instanceId)) return []; + const widget = widgetById.get(placement.widgetId); + if (widget && !widget.allowMultiple && seenWidgets.has(widget.id)) return []; + seenInstances.add(placement.instanceId); + seenWidgets.add(placement.widgetId); + return [normalizePlacement(placement, widget)]; + }); + const known = new Set(layout.knownWidgetIds); + + for (const widget of widgets) { + if ( + placements.length < MAX_DASHBOARD_WIDGETS + && !known.has(widget.id) + && widget.defaultVisible !== false + ) { + placements.push(createWidgetPlacement(widget)); + } + known.add(widget.id); + } + + return { + layoutVersion: 1, + revision: layout.revision, + knownWidgetIds: [...known], + placements: placements.slice(0, MAX_DASHBOARD_WIDGETS) + }; +} + +export function layoutFromResponse( + response: DashboardLayoutResponse +): DashboardLayoutState { + return { + layoutVersion: 1, + revision: response.revision, + knownWidgetIds: uniqueStrings(response.known_widget_ids), + placements: response.placements.map(placementFromResponse) + }; +} + +export function layoutUpdatePayload( + layout: DashboardLayoutState +): DashboardLayoutUpdate { + return { + expected_revision: layout.revision, + layout_version: 1, + placements: layout.placements.map(placementToResponse), + known_widget_ids: uniqueStrings(layout.knownWidgetIds) + }; +} + +export function appendPlacement( + layout: DashboardLayoutState, + widget: DashboardWidgetContribution +): DashboardLayoutState { + if ( + layout.placements.length >= MAX_DASHBOARD_WIDGETS + || ( + !widget.allowMultiple + && layout.placements.some((placement) => placement.widgetId === widget.id) + ) + ) { + return layout; + } + return { + ...layout, + knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), + placements: [...layout.placements, createWidgetPlacement(widget)] + }; +} + +export function insertPlacement( + layout: DashboardLayoutState, + widget: DashboardWidgetContribution, + targetInstanceId: string, + edge: "before" | "after" +): DashboardLayoutState { + if ( + layout.placements.length >= MAX_DASHBOARD_WIDGETS + || ( + !widget.allowMultiple + && layout.placements.some((placement) => placement.widgetId === widget.id) + ) + ) { + return layout; + } + const targetIndex = layout.placements.findIndex( + (placement) => placement.instanceId === targetInstanceId + ); + if (targetIndex < 0) return appendPlacement(layout, widget); + const insertionIndex = targetIndex + (edge === "after" ? 1 : 0); + return { + ...layout, + knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]), + placements: [ + ...layout.placements.slice(0, insertionIndex), + createWidgetPlacement(widget), + ...layout.placements.slice(insertionIndex) + ] + }; +} + +export function removePlacement( + layout: DashboardLayoutState, + instanceId: string +): DashboardLayoutState { + return { + ...layout, + placements: layout.placements.filter( + (placement) => placement.instanceId !== instanceId + ) + }; +} + +export function updatePlacement( + layout: DashboardLayoutState, + nextPlacement: DashboardWidgetPlacement +): DashboardLayoutState { + return { + ...layout, + placements: layout.placements.map((placement) => + placement.instanceId === nextPlacement.instanceId + ? nextPlacement + : placement + ) + }; +} + +export function reorderPlacement( + layout: DashboardLayoutState, + sourceInstanceId: string, + targetInstanceId: string, + edge: "before" | "after" +): DashboardLayoutState { + if (sourceInstanceId === targetInstanceId) return layout; + const source = layout.placements.find( + (placement) => placement.instanceId === sourceInstanceId + ); + if (!source) return layout; + const remaining = layout.placements.filter( + (placement) => placement.instanceId !== sourceInstanceId + ); + const targetIndex = remaining.findIndex( + (placement) => placement.instanceId === targetInstanceId + ); + if (targetIndex < 0) return layout; + const insertionIndex = targetIndex + (edge === "after" ? 1 : 0); + return { + ...layout, + placements: [ + ...remaining.slice(0, insertionIndex), + source, + ...remaining.slice(insertionIndex) + ] + }; +} + +export function dashboardMasonryRowSpan( + height: number, + rowHeight: number, + bottomGap: number +): number { + const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0; + const safeRowHeight = + Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : 4; + const safeBottomGap = + Number.isFinite(bottomGap) ? Math.max(0, bottomGap) : 0; + return Math.max(1, Math.ceil((safeHeight + safeBottomGap) / safeRowHeight)); +} + +export function layoutsEqual( + left: DashboardLayoutState, + right: DashboardLayoutState +): boolean { + return JSON.stringify(comparableLayout(left)) === JSON.stringify(comparableLayout(right)); +} + +export function localLayoutKey( + tenantId: string, + accountId: string, + viewId: string | null +): string { + return [ + "govoplan.dashboard.layout.v2", + encodeURIComponent(tenantId), + encodeURIComponent(accountId), + encodeURIComponent(viewId ?? "full") + ].join(":"); +} + +export function readLocalLayout( + storageKey: string, + legacyStorageKey: string | null, + widgets: DashboardWidgetContribution[] +): DashboardLayoutState | null { + if (typeof window === "undefined") return null; + try { + const current = window.localStorage.getItem(storageKey); + if (current) return parseStoredLayout(JSON.parse(current)); + if (!legacyStorageKey) return null; + const legacy = window.localStorage.getItem(legacyStorageKey); + if (!legacy) return null; + return migrateLegacyLayout(JSON.parse(legacy), widgets); + } catch { + return null; + } +} + +export function writeLocalLayout( + storageKey: string, + layout: DashboardLayoutState +): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(layout)); + } catch { + // Browser storage is only a resilience and migration fallback. + } +} + +export function defaultWidgetSize( + widget: DashboardWidgetContribution +): DashboardWidgetSize { + const supported = supportedWidgetSizes(widget); + return supported.includes(widget.defaultSize ?? "medium") + ? widget.defaultSize ?? "medium" + : supported[0]; +} + +export function supportedWidgetSizes( + widget: DashboardWidgetContribution +): DashboardWidgetSize[] { + const supported = (widget.supportedSizes ?? [widget.defaultSize ?? "medium"]) + .filter((size): size is DashboardWidgetSize => SIZES.includes(size)); + return supported.length ? [...new Set(supported)] : ["medium"]; +} + +export function widgetIsConfigurable( + widget: DashboardWidgetContribution +): boolean { + return Boolean( + widget.configurationFields?.length + || supportedWidgetSizes(widget).length > 1 + ); +} + +function normalizePlacement( + placement: DashboardWidgetPlacement, + widget: DashboardWidgetContribution | undefined +): DashboardWidgetPlacement { + if (!widget) return placement; + const supported = supportedWidgetSizes(widget); + return { + ...placement, + size: supported.includes(placement.size) + ? placement.size + : defaultWidgetSize(widget), + configuration: { + ...(widget.defaultConfiguration ?? {}), + ...placement.configuration + } + }; +} + +function placementFromResponse( + placement: DashboardWidgetPlacementResponse +): DashboardWidgetPlacement { + return { + instanceId: placement.instance_id, + widgetId: placement.widget_id, + size: placement.size, + configuration: { ...placement.configuration } + }; +} + +function placementToResponse( + placement: DashboardWidgetPlacement +): DashboardWidgetPlacementResponse { + return { + instance_id: placement.instanceId, + widget_id: placement.widgetId, + size: placement.size, + configuration: { ...placement.configuration } + }; +} + +function parseStoredLayout(value: unknown): DashboardLayoutState | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (!Array.isArray(candidate.placements) || !Array.isArray(candidate.knownWidgetIds)) { + return null; + } + const placements = candidate.placements.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const placement = item as Partial; + if ( + typeof placement.instanceId !== "string" + || typeof placement.widgetId !== "string" + || !SIZES.includes(placement.size as DashboardWidgetSize) + || !placement.configuration + || typeof placement.configuration !== "object" + || Array.isArray(placement.configuration) + ) { + return []; + } + return [{ + instanceId: placement.instanceId, + widgetId: placement.widgetId, + size: placement.size as DashboardWidgetSize, + configuration: sanitizeConfiguration(placement.configuration) + }]; + }); + return { + layoutVersion: 1, + revision: typeof candidate.revision === "number" ? candidate.revision : 0, + knownWidgetIds: uniqueStrings(candidate.knownWidgetIds), + placements + }; +} + +function migrateLegacyLayout( + value: unknown, + widgets: DashboardWidgetContribution[] +): DashboardLayoutState | null { + if (!value || typeof value !== "object") return null; + const candidate = value as Partial; + if (!Array.isArray(candidate.visible) || !Array.isArray(candidate.known)) { + return null; + } + const visible = new Set(uniqueStrings(candidate.visible)); + const widgetById = new Map(widgets.map((widget) => [widget.id, widget])); + return { + layoutVersion: 1, + revision: 0, + knownWidgetIds: uniqueStrings(candidate.known), + placements: [...visible].flatMap((widgetId) => { + const widget = widgetById.get(widgetId); + return widget ? [createWidgetPlacement(widget)] : []; + }) + }; +} + +function uniqueStrings(values: unknown[]): string[] { + return [...new Set(values.filter((value): value is string => typeof value === "string"))]; +} + +function sanitizeConfiguration( + value: Record +): DashboardWidgetConfiguration { + return Object.fromEntries( + Object.entries(value).filter(([key, item]) => + key.length > 0 + && key.length <= 120 + && ( + item === null + || typeof item === "string" + || typeof item === "boolean" + || (typeof item === "number" && Number.isFinite(item)) + ) + ) + ) as DashboardWidgetConfiguration; +} + +function comparableLayout(layout: DashboardLayoutState) { + return { + layoutVersion: layout.layoutVersion, + knownWidgetIds: layout.knownWidgetIds, + placements: layout.placements + }; +} + +function newInstanceId(): string { + if ( + typeof globalThis.crypto !== "undefined" + && typeof globalThis.crypto.randomUUID === "function" + ) { + return globalThis.crypto.randomUUID(); + } + return `widget-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} diff --git a/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx b/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx index 8486e05..40ff03b 100644 --- a/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx +++ b/webui/src/features/dashboard/widgets/InstalledModulesWidget.tsx @@ -1,16 +1,52 @@ -import { StatusBadge, type PlatformWebModule } from "@govoplan/core-webui"; +import { + StatusBadge, + type DashboardWidgetConfiguration, + type PlatformWebModule +} from "@govoplan/core-webui"; -export default function InstalledModulesWidget({ modules }: { modules: PlatformWebModule[] }) { +export default function InstalledModulesWidget({ + modules, + configuration +}: { + modules: PlatformWebModule[]; + configuration: DashboardWidgetConfiguration; +}) { if (!modules.length) return

No WebUI modules are active.

; + const maxItems = clampNumber(configuration.maxItems, 1, 50, 8); + const showVersions = configuration.showVersions !== false; + const sortBy = configuration.sortBy === "label" ? "label" : "module"; + const visibleModules = [...modules] + .sort((left, right) => { + const leftValue = sortBy === "label" ? left.label : left.id; + const rightValue = sortBy === "label" ? right.label : right.id; + return String(leftValue).localeCompare(String(rightValue)); + }) + .slice(0, maxItems); + const remaining = Math.max(modules.length - visibleModules.length, 0); return ( -
- {modules.map((module) => -
-
-
{module.label} v{module.version}
-
- )} -
); + <> +
+ {visibleModules.map((module) => +
+
+
+ {module.label} + {showVersions && v{module.version}} +
+
+ )} +
+ {remaining > 0 &&

+{remaining} more active modules

} + ); } +function clampNumber( + value: DashboardWidgetConfiguration[string], + minimum: number, + maximum: number, + fallback: number +): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(value))); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index edac3e2..d92fc9d 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -17,7 +17,41 @@ const dashboardWidgets: DashboardWidgetsUiCapability = { category: "Platform", order: 10, defaultSize: "medium", - render: ({ modules }) => createElement(InstalledModulesWidget, { modules }) + supportedSizes: ["small", "medium", "wide"], + defaultConfiguration: { + maxItems: 8, + showVersions: true, + sortBy: "module" + }, + configurationFields: [ + { + id: "maxItems", + label: "Maximum modules", + description: "Limit how many active modules are listed.", + kind: "number", + min: 1, + max: 50, + step: 1, + required: true + }, + { + id: "sortBy", + label: "Sort modules by", + kind: "select", + required: true, + options: [ + { value: "module", label: "Module id" }, + { value: "label", label: "Display name" } + ] + }, + { + id: "showVersions", + label: "Show module versions", + kind: "boolean" + } + ], + render: ({ modules, configuration }) => + createElement(InstalledModulesWidget, { modules, configuration }) } ] }; diff --git a/webui/src/styles/dashboard.css b/webui/src/styles/dashboard.css index bee8c89..89f9574 100644 --- a/webui/src/styles/dashboard.css +++ b/webui/src/styles/dashboard.css @@ -11,12 +11,23 @@ .dashboard-widget-grid { display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); - gap: 18px; + grid-auto-flow: row dense; + grid-auto-rows: 4px; + column-gap: 18px; + row-gap: 0; align-items: start; } .dashboard-widget { min-width: 0; + position: relative; + align-self: start; + margin-bottom: 18px; +} + +.dashboard-widget > .card { + height: auto; + margin-bottom: 0; } .dashboard-widget-small { @@ -41,10 +52,275 @@ margin-bottom: 0; } +.dashboard-config-workspace { + display: flex; + min-width: 0; + flex-direction: column; + gap: 18px; +} + +.dashboard-widget-library { + display: grid; + grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) 36px; + align-items: center; + min-width: 0; + min-height: 84px; + overflow: hidden; + border: var(--border-line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow-xs); +} + +.dashboard-widget-library-controls { + align-self: stretch; + display: grid; + align-content: center; + gap: 7px; + min-width: 0; + padding: 9px 12px; + border-right: var(--border-line); + background: var(--panel-header); +} + +.dashboard-widget-library-header { + display: flex; + align-items: center; +} + +.dashboard-widget-library-header h2 { + margin: 0; + font-size: 16px; +} + +.dashboard-widget-search { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 8px; + min-height: 0; + margin: 0; + padding: 4px 8px; + border: var(--border-line); + border-radius: 5px; + background: var(--surface); +} + +.dashboard-widget-search:focus-within { + box-shadow: var(--accent-ring-soft); +} + +.dashboard-widget-search input[type="search"] { + height: auto; + min-height: 0; + min-width: 0; + padding: 1px 0; + border: 0; + box-shadow: none; + font: inherit; + font-size: 13px; + line-height: 1.35; +} + +.dashboard-widget-library-list { + display: flex; + min-width: 0; + align-self: stretch; + overflow-x: auto; + overflow-y: hidden; + border-right: var(--border-line); +} + +.dashboard-widget-library-item { + flex: 0 0 min(250px, 32vw); + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 36px; + align-items: center; + gap: 10px; + min-height: 70px; + padding: 9px 10px 9px 12px; + border-right: var(--border-line); + cursor: grab; +} + +.dashboard-widget-library-item:hover { + background: var(--hover-tint-soft); +} + +.dashboard-widget-library-item:active { + cursor: grabbing; +} + +.dashboard-widget-library-item.is-disabled { + cursor: default; + opacity: .7; +} + +.dashboard-widget-library-item > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.dashboard-widget-library-item strong, +.dashboard-widget-library-item span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-widget-library-item span { + color: var(--muted); + font-size: 12px; +} + +.dashboard-widget-library-empty { + flex: 1 0 auto; + margin: 0; + padding: 18px 16px; +} + +.dashboard-widget-grid.is-configuring .dashboard-widget { + cursor: default; +} + +.dashboard-widget-grid.is-configuring .card-header { + align-items: center; + flex-wrap: nowrap; + gap: 6px; + padding: 12px 16px; +} + +.dashboard-widget-grid.is-configuring .card-header > h2 { + order: 2; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-widget-grid.is-configuring .card-actions { + display: contents; +} + +.dashboard-widget-actions { + display: contents; +} + +.dashboard-widget-actions > * { + order: 3; +} + +.dashboard-widget-drag-handle { + order: 1; + flex: 0 0 auto; + cursor: grab; +} + +.dashboard-widget-drag-handle:active { + cursor: grabbing; +} + +.dashboard-widget-drop-placeholder { + min-height: 142px; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: var(--accent-hover-bg); + color: var(--text); +} + +.dashboard-widget-drop-placeholder.preserves-widget-height { + border: 0; + background: transparent; +} + +.dashboard-widget-placeholder-content { + visibility: hidden; +} + +.dashboard-widget-placeholder-surface { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 142px; + padding: 16px; + border: 2px dashed var(--accent); + border-radius: var(--radius); + background: var(--accent-hover-bg); + color: var(--text); + text-align: center; + pointer-events: none; +} + +.dashboard-grid-end-drop-target, +.dashboard-empty-state.is-drop-target { + display: grid; + min-height: 74px; + place-items: center; + border: 2px dashed var(--line-dark); + border-radius: var(--radius); + color: var(--muted); + font-size: 13px; + font-weight: 700; +} + +.dashboard-grid-end-drop-target { + grid-column: 1 / -1; + grid-row-end: span 19; +} + +.dashboard-grid-end-drop-target:hover, +.dashboard-empty-state.is-drop-target:hover { + border-color: var(--accent); + background: var(--accent-hover-bg); + color: var(--text); +} + +.dashboard-empty-state { + min-height: 220px; + padding: 32px; + text-align: center; +} + +.dashboard-empty-state h2 { + margin: 0 0 8px; + font-size: 18px; +} + +.dashboard-empty-state p { + margin: 0; +} + +.dashboard-widget-config-dialog { + width: min(620px, 100%); +} + +.dashboard-widget-config-dialog .dialog-body { + display: grid; + gap: 18px; +} + +.dashboard-widget-config-dialog .dialog-footer-spacer { + flex: 1 1 auto; +} + +.dashboard-widget-overflow { + margin: 12px 0 0; +} + @media (max-width: 1120px) { .dashboard-widget-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); } + + .dashboard-widget-library { + grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px; + } } @media (max-width: 900px) { @@ -53,6 +329,29 @@ grid-template-columns: 1fr; } + .dashboard-widget-library { + grid-template-columns: minmax(0, 1fr) 36px; + } + + .dashboard-widget-library-controls { + grid-column: 1; + grid-row: 1; + border-right: 0; + } + + .dashboard-widget-library-list { + grid-column: 1 / -1; + grid-row: 2; + min-height: 70px; + border-top: var(--border-line); + border-right: 0; + border-left: 0; + } + + .dashboard-widget-library-item { + flex-basis: min(250px, 72vw); + } + .dashboard-widget-small, .dashboard-widget-medium, .dashboard-widget-wide, diff --git a/webui/tests/dashboard-layout.test.ts b/webui/tests/dashboard-layout.test.ts new file mode 100644 index 0000000..6b0b126 --- /dev/null +++ b/webui/tests/dashboard-layout.test.ts @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { + appendPlacement, + dashboardMasonryRowSpan, + defaultDashboardLayout, + layoutUpdatePayload, + reconcileDashboardLayout, + removePlacement, + reorderPlacement +} from "../src/features/dashboard/dashboardLayout.ts"; +import { dashboardGridPreview } from "../src/features/dashboard/dashboardEditorTypes.ts"; + +const widgets = [ + { + id: "one", + surfaceId: "one.widget", + title: "One", + moduleId: "one", + defaultSize: "small" as const, + supportedSizes: ["small", "medium"] as const, + defaultConfiguration: { count: 3 }, + render: () => null + }, + { + id: "two", + surfaceId: "two.widget", + title: "Two", + moduleId: "two", + defaultVisible: false, + defaultSize: "medium" as const, + render: () => null + } +]; + +const defaults = defaultDashboardLayout(widgets); +assert.deepEqual(defaults.knownWidgetIds, ["one", "two"]); +assert.equal(defaults.placements.length, 1); +assert.equal(defaults.placements[0].widgetId, "one"); +assert.deepEqual(defaults.placements[0].configuration, { count: 3 }); + +const withTwo = appendPlacement(defaults, widgets[1]); +assert.deepEqual( + withTwo.placements.map((placement) => placement.widgetId), + ["one", "two"] +); +assert.equal( + appendPlacement(withTwo, widgets[1]).placements.length, + 2, + "single-instance widgets must not be duplicated" +); + +const reordered = reorderPlacement( + withTwo, + withTwo.placements[1].instanceId, + withTwo.placements[0].instanceId, + "before" +); +assert.deepEqual( + reordered.placements.map((placement) => placement.widgetId), + ["two", "one"] +); + +const removed = removePlacement(reordered, reordered.placements[0].instanceId); +assert.deepEqual( + removed.placements.map((placement) => placement.widgetId), + ["one"] +); + +const placementPreview = dashboardGridPreview( + withTwo.placements, + { + kind: "placement", + instanceId: withTwo.placements[0].instanceId + }, + { + kind: "placement", + instanceId: withTwo.placements[1].instanceId, + edge: "after" + } +); +assert.deepEqual( + placementPreview.map((item) => + item.kind === "placement" + ? `${item.placement.widgetId}:${item.placeholder}` + : item.widgetId + ), + ["two:false", "one:true"], + "placement previews must show the prospective order" +); + +const cataloguePreview = dashboardGridPreview( + withTwo.placements, + { kind: "catalogue", widgetId: "three" }, + { + kind: "placement", + instanceId: withTwo.placements[0].instanceId, + edge: "after" + }, + "wide" +); +assert.deepEqual( + cataloguePreview.map((item) => + item.kind === "placement" + ? item.placement.widgetId + : `${item.widgetId}:${item.size}` + ), + ["one", "three:wide", "two"], + "catalogue previews must reserve the prospective widget size" +); + +const unavailablePlacement = { + instanceId: "unavailable-instance", + widgetId: "temporarily-disabled", + size: "wide" as const, + configuration: { mode: "summary" } +}; +const reconciled = reconcileDashboardLayout( + { + layoutVersion: 1, + revision: 4, + knownWidgetIds: ["temporarily-disabled"], + placements: [unavailablePlacement] + }, + widgets +); +assert.equal( + reconciled.placements[0], + unavailablePlacement, + "temporarily unavailable contributions must retain their placement" +); +assert.equal( + reconciled.placements[1].widgetId, + "one", + "new default-visible widgets are announced into existing layouts" +); +assert.equal(reconciled.revision, 4); + +const payload = layoutUpdatePayload(reconciled); +assert.equal(payload.expected_revision, 4); +assert.equal(payload.placements[0].instance_id, "unavailable-instance"); +assert.equal(payload.placements[0].configuration.mode, "summary"); + +const repeatingWidget = { + ...widgets[0], + id: "repeating", + allowMultiple: true, + defaultVisible: false +}; +let bounded = defaultDashboardLayout([repeatingWidget]); +for (let index = 0; index < 105; index += 1) { + bounded = appendPlacement(bounded, repeatingWidget); +} +assert.equal( + bounded.placements.length, + 100, + "client layout helpers must enforce the server placement limit" +); + +assert.equal( + dashboardMasonryRowSpan(142, 4, 18), + 40, + "masonry spans must reserve the widget height and inter-widget gap" +); +assert.equal( + dashboardMasonryRowSpan(0, Number.NaN, Number.NaN), + 1, + "masonry spans must remain valid while a widget is mounting" +); + +console.log("Dashboard layout tests passed.");