Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0732bead2 | |||
| b28229c003 | |||
| 2250d69765 | |||
| 64032b1ed1 | |||
| cfacc47ebb | |||
| 4a2d68b491 | |||
| a315a7c62b |
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
35
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal 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:
|
||||||
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal file
27
.gitea/ISSUE_TEMPLATE/docs_workflow.md
Normal 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?
|
||||||
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal file
32
.gitea/ISSUE_TEMPLATE/feature_request.md
Normal 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:
|
||||||
28
.gitea/ISSUE_TEMPLATE/task.md
Normal file
28
.gitea/ISSUE_TEMPLATE/task.md
Normal 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:
|
||||||
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal file
25
.gitea/ISSUE_TEMPLATE/tech_debt.md
Normal 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:
|
||||||
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
15
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
## Issue
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Follow-up issues:
|
||||||
28
README.md
28
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.
|
fallback home for installations where this module is absent.
|
||||||
|
|
||||||
Modules contribute widgets through the `dashboard.widgets` WebUI capability.
|
Modules contribute widgets through the `dashboard.widgets` WebUI capability.
|
||||||
The first implementation stores personal widget visibility in browser storage;
|
Personal widget layouts are stored by the backend for each tenant, account, and
|
||||||
server-side layout persistence can be added later without changing the widget
|
active View. Configure mode supports adding, removing, ordering, sizing, and
|
||||||
contract.
|
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.
|
||||||
|
|||||||
@@ -2,5 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.8"
|
||||||
|
|
||||||
|
|||||||
3
src/govoplan_dashboard/backend/db/__init__.py
Normal file
3
src/govoplan_dashboard/backend/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from govoplan_dashboard.backend.db.models import DashboardLayout
|
||||||
|
|
||||||
|
__all__ = ["DashboardLayout"]
|
||||||
79
src/govoplan_dashboard/backend/db/models.py
Normal file
79
src/govoplan_dashboard/backend/db/models.py
Normal 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"]
|
||||||
@@ -1,7 +1,39 @@
|
|||||||
from __future__ import annotations
|
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.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(
|
manifest = ModuleManifest(
|
||||||
@@ -10,12 +42,43 @@ manifest = ModuleManifest(
|
|||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
|
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),),
|
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="dashboard",
|
module_id="dashboard",
|
||||||
package_name="@govoplan/dashboard-webui",
|
package_name="@govoplan/dashboard-webui",
|
||||||
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
|
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
|
||||||
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", 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=(
|
documentation=(
|
||||||
DocumentationTopic(
|
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.",
|
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
|
||||||
body=(
|
body=(
|
||||||
"Core only provides a minimal fallback home when the dashboard module is absent. "
|
"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",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
|
|||||||
1
src/govoplan_dashboard/backend/migrations/__init__.py
Normal file
1
src/govoplan_dashboard/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Dashboard database migrations."""
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Dashboard migration revisions."""
|
||||||
123
src/govoplan_dashboard/backend/router.py
Normal file
123
src/govoplan_dashboard/backend/router.py
Normal 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"]
|
||||||
92
src/govoplan_dashboard/backend/schemas.py
Normal file
92
src/govoplan_dashboard/backend/schemas.py
Normal 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",
|
||||||
|
]
|
||||||
146
src/govoplan_dashboard/backend/service.py
Normal file
146
src/govoplan_dashboard/backend/service.py
Normal 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",
|
||||||
|
]
|
||||||
208
tests/test_dashboard_layouts.py
Normal file
208
tests/test_dashboard_layouts.py
Normal 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
49
tests/test_migrations.py
Normal 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()
|
||||||
@@ -13,12 +13,15 @@
|
|||||||
},
|
},
|
||||||
"./styles/dashboard.css": "./src/styles/dashboard.css"
|
"./styles/dashboard.css": "./src/styles/dashboard.css"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.8",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router-dom": ">=7.18.2 <8"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
53
webui/src/api/dashboard.ts
Normal file
53
webui/src/api/dashboard.ts
Normal 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)
|
||||||
|
});
|
||||||
|
}
|
||||||
368
webui/src/features/dashboard/DashboardGrid.tsx
Normal file
368
webui/src/features/dashboard/DashboardGrid.tsx
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,186 +1,560 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import {
|
||||||
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type DragEvent as ReactDragEvent
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Save,
|
||||||
|
SlidersHorizontal,
|
||||||
|
X
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
DismissibleAlert,
|
||||||
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
|
PageScrollViewport,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
dashboardWidgetsForModules,
|
dashboardWidgetsForModules,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
hasScope,
|
hasScope,
|
||||||
|
isApiError,
|
||||||
|
useEffectiveView,
|
||||||
usePlatformModules,
|
usePlatformModules,
|
||||||
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DashboardWidgetContribution,
|
type DashboardWidgetContribution
|
||||||
type DashboardWidgetSize
|
|
||||||
} from "@govoplan/core-webui";
|
} 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 = {
|
type LayoutSource = "server" | "browser" | "default";
|
||||||
visible: string[];
|
|
||||||
known: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
export default function DashboardPage({
|
||||||
|
settings,
|
||||||
|
auth
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
}) {
|
||||||
const modules = usePlatformModules();
|
const modules = usePlatformModules();
|
||||||
const widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, modules]);
|
const effectiveView = useEffectiveView();
|
||||||
const widgetSignature = widgets.map((widget) => widget.id).join("|");
|
const widgets = useMemo(
|
||||||
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`;
|
() =>
|
||||||
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets));
|
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 [configuring, setConfiguring] = useState(false);
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
|
let active = true;
|
||||||
}, [storageKey, widgetSignature]);
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setNotice("");
|
||||||
|
setConfiguring(false);
|
||||||
|
setEditingInstanceId(null);
|
||||||
|
|
||||||
const visibleWidgetIds = useMemo(() => {
|
void fetchDashboardLayout(settings, viewId)
|
||||||
const known = new Set(layout.known);
|
.then((response) => {
|
||||||
const visible = new Set(layout.visible);
|
if (!active) return;
|
||||||
for (const widget of widgets) {
|
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||||
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id);
|
const source: LayoutSource = response.exists
|
||||||
}
|
? "server"
|
||||||
const availableIds = new Set(widgets.map((widget) => widget.id));
|
: local
|
||||||
return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id));
|
? "browser"
|
||||||
}, [layout, widgets]);
|
: "default";
|
||||||
const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id));
|
const base = response.exists
|
||||||
const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0);
|
? layoutFromResponse(response)
|
||||||
|
: local
|
||||||
function saveLayout(next: DashboardLayout) {
|
? { ...local, revision: 0 }
|
||||||
setLayout(next);
|
: defaultDashboardLayout(widgets);
|
||||||
writeLayout(storageKey, next);
|
const next = reconcileDashboardLayout(base, widgets);
|
||||||
}
|
setSavedLayout(next);
|
||||||
|
setDraftLayout(next);
|
||||||
function setWidgetVisible(widgetId: string, visible: boolean) {
|
setLayoutSource(source);
|
||||||
const allIds = widgets.map((widget) => widget.id);
|
})
|
||||||
const nextVisible = new Set(visibleWidgetIds);
|
.catch((reason) => {
|
||||||
if (visible) {
|
if (!active) return;
|
||||||
nextVisible.add(widgetId);
|
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||||
} else {
|
const next = reconcileDashboardLayout(
|
||||||
nextVisible.delete(widgetId);
|
local ?? defaultDashboardLayout(widgets),
|
||||||
}
|
widgets
|
||||||
saveLayout({
|
);
|
||||||
known: allIds,
|
setSavedLayout(next);
|
||||||
visible: allIds.filter((id) => nextVisible.has(id))
|
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() {
|
function discardChanges() {
|
||||||
saveLayout(defaultLayout(widgets));
|
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 (
|
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 className="page-heading split workspace-heading">
|
||||||
<div>
|
<div>
|
||||||
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
|
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
|
||||||
<p>Personal workspace assembled from installed module widgets.</p>
|
<p>Personal workspace assembled from installed module widgets.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
|
{!configuring && (
|
||||||
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
|
<>
|
||||||
|
<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>
|
||||||
</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">
|
<div className="metric-grid dashboard-summary-grid">
|
||||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} />
|
<MetricCard
|
||||||
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" />
|
label="Installed modules"
|
||||||
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
|
value={modules.length}
|
||||||
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
|
tone="info"
|
||||||
</div>
|
detail={moduleLabels(modules)}
|
||||||
|
/>
|
||||||
{configuring &&
|
<MetricCard
|
||||||
<Card
|
label="Available widgets"
|
||||||
title="Dashboard widgets"
|
value={widgets.length}
|
||||||
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
|
tone="neutral"
|
||||||
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
|
detail="Provided by active modules"
|
||||||
<div className="dashboard-widget-picker">
|
/>
|
||||||
{widgets.map((widget) =>
|
<MetricCard
|
||||||
<label key={widget.id} className="dashboard-widget-toggle">
|
label="On dashboard"
|
||||||
<input
|
value={renderedPlacements.length}
|
||||||
type="checkbox"
|
tone="good"
|
||||||
checked={visibleWidgetIds.includes(widget.id)}
|
detail={hiddenCount ? `${hiddenCount} available to add` : "All shown"}
|
||||||
onChange={(event) => setWidgetVisible(widget.id, event.currentTarget.checked)}
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Layout"
|
||||||
|
value={viewLabel}
|
||||||
|
tone="neutral"
|
||||||
|
detail={layoutSourceLabel(layoutSource)}
|
||||||
/>
|
/>
|
||||||
<span>
|
|
||||||
<strong>{widget.title}</strong>
|
|
||||||
<small>{widget.description ?? widget.id}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
|
|
||||||
{visibleWidgets.length === 0 ?
|
<LoadingFrame loading={loading} label="Loading Dashboard layout">
|
||||||
<Card title="No widgets selected">
|
{configuring ? (
|
||||||
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
|
<div className="dashboard-config-workspace">
|
||||||
</Card> :
|
<WidgetLibrary
|
||||||
<div className="dashboard-widget-grid">
|
widgets={libraryWidgets}
|
||||||
{visibleWidgets.map((widget) =>
|
atCapacity={draftLayout.placements.length >= MAX_DASHBOARD_WIDGETS}
|
||||||
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
|
query={libraryQuery}
|
||||||
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
|
onQueryChange={setLibraryQuery}
|
||||||
{widget.render({
|
onReset={() =>
|
||||||
settings,
|
setDraftLayout({
|
||||||
auth,
|
...defaultDashboardLayout(widgets),
|
||||||
modules,
|
revision: savedLayout.revision
|
||||||
widgetId: widget.id,
|
})
|
||||||
refreshKey,
|
|
||||||
size: widgetSize(widget)
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</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 {
|
function canUseWidget(
|
||||||
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false;
|
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;
|
if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false;
|
||||||
return true;
|
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 {
|
function moduleLabels(modules: Array<{ id: string }>): string {
|
||||||
if (!modules.length) return "Core shell only";
|
if (!modules.length) return "Core shell only";
|
||||||
return modules.slice(0, 4).map((module) => module.id).join(", ");
|
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.";
|
||||||
|
}
|
||||||
|
|||||||
201
webui/src/features/dashboard/WidgetConfigurationDialog.tsx
Normal file
201
webui/src/features/dashboard/WidgetConfigurationDialog.tsx
Normal 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);
|
||||||
|
}
|
||||||
99
webui/src/features/dashboard/WidgetLibrary.tsx
Normal file
99
webui/src/features/dashboard/WidgetLibrary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
webui/src/features/dashboard/dashboardEditorTypes.ts
Normal file
96
webui/src/features/dashboard/dashboardEditorTypes.ts
Normal 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);
|
||||||
|
}
|
||||||
445
webui/src/features/dashboard/dashboardLayout.ts
Normal file
445
webui/src/features/dashboard/dashboardLayout.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
@@ -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>;
|
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 (
|
return (
|
||||||
|
<>
|
||||||
<dl className="detail-list dashboard-compact-list">
|
<dl className="detail-list dashboard-compact-list">
|
||||||
{modules.map((module) =>
|
{visibleModules.map((module) =>
|
||||||
<div key={module.id}>
|
<div key={module.id}>
|
||||||
<dt><StatusBadge status="success" label={module.id} /></dt>
|
<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>
|
</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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,13 +10,48 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
widgets: [
|
widgets: [
|
||||||
{
|
{
|
||||||
id: "dashboard.installed-modules",
|
id: "dashboard.installed-modules",
|
||||||
|
surfaceId: "dashboard.widget.installed-modules",
|
||||||
title: "Installed modules",
|
title: "Installed modules",
|
||||||
description: "Enabled WebUI modules in this browser session.",
|
description: "Enabled WebUI modules in this browser session.",
|
||||||
moduleId: "dashboard",
|
moduleId: "dashboard",
|
||||||
category: "Platform",
|
category: "Platform",
|
||||||
order: 10,
|
order: 10,
|
||||||
defaultSize: "medium",
|
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"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
|
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
|
||||||
translations,
|
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 }],
|
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
|
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
|
||||||
@@ -43,4 +87,3 @@ export const dashboardModule: PlatformWebModule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default dashboardModule;
|
export default dashboardModule;
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,23 @@
|
|||||||
.dashboard-widget-grid {
|
.dashboard-widget-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
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;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-widget {
|
.dashboard-widget {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
align-self: start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-widget > .card {
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-widget-small {
|
.dashboard-widget-small {
|
||||||
@@ -32,39 +43,6 @@
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.dashboard-compact-list.detail-list div {
|
||||||
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
|
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -74,19 +52,306 @@
|
|||||||
margin-bottom: 0;
|
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) {
|
@media (max-width: 1120px) {
|
||||||
.dashboard-widget-grid {
|
.dashboard-widget-grid {
|
||||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
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) {
|
@media (max-width: 900px) {
|
||||||
.dashboard-widget-grid,
|
.dashboard-widget-grid,
|
||||||
.dashboard-widget-picker,
|
|
||||||
.dashboard-widget-metrics.metric-grid {
|
.dashboard-widget-metrics.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
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-small,
|
||||||
.dashboard-widget-medium,
|
.dashboard-widget-medium,
|
||||||
.dashboard-widget-wide,
|
.dashboard-widget-wide,
|
||||||
@@ -94,4 +359,3 @@
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
170
webui/tests/dashboard-layout.test.ts
Normal file
170
webui/tests/dashboard-layout.test.ts
Normal 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.");
|
||||||
Reference in New Issue
Block a user