15 Commits
Author SHA1 Message Date
zemion c2c7ba8fb2 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:18:12 +02:00
zemion ed5b96f197 Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion f282a2ae47 Harden module package publication 2026-08-04 14:02:40 +02:00
zemion 556433ebab Add protected package release workflow 2026-08-04 04:14:03 +02:00
zemion da3947f417 Migrate Dashboard interface patterns 2026-08-03 14:25:02 +02:00
zemion 4280e42110 docs: declare institutional architecture boundary 2026-08-01 17:48:26 +02:00
zemion 6ae82b30f8 Align WebUI runtime peer dependencies 2026-07-31 02:48:56 +02:00
zemion 49afc6d73d fix(dashboard): stabilize four-column widget placement 2026-07-30 15:36:59 +02:00
zemion f0732bead2 feat: add configurable view-aware dashboards 2026-07-29 14:32:54 +02:00
zemion b28229c003 fix: preserve dashboard heading flow 2026-07-28 22:50:10 +02:00
zemion 2250d69765 fix: make dashboard page scrollable 2026-07-28 22:13:19 +02:00
zemion 64032b1ed1 Filter dashboard widgets by View 2026-07-28 21:04:54 +02:00
zemion cfacc47ebb refactor(webui): use central widget selection 2026-07-21 12:34:39 +02:00
zemion 4a2d68b491 fix(release): align package version metadata 2026-07-21 12:06:39 +02:00
zemion a315a7c62b intermittent commit 2026-07-14 13:22:11 +02:00
40 changed files with 4486 additions and 221 deletions
+35
View File
@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/dashboard
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+27
View File
@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/dashboard
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?
+32
View File
@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/dashboard
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:
+28
View File
@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/dashboard
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:
+25
View File
@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/dashboard
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:
+15
View File
@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Dashboard Codex Guide
## Scope
This repository owns configurable dashboards, widget layouts, view-specific dashboard state, and module-announced widget rendering.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Dashboard internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Widgets never grant access and must preserve provider-side authorization.
- Consume widget contributions without importing optional module internals.
+25 -3
View File
@@ -10,6 +10,28 @@ The module owns the `/dashboard` route when installed. Core keeps only a minimal
fallback home for installations where this module is absent.
Modules contribute widgets through the `dashboard.widgets` WebUI capability.
The first implementation stores personal widget visibility in browser storage;
server-side layout persistence can be added later without changing the widget
contract.
Personal widget layouts are stored by the backend for each tenant, account, and
active View. Configure mode supports adding, removing, ordering, sizing, and
widget-specific settings. Existing browser-only layouts are used as a migration
fallback until the first server save.
Widget providers can declare supported sizes and a small configuration schema:
```ts
{
id: "example.summary",
surfaceId: "example.dashboard.summary",
title: "Example summary",
supportedSizes: ["small", "medium", "wide"],
defaultConfiguration: { limit: 10, showDetails: true },
configurationFields: [
{ id: "limit", label: "Rows", kind: "number", min: 1, max: 50 },
{ id: "showDetails", label: "Show details", kind: "boolean" }
],
render: ({ configuration }) => <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.
+34
View File
@@ -0,0 +1,34 @@
# Dashboard Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the
view-specific summary, widget library, four-column placement grid, and widget
configuration dialog.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/dashboard` | Personal workspace | Inspect current task context | Stable loading, fallback, empty, error, and help states |
| Summary | Metric overview | Inspect module/widget/layout state | Installed, available, placed, active View, and persistence source remain distinguishable |
| Widget library | Capability-filtered catalogue | Add or reset placements | Permission/View filtering, search, capacity reason, drag/click parity, and reversible defaults |
| Dashboard grid | Configurable composition | Reorder, resize, configure, or remove placement | Four-column placement contract, visible drag target, guarded draft, and no provider-data mutation |
| Widget settings | Nested definition editor | Apply presentation/query settings | Guarded local draft, contextual field help, validation reason, reset, apply, and discard |
## Consequence And Availability Rules
- Layouts are scoped by tenant, account, and active focused View. A focused
View changes the composition key but never grants access to widget data.
- Configure mode edits a local draft. Save persists the complete layout using
its revision; stale concurrent saves are rejected rather than overwritten.
- Removing a widget affects only its placement. Reset restores the defaults
contributed by active modules and remains reversible until Save layout.
- Widgets are filtered by installed module, focused View surface, and declared
scopes. Every provider still authorizes its own requests.
- When the saved layout is unreachable, the browser or module defaults are
clearly identified as a fallback. Saving promotes the current draft to the
account-backed layout.
Backend and WebUI manifests publish matching page, summary, library, grid,
settings, and built-in-widget surface identifiers. English and German
catalogues cover Dashboard-owned vocabulary, contextual help resolves from
manifest documentation, and both layout and widget-setting drafts are guarded.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.8",
"version": "0.1.15",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.15",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-dashboard"
version = "0.1.8"
version = "0.1.15"
description = "GovOPlaN configurable dashboard module."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-access>=0.1.8",
"govoplan-core>=0.1.15",
"govoplan-access>=0.1.15",
]
[tool.setuptools.packages.find]
+1 -2
View File
@@ -2,5 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.6"
__version__ = "0.1.15"
@@ -0,0 +1,3 @@
from govoplan_dashboard.backend.db.models import DashboardLayout
__all__ = ["DashboardLayout"]
@@ -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"]
+165 -3
View File
@@ -1,21 +1,125 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleManifest,
NavItem,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_dashboard.backend.db.models import DashboardLayout
def _dashboard_router(_context):
from govoplan_dashboard.backend.router import router
return router
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"dashboard_layouts": (
session.query(DashboardLayout)
.filter(DashboardLayout.tenant_id == tenant_id)
.count()
)
}
manifest = ModuleManifest(
id="dashboard",
name="Dashboard",
version="0.1.8",
version="0.1.15",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
route_factory=_dashboard_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id="dashboard",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
DashboardLayout,
label="Dashboard",
),
retirement_notes=(
"Destructive retirement removes personal Dashboard layouts after "
"the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
DashboardLayout,
label="Dashboard",
),
),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
frontend=FrontendModule(
module_id="dashboard",
package_name="@govoplan/dashboard-webui",
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
view_surfaces=(
ViewSurface(
id="dashboard.page",
module_id="dashboard",
kind="route",
label="Dashboard",
order=10,
),
ViewSurface(
id="dashboard.summary",
module_id="dashboard",
kind="section",
label="Dashboard summary",
parent_id="dashboard.page",
order=10,
),
ViewSurface(
id="dashboard.library",
module_id="dashboard",
kind="section",
label="Widget library",
parent_id="dashboard.page",
order=20,
),
ViewSurface(
id="dashboard.grid",
module_id="dashboard",
kind="section",
label="Dashboard grid",
parent_id="dashboard.page",
order=30,
),
ViewSurface(
id="dashboard.widget-settings",
module_id="dashboard",
kind="action",
label="Widget settings",
parent_id="dashboard.grid",
order=40,
),
ViewSurface(
id="dashboard.widget.installed-modules",
module_id="dashboard",
kind="section",
label="Installed modules widget",
parent_id="dashboard.grid",
order=10,
),
),
),
documentation=(
DocumentationTopic(
@@ -24,14 +128,72 @@ manifest = ModuleManifest(
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
body=(
"Core only provides a minimal fallback home when the dashboard module is absent. "
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly."
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. "
"Personal layouts are stored per tenant, account, and active View."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator"),
related_modules=("core", "ops"),
order=20,
metadata={
"help_contexts": [
"dashboard.page",
"dashboard.summary",
"dashboard.library",
"dashboard.grid",
"dashboard.state.browser-fallback",
"dashboard.state.view-specific",
],
},
),
DocumentationTopic(
id="dashboard.reference.layout-and-widgets",
title="Dashboard layouts and widget consequences",
summary="Per-user, per-tenant, and per-View widget placement, sizing, configuration, availability, and fallback semantics.",
body=(
"A Dashboard layout belongs to the active tenant, account, and focused View. "
"Configuring changes only a local draft until Save layout is selected; Cancel or "
"Discard restores the last saved arrangement. Widget removal removes only the placement, "
"not the module data represented by the widget. Reset restores the defaults announced by "
"currently active modules and remains reversible until save. A widget is offered only when "
"its module, focused View surface, and permission contract are available. Widgets never grant "
"access and providers must authorize every data request. If the server layout is unreachable, "
"the browser or module default can be displayed but is identified as a fallback until saved. "
"Concurrent saves use the layout revision and reject stale updates instead of overwriting them."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "module_admin"),
related_modules=("core", "views", "access", "ops"),
order=21,
metadata={
"help_contexts": [
"dashboard.action.configure",
"dashboard.action.save",
"dashboard.action.reset",
"dashboard.action.remove-widget",
"dashboard.field.widget-size",
"dashboard.field.widget-configuration",
],
"consequence_classes": {
"save_layout": "Persists the complete layout for the active tenant, account, and View revision context.",
"reset_layout": "Replaces the draft with current module defaults and remains reversible until save.",
"remove_widget": "Removes only the placement from the draft; provider data is unchanged.",
"configure_widget": "Changes presentation and query preferences for one widget placement.",
},
},
),
),
architecture=declared_module_architecture(
layer="data_reporting_integration",
kind="presentation",
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_dashboard_layouts.py",
known_limits=("Widget configuration depth and target accessibility evidence vary by contributing module.",),
owned_concepts=("dashboard layout", "widget placement", "view-specific dashboard"),
non_owned_concepts=("widget domain data", "report definition", "view policy"),
),
)
@@ -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
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.schemas import (
DashboardLayoutResponse,
DashboardLayoutUpdateRequest,
)
from govoplan_dashboard.backend.service import (
DashboardLayoutConflict,
DashboardLayoutLimitExceeded,
delete_dashboard_layout,
get_dashboard_layout,
normalize_view_id,
save_dashboard_layout,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _response(
layout: DashboardLayout | None,
*,
view_id: str | None,
) -> DashboardLayoutResponse:
if layout is None:
return DashboardLayoutResponse(
exists=False,
view_id=normalize_view_id(view_id),
)
return DashboardLayoutResponse(
exists=True,
view_id=layout.view_id,
layout_version=layout.layout_version,
revision=layout.revision,
placements=layout.placements,
known_widget_ids=layout.known_widget_ids,
updated_at=layout.updated_at,
)
@router.get("/layout", response_model=DashboardLayoutResponse)
def api_get_dashboard_layout(
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
return _response(
get_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
),
view_id=view_id,
)
@router.put("/layout", response_model=DashboardLayoutResponse)
def api_save_dashboard_layout(
payload: DashboardLayoutUpdateRequest,
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse:
try:
layout = save_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
expected_revision=payload.expected_revision,
layout_version=payload.layout_version,
placements=payload.placements,
known_widget_ids=payload.known_widget_ids,
)
session.commit()
except DashboardLayoutConflict as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
except DashboardLayoutLimitExceeded as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"The dashboard layout changed in another session. "
"Reload it before saving."
),
) from exc
return _response(layout, view_id=view_id)
@router.delete("/layout", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_dashboard_layout(
view_id: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
delete_dashboard_layout(
session,
tenant_id=principal.tenant_id,
account_id=principal.account_id,
view_id=view_id,
)
session.commit()
__all__ = ["router"]
+110
View File
@@ -0,0 +1,110 @@
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"
column_start: int | None = Field(default=None, ge=1, le=4)
configuration: dict[str, ConfigurationValue] = Field(
default_factory=dict,
max_length=40,
)
@model_validator(mode="after")
def validate_column_span(self) -> "DashboardWidgetPlacementPayload":
if self.column_start is None:
return self
span = {
"small": 1,
"medium": 2,
"wide": 3,
"full": 4,
}[self.size]
if self.column_start + span - 1 > 4:
raise ValueError(
f"A {self.size} widget cannot start in column "
f"{self.column_start} of a four-column Dashboard."
)
return self
@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
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy.orm import Session
from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.schemas import DashboardWidgetPlacementPayload
MAX_LAYOUT_CONTEXTS_PER_ACCOUNT = 100
class DashboardLayoutConflict(ValueError):
pass
class DashboardLayoutLimitExceeded(ValueError):
pass
def normalize_view_id(view_id: str | None) -> str | None:
normalized = (view_id or "").strip()
if not normalized:
return None
if len(normalized) > 255:
raise ValueError("Dashboard View ids may contain at most 255 characters.")
return normalized
def layout_context_key(view_id: str | None) -> str:
normalized = normalize_view_id(view_id)
return f"view:{normalized}" if normalized else "full"
def get_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
) -> DashboardLayout | None:
return (
session.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.account_id == account_id,
DashboardLayout.context_key == layout_context_key(view_id),
)
.one_or_none()
)
def save_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
expected_revision: int | None,
layout_version: int,
placements: Sequence[DashboardWidgetPlacementPayload],
known_widget_ids: Sequence[str],
) -> DashboardLayout:
normalized_view_id = normalize_view_id(view_id)
layout = get_dashboard_layout(
session,
tenant_id=tenant_id,
account_id=account_id,
view_id=normalized_view_id,
)
current_revision = layout.revision if layout is not None else 0
if (
expected_revision is not None
and expected_revision != current_revision
):
raise DashboardLayoutConflict(
"The dashboard layout changed in another session. Reload it before "
"saving."
)
if layout is None:
layout_count = (
session.query(DashboardLayout)
.filter(
DashboardLayout.tenant_id == tenant_id,
DashboardLayout.account_id == account_id,
)
.count()
)
if layout_count >= MAX_LAYOUT_CONTEXTS_PER_ACCOUNT:
raise DashboardLayoutLimitExceeded(
"At most 100 Dashboard layouts may be stored per account and tenant."
)
layout = DashboardLayout(
tenant_id=tenant_id,
account_id=account_id,
context_key=layout_context_key(normalized_view_id),
view_id=normalized_view_id,
layout_version=layout_version,
revision=1,
placements=[],
known_widget_ids=[],
)
session.add(layout)
else:
layout.layout_version = layout_version
layout.revision += 1
layout.placements = [
placement.model_dump(mode="json")
for placement in placements
]
layout.known_widget_ids = list(dict.fromkeys(known_widget_ids))
session.flush()
return layout
def delete_dashboard_layout(
session: Session,
*,
tenant_id: str,
account_id: str,
view_id: str | None,
) -> bool:
layout = get_dashboard_layout(
session,
tenant_id=tenant_id,
account_id=account_id,
view_id=view_id,
)
if layout is None:
return False
session.delete(layout)
session.flush()
return True
__all__ = [
"DashboardLayoutConflict",
"DashboardLayoutLimitExceeded",
"MAX_LAYOUT_CONTEXTS_PER_ACCOUNT",
"delete_dashboard_layout",
"get_dashboard_layout",
"layout_context_key",
"normalize_view_id",
"save_dashboard_layout",
]
+255
View File
@@ -0,0 +1,255 @@
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",
"column_start": 2,
"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"],
)
self.assertEqual(
2,
original.json()["placements"][0]["column_start"],
)
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_widget_span_must_fit_the_four_column_grid(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",
"column_start": 4,
"configuration": {},
}
],
"known_widget_ids": ["example.widget"],
},
)
self.assertEqual(422, response.status_code)
def test_legacy_placement_without_column_is_still_accepted(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": {},
}
],
"known_widget_ids": ["example.widget"],
},
)
self.assertEqual(200, response.status_code)
self.assertIsNone(response.json()["placements"][0]["column_start"])
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()
@@ -0,0 +1,62 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_dashboard.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
def test_surface_hierarchy_remains_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
self.assertEqual(
{
"dashboard.page",
"dashboard.summary",
"dashboard.library",
"dashboard.grid",
"dashboard.widget-settings",
"dashboard.widget.installed-modules",
},
set(surfaces),
)
self.assertEqual("dashboard.page", surfaces["dashboard.library"].parent_id)
self.assertEqual("dashboard.page", surfaces["dashboard.grid"].parent_id)
self.assertEqual("dashboard.grid", surfaces["dashboard.widget-settings"].parent_id)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
home = topics["dashboard.configurable-home"]
reference = topics["dashboard.reference.layout-and-widgets"]
self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"])
self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"])
self.assertIn("save_layout", reference.metadata["consequence_classes"])
self.assertIn("remove_widget", reference.metadata["consequence_classes"])
def test_webui_guards_layout_and_nested_widget_drafts(self) -> None:
page = (REPO_ROOT / "webui/src/features/dashboard/DashboardPage.tsx").read_text(
encoding="utf-8"
)
dialog = (
REPO_ROOT
/ "webui/src/features/dashboard/WidgetConfigurationDialog.tsx"
).read_text(encoding="utf-8")
library = (
REPO_ROOT / "webui/src/features/dashboard/WidgetLibrary.tsx"
).read_text(encoding="utf-8")
self.assertIn("DocumentationHelpLink", page)
self.assertIn("useUnsavedDraftGuard", page)
self.assertIn("useUnsavedDraftGuard", dialog)
self.assertIn("DASHBOARD_LAYOUT_DOCUMENTATION", dialog)
self.assertIn("disabled={atCapacity}", library)
if __name__ == "__main__":
unittest.main()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_dashboard.backend.manifest import get_manifest
class DashboardMigrationTests(unittest.TestCase):
def test_migration_creates_dashboard_layouts_and_head(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-dashboard-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'dashboard.db'}"
migrate_database(
database_url=url,
enabled_modules=("dashboard",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"7b9d2f4a6c8e",
set(
MigrationContext.configure(
connection
).get_current_heads()
),
)
self.assertEqual(
{"dashboard_layouts"},
{
name
for name in inspect(connection).get_table_names()
if name.startswith("dashboard_")
},
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+8 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/dashboard-webui",
"version": "0.1.8",
"version": "0.1.15",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,12 +13,15 @@
},
"./styles/dashboard.css": "./src/styles/dashboard.css"
},
"scripts": {
"test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.15",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
+54
View File
@@ -0,0 +1,54 @@
import {
apiFetch,
apiPath,
type ApiSettings,
type DashboardWidgetConfiguration,
type DashboardWidgetSize
} from "@govoplan/core-webui";
export type DashboardWidgetPlacementResponse = {
instance_id: string;
widget_id: string;
size: DashboardWidgetSize;
column_start?: number | null;
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)
});
}
@@ -0,0 +1,386 @@
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
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,
dashboardWidgetColumnSpan,
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" : ""}`}
data-dashboard-grid
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}
columnStart={item.columnStart}
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}
columnStart={placement.columnStart}
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}`}
style={dashboardColumnStyle(placement.columnStart)}
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,
columnStart,
preserveHeight = false,
onDragOver,
onDrop,
children
}: {
title: string;
size: DashboardWidgetPlacement["size"];
columnStart: number;
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(" ")}
style={dashboardColumnStyle(columnStart)}
onDragOver={onDragOver}
onDrop={onDrop}
aria-label={
`Place ${title} in column ${columnStart}, spanning `
+ `${dashboardWidgetColumnSpan(size)} columns`
}
>
{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 dashboardColumnStyle(columnStart: number): CSSProperties {
return {
"--dashboard-column-start": columnStart
} as CSSProperties;
}
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
});
}
+603 -140
View File
@@ -1,186 +1,649 @@
import { useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
import {
useEffect,
useMemo,
useState,
type DragEvent as ReactDragEvent
} from "react";
import {
RefreshCw,
Save,
SlidersHorizontal,
X
} from "lucide-react";
import {
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard,
PageScrollViewport,
PageTitle,
dashboardWidgetsForModules,
hasAnyScope,
hasScope,
isApiError,
useEffectiveView,
usePlatformModules,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo,
type DashboardWidgetContribution,
type DashboardWidgetSize
} from "@govoplan/core-webui";
import {
fetchDashboardLayout,
saveDashboardLayout
} from "../../api/dashboard";
import DashboardGrid from "./DashboardGrid";
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary";
import {
DASHBOARD_DOCUMENTATION,
DASHBOARD_I18N
} from "./interfacePatterns";
import {
appendPlacement,
DASHBOARD_COLUMN_COUNT,
dashboardWidgetColumnSpan,
defaultWidgetSize,
defaultDashboardLayout,
insertPlacement,
layoutFromResponse,
layoutUpdatePayload,
layoutsEqual,
localLayoutKey,
MAX_DASHBOARD_WIDGETS,
nextDashboardColumnStart,
readLocalLayout,
reconcileDashboardLayout,
removePlacement,
reorderPlacement,
updatePlacement,
writeLocalLayout,
type DashboardLayoutState,
type DashboardWidgetPlacement
} from "./dashboardLayout";
import type {
DashboardDragItem,
DashboardDropTarget
} from "./dashboardEditorTypes";
import { dashboardColumnStartFromPointer } from "./dashboardEditorTypes";
type DashboardLayout = {
visible: string[];
known: string[];
};
type LayoutSource = "server" | "browser" | "default";
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
export default function DashboardPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const modules = usePlatformModules();
const widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, modules]);
const widgetSignature = widgets.map((widget) => widget.id).join("|");
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`;
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets));
const effectiveView = useEffectiveView();
const widgets = useMemo(
() =>
dashboardWidgetsForModules(modules, effectiveView).filter((widget) =>
canUseWidget(auth, widget)
),
[auth, effectiveView, modules]
);
const widgetSignature = JSON.stringify(
widgets.map((widget) => ({
id: widget.id,
allowMultiple: widget.allowMultiple ?? false,
defaultVisible: widget.defaultVisible ?? true,
defaultSize: widget.defaultSize ?? "medium",
supportedSizes: widget.supportedSizes ?? [],
defaultConfiguration: widget.defaultConfiguration ?? {}
}))
);
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const accountId = auth.user.account_id || auth.user.id;
const viewId = effectiveView?.activeViewId ?? null;
const storageKey = localLayoutKey(tenantId, accountId, viewId);
const legacyStorageKey = viewId === null
? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}`
: null;
const initialLayout = useMemo(
() => defaultDashboardLayout(widgets),
[widgets]
);
const [savedLayout, setSavedLayout] = useState<DashboardLayoutState>(initialLayout);
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
const [layoutSource, setLayoutSource] = useState<LayoutSource>("default");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [configuring, setConfiguring] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [libraryQuery, setLibraryQuery] = useState("");
const [dragItem, setDragItem] = useState<DashboardDragItem | null>(null);
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(null);
useEffect(() => {
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
}, [storageKey, widgetSignature]);
let active = true;
setLoading(true);
setError("");
setNotice("");
setConfiguring(false);
setEditingInstanceId(null);
const visibleWidgetIds = useMemo(() => {
const known = new Set(layout.known);
const visible = new Set(layout.visible);
for (const widget of widgets) {
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id);
void fetchDashboardLayout(settings, viewId)
.then((response) => {
if (!active) return;
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
const source: LayoutSource = response.exists
? "server"
: local
? "browser"
: "default";
const base = response.exists
? layoutFromResponse(response)
: local
? { ...local, revision: 0 }
: defaultDashboardLayout(widgets);
const next = reconcileDashboardLayout(base, widgets);
setSavedLayout(next);
setDraftLayout(next);
setLayoutSource(source);
})
.catch((reason) => {
if (!active) return;
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
const next = reconcileDashboardLayout(
local ?? defaultDashboardLayout(widgets),
widgets
);
setSavedLayout(next);
setDraftLayout(next);
setLayoutSource(local ? "browser" : "default");
setError(
`The saved Dashboard layout could not be loaded. ${
local
? "The browser fallback is shown."
: "The module defaults are shown."
} ${errorMessage(reason)}`
);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [
legacyStorageKey,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
storageKey,
viewId,
widgetSignature
]);
const dirty = configuring && !layoutsEqual(savedLayout, draftLayout);
useUnsavedDraftGuard({
dirty,
onSave: persistLayout,
onDiscard: discardChanges,
title: "i18n:govoplan-dashboard.unsaved_layout_title",
message: "i18n:govoplan-dashboard.unsaved_layout_message"
});
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);
}
const availableIds = new Set(widgets.map((widget) => widget.id));
return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id));
}, [layout, widgets]);
const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id));
const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0);
function saveLayout(next: DashboardLayout) {
setLayout(next);
writeLayout(storageKey, next);
}
function setWidgetVisible(widgetId: string, visible: boolean) {
const allIds = widgets.map((widget) => widget.id);
const nextVisible = new Set(visibleWidgetIds);
if (visible) {
nextVisible.add(widgetId);
function discardChanges() {
setDraftLayout(savedLayout);
setConfiguring(false);
setEditingInstanceId(null);
setDragItem(null);
setDropTarget(null);
}
function beginConfiguration() {
setDraftLayout(savedLayout);
setConfiguring(true);
setError("");
setNotice("");
}
function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) {
setDraftLayout((current) => updater(current));
}
function addWidget(
widget: DashboardWidgetContribution,
columnStart?: number
) {
updateDraft((layout) => appendPlacement(layout, widget, columnStart));
}
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.5 ? "before" : "after";
const columnStart = dropColumnStart(event);
setDropTarget((current) =>
current?.kind === "placement"
&& current.instanceId === instanceId
&& current.edge === edge
&& current.columnStart === columnStart
? current
: { kind: "placement", instanceId, edge, columnStart }
);
}
function markEndDropTarget(event: ReactDragEvent<HTMLElement>) {
if (!dragItem) return;
event.preventDefault();
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
const columnStart = dropColumnStart(event);
setDropTarget((current) =>
current?.kind === "end" && current.columnStart === columnStart
? current
: { kind: "end", columnStart }
);
}
function draggedWidgetSize(): DashboardWidgetSize | null {
if (!dragItem) return null;
if (dragItem.kind === "placement") {
return draftLayout.placements.find(
(placement) => placement.instanceId === dragItem.instanceId
)?.size ?? null;
}
const widget = widgetById.get(dragItem.widgetId);
return widget ? defaultWidgetSize(widget) : null;
}
function dropColumnStart(
event: ReactDragEvent<HTMLElement>
): number {
const size = draggedWidgetSize();
if (!size) return 1;
const grid = event.currentTarget.closest<HTMLElement>(
"[data-dashboard-grid]"
);
if (!grid) return 1;
const styles = window.getComputedStyle(grid);
const visibleColumns = styles.gridTemplateColumns
.split(/\s+/)
.filter(Boolean).length;
if (visibleColumns !== 4) {
if (dragItem?.kind === "placement") {
return draftLayout.placements.find(
(placement) => placement.instanceId === dragItem.instanceId
)?.columnStart ?? 1;
}
return nextDashboardColumnStart(draftLayout.placements, size);
}
const bounds = grid.getBoundingClientRect();
return dashboardColumnStartFromPointer(
event.clientX,
bounds.left,
bounds.width,
Number.parseFloat(styles.columnGap),
dashboardWidgetColumnSpan(size),
DASHBOARD_COLUMN_COUNT
);
}
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";
const columnStart = dropTarget?.columnStart ?? target.columnStart;
if (dragItem.kind === "placement") {
updateDraft((layout) =>
reorderPlacement(
layout,
dragItem.instanceId,
target.instanceId,
edge,
columnStart
)
);
} else {
nextVisible.delete(widgetId);
const widget = widgetById.get(dragItem.widgetId);
if (widget) {
updateDraft((layout) =>
insertPlacement(
layout,
widget,
target.instanceId,
edge,
columnStart
)
);
}
}
saveLayout({
known: allIds,
visible: allIds.filter((id) => nextVisible.has(id))
});
finishDrag();
}
function resetLayout() {
saveLayout(defaultLayout(widgets));
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;
const columnStart = dropTarget?.columnStart;
if (dragItem.kind === "placement") {
const last = draftLayout.placements.at(-1);
if (last) {
updateDraft((layout) =>
reorderPlacement(
layout,
dragItem.instanceId,
last.instanceId,
"after",
columnStart
)
);
}
} else {
const widget = widgetById.get(dragItem.widgetId);
if (widget) addWidget(widget, columnStart);
}
finishDrag();
}
return (
<div className="content-pad workspace-data-page dashboard-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
<PageScrollViewport className="dashboard-page">
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
<p>Personal workspace assembled from installed module widgets.</p>
</div>
<div className="button-row compact-actions">
<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />
{!configuring && (
<>
<Button
onClick={() => setRefreshKey((value) => value + 1)}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<RefreshCw size={16} /> Refresh
</Button>
<Button
onClick={beginConfiguration}
disabled={loading}
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
>
<SlidersHorizontal size={16} /> Configure
</Button>
</>
)}
{configuring && (
<>
<Button
onClick={discardChanges}
disabled={saving}
disabledReason={saving ? DASHBOARD_I18N.saving : undefined}
>
<X size={16} /> Cancel
</Button>
<Button
variant="primary"
onClick={() => void persistLayout()}
disabled={saving || !dirty}
disabledReason={saving ? DASHBOARD_I18N.saving : !dirty ? DASHBOARD_I18N.noChanges : undefined}
>
<Save size={16} /> {saving ? "Saving..." : "Save layout"}
</Button>
</>
)}
</div>
</div>
<div className="button-row compact-actions">
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
</div>
</div>
<div className="metric-grid dashboard-summary-grid">
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} />
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" />
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
</div>
{configuring &&
<Card
title="Dashboard widgets"
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
<div className="dashboard-widget-picker">
{widgets.map((widget) =>
<label key={widget.id} className="dashboard-widget-toggle">
<input
type="checkbox"
checked={visibleWidgetIds.includes(widget.id)}
onChange={(event) => setWidgetVisible(widget.id, event.currentTarget.checked)}
/>
<span>
<strong>{widget.title}</strong>
<small>{widget.description ?? widget.id}</small>
</span>
</label>
)}
</div>
}
</Card>
}
{visibleWidgets.length === 0 ?
<Card title="No widgets selected">
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
</Card> :
<div className="dashboard-widget-grid">
{visibleWidgets.map((widget) =>
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
{widget.render({
settings,
auth,
modules,
widgetId: widget.id,
refreshKey,
size: widgetSize(widget)
})}
</Card>
</section>
{error && (
<DismissibleAlert tone="danger" resetKey={error} floating>
{error}
</DismissibleAlert>
)}
{notice && !error && (
<DismissibleAlert tone="success" resetKey={notice} floating>
{notice}
</DismissibleAlert>
)}
<div className="metric-grid dashboard-summary-grid">
<MetricCard
label="Installed modules"
value={modules.length}
tone="info"
detail={moduleLabels(modules)}
/>
<MetricCard
label="Available widgets"
value={widgets.length}
tone="neutral"
detail="Provided by active modules"
/>
<MetricCard
label="On dashboard"
value={renderedPlacements.length}
tone="good"
detail={hiddenCount ? `${hiddenCount} available to add` : "All shown"}
/>
<MetricCard
label="Layout"
value={viewLabel}
tone="neutral"
detail={layoutSourceLabel(layoutSource)}
/>
</div>
}
</div>);
<LoadingFrame loading={loading} label="Loading Dashboard layout">
{configuring ? (
<div className="dashboard-config-workspace">
<WidgetLibrary
widgets={libraryWidgets}
atCapacity={draftLayout.placements.length >= MAX_DASHBOARD_WIDGETS}
query={libraryQuery}
onQueryChange={setLibraryQuery}
onReset={() =>
setDraftLayout({
...defaultDashboardLayout(widgets),
revision: savedLayout.revision
})
}
onAdd={addWidget}
onDragStart={startDrag}
onDragEnd={finishDrag}
/>
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring
dragItem={dragItem}
dropTarget={dropTarget}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={(instanceId) =>
updateDraft((layout) => removePlacement(layout, instanceId))
}
onConfigure={setEditingInstanceId}
/>
</div>
) : (
<DashboardGrid
placements={renderedPlacements}
widgetById={widgetById}
settings={settings}
auth={auth}
modules={modules}
effectiveView={effectiveView}
refreshKey={refreshKey}
configuring={false}
dragItem={null}
dropTarget={null}
onDragStart={startDrag}
onDragEnd={finishDrag}
onDragOver={markDropTarget}
onDragOverEnd={markEndDropTarget}
onDrop={dropOnPlacement}
onDropPreview={dropOnPreview}
onDropAtEnd={dropAtEnd}
onRemove={() => undefined}
onConfigure={() => undefined}
/>
)}
</LoadingFrame>
</div>
<WidgetConfigurationDialog
open={Boolean(editingPlacement && editingWidget)}
widget={editingWidget}
placement={editingPlacement}
onClose={() => setEditingInstanceId(null)}
onSave={(placement) => {
updateDraft((layout) => updatePlacement(layout, placement));
setEditingInstanceId(null);
}}
/>
</PageScrollViewport>
);
}
function canUseWidget(auth: AuthInfo, widget: DashboardWidgetContribution): boolean {
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false;
function canUseWidget(
auth: AuthInfo,
widget: DashboardWidgetContribution
): boolean {
if (
widget.allOf?.length
&& !widget.allOf.every((scope) => hasScope(auth, scope))
) {
return false;
}
if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false;
return true;
}
function widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize {
return widget.defaultSize ?? "medium";
}
function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout {
const ids = widgets.map((widget) => widget.id);
return {
known: ids,
visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id)
};
}
function readLayout(storageKey: string): DashboardLayout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<DashboardLayout>;
if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null;
return {
visible: parsed.visible.filter((id): id is string => typeof id === "string"),
known: parsed.known.filter((id): id is string => typeof id === "string")
};
} catch {
return null;
}
}
function writeLayout(storageKey: string, layout: DashboardLayout): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(layout));
} catch {
// localStorage may be unavailable in restricted browsing contexts.
}
}
function moduleLabels(modules: Array<{ id: string }>): string {
if (!modules.length) return "Core shell only";
return modules.slice(0, 4).map((module) => module.id).join(", ");
}
function layoutSourceLabel(source: LayoutSource): string {
if (source === "server") return "Saved for your account";
if (source === "browser") return "Browser layout; save to synchronize";
return "Module defaults";
}
function errorMessage(error: unknown): string {
if (isApiError(error)) {
if (error.status === 409) {
return "This layout changed in another session. Reload the page before saving again.";
}
return error.message;
}
return error instanceof Error ? error.message : "The Dashboard request failed.";
}
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DocumentationHelpLink,
FormField,
SegmentedControl,
ToggleSwitch,
useUnsavedChanges,
useUnsavedDraftGuard,
type DashboardWidgetConfiguration,
type DashboardWidgetConfigurationField,
type DashboardWidgetConfigurationValue,
type DashboardWidgetContribution,
type DashboardWidgetSize
} from "@govoplan/core-webui";
import {
defaultWidgetSize,
supportedWidgetSizes,
type DashboardWidgetPlacement
} from "./dashboardLayout";
import {
DASHBOARD_I18N,
DASHBOARD_LAYOUT_DOCUMENTATION
} from "./interfacePatterns";
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 { requestDiscard } = useUnsavedChanges();
const [size, setSize] = useState<DashboardWidgetSize>("medium");
const [configuration, setConfiguration] = useState<DashboardWidgetConfiguration>({});
const savedConfiguration = useMemo(
() => ({
...(widget?.defaultConfiguration ?? {}),
...(placement?.configuration ?? {})
}),
[placement, widget]
);
const dirty = Boolean(
open
&& widget
&& placement
&& (
size !== placement.size
|| JSON.stringify(configuration) !== JSON.stringify(savedConfiguration)
)
);
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]
);
function discardDraft() {
if (!widget || !placement) return;
setSize(placement.size);
setConfiguration({
...(widget.defaultConfiguration ?? {}),
...placement.configuration
});
}
function applyDraft(): boolean {
if (!placement || invalidConfiguration) return false;
onSave({ ...placement, size, configuration });
return true;
}
useUnsavedDraftGuard({
dirty,
title: "i18n:govoplan-dashboard.unsaved_widget_title",
message: "i18n:govoplan-dashboard.unsaved_widget_message",
onSave: applyDraft,
onDiscard: discardDraft
});
function close() {
if (dirty) requestDiscard(onClose);
else onClose();
}
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={close}
className="dashboard-widget-config-dialog"
footer={
<>
<Button type="button" variant="ghost" onClick={reset}>Reset</Button>
<span className="dialog-footer-spacer" />
<Button type="button" onClick={close}>Cancel</Button>
<Button
type="button"
variant="primary"
disabled={invalidConfiguration}
disabledReason={invalidConfiguration ? DASHBOARD_I18N.invalidWidget : undefined}
onClick={applyDraft}
>
Apply
</Button>
</>
}
>
<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />
{supportedSizes.length > 1 && (
<FormField label="Widget size" documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<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} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<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} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<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} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<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 {
const columns = {
small: 1,
medium: 2,
wide: 3,
full: 4
}[size];
const label = size.charAt(0).toUpperCase() + size.slice(1);
return `${label} (${columns} ${columns === 1 ? "column" : "columns"})`;
}
@@ -0,0 +1,100 @@
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"
disabled={atCapacity}
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>
);
}
@@ -0,0 +1,145 @@
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";
columnStart: number;
}
| { kind: "end"; columnStart: number };
export type DashboardGridPreviewItem =
| {
kind: "placement";
placement: DashboardWidgetPlacement;
placeholder: boolean;
}
| {
kind: "catalogue";
widgetId: string;
size: DashboardWidgetSize;
columnStart: number;
};
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,
columnStart: dropTarget.columnStart
});
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,
columnStart: dropTarget.columnStart
},
placeholder: true
});
return preview;
}
export function dashboardColumnStartFromPointer(
pointerX: number,
gridLeft: number,
gridWidth: number,
columnGap: number,
columnSpan: number,
columnCount: number
): number {
const safeColumnCount =
Number.isFinite(columnCount) ? Math.max(1, Math.trunc(columnCount)) : 1;
const safeWidth =
Number.isFinite(gridWidth) && gridWidth > 0 ? gridWidth : 1;
const safeGap =
Number.isFinite(columnGap) ? Math.max(0, columnGap) : 0;
const columnWidth = Math.max(
1,
(
safeWidth
- safeGap * (safeColumnCount - 1)
) / safeColumnCount
);
const span =
Number.isFinite(columnSpan)
? Math.min(safeColumnCount, Math.max(1, Math.trunc(columnSpan)))
: 1;
const spanWidth = columnWidth * span + safeGap * (span - 1);
const stride = columnWidth + safeGap;
const maximumStart = safeColumnCount - span + 1;
let nearestStart = 1;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let columnStart = 1; columnStart <= maximumStart; columnStart += 1) {
const center =
gridLeft + (columnStart - 1) * stride + spanWidth / 2;
const distance = Math.abs(pointerX - center);
if (distance < nearestDistance) {
nearestStart = columnStart;
nearestDistance = distance;
}
}
return nearestStart;
}
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);
}
@@ -0,0 +1,585 @@
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;
columnStart: number;
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 DASHBOARD_COLUMN_COUNT = 4;
export const MAX_DASHBOARD_WIDGETS = 100;
export function createWidgetPlacement(
widget: DashboardWidgetContribution,
columnStart = 1
): DashboardWidgetPlacement {
const size = defaultWidgetSize(widget);
return {
instanceId: newInstanceId(),
widgetId: widget.id,
size,
columnStart: normalizeDashboardColumnStart(size, columnStart),
configuration: { ...(widget.defaultConfiguration ?? {}) }
};
}
export function defaultDashboardLayout(
widgets: DashboardWidgetContribution[]
): DashboardLayoutState {
const placements: DashboardWidgetPlacement[] = [];
for (const widget of widgets) {
if (
widget.defaultVisible === false
|| placements.length >= MAX_DASHBOARD_WIDGETS
) {
continue;
}
const size = defaultWidgetSize(widget);
placements.push(
createWidgetPlacement(
widget,
nextDashboardColumnStart(placements, size)
)
);
}
return {
layoutVersion: 1,
revision: 0,
knownWidgetIds: widgets.map((widget) => widget.id),
placements
};
}
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: DashboardWidgetPlacement[] = [];
for (const placement of layout.placements) {
if (!placement.instanceId || seenInstances.has(placement.instanceId)) continue;
const widget = widgetById.get(placement.widgetId);
if (widget && !widget.allowMultiple && seenWidgets.has(widget.id)) continue;
seenInstances.add(placement.instanceId);
seenWidgets.add(placement.widgetId);
placements.push(
normalizePlacement(
placement,
widget,
nextDashboardColumnStart(placements, placement.size)
)
);
}
const known = new Set(layout.knownWidgetIds);
for (const widget of widgets) {
if (
placements.length < MAX_DASHBOARD_WIDGETS
&& !known.has(widget.id)
&& widget.defaultVisible !== false
) {
const size = defaultWidgetSize(widget);
placements.push(
createWidgetPlacement(
widget,
nextDashboardColumnStart(placements, size)
)
);
}
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 {
const placements: DashboardWidgetPlacement[] = [];
for (const placement of response.placements) {
placements.push(
placementFromResponse(
placement,
nextDashboardColumnStart(placements, placement.size)
)
);
}
return {
layoutVersion: 1,
revision: response.revision,
knownWidgetIds: uniqueStrings(response.known_widget_ids),
placements
};
}
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,
columnStart?: number
): DashboardLayoutState {
if (
layout.placements.length >= MAX_DASHBOARD_WIDGETS
|| (
!widget.allowMultiple
&& layout.placements.some((placement) => placement.widgetId === widget.id)
)
) {
return layout;
}
const size = defaultWidgetSize(widget);
return {
...layout,
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]),
placements: [
...layout.placements,
createWidgetPlacement(
widget,
columnStart ?? nextDashboardColumnStart(layout.placements, size)
)
]
};
}
export function insertPlacement(
layout: DashboardLayoutState,
widget: DashboardWidgetContribution,
targetInstanceId: string,
edge: "before" | "after",
columnStart?: number
): 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, columnStart);
const insertionIndex = targetIndex + (edge === "after" ? 1 : 0);
const size = defaultWidgetSize(widget);
const fallbackColumnStart = edge === "before"
? layout.placements[targetIndex].columnStart
: nextDashboardColumnStart(
layout.placements.slice(0, insertionIndex),
size
);
return {
...layout,
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]),
placements: [
...layout.placements.slice(0, insertionIndex),
createWidgetPlacement(widget, columnStart ?? fallbackColumnStart),
...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 {
const normalized = {
...nextPlacement,
columnStart: normalizeDashboardColumnStart(
nextPlacement.size,
nextPlacement.columnStart
)
};
return {
...layout,
placements: layout.placements.map((placement) =>
placement.instanceId === nextPlacement.instanceId
? normalized
: placement
)
};
}
export function reorderPlacement(
layout: DashboardLayoutState,
sourceInstanceId: string,
targetInstanceId: string,
edge: "before" | "after",
columnStart?: number
): DashboardLayoutState {
const source = layout.placements.find(
(placement) => placement.instanceId === sourceInstanceId
);
if (!source) return layout;
const movedSource = {
...source,
columnStart: normalizeDashboardColumnStart(
source.size,
columnStart ?? source.columnStart
)
};
if (sourceInstanceId === targetInstanceId) {
return updatePlacement(layout, movedSource);
}
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),
movedSource,
...remaining.slice(insertionIndex)
]
};
}
export function dashboardWidgetColumnSpan(
size: DashboardWidgetSize
): number {
if (size === "small") return 1;
if (size === "medium") return 2;
if (size === "wide") return 3;
return DASHBOARD_COLUMN_COUNT;
}
export function normalizeDashboardColumnStart(
size: DashboardWidgetSize,
columnStart: unknown,
fallback = 1
): number {
const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1;
const candidate =
typeof columnStart === "number" && Number.isFinite(columnStart)
? Math.trunc(columnStart)
: fallback;
return Math.min(maximum, Math.max(1, candidate));
}
export function nextDashboardColumnStart(
placements: DashboardWidgetPlacement[],
size: DashboardWidgetSize
): number {
const previous = placements.at(-1);
if (!previous) return 1;
const candidate =
previous.columnStart + dashboardWidgetColumnSpan(previous.size);
const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1;
return candidate <= maximum ? candidate : 1;
}
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,
fallbackColumnStart: number
): DashboardWidgetPlacement {
const supported = widget ? supportedWidgetSizes(widget) : SIZES;
const size = supported.includes(placement.size)
? placement.size
: widget
? defaultWidgetSize(widget)
: "medium";
return {
...placement,
size,
columnStart: normalizeDashboardColumnStart(
size,
placement.columnStart,
fallbackColumnStart
),
configuration: {
...(widget?.defaultConfiguration ?? {}),
...placement.configuration
}
};
}
function placementFromResponse(
placement: DashboardWidgetPlacementResponse,
fallbackColumnStart: number
): DashboardWidgetPlacement {
return {
instanceId: placement.instance_id,
widgetId: placement.widget_id,
size: placement.size,
columnStart: normalizeDashboardColumnStart(
placement.size,
placement.column_start,
fallbackColumnStart
),
configuration: { ...placement.configuration }
};
}
function placementToResponse(
placement: DashboardWidgetPlacement
): DashboardWidgetPlacementResponse {
return {
instance_id: placement.instanceId,
widget_id: placement.widgetId,
size: placement.size,
column_start: placement.columnStart,
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: DashboardWidgetPlacement[] = [];
for (const item of candidate.placements) {
if (!item || typeof item !== "object") continue;
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)
) {
continue;
}
placements.push({
instanceId: placement.instanceId,
widgetId: placement.widgetId,
size: placement.size as DashboardWidgetSize,
columnStart: normalizeDashboardColumnStart(
placement.size as DashboardWidgetSize,
placement.columnStart,
nextDashboardColumnStart(
placements,
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]));
const placements: DashboardWidgetPlacement[] = [];
for (const widgetId of visible) {
const widget = widgetById.get(widgetId);
if (!widget) continue;
const size = defaultWidgetSize(widget);
placements.push(
createWidgetPlacement(
widget,
nextDashboardColumnStart(placements, size)
)
);
}
return {
layoutVersion: 1,
revision: 0,
knownWidgetIds: uniqueStrings(candidate.known),
placements
};
}
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)}`;
}
@@ -0,0 +1,18 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const DASHBOARD_DOCUMENTATION = {
topicId: "dashboard.configurable-home",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const DASHBOARD_LAYOUT_DOCUMENTATION = {
topicId: "dashboard.reference.layout-and-widgets",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const DASHBOARD_I18N = {
loading: "i18n:govoplan-dashboard.loading_reason",
saving: "i18n:govoplan-dashboard.saving_reason",
noChanges: "i18n:govoplan-dashboard.no_changes_reason",
invalidWidget: "i18n:govoplan-dashboard.invalid_widget_reason"
} as const;
@@ -1,16 +1,52 @@
import { StatusBadge, type PlatformWebModule } from "@govoplan/core-webui";
import {
StatusBadge,
type DashboardWidgetConfiguration,
type PlatformWebModule
} from "@govoplan/core-webui";
export default function InstalledModulesWidget({ modules }: { modules: PlatformWebModule[] }) {
export default function InstalledModulesWidget({
modules,
configuration
}: {
modules: PlatformWebModule[];
configuration: DashboardWidgetConfiguration;
}) {
if (!modules.length) return <p className="muted">No WebUI modules are active.</p>;
const maxItems = clampNumber(configuration.maxItems, 1, 50, 8);
const showVersions = configuration.showVersions !== false;
const sortBy = configuration.sortBy === "label" ? "label" : "module";
const visibleModules = [...modules]
.sort((left, right) => {
const leftValue = sortBy === "label" ? left.label : left.id;
const rightValue = sortBy === "label" ? right.label : right.id;
return String(leftValue).localeCompare(String(rightValue));
})
.slice(0, maxItems);
const remaining = Math.max(modules.length - visibleModules.length, 0);
return (
<dl className="detail-list dashboard-compact-list">
{modules.map((module) =>
<div key={module.id}>
<dt><StatusBadge status="success" label={module.id} /></dt>
<dd><strong>{module.label}</strong><span className="muted"> v{module.version}</span></dd>
</div>
)}
</dl>);
<>
<dl className="detail-list dashboard-compact-list">
{visibleModules.map((module) =>
<div key={module.id}>
<dt><StatusBadge status="success" label={module.id} /></dt>
<dd>
<strong>{module.label}</strong>
{showVersions && <span className="muted"> v{module.version}</span>}
</dd>
</div>
)}
</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)));
}
+71 -7
View File
@@ -1,9 +1,73 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
},
de: {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
}
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard",
"i18n:govoplan-dashboard.summary": "Dashboard summary",
"i18n:govoplan-dashboard.library": "Widget library",
"i18n:govoplan-dashboard.grid": "Dashboard grid",
"i18n:govoplan-dashboard.widget_settings": "Widget settings",
"i18n:govoplan-dashboard.installed_modules_widget": "Installed modules widget",
"i18n:govoplan-dashboard.loading_reason": "The saved Dashboard layout is still loading.",
"i18n:govoplan-dashboard.saving_reason": "The Dashboard layout is still being saved.",
"i18n:govoplan-dashboard.no_changes_reason": "There are no unsaved Dashboard changes.",
"i18n:govoplan-dashboard.invalid_widget_reason": "Complete the required widget settings with values inside their allowed ranges.",
"i18n:govoplan-dashboard.unsaved_layout_title": "Unsaved Dashboard layout",
"i18n:govoplan-dashboard.unsaved_layout_message": "Save or discard the Dashboard arrangement before leaving this page.",
"i18n:govoplan-dashboard.unsaved_widget_title": "Unapplied widget settings",
"i18n:govoplan-dashboard.unsaved_widget_message": "Apply or discard the widget settings before leaving this dialog.",
"Personal workspace assembled from installed module widgets.": "Personal workspace assembled from installed module widgets.",
"Refresh": "Refresh",
"Configure": "Configure",
"Cancel": "Cancel",
"Save layout": "Save layout",
"Saving...": "Saving...",
"Installed modules": "Installed modules",
"Available widgets": "Available widgets",
"On dashboard": "On dashboard",
"Layout": "Layout",
"Widget library": "Widget library",
"Search widgets": "Search widgets",
"Reset to module defaults": "Reset to module defaults",
"Widget size": "Widget size",
"Reset": "Reset",
"Apply": "Apply",
"Select an option": "Select an option",
"None": "None"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Übersicht",
"i18n:govoplan-dashboard.summary": "Übersichtszusammenfassung",
"i18n:govoplan-dashboard.library": "Widget-Bibliothek",
"i18n:govoplan-dashboard.grid": "Übersichtsraster",
"i18n:govoplan-dashboard.widget_settings": "Widget-Einstellungen",
"i18n:govoplan-dashboard.installed_modules_widget": "Widget für installierte Module",
"i18n:govoplan-dashboard.loading_reason": "Die gespeicherte Übersichtsanordnung wird noch geladen.",
"i18n:govoplan-dashboard.saving_reason": "Die Übersichtsanordnung wird noch gespeichert.",
"i18n:govoplan-dashboard.no_changes_reason": "Es gibt keine ungespeicherten Änderungen an der Übersicht.",
"i18n:govoplan-dashboard.invalid_widget_reason": "Füllen Sie die erforderlichen Widget-Einstellungen mit Werten innerhalb der erlaubten Bereiche aus.",
"i18n:govoplan-dashboard.unsaved_layout_title": "Ungespeicherte Übersichtsanordnung",
"i18n:govoplan-dashboard.unsaved_layout_message": "Speichern oder verwerfen Sie die Übersichtsanordnung, bevor Sie diese Seite verlassen.",
"i18n:govoplan-dashboard.unsaved_widget_title": "Nicht angewendete Widget-Einstellungen",
"i18n:govoplan-dashboard.unsaved_widget_message": "Wenden Sie die Widget-Einstellungen an oder verwerfen Sie sie, bevor Sie den Dialog verlassen.",
"Personal workspace assembled from installed module widgets.": "Persönlicher Arbeitsbereich aus Widgets der installierten Module.",
"Refresh": "Aktualisieren",
"Configure": "Konfigurieren",
"Cancel": "Abbrechen",
"Save layout": "Anordnung speichern",
"Saving...": "Speichert...",
"Installed modules": "Installierte Module",
"Available widgets": "Verfügbare Widgets",
"On dashboard": "Auf der Übersicht",
"Layout": "Anordnung",
"Widget library": "Widget-Bibliothek",
"Search widgets": "Widgets suchen",
"Reset to module defaults": "Auf Modulvorgaben zurücksetzen",
"Widget size": "Widget-Größe",
"Reset": "Zurücksetzen",
"Apply": "Anwenden",
"Select an option": "Option auswählen",
"None": "Keine"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+91 -3
View File
@@ -10,13 +10,48 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "dashboard.installed-modules",
surfaceId: "dashboard.widget.installed-modules",
title: "Installed modules",
description: "Enabled WebUI modules in this browser session.",
moduleId: "dashboard",
category: "Platform",
order: 10,
defaultSize: "medium",
render: ({ modules }) => createElement(InstalledModulesWidget, { modules })
supportedSizes: ["small", "medium", "wide"],
defaultConfiguration: {
maxItems: 8,
showVersions: true,
sortBy: "module"
},
configurationFields: [
{
id: "maxItems",
label: "Maximum modules",
description: "Limit how many active modules are listed.",
kind: "number",
min: 1,
max: 50,
step: 1,
required: true
},
{
id: "sortBy",
label: "Sort modules by",
kind: "select",
required: true,
options: [
{ value: "module", label: "Module id" },
{ value: "label", label: "Display name" }
]
},
{
id: "showVersions",
label: "Show module versions",
kind: "boolean"
}
],
render: ({ modules, configuration }) =>
createElement(InstalledModulesWidget, { modules, configuration })
}
]
};
@@ -33,9 +68,63 @@ export const dashboardModule: PlatformWebModule = {
dependencies: ["access"],
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
translations,
viewSurfaces: [
{
id: "dashboard.page",
moduleId: "dashboard",
kind: "route",
label: "i18n:govoplan-dashboard.dashboard.3f8b4df2",
order: 10
},
{
id: "dashboard.summary",
moduleId: "dashboard",
kind: "section",
label: "i18n:govoplan-dashboard.summary",
parentId: "dashboard.page",
order: 10
},
{
id: "dashboard.library",
moduleId: "dashboard",
kind: "section",
label: "i18n:govoplan-dashboard.library",
parentId: "dashboard.page",
order: 20
},
{
id: "dashboard.grid",
moduleId: "dashboard",
kind: "section",
label: "i18n:govoplan-dashboard.grid",
parentId: "dashboard.page",
order: 30
},
{
id: "dashboard.widget-settings",
moduleId: "dashboard",
kind: "action",
label: "i18n:govoplan-dashboard.widget_settings",
parentId: "dashboard.grid",
order: 40
},
{
id: "dashboard.widget.installed-modules",
moduleId: "dashboard",
kind: "section",
label: "i18n:govoplan-dashboard.installed_modules_widget",
parentId: "dashboard.grid",
order: 10
}
],
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
routes: [
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
{
path: "/dashboard",
order: 10,
surfaceId: "dashboard.page",
render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })
}
],
uiCapabilities: {
"dashboard.widgets": dashboardWidgets
@@ -43,4 +132,3 @@ export const dashboardModule: PlatformWebModule = {
};
export default dashboardModule;
+317 -40
View File
@@ -11,58 +11,40 @@
.dashboard-widget-grid {
display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 18px;
grid-auto-flow: row dense;
grid-auto-rows: 4px;
column-gap: 18px;
row-gap: 0;
align-items: start;
}
.dashboard-widget {
min-width: 0;
position: relative;
align-self: start;
margin-bottom: 18px;
grid-column-start: var(--dashboard-column-start, auto);
}
.dashboard-widget > .card {
height: auto;
margin-bottom: 0;
}
.dashboard-widget-small {
grid-column: span 1;
grid-column-end: span 1;
}
.dashboard-widget-medium {
grid-column: span 2;
grid-column-end: span 2;
}
.dashboard-widget-wide {
grid-column-end: span 3;
}
.dashboard-widget-wide,
.dashboard-widget-full {
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;
grid-column-end: span 4;
}
.dashboard-compact-list.detail-list div {
@@ -74,19 +56,315 @@
margin-bottom: 0;
}
.dashboard-config-workspace {
display: flex;
min-width: 0;
flex-direction: column;
gap: 18px;
}
.dashboard-widget-library {
display: grid;
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) 36px;
align-items: center;
min-width: 0;
min-height: 84px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
.dashboard-widget-library-controls {
align-self: stretch;
display: grid;
align-content: center;
gap: 7px;
min-width: 0;
padding: 9px 12px;
border-right: var(--border-line);
background: var(--panel-header);
}
.dashboard-widget-library-header {
display: flex;
align-items: center;
}
.dashboard-widget-library-header h2 {
margin: 0;
font-size: 16px;
}
.dashboard-widget-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 0;
margin: 0;
padding: 4px 8px;
border: var(--border-line);
border-radius: 5px;
background: var(--surface);
}
.dashboard-widget-search:focus-within {
box-shadow: var(--accent-ring-soft);
}
.dashboard-widget-search input[type="search"] {
height: auto;
min-height: 0;
min-width: 0;
padding: 1px 0;
border: 0;
box-shadow: none;
font: inherit;
font-size: 13px;
line-height: 1.35;
}
.dashboard-widget-library-list {
display: flex;
min-width: 0;
align-self: stretch;
overflow-x: auto;
overflow-y: hidden;
border-right: var(--border-line);
}
.dashboard-widget-library-item {
flex: 0 0 min(250px, 32vw);
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 36px;
align-items: center;
gap: 10px;
min-height: 70px;
padding: 9px 10px 9px 12px;
border-right: var(--border-line);
cursor: grab;
}
.dashboard-widget-library-item:hover {
background: var(--hover-tint-soft);
}
.dashboard-widget-library-item:active {
cursor: grabbing;
}
.dashboard-widget-library-item.is-disabled {
cursor: default;
opacity: .7;
}
.dashboard-widget-library-item > div {
display: grid;
min-width: 0;
gap: 3px;
}
.dashboard-widget-library-item strong,
.dashboard-widget-library-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-widget-library-item span {
color: var(--muted);
font-size: 12px;
}
.dashboard-widget-library-empty {
flex: 1 0 auto;
margin: 0;
padding: 18px 16px;
}
.dashboard-widget-grid.is-configuring .dashboard-widget {
cursor: default;
}
.dashboard-widget-grid.is-configuring .card-header {
align-items: center;
flex-wrap: nowrap;
gap: 6px;
padding: 12px 16px;
}
.dashboard-widget-grid.is-configuring .card-header > h2 {
order: 2;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-widget-grid.is-configuring .card-actions {
display: contents;
}
.dashboard-widget-actions {
display: contents;
}
.dashboard-widget-actions > * {
order: 3;
}
.dashboard-widget-drag-handle {
order: 1;
flex: 0 0 auto;
cursor: grab;
}
.dashboard-widget-drag-handle:active {
cursor: grabbing;
}
.dashboard-widget-drop-placeholder {
min-height: 142px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-hover-bg);
color: var(--text);
}
.dashboard-widget-drop-placeholder.preserves-widget-height {
border: 0;
background: transparent;
}
.dashboard-widget-placeholder-content {
visibility: hidden;
}
.dashboard-widget-placeholder-surface {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 142px;
padding: 16px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-hover-bg);
color: var(--text);
text-align: center;
pointer-events: none;
}
.dashboard-grid-end-drop-target,
.dashboard-empty-state.is-drop-target {
display: grid;
min-height: 74px;
place-items: center;
border: 2px dashed var(--line-dark);
border-radius: var(--radius);
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.dashboard-grid-end-drop-target {
grid-column: 1 / -1;
grid-row-end: span 19;
}
.dashboard-grid-end-drop-target:hover,
.dashboard-empty-state.is-drop-target:hover {
border-color: var(--accent);
background: var(--accent-hover-bg);
color: var(--text);
}
.dashboard-empty-state {
min-height: 220px;
padding: 32px;
text-align: center;
}
.dashboard-empty-state h2 {
margin: 0 0 8px;
font-size: 18px;
}
.dashboard-empty-state p {
margin: 0;
}
.dashboard-widget-config-dialog {
width: min(620px, 100%);
}
.dashboard-widget-config-dialog .dialog-body {
display: grid;
gap: 18px;
}
.dashboard-widget-config-dialog .dialog-footer-spacer {
flex: 1 1 auto;
}
.dashboard-widget-overflow {
margin: 12px 0 0;
}
@media (max-width: 1120px) {
.dashboard-widget-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.dashboard-widget {
grid-column-start: auto;
}
.dashboard-widget-wide,
.dashboard-widget-full {
grid-column: 1 / -1;
}
.dashboard-widget-library {
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px;
}
}
@media (max-width: 900px) {
.dashboard-widget-grid,
.dashboard-widget-picker,
.dashboard-widget-metrics.metric-grid {
grid-template-columns: 1fr;
}
.dashboard-widget-library {
grid-template-columns: minmax(0, 1fr) 36px;
}
.dashboard-widget-library-controls {
grid-column: 1;
grid-row: 1;
border-right: 0;
}
.dashboard-widget-library-list {
grid-column: 1 / -1;
grid-row: 2;
min-height: 70px;
border-top: var(--border-line);
border-right: 0;
border-left: 0;
}
.dashboard-widget-library-item {
flex-basis: min(250px, 72vw);
}
.dashboard-widget-small,
.dashboard-widget-medium,
.dashboard-widget-wide,
@@ -94,4 +372,3 @@
grid-column: 1;
}
}
+226
View File
@@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import {
appendPlacement,
dashboardMasonryRowSpan,
dashboardWidgetColumnSpan,
defaultDashboardLayout,
layoutUpdatePayload,
normalizeDashboardColumnStart,
reconcileDashboardLayout,
removePlacement,
reorderPlacement
} from "../src/features/dashboard/dashboardLayout.ts";
import {
dashboardColumnStartFromPointer,
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.equal(defaults.placements[0].columnStart, 1);
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(
withTwo.placements[1].columnStart,
2,
"new widgets should use the next available horizontal position"
);
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",
3
);
assert.deepEqual(
reordered.placements.map((placement) => placement.widgetId),
["two", "one"]
);
assert.equal(
reordered.placements[0].columnStart,
3,
"moving a widget must change its column without moving other columns"
);
assert.equal(reordered.placements[1].columnStart, 1);
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",
columnStart: 3
}
);
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"
);
assert.equal(
placementPreview[1].kind === "placement"
? placementPreview[1].placement.columnStart
: null,
3,
"placement previews must show the prospective column"
);
const cataloguePreview = dashboardGridPreview(
withTwo.placements,
{ kind: "catalogue", widgetId: "three" },
{
kind: "placement",
instanceId: withTwo.placements[0].instanceId,
edge: "after",
columnStart: 2
},
"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"
);
assert.equal(
cataloguePreview[1].kind === "catalogue"
? cataloguePreview[1].columnStart
: null,
2,
"catalogue previews must reserve the selected horizontal position"
);
const unavailablePlacement = {
instanceId: "unavailable-instance",
widgetId: "temporarily-disabled",
size: "wide" as const,
columnStart: 2,
configuration: { mode: "summary" }
};
const reconciled = reconcileDashboardLayout(
{
layoutVersion: 1,
revision: 4,
knownWidgetIds: ["temporarily-disabled"],
placements: [unavailablePlacement]
},
widgets
);
assert.equal(
reconciled.placements[0].columnStart,
2,
"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].column_start, 2);
assert.equal(payload.placements[0].configuration.mode, "summary");
assert.equal(dashboardWidgetColumnSpan("small"), 1);
assert.equal(dashboardWidgetColumnSpan("medium"), 2);
assert.equal(dashboardWidgetColumnSpan("wide"), 3);
assert.equal(dashboardWidgetColumnSpan("full"), 4);
assert.equal(
normalizeDashboardColumnStart("medium", 4),
3,
"column starts must be clamped so the widget stays in the four-column grid"
);
assert.equal(
dashboardColumnStartFromPointer(500, 0, 1000, 20, 2, 4),
2,
"a two-column widget must be placeable in the middle columns"
);
assert.equal(
dashboardColumnStartFromPointer(950, 0, 1000, 20, 3, 4),
2,
"a three-column widget must be placeable from column two"
);
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.");