17 Commits
Author SHA1 Message Date
zemion 77924e7a2c Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:00 +02:00
zemion 61333c08e9 fix: clarify active interface module count 2026-08-05 00:03:31 +02:00
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 4497 additions and 224 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.
+31 -3
View File
@@ -10,6 +10,34 @@ The module owns the `/dashboard` route when installed. Core keeps only a minimal
fallback home for installations where this module is absent. fallback home for installations where this module is absent.
Modules contribute widgets through the `dashboard.widgets` WebUI capability. Modules contribute widgets through the `dashboard.widgets` WebUI capability.
The first implementation stores personal widget visibility in browser storage; Personal widget layouts are stored by the backend for each tenant, account, and
server-side layout persistence can be added later without changing the widget active View. Configure mode supports adding, removing, ordering, sizing, and
contract. widget-specific settings. Existing browser-only layouts are used as a migration
fallback until the first server save.
The Dashboard's **Active interface modules** metric and widget count only
tenant-enabled modules whose WebUI is loaded in the current browser session.
The Administration module catalogue has a different operator-facing scope: it
also includes discovered backend-only and headless module manifests. The two
figures therefore need not match.
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", "name": "@govoplan/dashboard-webui",
"version": "0.1.8", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,11 +18,11 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.16",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
+3 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-dashboard" name = "govoplan-dashboard"
version = "0.1.8" version = "0.1.16"
description = "GovOPlaN configurable dashboard module." description = "GovOPlaN configurable dashboard module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.16",
"govoplan-access>=0.1.8", "govoplan-access>=0.1.16",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+1 -2
View File
@@ -2,5 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.6" __version__ = "0.1.16"
@@ -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"]
+167 -3
View File
@@ -1,21 +1,125 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleManifest,
NavItem,
)
from govoplan_core.core.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( manifest = ModuleManifest(
id="dashboard", id="dashboard",
name="Dashboard", name="Dashboard",
version="0.1.8", version="0.1.16",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"), optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
route_factory=_dashboard_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id="dashboard",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
DashboardLayout,
label="Dashboard",
),
retirement_notes=(
"Destructive retirement removes personal Dashboard layouts after "
"the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
DashboardLayout,
label="Dashboard",
),
),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
frontend=FrontendModule( frontend=FrontendModule(
module_id="dashboard", module_id="dashboard",
package_name="@govoplan/dashboard-webui", package_name="@govoplan/dashboard-webui",
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),), routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
view_surfaces=(
ViewSurface(
id="dashboard.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="Active interface modules widget",
parent_id="dashboard.grid",
order=10,
),
),
), ),
documentation=( documentation=(
DocumentationTopic( DocumentationTopic(
@@ -24,14 +128,74 @@ manifest = ModuleManifest(
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.", summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
body=( body=(
"Core only provides a minimal fallback home when the dashboard module is absent. " "Core only provides a minimal fallback home when the dashboard module is absent. "
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly." "Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. "
"Personal layouts are stored per tenant, account, and active View. The active interface-module count includes only "
"tenant-enabled modules whose WebUI loaded in the current browser session. It intentionally differs from the "
"Administration package catalogue, which includes discovered backend-only and headless module manifests."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator"), audience=("user", "tenant_admin", "operator"),
related_modules=("core", "ops"), related_modules=("core", "ops"),
order=20, 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", "name": "@govoplan/dashboard-webui",
"version": "0.1.8", "version": "0.1.16",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,12 +13,15 @@
}, },
"./styles/dashboard.css": "./src/styles/dashboard.css" "./styles/dashboard.css": "./src/styles/dashboard.css"
}, },
"scripts": {
"test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.16",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": ">=19.2.7 <20",
"react-dom": "^19.0.0", "react-dom": ">=19.2.7 <20",
"react-router-dom": "^7.1.1" "react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@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 {
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react"; useEffect,
useMemo,
useState,
type DragEvent as ReactDragEvent
} from "react";
import {
RefreshCw,
Save,
SlidersHorizontal,
X
} from "lucide-react";
import { import {
Button, Button,
Card, DismissibleAlert,
DocumentationHelpLink,
LoadingFrame,
MetricCard, MetricCard,
PageScrollViewport,
PageTitle, PageTitle,
dashboardWidgetsForModules, dashboardWidgetsForModules,
hasAnyScope, hasAnyScope,
hasScope, hasScope,
isApiError,
useEffectiveView,
usePlatformModules, usePlatformModules,
useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
type DashboardWidgetContribution, type DashboardWidgetContribution,
type DashboardWidgetSize type DashboardWidgetSize
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import {
fetchDashboardLayout,
saveDashboardLayout
} from "../../api/dashboard";
import DashboardGrid from "./DashboardGrid";
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
import WidgetLibrary from "./WidgetLibrary";
import {
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 = { type LayoutSource = "server" | "browser" | "default";
visible: string[];
known: string[];
};
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { export default function DashboardPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const modules = usePlatformModules(); const modules = usePlatformModules();
const widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, modules]); const effectiveView = useEffectiveView();
const widgetSignature = widgets.map((widget) => widget.id).join("|"); const widgets = useMemo(
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`; () =>
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets)); dashboardWidgetsForModules(modules, effectiveView).filter((widget) =>
canUseWidget(auth, widget)
),
[auth, effectiveView, modules]
);
const widgetSignature = JSON.stringify(
widgets.map((widget) => ({
id: widget.id,
allowMultiple: widget.allowMultiple ?? false,
defaultVisible: widget.defaultVisible ?? true,
defaultSize: widget.defaultSize ?? "medium",
supportedSizes: widget.supportedSizes ?? [],
defaultConfiguration: widget.defaultConfiguration ?? {}
}))
);
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const accountId = auth.user.account_id || auth.user.id;
const viewId = effectiveView?.activeViewId ?? null;
const storageKey = localLayoutKey(tenantId, accountId, viewId);
const legacyStorageKey = viewId === null
? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}`
: null;
const initialLayout = useMemo(
() => defaultDashboardLayout(widgets),
[widgets]
);
const [savedLayout, setSavedLayout] = useState<DashboardLayoutState>(initialLayout);
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
const [layoutSource, setLayoutSource] = useState<LayoutSource>("default");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [configuring, setConfiguring] = useState(false); const [configuring, setConfiguring] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [libraryQuery, setLibraryQuery] = useState("");
const [dragItem, setDragItem] = useState<DashboardDragItem | null>(null);
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setLayout(readLayout(storageKey) ?? defaultLayout(widgets)); let active = true;
}, [storageKey, widgetSignature]); setLoading(true);
setError("");
setNotice("");
setConfiguring(false);
setEditingInstanceId(null);
const visibleWidgetIds = useMemo(() => { void fetchDashboardLayout(settings, viewId)
const known = new Set(layout.known); .then((response) => {
const visible = new Set(layout.visible); if (!active) return;
for (const widget of widgets) { const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id); const source: LayoutSource = response.exists
? "server"
: 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) { function discardChanges() {
const allIds = widgets.map((widget) => widget.id); setDraftLayout(savedLayout);
const nextVisible = new Set(visibleWidgetIds); setConfiguring(false);
if (visible) { setEditingInstanceId(null);
nextVisible.add(widgetId); 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 { } else {
nextVisible.delete(widgetId); const widget = widgetById.get(dragItem.widgetId);
if (widget) {
updateDraft((layout) =>
insertPlacement(
layout,
widget,
target.instanceId,
edge,
columnStart
)
);
}
} }
saveLayout({ finishDrag();
known: allIds,
visible: allIds.filter((id) => nextVisible.has(id))
});
} }
function resetLayout() { function dropOnPreview(event: ReactDragEvent<HTMLElement>) {
saveLayout(defaultLayout(widgets)); 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 ( return (
<div className="content-pad workspace-data-page dashboard-page"> <PageScrollViewport className="dashboard-page">
<div className="page-heading split workspace-heading"> <div className="content-pad workspace-data-page">
<div> <div className="page-heading split workspace-heading">
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle> <div>
<p>Personal workspace assembled from installed module widgets.</p> <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>
<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"> {error && (
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} /> <DismissibleAlert tone="danger" resetKey={error} floating>
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" /> {error}
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} /> </DismissibleAlert>
<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>
)} )}
{notice && !error && (
<DismissibleAlert tone="success" resetKey={notice} floating>
{notice}
</DismissibleAlert>
)}
<div className="metric-grid dashboard-summary-grid">
<MetricCard
label="Active interface 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>
}
</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 { function canUseWidget(
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false; auth: AuthInfo,
widget: DashboardWidgetContribution
): boolean {
if (
widget.allOf?.length
&& !widget.allOf.every((scope) => hasScope(auth, scope))
) {
return false;
}
if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false; if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false;
return true; return true;
} }
function widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize {
return widget.defaultSize ?? "medium";
}
function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout {
const ids = widgets.map((widget) => widget.id);
return {
known: ids,
visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id)
};
}
function readLayout(storageKey: string): DashboardLayout | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<DashboardLayout>;
if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null;
return {
visible: parsed.visible.filter((id): id is string => typeof id === "string"),
known: parsed.known.filter((id): id is string => typeof id === "string")
};
} catch {
return null;
}
}
function writeLayout(storageKey: string, layout: DashboardLayout): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(storageKey, JSON.stringify(layout));
} catch {
// localStorage may be unavailable in restricted browsing contexts.
}
}
function moduleLabels(modules: Array<{ id: string }>): string { function moduleLabels(modules: Array<{ id: string }>): string {
if (!modules.length) return "Core shell only"; if (!modules.length) return "Core shell only";
return modules.slice(0, 4).map((module) => module.id).join(", "); return modules.slice(0, 4).map((module) => module.id).join(", ");
} }
function layoutSourceLabel(source: LayoutSource): string {
if (source === "server") return "Saved for your account";
if (source === "browser") return "Browser layout; save to synchronize";
return "Module defaults";
}
function errorMessage(error: unknown): string {
if (isApiError(error)) {
if (error.status === 409) {
return "This layout changed in another session. Reload the page before saving again.";
}
return error.message;
}
return error instanceof Error ? error.message : "The Dashboard request failed.";
}
@@ -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({
if (!modules.length) return <p className="muted">No WebUI modules are active.</p>; modules,
configuration
}: {
modules: PlatformWebModule[];
configuration: DashboardWidgetConfiguration;
}) {
if (!modules.length) return <p className="muted">No interface modules are active.</p>;
const maxItems = clampNumber(configuration.maxItems, 1, 50, 8);
const showVersions = configuration.showVersions !== false;
const sortBy = configuration.sortBy === "label" ? "label" : "module";
const visibleModules = [...modules]
.sort((left, right) => {
const leftValue = sortBy === "label" ? left.label : left.id;
const rightValue = sortBy === "label" ? right.label : right.id;
return String(leftValue).localeCompare(String(rightValue));
})
.slice(0, maxItems);
const remaining = Math.max(modules.length - visibleModules.length, 0);
return ( return (
<dl className="detail-list dashboard-compact-list"> <>
{modules.map((module) => <dl className="detail-list dashboard-compact-list">
<div key={module.id}> {visibleModules.map((module) =>
<dt><StatusBadge status="success" label={module.id} /></dt> <div key={module.id}>
<dd><strong>{module.label}</strong><span className="muted"> v{module.version}</span></dd> <dt><StatusBadge status="success" label={module.id} /></dt>
</div> <dd>
)} <strong>{module.label}</strong>
</dl>); {showVersions && <span className="muted"> v{module.version}</span>}
</dd>
</div>
)}
</dl>
{remaining > 0 && <p className="muted dashboard-widget-overflow">+{remaining} more active interface 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 = { import type { PlatformTranslations } from "@govoplan/core-webui";
en: {
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" const en = {
}, "i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard",
de: { "i18n:govoplan-dashboard.summary": "Dashboard summary",
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard" "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": "Active interface 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...",
"Active interface modules": "Active interface 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 aktive Oberflächenmodule",
"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...",
"Active interface modules": "Aktive Oberflächenmodule",
"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 };
+93 -5
View File
@@ -10,13 +10,48 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [ widgets: [
{ {
id: "dashboard.installed-modules", id: "dashboard.installed-modules",
title: "Installed modules", surfaceId: "dashboard.widget.installed-modules",
description: "Enabled WebUI modules in this browser session.", title: "Active interface modules",
description: "Tenant-enabled modules with a loaded WebUI in this browser session.",
moduleId: "dashboard", moduleId: "dashboard",
category: "Platform", category: "Platform",
order: 10, order: 10,
defaultSize: "medium", defaultSize: "medium",
render: ({ modules }) => createElement(InstalledModulesWidget, { modules }) supportedSizes: ["small", "medium", "wide"],
defaultConfiguration: {
maxItems: 8,
showVersions: true,
sortBy: "module"
},
configurationFields: [
{
id: "maxItems",
label: "Maximum modules",
description: "Limit how many active modules are listed.",
kind: "number",
min: 1,
max: 50,
step: 1,
required: true
},
{
id: "sortBy",
label: "Sort modules by",
kind: "select",
required: true,
options: [
{ value: "module", label: "Module id" },
{ value: "label", label: "Display name" }
]
},
{
id: "showVersions",
label: "Show module versions",
kind: "boolean"
}
],
render: ({ modules, configuration }) =>
createElement(InstalledModulesWidget, { modules, configuration })
} }
] ]
}; };
@@ -33,9 +68,63 @@ export const dashboardModule: PlatformWebModule = {
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"], optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
translations, translations,
viewSurfaces: [
{
id: "dashboard.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 }], navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
routes: [ routes: [
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) } {
path: "/dashboard",
order: 10,
surfaceId: "dashboard.page",
render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })
}
], ],
uiCapabilities: { uiCapabilities: {
"dashboard.widgets": dashboardWidgets "dashboard.widgets": dashboardWidgets
@@ -43,4 +132,3 @@ export const dashboardModule: PlatformWebModule = {
}; };
export default dashboardModule; export default dashboardModule;
+317 -40
View File
@@ -11,58 +11,40 @@
.dashboard-widget-grid { .dashboard-widget-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(220px, 1fr)); grid-template-columns: repeat(4, minmax(220px, 1fr));
gap: 18px; grid-auto-flow: row dense;
grid-auto-rows: 4px;
column-gap: 18px;
row-gap: 0;
align-items: start; align-items: start;
} }
.dashboard-widget { .dashboard-widget {
min-width: 0; min-width: 0;
position: relative;
align-self: start;
margin-bottom: 18px;
grid-column-start: var(--dashboard-column-start, auto);
}
.dashboard-widget > .card {
height: auto;
margin-bottom: 0;
} }
.dashboard-widget-small { .dashboard-widget-small {
grid-column: span 1; grid-column-end: span 1;
} }
.dashboard-widget-medium { .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 { .dashboard-widget-full {
grid-column: 1 / -1; grid-column-end: span 4;
}
.dashboard-widget-picker {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 12px;
}
.dashboard-widget-toggle {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
padding: 12px;
}
.dashboard-widget-toggle input {
margin-top: 3px;
}
.dashboard-widget-toggle strong,
.dashboard-widget-toggle small {
display: block;
}
.dashboard-widget-toggle small {
color: var(--muted);
margin-top: 4px;
line-height: 1.35;
} }
.dashboard-compact-list.detail-list div { .dashboard-compact-list.detail-list div {
@@ -74,19 +56,315 @@
margin-bottom: 0; margin-bottom: 0;
} }
.dashboard-config-workspace {
display: flex;
min-width: 0;
flex-direction: column;
gap: 18px;
}
.dashboard-widget-library {
display: grid;
grid-template-columns: minmax(200px, 260px) minmax(0, 1fr) 36px;
align-items: center;
min-width: 0;
min-height: 84px;
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius);
background: var(--surface);
box-shadow: var(--shadow-xs);
}
.dashboard-widget-library-controls {
align-self: stretch;
display: grid;
align-content: center;
gap: 7px;
min-width: 0;
padding: 9px 12px;
border-right: var(--border-line);
background: var(--panel-header);
}
.dashboard-widget-library-header {
display: flex;
align-items: center;
}
.dashboard-widget-library-header h2 {
margin: 0;
font-size: 16px;
}
.dashboard-widget-search {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 0;
margin: 0;
padding: 4px 8px;
border: var(--border-line);
border-radius: 5px;
background: var(--surface);
}
.dashboard-widget-search:focus-within {
box-shadow: var(--accent-ring-soft);
}
.dashboard-widget-search input[type="search"] {
height: auto;
min-height: 0;
min-width: 0;
padding: 1px 0;
border: 0;
box-shadow: none;
font: inherit;
font-size: 13px;
line-height: 1.35;
}
.dashboard-widget-library-list {
display: flex;
min-width: 0;
align-self: stretch;
overflow-x: auto;
overflow-y: hidden;
border-right: var(--border-line);
}
.dashboard-widget-library-item {
flex: 0 0 min(250px, 32vw);
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 36px;
align-items: center;
gap: 10px;
min-height: 70px;
padding: 9px 10px 9px 12px;
border-right: var(--border-line);
cursor: grab;
}
.dashboard-widget-library-item:hover {
background: var(--hover-tint-soft);
}
.dashboard-widget-library-item:active {
cursor: grabbing;
}
.dashboard-widget-library-item.is-disabled {
cursor: default;
opacity: .7;
}
.dashboard-widget-library-item > div {
display: grid;
min-width: 0;
gap: 3px;
}
.dashboard-widget-library-item strong,
.dashboard-widget-library-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-widget-library-item span {
color: var(--muted);
font-size: 12px;
}
.dashboard-widget-library-empty {
flex: 1 0 auto;
margin: 0;
padding: 18px 16px;
}
.dashboard-widget-grid.is-configuring .dashboard-widget {
cursor: default;
}
.dashboard-widget-grid.is-configuring .card-header {
align-items: center;
flex-wrap: nowrap;
gap: 6px;
padding: 12px 16px;
}
.dashboard-widget-grid.is-configuring .card-header > h2 {
order: 2;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-widget-grid.is-configuring .card-actions {
display: contents;
}
.dashboard-widget-actions {
display: contents;
}
.dashboard-widget-actions > * {
order: 3;
}
.dashboard-widget-drag-handle {
order: 1;
flex: 0 0 auto;
cursor: grab;
}
.dashboard-widget-drag-handle:active {
cursor: grabbing;
}
.dashboard-widget-drop-placeholder {
min-height: 142px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-hover-bg);
color: var(--text);
}
.dashboard-widget-drop-placeholder.preserves-widget-height {
border: 0;
background: transparent;
}
.dashboard-widget-placeholder-content {
visibility: hidden;
}
.dashboard-widget-placeholder-surface {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 142px;
padding: 16px;
border: 2px dashed var(--accent);
border-radius: var(--radius);
background: var(--accent-hover-bg);
color: var(--text);
text-align: center;
pointer-events: none;
}
.dashboard-grid-end-drop-target,
.dashboard-empty-state.is-drop-target {
display: grid;
min-height: 74px;
place-items: center;
border: 2px dashed var(--line-dark);
border-radius: var(--radius);
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.dashboard-grid-end-drop-target {
grid-column: 1 / -1;
grid-row-end: span 19;
}
.dashboard-grid-end-drop-target:hover,
.dashboard-empty-state.is-drop-target:hover {
border-color: var(--accent);
background: var(--accent-hover-bg);
color: var(--text);
}
.dashboard-empty-state {
min-height: 220px;
padding: 32px;
text-align: center;
}
.dashboard-empty-state h2 {
margin: 0 0 8px;
font-size: 18px;
}
.dashboard-empty-state p {
margin: 0;
}
.dashboard-widget-config-dialog {
width: min(620px, 100%);
}
.dashboard-widget-config-dialog .dialog-body {
display: grid;
gap: 18px;
}
.dashboard-widget-config-dialog .dialog-footer-spacer {
flex: 1 1 auto;
}
.dashboard-widget-overflow {
margin: 12px 0 0;
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
.dashboard-widget-grid { .dashboard-widget-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr)); grid-template-columns: repeat(2, minmax(220px, 1fr));
} }
.dashboard-widget {
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) { @media (max-width: 900px) {
.dashboard-widget-grid, .dashboard-widget-grid,
.dashboard-widget-picker,
.dashboard-widget-metrics.metric-grid { .dashboard-widget-metrics.metric-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-widget-library {
grid-template-columns: minmax(0, 1fr) 36px;
}
.dashboard-widget-library-controls {
grid-column: 1;
grid-row: 1;
border-right: 0;
}
.dashboard-widget-library-list {
grid-column: 1 / -1;
grid-row: 2;
min-height: 70px;
border-top: var(--border-line);
border-right: 0;
border-left: 0;
}
.dashboard-widget-library-item {
flex-basis: min(250px, 72vw);
}
.dashboard-widget-small, .dashboard-widget-small,
.dashboard-widget-medium, .dashboard-widget-medium,
.dashboard-widget-wide, .dashboard-widget-wide,
@@ -94,4 +372,3 @@
grid-column: 1; 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.");