8 Commits
v0.1.7 ... main

34 changed files with 3374 additions and 206 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/dashboard
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/dashboard
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/dashboard
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/dashboard
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/dashboard
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -1,12 +1,37 @@
# GovOPlaN Dashboard
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
Configurable dashboard module for GovOPlaN.
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 }) => <ExampleWidget configuration={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.

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.7",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.7",
"@govoplan/core-webui": "^0.1.8",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-dashboard"
version = "0.1.7"
version = "0.1.8"
description = "GovOPlaN configurable dashboard module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.7",
"govoplan-access>=0.1.7",
"govoplan-core>=0.1.8",
"govoplan-access>=0.1.8",
]
[tool.setuptools.packages.find]

View File

@@ -2,5 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.6"
__version__ = "0.1.8"

View File

@@ -0,0 +1,3 @@
from govoplan_dashboard.backend.db.models import DashboardLayout
__all__ = ["DashboardLayout"]

View File

@@ -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"]

View File

@@ -1,21 +1,84 @@
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(
id="dashboard",
name="Dashboard",
version="0.1.7",
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",
package_name="@govoplan/dashboard-webui",
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
view_surfaces=(
ViewSurface(
id="dashboard.widget.installed-modules",
module_id="dashboard",
kind="section",
label="Installed modules widget",
order=10,
),
),
),
documentation=(
DocumentationTopic(
@@ -24,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"),

View File

@@ -0,0 +1 @@
"""Dashboard database migrations."""

View File

@@ -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")

View File

@@ -0,0 +1 @@
"""Dashboard migration revisions."""

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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()

49
tests/test_migrations.py Normal file
View File

@@ -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()

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.7",
"version": "0.1.8",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -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.7",
"@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": {

View File

@@ -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<DashboardLayoutResponse> {
return apiFetch<DashboardLayoutResponse>(settings, layoutPath(viewId));
}
export function saveDashboardLayout(
settings: ApiSettings,
viewId: string | null,
payload: DashboardLayoutUpdate
): Promise<DashboardLayoutResponse> {
return apiFetch<DashboardLayoutResponse>(settings, layoutPath(viewId), {
method: "PUT",
body: JSON.stringify(payload)
});
}

View File

@@ -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<string, DashboardWidgetContribution>;
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<HTMLElement>, instanceId: string) => void;
onDragOverEnd: (event: ReactDragEvent<HTMLElement>) => void;
onDrop: (
event: ReactDragEvent<HTMLElement>,
placement: DashboardWidgetPlacement
) => void;
onDropPreview: (event: ReactDragEvent<HTMLElement>) => void;
onDropAtEnd: (event: ReactDragEvent<HTMLElement>) => 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 (
<div
className={`dashboard-empty-state${configuring ? " is-drop-target" : ""}`}
onDragOver={(event) => {
if (!configuring) return;
onDragOverEnd(event);
}}
onDrop={configuring ? onDropAtEnd : undefined}
>
<h2>No widgets on this Dashboard</h2>
<p className="muted">
{configuring
? "Drag a widget here from the library."
: "Open Configure to add widgets provided by active modules."}
</p>
</div>
);
}
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 (
<div
className={`dashboard-widget-grid${configuring ? " is-configuring" : ""}`}
onDragOver={(event) => {
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 (
<DashboardDropPlaceholder
key={`catalogue-placeholder:${item.widgetId}`}
title={widget?.title ?? "New widget"}
size={item.size}
onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview}
/>
);
}
const placement = item.placement;
const widget = widgetById.get(placement.widgetId);
if (!widget) return null;
if (item.placeholder) {
return (
<DashboardDropPlaceholder
key={placement.instanceId}
title={widget.title}
size={placement.size}
preserveHeight
onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview}
>
<Card title={widget.title}>
<DashboardWidgetContent
widget={widget}
placement={placement}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
/>
</Card>
</DashboardDropPlaceholder>
);
}
return (
<DashboardMasonryItem
key={placement.instanceId}
className={`dashboard-widget dashboard-widget-${placement.size}`}
onDragOver={
configuring
? (event) => onDragOver(event, placement.instanceId)
: undefined
}
onDrop={
configuring
? (event) => onDrop(event, placement)
: undefined
}
>
<Card
title={widget.title}
collapsible={!configuring}
collapseKey={`dashboard-widget:${placement.instanceId}`}
actions={
configuring ? (
<div className="dashboard-widget-actions">
<IconButton
label={`Drag ${widget.title}`}
icon={<GripVertical size={16} />}
variant="ghost"
className="dashboard-widget-drag-handle"
draggable
onDragStart={(event) =>
onDragStart(event, {
kind: "placement",
instanceId: placement.instanceId
})
}
onDragEnd={onDragEnd}
/>
{widgetIsConfigurable(widget) && (
<IconButton
label={`Configure ${widget.title}`}
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => onConfigure(placement.instanceId)}
/>
)}
<IconButton
label={`Remove ${widget.title}`}
icon={<Trash2 size={16} />}
variant="ghost"
onClick={() => onRemove(placement.instanceId)}
/>
</div>
) : undefined
}
>
<DashboardWidgetContent
widget={widget}
placement={placement}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
/>
</Card>
</DashboardMasonryItem>
);
})}
{configuring && (
<div
className="dashboard-grid-end-drop-target"
onDragOver={onDragOverEnd}
onDrop={onDropAtEnd}
>
Drop here to place the widget last
</div>
)}
</div>
);
}
function DashboardDropPlaceholder({
title,
size,
preserveHeight = false,
onDragOver,
onDrop,
children
}: {
title: string;
size: DashboardWidgetPlacement["size"];
preserveHeight?: boolean;
onDragOver: (event: ReactDragEvent<HTMLElement>) => void;
onDrop: (event: ReactDragEvent<HTMLElement>) => void;
children?: ReactNode;
}) {
return (
<DashboardMasonryItem
className={[
"dashboard-widget",
`dashboard-widget-${size}`,
"dashboard-widget-drop-placeholder",
preserveHeight ? "preserves-widget-height" : ""
]
.filter(Boolean)
.join(" ")}
onDragOver={onDragOver}
onDrop={onDrop}
aria-label={`Place ${title} here`}
>
{children && (
<div className="dashboard-widget-placeholder-content">
{children}
</div>
)}
<div className="dashboard-widget-placeholder-surface">
<GripVertical size={18} aria-hidden="true" />
<strong>{title}</strong>
</div>
</DashboardMasonryItem>
);
}
function DashboardMasonryItem({
children,
...props
}: HTMLAttributes<HTMLElement>) {
const itemRef = useRef<HTMLElement>(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 (
<section ref={itemRef} {...props}>
{children}
</section>
);
}
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
});
}

View File

@@ -1,186 +1,560 @@
import { useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
import {
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 widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, 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<DashboardLayout>(() => defaultLayout(widgets));
const effectiveView = useEffectiveView();
const widgets = useMemo(
() =>
dashboardWidgetsForModules(modules, effectiveView).filter((widget) =>
canUseWidget(auth, widget)
),
[auth, effectiveView, modules]
);
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<DashboardLayoutState>(initialLayout);
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
const [layoutSource, setLayoutSource] = useState<LayoutSource>("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<DashboardDragItem | null>(null);
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(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);
}
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 setWidgetVisible(widgetId: string, visible: boolean) {
const allIds = widgets.map((widget) => widget.id);
const nextVisible = new Set(visibleWidgetIds);
if (visible) {
nextVisible.add(widgetId);
} else {
nextVisible.delete(widgetId);
}
saveLayout({
known: allIds,
visible: allIds.filter((id) => nextVisible.has(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<boolean> {
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);
}
}
function resetLayout() {
saveLayout(defaultLayout(widgets));
function discardChanges() {
setDraftLayout(savedLayout);
setConfiguring(false);
setEditingInstanceId(null);
setDragItem(null);
setDropTarget(null);
}
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<HTMLElement>,
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<HTMLElement>) {
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<HTMLElement>,
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<HTMLElement>) {
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<HTMLElement>) {
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 (
<div className="content-pad workspace-data-page dashboard-page">
<PageScrollViewport className="dashboard-page">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
{!configuring && (
<>
<Button onClick={() => setRefreshKey((value) => value + 1)}>
<RefreshCw size={16} /> Refresh
</Button>
<Button onClick={beginConfiguration}>
<SlidersHorizontal size={16} /> Configure
</Button>
</>
)}
{configuring && (
<>
<Button onClick={discardChanges} disabled={saving}>
<X size={16} /> Cancel
</Button>
<Button
variant="primary"
onClick={() => void persistLayout()}
disabled={saving || !dirty}
>
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
</Button>
</>
)}
</div>
</div>
{error && (
<DismissibleAlert tone="danger" resetKey={error} floating>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert tone="success" resetKey={notice} floating>
{notice}
</DismissibleAlert>
)}
<div className="metric-grid dashboard-summary-grid">
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} />
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" />
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
</div>
{configuring &&
<Card
title="Dashboard widgets"
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
<div className="dashboard-widget-picker">
{widgets.map((widget) =>
<label key={widget.id} className="dashboard-widget-toggle">
<input
type="checkbox"
checked={visibleWidgetIds.includes(widget.id)}
onChange={(event) => setWidgetVisible(widget.id, event.currentTarget.checked)}
<MetricCard
label="Installed modules"
value={modules.length}
tone="info"
detail={moduleLabels(modules)}
/>
<MetricCard
label="Available widgets"
value={widgets.length}
tone="neutral"
detail="Provided by active modules"
/>
<MetricCard
label="On dashboard"
value={renderedPlacements.length}
tone="good"
detail={hiddenCount ? `${hiddenCount} available to add` : "All shown"}
/>
<MetricCard
label="Layout"
value={viewLabel}
tone="neutral"
detail={layoutSourceLabel(layoutSource)}
/>
<span>
<strong>{widget.title}</strong>
<small>{widget.description ?? widget.id}</small>
</span>
</label>
)}
</div>
}
</Card>
}
{visibleWidgets.length === 0 ?
<Card title="No widgets selected">
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
</Card> :
<div className="dashboard-widget-grid">
{visibleWidgets.map((widget) =>
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
{widget.render({
settings,
auth,
modules,
widgetId: widget.id,
refreshKey,
size: widgetSize(widget)
})}
</Card>
</section>
)}
</div>
<LoadingFrame loading={loading} label="Loading Dashboard layout">
{configuring ? (
<div className="dashboard-config-workspace">
<WidgetLibrary
widgets={libraryWidgets}
atCapacity={draftLayout.placements.length >= MAX_DASHBOARD_WIDGETS}
query={libraryQuery}
onQueryChange={setLibraryQuery}
onReset={() =>
setDraftLayout({
...defaultDashboardLayout(widgets),
revision: savedLayout.revision
})
}
</div>);
onAdd={addWidget}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring
dragItem={dragItem}
dropTarget={dropTarget}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={(instanceId) =>
updateDraft((layout) => removePlacement(layout, instanceId))
}
onConfigure={setEditingInstanceId}
/>
</div>
) : (
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring={false}
dragItem={null}
dropTarget={null}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={() => undefined}
onConfigure={() => undefined}
/>
)}
</LoadingFrame>
</div>
<WidgetConfigurationDialog
open={Boolean(editingPlacement && editingWidget)}
widget={editingWidget}
placement={editingPlacement}
onClose={() => setEditingInstanceId(null)}
onSave={(placement) => {
updateDraft((layout) => updatePlacement(layout, placement));
setEditingInstanceId(null);
}}
/>
</PageScrollViewport>
);
}
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<DashboardLayout>;
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.";
}

View File

@@ -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<DashboardWidgetSize>("medium");
const [configuration, setConfiguration] = useState<DashboardWidgetConfiguration>({});
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 (
<Dialog
open={open}
title={`Configure ${widget.title}`}
onClose={onClose}
className="dashboard-widget-config-dialog"
footer={
<>
<Button type="button" variant="ghost" onClick={reset}>Reset</Button>
<span className="dialog-footer-spacer" />
<Button type="button" onClick={onClose}>Cancel</Button>
<Button
type="button"
variant="primary"
disabled={invalidConfiguration}
onClick={() => onSave({ ...placement, size, configuration })}
>
Apply
</Button>
</>
}
>
{supportedSizes.length > 1 && (
<FormField label="Widget size">
<SegmentedControl
options={supportedSizes.map((candidate) => ({
id: candidate,
label: sizeLabel(candidate)
}))}
value={size}
onChange={setSize}
ariaLabel="Widget size"
role="group"
size="equal"
width="fill"
/>
</FormField>
)}
{(widget.configurationFields ?? []).map((field) => (
<ConfigurationField
key={field.id}
field={field}
value={configuration[field.id] ?? widget.defaultConfiguration?.[field.id] ?? null}
onChange={(value) => setValue(field.id, value)}
/>
))}
</Dialog>
);
}
function ConfigurationField({
field,
value,
onChange
}: {
field: DashboardWidgetConfigurationField;
value: DashboardWidgetConfigurationValue;
onChange: (value: DashboardWidgetConfigurationValue) => void;
}) {
if (field.kind === "boolean") {
return (
<ToggleSwitch
label={field.label}
help={field.description}
checked={value === true}
onChange={onChange}
/>
);
}
if (field.kind === "select") {
return (
<FormField label={field.label} help={field.description}>
<select
value={typeof value === "string" ? value : ""}
required={field.required}
onChange={(event) => onChange(event.target.value)}
>
<option value="" disabled={field.required}>
{field.required ? "Select an option" : "None"}
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</FormField>
);
}
if (field.kind === "number") {
return (
<FormField label={field.label} help={field.description}>
<input
type="number"
value={typeof value === "number" ? value : ""}
required={field.required}
min={field.min}
max={field.max}
step={field.step}
placeholder={field.placeholder}
onChange={(event) => {
const next = event.target.value;
onChange(next === "" ? null : Number(next));
}}
/>
</FormField>
);
}
return (
<FormField label={field.label} help={field.description}>
<input
type="text"
value={typeof value === "string" ? value : ""}
required={field.required}
placeholder={field.placeholder}
onChange={(event) => onChange(event.target.value)}
/>
</FormField>
);
}
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);
}

View File

@@ -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 (
<aside className="dashboard-widget-library" aria-label="Widget library">
<div className="dashboard-widget-library-controls">
<div className="dashboard-widget-library-header">
<h2>Widget library</h2>
</div>
<label className="dashboard-widget-search">
<Search size={16} aria-hidden="true" />
<input
type="search"
value={query}
placeholder="Search widgets"
onChange={(event) => onQueryChange(event.target.value)}
/>
</label>
</div>
<div className="dashboard-widget-library-list">
{widgets.map((widget) => (
<div
key={widget.id}
className={`dashboard-widget-library-item${atCapacity ? " is-disabled" : ""}`}
draggable={!atCapacity}
onDragStart={(event) =>
onDragStart(event, { kind: "catalogue", widgetId: widget.id })
}
onDragEnd={onDragEnd}
>
<GripVertical size={16} aria-hidden="true" />
<div>
<strong>{widget.title}</strong>
<span>{widget.description ?? widget.category ?? widget.moduleId}</span>
</div>
<IconButton
label={`Add ${widget.title}`}
icon={<Plus size={16} />}
variant="ghost"
disabledReason={
atCapacity
? "A Dashboard can contain at most 100 widgets."
: undefined
}
onClick={() => onAdd(widget)}
/>
</div>
))}
{widgets.length === 0 && !atCapacity && (
<p className="muted dashboard-widget-library-empty">
{query.trim()
? "No available widgets match the search."
: "All available widgets are already on this Dashboard."}
</p>
)}
{atCapacity && (
<p className="muted dashboard-widget-library-empty">
This Dashboard has reached the 100-widget limit.
</p>
)}
</div>
<IconButton
label="Reset to module defaults"
icon={<RotateCcw size={16} />}
variant="ghost"
onClick={onReset}
/>
</aside>
);
}

View File

@@ -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);
}

View File

@@ -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<string>();
const seenWidgets = new Set<string>();
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<DashboardLayoutState>;
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<DashboardWidgetPlacement>;
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<LegacyDashboardLayout>;
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<string, unknown>
): 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)}`;
}

View File

@@ -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 <p className="muted">No WebUI modules are active.</p>;
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 (
<>
<dl className="detail-list dashboard-compact-list">
{modules.map((module) =>
{visibleModules.map((module) =>
<div key={module.id}>
<dt><StatusBadge status="success" label={module.id} /></dt>
<dd><strong>{module.label}</strong><span className="muted"> v{module.version}</span></dd>
<dd>
<strong>{module.label}</strong>
{showVersions && <span className="muted"> v{module.version}</span>}
</dd>
</div>
)}
</dl>);
</dl>
{remaining > 0 && <p className="muted dashboard-widget-overflow">+{remaining} more active modules</p>}
</>);
}
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)));
}

View File

@@ -10,13 +10,48 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "dashboard.installed-modules",
surfaceId: "dashboard.widget.installed-modules",
title: "Installed modules",
description: "Enabled WebUI modules in this browser session.",
moduleId: "dashboard",
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 })
}
]
};
@@ -33,6 +68,15 @@ export const dashboardModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
translations,
viewSurfaces: [
{
id: "dashboard.widget.installed-modules",
moduleId: "dashboard",
kind: "section",
label: "Installed modules widget",
order: 10
}
],
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
routes: [
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
@@ -43,4 +87,3 @@ export const dashboardModule: PlatformWebModule = {
};
export default dashboardModule;

View File

@@ -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 {
@@ -32,39 +43,6 @@
grid-column: 1 / -1;
}
.dashboard-widget-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.dashboard-widget-toggle {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
padding: 12px;
}
.dashboard-widget-toggle input {
margin-top: 3px;
}
.dashboard-widget-toggle strong,
.dashboard-widget-toggle small {
display: block;
}
.dashboard-widget-toggle small {
color: var(--muted);
margin-top: 4px;
line-height: 1.35;
}
.dashboard-compact-list.detail-list div {
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
}
@@ -74,19 +52,306 @@
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) {
.dashboard-widget-grid,
.dashboard-widget-picker,
.dashboard-widget-metrics.metric-grid {
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,
@@ -94,4 +359,3 @@
grid-column: 1;
}
}

View File

@@ -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.");