Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a48724f16 | ||
|
|
f8305f11f2 | ||
|
|
b3f612bfa0 | ||
|
|
c8a7a155aa | ||
|
|
d0df0b146f | ||
|
|
2d70f6521b | ||
|
|
dbf46da346 | ||
|
|
ede16c5439 | ||
|
|
d80e4db3ec | ||
|
|
77924e7a2c | ||
|
|
61333c08e9 | ||
|
|
c2c7ba8fb2 | ||
|
|
ed5b96f197 | ||
|
|
f282a2ae47 | ||
|
|
556433ebab | ||
|
|
da3947f417 | ||
|
|
4280e42110 | ||
|
|
6ae82b30f8 | ||
|
|
49afc6d73d | ||
|
|
f0732bead2 | ||
|
|
b28229c003 | ||
|
|
2250d69765 | ||
|
|
64032b1ed1 | ||
|
|
cfacc47ebb | ||
|
|
4a2d68b491 | ||
|
|
a315a7c62b | ||
|
|
4b960ad37f |
@@ -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:
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -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?
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -0,0 +1,15 @@
|
||||
## Issue
|
||||
|
||||
Closes #
|
||||
|
||||
## Summary
|
||||
|
||||
-
|
||||
|
||||
## Verification
|
||||
|
||||
-
|
||||
|
||||
## Notes
|
||||
|
||||
Follow-up issues:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -1,12 +1,43 @@
|
||||
# GovOPlaN Dashboard
|
||||
|
||||
<!-- govoplan-repository-type:start -->
|
||||
**Repository type:** module (platform).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
Configurable dashboard module for GovOPlaN.
|
||||
|
||||
The module owns the `/dashboard` route when installed. Core keeps only a minimal
|
||||
fallback home for installations where this module is absent.
|
||||
|
||||
Modules contribute widgets through the `dashboard.widgets` WebUI capability.
|
||||
The first implementation stores personal widget visibility in browser storage;
|
||||
server-side layout persistence can be added later without changing the widget
|
||||
contract.
|
||||
Personal widget layouts are stored by the backend for each tenant, account, and
|
||||
active View. Configure mode supports adding, removing, ordering, sizing, and
|
||||
widget-specific settings. Existing browser-only layouts are used as a migration
|
||||
fallback until the first server save.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -18,11 +18,11 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+3
-4
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-dashboard"
|
||||
version = "0.1.7"
|
||||
version = "0.1.19"
|
||||
description = "GovOPlaN configurable dashboard module."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.7",
|
||||
"govoplan-access>=0.1.7",
|
||||
"govoplan-core>=0.1.18",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -22,4 +22,3 @@ govoplan_dashboard = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
dashboard = "govoplan_dashboard.backend.manifest:get_manifest"
|
||||
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
|
||||
__version__ = "0.1.19"
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
from govoplan_dashboard.backend.db.models import DashboardLayout
|
||||
|
||||
|
||||
DASHBOARD_DSAR_CAPABILITY = dsar_capability_name("dashboard")
|
||||
_MAX_RECORDS = 500
|
||||
_CONFLICT = object()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _SubjectSelectors:
|
||||
account_id: str
|
||||
layout_id: str | None
|
||||
view_id: str | None
|
||||
|
||||
|
||||
class DashboardDsarProvider:
|
||||
provider_id = "dashboard"
|
||||
module_id = "dashboard"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
return ()
|
||||
|
||||
query = db.query(DashboardLayout).filter(
|
||||
DashboardLayout.tenant_id == tenant_id,
|
||||
DashboardLayout.account_id == selectors.account_id,
|
||||
)
|
||||
if selectors.layout_id:
|
||||
query = query.filter(DashboardLayout.id == selectors.layout_id)
|
||||
if selectors.view_id:
|
||||
query = query.filter(DashboardLayout.view_id == selectors.view_id)
|
||||
rows = (
|
||||
query.order_by(DashboardLayout.context_key, DashboardLayout.id)
|
||||
.limit(_MAX_RECORDS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(rows) > _MAX_RECORDS:
|
||||
raise ValueError(
|
||||
"Dashboard DSAR result limit exceeded; narrow the selectors."
|
||||
)
|
||||
return tuple(_record(row) for row in rows)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Dashboard DSAR requires one corroborated account.")
|
||||
|
||||
actions: list[DsarErasureActionRef] = []
|
||||
for record in records:
|
||||
_validate_record(record)
|
||||
layout = (
|
||||
db.query(DashboardLayout)
|
||||
.filter(
|
||||
DashboardLayout.tenant_id == tenant_id,
|
||||
DashboardLayout.id == record.resource_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if (
|
||||
layout is None
|
||||
or layout.account_id != selectors.account_id
|
||||
or (selectors.layout_id and layout.id != selectors.layout_id)
|
||||
or (selectors.view_id and layout.view_id != selectors.view_id)
|
||||
):
|
||||
actions.append(_manual_action(record))
|
||||
continue
|
||||
actions.append(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"dashboard:delete:dashboard_layout:{layout.id}:r{layout.revision}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="delete",
|
||||
resource_type="dashboard_layout",
|
||||
resource_id=layout.id,
|
||||
title="Delete personal Dashboard layout",
|
||||
rationale=(
|
||||
"The layout is an account-owned display preference. Its "
|
||||
"deletion does not change widgets or their source data."
|
||||
),
|
||||
executable=True,
|
||||
irreversible=True,
|
||||
metadata={
|
||||
"account_id": layout.account_id,
|
||||
"revision": layout.revision,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(actions)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
db = _session(session)
|
||||
selectors = _subject_selectors(subject)
|
||||
if selectors is None:
|
||||
raise ValueError("Dashboard DSAR requires one corroborated account.")
|
||||
|
||||
results: list[DsarExecutionResultRef] = []
|
||||
for action in actions:
|
||||
_validate_action(action)
|
||||
if not action.executable or action.kind != "delete":
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary="The Dashboard action is not an executable deletion.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if action.resource_type != "dashboard_layout":
|
||||
raise ValueError("Unsupported executable Dashboard DSAR action.")
|
||||
|
||||
layout = (
|
||||
db.query(DashboardLayout)
|
||||
.filter(
|
||||
DashboardLayout.tenant_id == tenant_id,
|
||||
DashboardLayout.id == action.resource_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if layout is None:
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="unchanged",
|
||||
summary="The personal Dashboard layout was already absent.",
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
expected_account = str(action.metadata.get("account_id") or "")
|
||||
expected_revision = action.metadata.get("revision")
|
||||
if (
|
||||
layout.account_id != selectors.account_id
|
||||
or expected_account != selectors.account_id
|
||||
or (selectors.layout_id and layout.id != selectors.layout_id)
|
||||
or (selectors.view_id and layout.view_id != selectors.view_id)
|
||||
):
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The layout is not owned by the corroborated subject "
|
||||
"account and selector context."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if expected_revision != layout.revision:
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"The layout changed after the erasure plan; create a "
|
||||
"new plan before deleting it."
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
layout_id = layout.id
|
||||
db.delete(layout)
|
||||
db.flush()
|
||||
results.append(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="executed",
|
||||
summary=(
|
||||
"Deleted the personal Dashboard layout without changing "
|
||||
"widget or domain data."
|
||||
),
|
||||
evidence={
|
||||
"request_id": request_id,
|
||||
"layout_id": layout_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
|
||||
references = subject.external_references
|
||||
account_id = _coalesce(
|
||||
subject.account_id,
|
||||
references.get("dashboard.account"),
|
||||
references.get("access.account"),
|
||||
)
|
||||
layout_id = _coalesce(
|
||||
references.get("dashboard.layout"),
|
||||
references.get("dashboard.layout_id"),
|
||||
)
|
||||
view_id = _coalesce(
|
||||
references.get("dashboard.view"),
|
||||
references.get("views.view"),
|
||||
)
|
||||
if _CONFLICT in (account_id, layout_id, view_id):
|
||||
return None
|
||||
normalized_account = _optional_string(account_id)
|
||||
if normalized_account is None:
|
||||
return None
|
||||
return _SubjectSelectors(
|
||||
account_id=normalized_account,
|
||||
layout_id=_optional_string(layout_id),
|
||||
view_id=_optional_string(view_id),
|
||||
)
|
||||
|
||||
|
||||
def _coalesce(*values: str | None) -> str | None | object:
|
||||
normalized = {str(value).strip() for value in values if str(value or "").strip()}
|
||||
if len(normalized) > 1:
|
||||
return _CONFLICT
|
||||
return next(iter(normalized), None)
|
||||
|
||||
|
||||
def _optional_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _record(layout: DashboardLayout) -> DsarRecordRef:
|
||||
placements = []
|
||||
for value in layout.placements[:100]:
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
placements.append(
|
||||
{
|
||||
"instance_id": str(value.get("instance_id") or "")[:120],
|
||||
"widget_id": str(value.get("widget_id") or "")[:160],
|
||||
"size": str(value.get("size") or "")[:20],
|
||||
"column_start": value.get("column_start"),
|
||||
}
|
||||
)
|
||||
return DsarRecordRef(
|
||||
provider_id="dashboard",
|
||||
module_id="dashboard",
|
||||
resource_type="dashboard_layout",
|
||||
resource_id=layout.id,
|
||||
category="personal_interface_preference",
|
||||
title="Personal Dashboard layout",
|
||||
data={
|
||||
"view_id": layout.view_id,
|
||||
"layout_version": layout.layout_version,
|
||||
"revision": layout.revision,
|
||||
"placements": placements,
|
||||
"known_widget_ids": [
|
||||
str(value)[:160] for value in layout.known_widget_ids[:500]
|
||||
],
|
||||
},
|
||||
observed_at=_aware(layout.updated_at),
|
||||
)
|
||||
|
||||
|
||||
def _manual_action(record: DsarRecordRef) -> DsarErasureActionRef:
|
||||
return DsarErasureActionRef(
|
||||
action_id=f"dashboard:manual_review:{record.resource_type}:{record.resource_id}",
|
||||
provider_id="dashboard",
|
||||
module_id="dashboard",
|
||||
kind="manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title=f"Review {record.title}",
|
||||
rationale=(
|
||||
"The selected layout is absent or no longer belongs to the exact "
|
||||
"tenant, account, and narrowing selectors."
|
||||
),
|
||||
executable=False,
|
||||
)
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Dashboard DSAR requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_record(record: DsarRecordRef) -> None:
|
||||
if record.provider_id != "dashboard" or record.module_id != "dashboard":
|
||||
raise ValueError("Dashboard DSAR cannot plan a foreign provider record.")
|
||||
if record.resource_type != "dashboard_layout" or not record.resource_id:
|
||||
raise ValueError("Dashboard DSAR record identity is invalid.")
|
||||
|
||||
|
||||
def _validate_action(action: DsarErasureActionRef) -> None:
|
||||
if action.provider_id != "dashboard" or action.module_id != "dashboard":
|
||||
raise ValueError("Dashboard DSAR cannot execute a foreign provider action.")
|
||||
if not action.action_id.startswith("dashboard:"):
|
||||
raise ValueError("Dashboard DSAR action identity is invalid.")
|
||||
|
||||
|
||||
__all__ = ["DASHBOARD_DSAR_CAPABILITY", "DashboardDsarProvider"]
|
||||
@@ -1,37 +1,343 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.modules import DocumentationTopic, FrontendModule, FrontendRoute, ModuleManifest, NavItem
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
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
|
||||
from govoplan_dashboard.backend.dsar_provider import (
|
||||
DASHBOARD_DSAR_CAPABILITY,
|
||||
DashboardDsarProvider,
|
||||
)
|
||||
|
||||
|
||||
def _dashboard_router(_context):
|
||||
from govoplan_dashboard.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> DashboardDsarProvider:
|
||||
return DashboardDsarProvider()
|
||||
|
||||
|
||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {
|
||||
"dashboard_layouts": (
|
||||
session.query(DashboardLayout)
|
||||
.filter(DashboardLayout.tenant_id == tenant_id)
|
||||
.count()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="dashboard",
|
||||
name="Dashboard",
|
||||
version="0.1.7",
|
||||
version="0.1.19",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=DASHBOARD_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
route_factory=_dashboard_router,
|
||||
capability_factories={DASHBOARD_DSAR_CAPABILITY: _dsar_provider},
|
||||
capability_documentation={
|
||||
DASHBOARD_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Dashboard data-subject request provider",
|
||||
summary=(
|
||||
"Exports and deletes exact account-owned Dashboard layout "
|
||||
"preferences without traversing widget data."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="dashboard",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
DashboardLayout,
|
||||
label="Dashboard",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement removes personal Dashboard layouts after "
|
||||
"the installer captures a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
DashboardLayout,
|
||||
label="Dashboard",
|
||||
),
|
||||
),
|
||||
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
|
||||
frontend=FrontendModule(
|
||||
module_id="dashboard",
|
||||
package_name="@govoplan/dashboard-webui",
|
||||
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),),
|
||||
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="dashboard.page",
|
||||
module_id="dashboard",
|
||||
kind="route",
|
||||
label="Dashboard",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dashboard.summary",
|
||||
module_id="dashboard",
|
||||
kind="section",
|
||||
label="Dashboard summary",
|
||||
parent_id="dashboard.page",
|
||||
order=10,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dashboard.library",
|
||||
module_id="dashboard",
|
||||
kind="section",
|
||||
label="Widget library",
|
||||
parent_id="dashboard.page",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dashboard.grid",
|
||||
module_id="dashboard",
|
||||
kind="section",
|
||||
label="Dashboard grid",
|
||||
parent_id="dashboard.page",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dashboard.widget-settings",
|
||||
module_id="dashboard",
|
||||
kind="action",
|
||||
label="Widget settings",
|
||||
parent_id="dashboard.grid",
|
||||
order=40,
|
||||
),
|
||||
ViewSurface(
|
||||
id="dashboard.widget.installed-modules",
|
||||
module_id="dashboard",
|
||||
kind="section",
|
||||
label="Active interface modules widget",
|
||||
parent_id="dashboard.grid",
|
||||
order=10,
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="dashboard.data-subject-requests",
|
||||
title="Dashboard data-subject requests",
|
||||
summary=(
|
||||
"Export or delete personal Dashboard layouts without changing "
|
||||
"the data displayed by their widgets."
|
||||
),
|
||||
body=(
|
||||
"Dashboard contributes layouts only when the request contains one "
|
||||
"exact, corroborated account identifier in the active tenant. A "
|
||||
"layout or View reference can narrow that result. The access package "
|
||||
"contains layout version, revision, View, widget identities, sizes, "
|
||||
"and positions. It deliberately omits arbitrary widget configuration "
|
||||
"values and never follows a widget into its owning module. Erasure "
|
||||
"deletes the selected account-owned layout after verifying its owner "
|
||||
"and revision; a repeated execution is unchanged. The operation does "
|
||||
"not delete widgets, reports, files, tasks, or any other domain data."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "operator", "auditor"),
|
||||
related_modules=("core", "views"),
|
||||
order=19,
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"dashboard.page",
|
||||
"dashboard.action.reset",
|
||||
"privacy.data-subject-requests",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"export_layout": (
|
||||
"Returns minimized layout structure, never widget-domain data."
|
||||
),
|
||||
"delete_layout": (
|
||||
"Irreversibly removes the selected personal layout only."
|
||||
),
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Datenschutzanfragen zum Dashboard",
|
||||
"summary": (
|
||||
"Persönliche Dashboard-Layouts ausgeben oder löschen, ohne die von Widgets dargestellten Daten zu verändern."
|
||||
),
|
||||
"body": (
|
||||
"Dashboard trägt Layouts nur bei, wenn die Anfrage im aktiven Mandanten genau eine bestätigte "
|
||||
"Kontokennung enthält. Ein Layout- oder View-Verweis kann das Ergebnis einschränken. Das Auskunftspaket "
|
||||
"enthält Layoutversion, Revision, View, Widgetkennungen, Größen und Positionen. Beliebige Werte der "
|
||||
"Widgetkonfiguration werden bewusst ausgelassen; einem Widget wird nie in sein Eigentümermodul gefolgt. "
|
||||
"Die Löschung entfernt das ausgewählte kontoeigene Layout nach Prüfung von Eigentümer und Revision; eine "
|
||||
"Wiederholung bleibt unverändert. Widgets, Berichte, Dateien, Tasks und andere Fachdaten werden nicht gelöscht."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"export_layout": "Gibt eine minimierte Layoutstruktur zurück, niemals Daten der Widget-Fachdomäne.",
|
||||
"delete_layout": "Entfernt ausschließlich das ausgewählte persönliche Layout unwiderruflich.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="dashboard.configurable-home",
|
||||
title="Configurable user dashboard",
|
||||
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
|
||||
body=(
|
||||
"Core only provides a minimal fallback home when the dashboard module is absent. "
|
||||
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly."
|
||||
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. "
|
||||
"Personal layouts are stored per tenant, account, and active View. 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",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "operator"),
|
||||
related_modules=("core", "ops"),
|
||||
order=20,
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"dashboard.page",
|
||||
"dashboard.summary",
|
||||
"dashboard.library",
|
||||
"dashboard.grid",
|
||||
"dashboard.state.browser-fallback",
|
||||
"dashboard.state.view-specific",
|
||||
],
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Konfigurierbares Benutzer-Dashboard",
|
||||
"summary": (
|
||||
"Das Dashboard-Modul führt die konfigurierbare Startoberfläche; Fachmodule stellen Widgets über die enge Fähigkeit dashboard.widgets bereit."
|
||||
),
|
||||
"body": (
|
||||
"Core stellt nur dann eine minimale Ersatzstartseite bereit, wenn das Dashboard-Modul fehlt. Dashboard-Widgets "
|
||||
"müssen über Core-Verträge beigetragen werden und dürfen Komponenten anderer Module nicht direkt importieren. "
|
||||
"Persönliche Layouts werden je Mandant, Konto und aktivem View gespeichert. Die Anzahl aktiver Oberflächenmodule "
|
||||
"umfasst nur mandantenaktivierte Module, deren WebUI in der aktuellen Browsersitzung geladen wurde. Sie unterscheidet "
|
||||
"sich bewusst vom Paketkatalog der Administration, der auch entdeckte reine Backend- und Headless-Manifeste enthält."
|
||||
),
|
||||
}
|
||||
},
|
||||
),
|
||||
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. The page action bar always reports whether "
|
||||
"the draft is saved, unsaved, or currently saving; Save and Cancel remain visible in stable "
|
||||
"positions and are disabled with an explanation when no draft change exists. Leaving while "
|
||||
"dirty invokes the shared save-or-discard guard. 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={
|
||||
"kind": "reference",
|
||||
"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.",
|
||||
},
|
||||
},
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dashboard-Layouts und Widgetfolgen",
|
||||
"summary": (
|
||||
"Platzierung, Größe, Konfiguration, Verfügbarkeit und Rückfallverhalten von Widgets je Benutzer, Mandant und View."
|
||||
),
|
||||
"body": (
|
||||
"Ein Dashboard-Layout gehört zum aktiven Mandanten, Konto und fokussierten View. Konfigurationen ändern "
|
||||
"zunächst nur einen lokalen Entwurf; erst Layout speichern übernimmt sie. Abbrechen oder Verwerfen stellt "
|
||||
"die zuletzt gespeicherte Anordnung wieder her. Die Seitenaktionsleiste zeigt stets, ob der Entwurf gespeichert, "
|
||||
"ungespeichert oder in Speicherung ist; Speichern und Abbrechen bleiben an stabilen Positionen und sind mit "
|
||||
"Erklärung deaktiviert, wenn keine Änderung vorliegt. Beim Verlassen eines geänderten Entwurfs greift die gemeinsame "
|
||||
"Speichern-oder-Verwerfen-Sicherung. Das Entfernen eines Widgets entfernt nur seine Platzierung, nicht die dargestellten "
|
||||
"Moduldaten. Zurücksetzen übernimmt die von aktuell aktiven Modulen angekündigten Standardwerte und bleibt bis zum "
|
||||
"Speichern umkehrbar. Ein Widget wird nur angeboten, wenn Modul, fokussierte View-Oberfläche und Berechtigungsvertrag "
|
||||
"verfügbar sind. Widgets gewähren keinen Zugriff; Anbieter müssen jede Datenanfrage autorisieren. Ist das Serverlayout "
|
||||
"nicht erreichbar, kann ein Browser- oder Modulstandard angezeigt werden, bleibt aber bis zum Speichern als Rückfall "
|
||||
"gekennzeichnet. Gleichzeitige Speicherungen verwenden die Layoutrevision und weisen veraltete Änderungen zurück, "
|
||||
"statt sie zu überschreiben."
|
||||
),
|
||||
}
|
||||
},
|
||||
structured_translation_version="1",
|
||||
structured_translations={
|
||||
"de": {
|
||||
"consequence_classes": {
|
||||
"save_layout": "Speichert das vollständige Layout im Revisionskontext von aktivem Mandant, Konto und View.",
|
||||
"reset_layout": "Ersetzt den Entwurf durch aktuelle Modulstandardwerte und bleibt bis zum Speichern umkehrbar.",
|
||||
"remove_widget": "Entfernt nur die Platzierung aus dem Entwurf; Anbieterdaten bleiben unverändert.",
|
||||
"configure_widget": "Ändert Darstellungs- und Abfrageeinstellungen einer Widgetplatzierung.",
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
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."""
|
||||
+63
@@ -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."""
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,356 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarProvider,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.privacy.dsar_workflow import (
|
||||
create_data_subject_request,
|
||||
search_data_subject_request,
|
||||
)
|
||||
from govoplan_dashboard.backend.db.models import DashboardLayout
|
||||
from govoplan_dashboard.backend.dsar_provider import (
|
||||
DASHBOARD_DSAR_CAPABILITY,
|
||||
DashboardDsarProvider,
|
||||
)
|
||||
from govoplan_dashboard.backend.manifest import manifest
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(
|
||||
self,
|
||||
provider: DashboardDsarProvider,
|
||||
*,
|
||||
active: bool = True,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.active = active
|
||||
|
||||
def capability_names(self):
|
||||
return (DASHBOARD_DSAR_CAPABILITY,)
|
||||
|
||||
def capability_owner(self, name):
|
||||
self._assert_capability(name)
|
||||
return "dashboard"
|
||||
|
||||
def tenant_entitlement_resolver(self):
|
||||
active = self.active
|
||||
|
||||
class _Resolver:
|
||||
@staticmethod
|
||||
def resolve(session, tenant_id):
|
||||
del session, tenant_id
|
||||
return type(
|
||||
"State",
|
||||
(),
|
||||
{"effective_modules": ("dashboard",) if active else ()},
|
||||
)()
|
||||
|
||||
return _Resolver()
|
||||
|
||||
def require_tenant_capability(self, name, session, **kwargs):
|
||||
del session, kwargs
|
||||
self._assert_capability(name)
|
||||
return self.provider
|
||||
|
||||
def manifests(self):
|
||||
return (type("Manifest", (), {"id": "dashboard"})(),)
|
||||
|
||||
@staticmethod
|
||||
def _assert_capability(name: str) -> None:
|
||||
if name != DASHBOARD_DSAR_CAPABILITY:
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class DashboardDsarProviderTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.provider = DashboardDsarProvider()
|
||||
self.assertIsInstance(self.provider, DsarProvider)
|
||||
self._seed()
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def _seed(self) -> None:
|
||||
def layout(
|
||||
layout_id: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
account_id: str = "account-1",
|
||||
view_id: str | None = None,
|
||||
) -> DashboardLayout:
|
||||
return DashboardLayout(
|
||||
id=layout_id,
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
context_key=f"view:{view_id}" if view_id else "full",
|
||||
view_id=view_id,
|
||||
layout_version=1,
|
||||
revision=3,
|
||||
placements=[
|
||||
{
|
||||
"instance_id": f"instance-{layout_id}",
|
||||
"widget_id": "reporting.metric",
|
||||
"size": "medium",
|
||||
"column_start": 2,
|
||||
"configuration": {
|
||||
"subjectFilter": f"private-{layout_id}-do-not-export"
|
||||
},
|
||||
}
|
||||
],
|
||||
known_widget_ids=["reporting.metric"],
|
||||
)
|
||||
|
||||
self.session.add_all(
|
||||
(
|
||||
layout("layout-full"),
|
||||
layout("layout-view", view_id="view-1"),
|
||||
layout("layout-other-account", account_id="account-other"),
|
||||
layout("layout-other-tenant", tenant_id="tenant-2"),
|
||||
)
|
||||
)
|
||||
|
||||
def test_search_is_tenant_and_account_scoped_and_minimized(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["layout-full", "layout-view"],
|
||||
[record.resource_id for record in records],
|
||||
)
|
||||
exported = json.dumps([record.to_dict() for record in records])
|
||||
self.assertNotIn("private-layout-full-do-not-export", exported)
|
||||
self.assertNotIn("private-layout-view-do-not-export", exported)
|
||||
self.assertNotIn("layout-other-account", exported)
|
||||
self.assertNotIn("layout-other-tenant", exported)
|
||||
self.assertIn("reporting.metric", exported)
|
||||
|
||||
def test_references_narrow_and_conflicts_fail_closed(self) -> None:
|
||||
by_layout = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"dashboard.layout": "layout-view"},
|
||||
),
|
||||
)
|
||||
by_view = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"dashboard.view": "view-1"},
|
||||
),
|
||||
)
|
||||
conflict = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"dashboard.account": "account-other"},
|
||||
),
|
||||
)
|
||||
without_account = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
external_references={"dashboard.layout": "layout-view"}
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(["layout-view"], [item.resource_id for item in by_layout])
|
||||
self.assertEqual(["layout-view"], [item.resource_id for item in by_view])
|
||||
self.assertEqual((), conflict)
|
||||
self.assertEqual((), without_account)
|
||||
|
||||
def test_erasure_is_owner_scoped_revision_safe_and_idempotent(self) -> None:
|
||||
subject = DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"dashboard.layout": "layout-view"},
|
||||
)
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
)
|
||||
actions = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=records,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(actions))
|
||||
self.assertEqual("delete", actions[0].kind)
|
||||
first = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
second = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=actions,
|
||||
request_id="dsar-1",
|
||||
)
|
||||
|
||||
self.assertEqual("executed", first[0].status)
|
||||
self.assertEqual("unchanged", second[0].status)
|
||||
self.assertIsNone(self.session.get(DashboardLayout, "layout-view"))
|
||||
self.assertIsNotNone(self.session.get(DashboardLayout, "layout-full"))
|
||||
self.assertIsNotNone(
|
||||
self.session.get(DashboardLayout, "layout-other-account")
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
self.session.get(DashboardLayout, "layout-other-tenant")
|
||||
)
|
||||
|
||||
def test_changed_or_foreign_resources_are_blocked(self) -> None:
|
||||
subject = DsarSubjectRef(account_id="account-1")
|
||||
record = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(
|
||||
account_id="account-1",
|
||||
external_references={"dashboard.layout": "layout-full"},
|
||||
),
|
||||
)[0]
|
||||
action = self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=(record,),
|
||||
)[0]
|
||||
self.session.get(DashboardLayout, "layout-full").revision += 1
|
||||
changed = self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(action,),
|
||||
request_id="dsar-1",
|
||||
)
|
||||
self.assertEqual("blocked", changed[0].status)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider record"):
|
||||
self.provider.plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
records=(
|
||||
DsarRecordRef(
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
resource_type="dashboard_layout",
|
||||
resource_id="layout-full",
|
||||
category="preference",
|
||||
title="Foreign layout",
|
||||
),
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "foreign provider action"):
|
||||
self.provider.execute_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=subject,
|
||||
actions=(
|
||||
DsarErasureActionRef(
|
||||
action_id="views:delete:dashboard_layout:layout-full",
|
||||
provider_id="views",
|
||||
module_id="views",
|
||||
kind="delete",
|
||||
resource_type="dashboard_layout",
|
||||
resource_id="layout-full",
|
||||
title="Delete layout",
|
||||
rationale="Foreign action",
|
||||
executable=True,
|
||||
),
|
||||
),
|
||||
request_id="dsar-1",
|
||||
)
|
||||
|
||||
def test_core_workflow_reports_active_and_inactive_provider(self) -> None:
|
||||
row = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-DASHBOARD-1",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider),
|
||||
row=row,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual(
|
||||
[DASHBOARD_DSAR_CAPABILITY], row.coverage["provider_capabilities"]
|
||||
)
|
||||
self.assertEqual(2, row.search_result["record_count"])
|
||||
|
||||
inactive = create_data_subject_request(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
reference="DSAR-DASHBOARD-2",
|
||||
request_kind="access",
|
||||
subject=DsarSubjectRef(account_id="account-1"),
|
||||
purpose="Respond to a verified request.",
|
||||
legal_basis="Article 15 GDPR",
|
||||
due_at=None,
|
||||
requested_by_account_id="privacy-officer",
|
||||
)
|
||||
self.session.commit()
|
||||
search_data_subject_request(
|
||||
self.session,
|
||||
registry=_Registry(self.provider, active=False),
|
||||
row=inactive,
|
||||
expected_revision=1,
|
||||
)
|
||||
self.assertEqual([], inactive.coverage["provider_capabilities"])
|
||||
self.assertEqual(
|
||||
[DASHBOARD_DSAR_CAPABILITY],
|
||||
inactive.coverage["inactive_provider_capabilities"],
|
||||
)
|
||||
|
||||
def test_manifest_registers_and_documents_the_capability(self) -> None:
|
||||
self.assertIn(DASHBOARD_DSAR_CAPABILITY, manifest.capability_factories)
|
||||
self.assertIn(DASHBOARD_DSAR_CAPABILITY, manifest.capability_documentation)
|
||||
self.assertIn(
|
||||
DASHBOARD_DSAR_CAPABILITY,
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
topic.id == "dashboard.data-subject-requests"
|
||||
and {"admin", "user"}.issubset(topic.documentation_types)
|
||||
for topic in manifest.documentation
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
documentation_structured_translation_issues,
|
||||
localizable_documentation_metadata_keys,
|
||||
)
|
||||
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_german_reference_documentation_is_complete(self) -> None:
|
||||
topics = get_manifest().documentation
|
||||
self.assertEqual(3, len(topics))
|
||||
for topic in topics:
|
||||
translation = topic.translations.get("de", {})
|
||||
self.assertTrue(translation.get("title"), topic.id)
|
||||
self.assertTrue(translation.get("summary"), topic.id)
|
||||
self.assertTrue(translation.get("body"), topic.id)
|
||||
if localizable_documentation_metadata_keys(topic):
|
||||
self.assertEqual("1", topic.structured_translation_version, topic.id)
|
||||
self.assertIn("de", topic.structured_translations, topic.id)
|
||||
self.assertEqual((), documentation_structured_translation_issues(topic))
|
||||
|
||||
reference = next(
|
||||
topic
|
||||
for topic in topics
|
||||
if topic.id == "dashboard.reference.layout-and-widgets"
|
||||
)
|
||||
self.assertEqual("reference", reference.metadata["kind"])
|
||||
|
||||
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()
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/dashboard-webui",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -13,12 +13,15 @@
|
||||
},
|
||||
"./styles/dashboard.css": "./src/styles/dashboard.css"
|
||||
},
|
||||
"scripts": {
|
||||
"test:dashboard-layout": "node --experimental-strip-types tests/dashboard-layout.test.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.7",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -1,186 +1,625 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RefreshCw, RotateCcw, SlidersHorizontal } from "lucide-react";
|
||||
import { MetricGrid } from "@govoplan/core-webui";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent
|
||||
} from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
SlidersHorizontal,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DocumentationHelpLink,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
dashboardWidgetsForModules,
|
||||
hasAnyScope,
|
||||
hasScope,
|
||||
isApiError,
|
||||
useEffectiveView,
|
||||
usePlatformModules,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DashboardWidgetContribution,
|
||||
type DashboardWidgetSize
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchDashboardLayout,
|
||||
saveDashboardLayout
|
||||
} from "../../api/dashboard";
|
||||
import DashboardGrid from "./DashboardGrid";
|
||||
import WidgetConfigurationDialog from "./WidgetConfigurationDialog";
|
||||
import WidgetLibrary from "./WidgetLibrary";
|
||||
import {
|
||||
DASHBOARD_DOCUMENTATION,
|
||||
DASHBOARD_I18N
|
||||
} from "./interfacePatterns";
|
||||
import {
|
||||
appendPlacement,
|
||||
DASHBOARD_COLUMN_COUNT,
|
||||
dashboardWidgetColumnSpan,
|
||||
defaultWidgetSize,
|
||||
defaultDashboardLayout,
|
||||
insertPlacement,
|
||||
layoutFromResponse,
|
||||
layoutUpdatePayload,
|
||||
layoutsEqual,
|
||||
localLayoutKey,
|
||||
MAX_DASHBOARD_WIDGETS,
|
||||
nextDashboardColumnStart,
|
||||
readLocalLayout,
|
||||
reconcileDashboardLayout,
|
||||
removePlacement,
|
||||
reorderPlacement,
|
||||
updatePlacement,
|
||||
writeLocalLayout,
|
||||
type DashboardLayoutState,
|
||||
type DashboardWidgetPlacement
|
||||
} from "./dashboardLayout";
|
||||
import type {
|
||||
DashboardDragItem,
|
||||
DashboardDropTarget
|
||||
} from "./dashboardEditorTypes";
|
||||
import { dashboardColumnStartFromPointer } from "./dashboardEditorTypes";
|
||||
|
||||
type DashboardLayout = {
|
||||
visible: string[];
|
||||
known: string[];
|
||||
};
|
||||
type LayoutSource = "server" | "browser" | "default";
|
||||
|
||||
export default function DashboardPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
|
||||
export default function DashboardPage({
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const modules = usePlatformModules();
|
||||
const widgets = useMemo(() => dashboardWidgetsForModules(modules).filter((widget) => canUseWidget(auth, widget)), [auth, modules]);
|
||||
const widgetSignature = widgets.map((widget) => widget.id).join("|");
|
||||
const storageKey = `govoplan.dashboard.layout:${auth.active_tenant?.id ?? auth.tenant.id}:${auth.user.id}`;
|
||||
const [layout, setLayout] = useState<DashboardLayout>(() => defaultLayout(widgets));
|
||||
const effectiveView = useEffectiveView();
|
||||
const widgets = useMemo(
|
||||
() =>
|
||||
dashboardWidgetsForModules(modules, effectiveView).filter((widget) =>
|
||||
canUseWidget(auth, widget)
|
||||
),
|
||||
[auth, effectiveView, modules]
|
||||
);
|
||||
const widgetSignature = JSON.stringify(
|
||||
widgets.map((widget) => ({
|
||||
id: widget.id,
|
||||
allowMultiple: widget.allowMultiple ?? false,
|
||||
defaultVisible: widget.defaultVisible ?? true,
|
||||
defaultSize: widget.defaultSize ?? "medium",
|
||||
supportedSizes: widget.supportedSizes ?? [],
|
||||
defaultConfiguration: widget.defaultConfiguration ?? {}
|
||||
}))
|
||||
);
|
||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
const accountId = auth.user.account_id || auth.user.id;
|
||||
const viewId = effectiveView?.activeViewId ?? null;
|
||||
const storageKey = localLayoutKey(tenantId, accountId, viewId);
|
||||
const legacyStorageKey = viewId === null
|
||||
? `govoplan.dashboard.layout:${tenantId}:${auth.user.id}`
|
||||
: null;
|
||||
const initialLayout = useMemo(
|
||||
() => defaultDashboardLayout(widgets),
|
||||
[widgets]
|
||||
);
|
||||
const [savedLayout, setSavedLayout] = useState<DashboardLayoutState>(initialLayout);
|
||||
const [draftLayout, setDraftLayout] = useState<DashboardLayoutState>(initialLayout);
|
||||
const [layoutSource, setLayoutSource] = useState<LayoutSource>("default");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [configuring, setConfiguring] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [libraryQuery, setLibraryQuery] = useState("");
|
||||
const [dragItem, setDragItem] = useState<DashboardDragItem | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<DashboardDropTarget | null>(null);
|
||||
const [editingInstanceId, setEditingInstanceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(readLayout(storageKey) ?? defaultLayout(widgets));
|
||||
}, [storageKey, widgetSignature]);
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
setConfiguring(false);
|
||||
setEditingInstanceId(null);
|
||||
|
||||
const visibleWidgetIds = useMemo(() => {
|
||||
const known = new Set(layout.known);
|
||||
const visible = new Set(layout.visible);
|
||||
for (const widget of widgets) {
|
||||
if (!known.has(widget.id) && widget.defaultVisible !== false) visible.add(widget.id);
|
||||
void fetchDashboardLayout(settings, viewId)
|
||||
.then((response) => {
|
||||
if (!active) return;
|
||||
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||
const source: LayoutSource = response.exists
|
||||
? "server"
|
||||
: local
|
||||
? "browser"
|
||||
: "default";
|
||||
const base = response.exists
|
||||
? layoutFromResponse(response)
|
||||
: local
|
||||
? { ...local, revision: 0 }
|
||||
: defaultDashboardLayout(widgets);
|
||||
const next = reconcileDashboardLayout(base, widgets);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource(source);
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!active) return;
|
||||
const local = readLocalLayout(storageKey, legacyStorageKey, widgets);
|
||||
const next = reconcileDashboardLayout(
|
||||
local ?? defaultDashboardLayout(widgets),
|
||||
widgets
|
||||
);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource(local ? "browser" : "default");
|
||||
setError(
|
||||
`The saved Dashboard layout could not be loaded. ${
|
||||
local
|
||||
? "The browser fallback is shown."
|
||||
: "The module defaults are shown."
|
||||
} ${errorMessage(reason)}`
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
legacyStorageKey,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
storageKey,
|
||||
viewId,
|
||||
widgetSignature
|
||||
]);
|
||||
|
||||
const dirty = configuring && !layoutsEqual(savedLayout, draftLayout);
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: persistLayout,
|
||||
onDiscard: discardChanges,
|
||||
title: "i18n:govoplan-dashboard.unsaved_layout_title",
|
||||
message: "i18n:govoplan-dashboard.unsaved_layout_message"
|
||||
});
|
||||
|
||||
const activeLayout = configuring ? draftLayout : savedLayout;
|
||||
const widgetById = useMemo(
|
||||
() => new Map(widgets.map((widget) => [widget.id, widget])),
|
||||
[widgets]
|
||||
);
|
||||
const renderedPlacements = activeLayout.placements.filter((placement) =>
|
||||
widgetById.has(placement.widgetId)
|
||||
);
|
||||
const placedWidgetIds = new Set(
|
||||
draftLayout.placements.map((placement) => placement.widgetId)
|
||||
);
|
||||
const libraryWidgets = widgets.filter((widget) => {
|
||||
if (!widget.allowMultiple && placedWidgetIds.has(widget.id)) return false;
|
||||
if (!libraryQuery.trim()) return true;
|
||||
const query = libraryQuery.trim().toLocaleLowerCase();
|
||||
return [widget.title, widget.description, widget.category, widget.moduleId, widget.id]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLocaleLowerCase().includes(query));
|
||||
});
|
||||
const hiddenCount = widgets.filter(
|
||||
(widget) => !placedWidgetIds.has(widget.id)
|
||||
).length;
|
||||
const editingPlacement = editingInstanceId
|
||||
? draftLayout.placements.find(
|
||||
(placement) => placement.instanceId === editingInstanceId
|
||||
) ?? null
|
||||
: null;
|
||||
const editingWidget = editingPlacement
|
||||
? widgetById.get(editingPlacement.widgetId) ?? null
|
||||
: null;
|
||||
const viewLabel = effectiveView?.activeViewName ?? "Full interface";
|
||||
|
||||
async function persistLayout(): Promise<boolean> {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const response = await saveDashboardLayout(
|
||||
settings,
|
||||
viewId,
|
||||
layoutUpdatePayload(draftLayout)
|
||||
);
|
||||
const next = reconcileDashboardLayout(layoutFromResponse(response), widgets);
|
||||
setSavedLayout(next);
|
||||
setDraftLayout(next);
|
||||
setLayoutSource("server");
|
||||
setConfiguring(false);
|
||||
writeLocalLayout(storageKey, next);
|
||||
setNotice(`Dashboard layout saved for ${viewLabel}.`);
|
||||
return true;
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
const availableIds = new Set(widgets.map((widget) => widget.id));
|
||||
return widgets.map((widget) => widget.id).filter((id) => availableIds.has(id) && visible.has(id));
|
||||
}, [layout, widgets]);
|
||||
const visibleWidgets = widgets.filter((widget) => visibleWidgetIds.includes(widget.id));
|
||||
const hiddenCount = Math.max(widgets.length - visibleWidgets.length, 0);
|
||||
|
||||
function saveLayout(next: DashboardLayout) {
|
||||
setLayout(next);
|
||||
writeLayout(storageKey, next);
|
||||
}
|
||||
|
||||
function setWidgetVisible(widgetId: string, visible: boolean) {
|
||||
const allIds = widgets.map((widget) => widget.id);
|
||||
const nextVisible = new Set(visibleWidgetIds);
|
||||
if (visible) {
|
||||
nextVisible.add(widgetId);
|
||||
function discardChanges() {
|
||||
setDraftLayout(savedLayout);
|
||||
setConfiguring(false);
|
||||
setEditingInstanceId(null);
|
||||
setDragItem(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function beginConfiguration() {
|
||||
setDraftLayout(savedLayout);
|
||||
setConfiguring(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
function updateDraft(updater: (layout: DashboardLayoutState) => DashboardLayoutState) {
|
||||
setDraftLayout((current) => updater(current));
|
||||
}
|
||||
|
||||
function addWidget(
|
||||
widget: DashboardWidgetContribution,
|
||||
columnStart?: number
|
||||
) {
|
||||
updateDraft((layout) => appendPlacement(layout, widget, columnStart));
|
||||
}
|
||||
|
||||
function startDrag(event: ReactDragEvent, item: DashboardDragItem) {
|
||||
event.dataTransfer.effectAllowed = item.kind === "catalogue" ? "copy" : "move";
|
||||
event.dataTransfer.setData("application/x-govoplan-dashboard", JSON.stringify(item));
|
||||
setDragItem(item);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function finishDrag() {
|
||||
setDragItem(null);
|
||||
setDropTarget(null);
|
||||
}
|
||||
|
||||
function markDropTarget(
|
||||
event: ReactDragEvent<HTMLElement>,
|
||||
instanceId: string
|
||||
) {
|
||||
if (!dragItem) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
const verticalPosition = (event.clientY - bounds.top) / bounds.height;
|
||||
const edge = verticalPosition < 0.5 ? "before" : "after";
|
||||
const columnStart = dropColumnStart(event);
|
||||
setDropTarget((current) =>
|
||||
current?.kind === "placement"
|
||||
&& current.instanceId === instanceId
|
||||
&& current.edge === edge
|
||||
&& current.columnStart === columnStart
|
||||
? current
|
||||
: { kind: "placement", instanceId, edge, columnStart }
|
||||
);
|
||||
}
|
||||
|
||||
function markEndDropTarget(event: ReactDragEvent<HTMLElement>) {
|
||||
if (!dragItem) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragItem.kind === "catalogue" ? "copy" : "move";
|
||||
const columnStart = dropColumnStart(event);
|
||||
setDropTarget((current) =>
|
||||
current?.kind === "end" && current.columnStart === columnStart
|
||||
? current
|
||||
: { kind: "end", columnStart }
|
||||
);
|
||||
}
|
||||
|
||||
function draggedWidgetSize(): DashboardWidgetSize | null {
|
||||
if (!dragItem) return null;
|
||||
if (dragItem.kind === "placement") {
|
||||
return draftLayout.placements.find(
|
||||
(placement) => placement.instanceId === dragItem.instanceId
|
||||
)?.size ?? null;
|
||||
}
|
||||
const widget = widgetById.get(dragItem.widgetId);
|
||||
return widget ? defaultWidgetSize(widget) : null;
|
||||
}
|
||||
|
||||
function dropColumnStart(
|
||||
event: ReactDragEvent<HTMLElement>
|
||||
): number {
|
||||
const size = draggedWidgetSize();
|
||||
if (!size) return 1;
|
||||
const grid = event.currentTarget.closest<HTMLElement>(
|
||||
"[data-dashboard-grid]"
|
||||
);
|
||||
if (!grid) return 1;
|
||||
const styles = window.getComputedStyle(grid);
|
||||
const visibleColumns = styles.gridTemplateColumns
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length;
|
||||
if (visibleColumns !== 4) {
|
||||
if (dragItem?.kind === "placement") {
|
||||
return draftLayout.placements.find(
|
||||
(placement) => placement.instanceId === dragItem.instanceId
|
||||
)?.columnStart ?? 1;
|
||||
}
|
||||
return nextDashboardColumnStart(draftLayout.placements, size);
|
||||
}
|
||||
const bounds = grid.getBoundingClientRect();
|
||||
return dashboardColumnStartFromPointer(
|
||||
event.clientX,
|
||||
bounds.left,
|
||||
bounds.width,
|
||||
Number.parseFloat(styles.columnGap),
|
||||
dashboardWidgetColumnSpan(size),
|
||||
DASHBOARD_COLUMN_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
function dropOnPlacement(
|
||||
event: ReactDragEvent<HTMLElement>,
|
||||
target: DashboardWidgetPlacement
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!dragItem) return;
|
||||
const edge = dropTarget?.kind === "placement"
|
||||
&& dropTarget.instanceId === target.instanceId
|
||||
? dropTarget.edge
|
||||
: "after";
|
||||
const columnStart = dropTarget?.columnStart ?? target.columnStart;
|
||||
if (dragItem.kind === "placement") {
|
||||
updateDraft((layout) =>
|
||||
reorderPlacement(
|
||||
layout,
|
||||
dragItem.instanceId,
|
||||
target.instanceId,
|
||||
edge,
|
||||
columnStart
|
||||
)
|
||||
);
|
||||
} else {
|
||||
nextVisible.delete(widgetId);
|
||||
const widget = widgetById.get(dragItem.widgetId);
|
||||
if (widget) {
|
||||
updateDraft((layout) =>
|
||||
insertPlacement(
|
||||
layout,
|
||||
widget,
|
||||
target.instanceId,
|
||||
edge,
|
||||
columnStart
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
saveLayout({
|
||||
known: allIds,
|
||||
visible: allIds.filter((id) => nextVisible.has(id))
|
||||
});
|
||||
finishDrag();
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
saveLayout(defaultLayout(widgets));
|
||||
function dropOnPreview(event: ReactDragEvent<HTMLElement>) {
|
||||
if (dropTarget?.kind === "placement") {
|
||||
const target = renderedPlacements.find(
|
||||
(placement) => placement.instanceId === dropTarget.instanceId
|
||||
);
|
||||
if (target) {
|
||||
dropOnPlacement(event, target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
dropAtEnd(event);
|
||||
}
|
||||
|
||||
function dropAtEnd(event: ReactDragEvent<HTMLElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!dragItem) return;
|
||||
const columnStart = dropTarget?.columnStart;
|
||||
if (dragItem.kind === "placement") {
|
||||
const last = draftLayout.placements.at(-1);
|
||||
if (last) {
|
||||
updateDraft((layout) =>
|
||||
reorderPlacement(
|
||||
layout,
|
||||
dragItem.instanceId,
|
||||
last.instanceId,
|
||||
"after",
|
||||
columnStart
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const widget = widgetById.get(dragItem.widgetId);
|
||||
if (widget) addWidget(widget, columnStart);
|
||||
}
|
||||
finishDrag();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page dashboard-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle>i18n:govoplan-dashboard.dashboard.3f8b4df2</PageTitle>
|
||||
<p>Personal workspace assembled from installed module widgets.</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setRefreshKey((value) => value + 1)}><RefreshCw size={16} /> Refresh</Button>
|
||||
<Button onClick={() => setConfiguring((value) => !value)}><SlidersHorizontal size={16} /> Configure</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid dashboard-summary-grid">
|
||||
<MetricCard label="Installed modules" value={modules.length} tone="info" detail={moduleLabels(modules)} />
|
||||
<MetricCard label="Available widgets" value={widgets.length} tone="neutral" detail="Provided by active modules" />
|
||||
<MetricCard label="Visible widgets" value={visibleWidgets.length} tone="good" detail={hiddenCount ? `${hiddenCount} hidden` : "All shown"} />
|
||||
<MetricCard label="Layout" value="personal" tone="neutral" detail="Saved in this browser" />
|
||||
</div>
|
||||
|
||||
{configuring &&
|
||||
<Card
|
||||
title="Dashboard widgets"
|
||||
actions={<Button onClick={resetLayout}><RotateCcw size={16} /> Reset</Button>}>
|
||||
{widgets.length === 0 ? <p className="muted">No installed module exposes dashboard widgets yet.</p> :
|
||||
<div className="dashboard-widget-picker">
|
||||
{widgets.map((widget) =>
|
||||
<label key={widget.id} className="dashboard-widget-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleWidgetIds.includes(widget.id)}
|
||||
onChange={(event) => setWidgetVisible(widget.id, event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{widget.title}</strong>
|
||||
<small>{widget.description ?? widget.id}</small>
|
||||
</span>
|
||||
</label>
|
||||
<PageLayout
|
||||
archetype={configuring ? "editor" : "overview"}
|
||||
className="dashboard-page"
|
||||
title="i18n:govoplan-dashboard.dashboard.3f8b4df2"
|
||||
description="Personal workspace assembled from installed module widgets."
|
||||
error={error}
|
||||
success={error ? "" : notice}
|
||||
actions={configuring ? (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
|
||||
discardAction={{ label: <><X size={16} /> Cancel</>, onClick: discardChanges }}
|
||||
saveAction={{ label: <><Save size={16} /> Save layout</>, onClick: () => void persistLayout() }}
|
||||
/>
|
||||
) : (
|
||||
<PageActionBar
|
||||
variant="overview"
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => setRefreshKey((value) => value + 1),
|
||||
loading,
|
||||
disabledReason: loading ? DASHBOARD_I18N.loading : undefined
|
||||
}}
|
||||
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
|
||||
primaryActions={(
|
||||
<Button
|
||||
onClick={beginConfiguration}
|
||||
disabled={loading}
|
||||
disabledReason={loading ? DASHBOARD_I18N.loading : undefined}
|
||||
>
|
||||
<SlidersHorizontal size={16} /> Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
|
||||
{visibleWidgets.length === 0 ?
|
||||
<Card title="No widgets selected">
|
||||
<p className="muted">Open Configure and select at least one widget. Modules can add more widgets by exposing the dashboard.widgets capability.</p>
|
||||
</Card> :
|
||||
<div className="dashboard-widget-grid">
|
||||
{visibleWidgets.map((widget) =>
|
||||
<section key={widget.id} className={`dashboard-widget dashboard-widget-${widgetSize(widget)}`}>
|
||||
<Card title={widget.title} collapsible collapseKey={`dashboard-widget:${widget.id}`}>
|
||||
{widget.render({
|
||||
settings,
|
||||
auth,
|
||||
modules,
|
||||
widgetId: widget.id,
|
||||
refreshKey,
|
||||
size: widgetSize(widget)
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>);
|
||||
<MetricGrid className="dashboard-summary-metrics">
|
||||
<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)}
|
||||
/>
|
||||
</MetricGrid>
|
||||
|
||||
<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>
|
||||
<WidgetConfigurationDialog
|
||||
open={Boolean(editingPlacement && editingWidget)}
|
||||
widget={editingWidget}
|
||||
placement={editingPlacement}
|
||||
onClose={() => setEditingInstanceId(null)}
|
||||
onSave={(placement) => {
|
||||
updateDraft((layout) => updatePlacement(layout, placement));
|
||||
setEditingInstanceId(null);
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function canUseWidget(auth: AuthInfo, widget: DashboardWidgetContribution): boolean {
|
||||
if (widget.allOf?.length && !widget.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
function canUseWidget(
|
||||
auth: AuthInfo,
|
||||
widget: DashboardWidgetContribution
|
||||
): boolean {
|
||||
if (
|
||||
widget.allOf?.length
|
||||
&& !widget.allOf.every((scope) => hasScope(auth, scope))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (widget.anyOf?.length && !hasAnyScope(auth, widget.anyOf)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function widgetSize(widget: DashboardWidgetContribution): DashboardWidgetSize {
|
||||
return widget.defaultSize ?? "medium";
|
||||
}
|
||||
|
||||
function defaultLayout(widgets: DashboardWidgetContribution[]): DashboardLayout {
|
||||
const ids = widgets.map((widget) => widget.id);
|
||||
return {
|
||||
known: ids,
|
||||
visible: widgets.filter((widget) => widget.defaultVisible !== false).map((widget) => widget.id)
|
||||
};
|
||||
}
|
||||
|
||||
function readLayout(storageKey: string): DashboardLayout | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<DashboardLayout>;
|
||||
if (!Array.isArray(parsed.visible) || !Array.isArray(parsed.known)) return null;
|
||||
return {
|
||||
visible: parsed.visible.filter((id): id is string => typeof id === "string"),
|
||||
known: parsed.known.filter((id): id is string => typeof id === "string")
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLayout(storageKey: string, layout: DashboardLayout): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(layout));
|
||||
} catch {
|
||||
// localStorage may be unavailable in restricted browsing contexts.
|
||||
}
|
||||
}
|
||||
|
||||
function moduleLabels(modules: Array<{ id: string }>): string {
|
||||
if (!modules.length) return "Core shell only";
|
||||
return modules.slice(0, 4).map((module) => module.id).join(", ");
|
||||
}
|
||||
|
||||
function layoutSourceLabel(source: LayoutSource): string {
|
||||
if (source === "server") return "Saved for your account";
|
||||
if (source === "browser") return "Browser layout; save to synchronize";
|
||||
return "Module defaults";
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (isApiError(error)) {
|
||||
if (error.status === 409) {
|
||||
return "This layout changed in another session. Reload the page before saving again.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "The Dashboard request failed.";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DocumentationHelpLink,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
ToggleSwitch,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type DashboardWidgetConfiguration,
|
||||
type DashboardWidgetConfigurationField,
|
||||
type DashboardWidgetConfigurationValue,
|
||||
type DashboardWidgetContribution,
|
||||
type DashboardWidgetSize
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
defaultWidgetSize,
|
||||
supportedWidgetSizes,
|
||||
type DashboardWidgetPlacement
|
||||
} from "./dashboardLayout";
|
||||
import {
|
||||
DASHBOARD_I18N,
|
||||
DASHBOARD_LAYOUT_DOCUMENTATION
|
||||
} from "./interfacePatterns";
|
||||
|
||||
type WidgetConfigurationDialogProps = {
|
||||
open: boolean;
|
||||
widget: DashboardWidgetContribution | null;
|
||||
placement: DashboardWidgetPlacement | null;
|
||||
onClose: () => void;
|
||||
onSave: (placement: DashboardWidgetPlacement) => void;
|
||||
};
|
||||
|
||||
export default function WidgetConfigurationDialog({
|
||||
open,
|
||||
widget,
|
||||
placement,
|
||||
onClose,
|
||||
onSave
|
||||
}: WidgetConfigurationDialogProps) {
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
const [size, setSize] = useState<DashboardWidgetSize>("medium");
|
||||
const [configuration, setConfiguration] = useState<DashboardWidgetConfiguration>({});
|
||||
const savedConfiguration = useMemo(
|
||||
() => ({
|
||||
...(widget?.defaultConfiguration ?? {}),
|
||||
...(placement?.configuration ?? {})
|
||||
}),
|
||||
[placement, widget]
|
||||
);
|
||||
const dirty = Boolean(
|
||||
open
|
||||
&& widget
|
||||
&& placement
|
||||
&& (
|
||||
size !== placement.size
|
||||
|| JSON.stringify(configuration) !== JSON.stringify(savedConfiguration)
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !widget || !placement) return;
|
||||
setSize(placement.size);
|
||||
setConfiguration({
|
||||
...(widget.defaultConfiguration ?? {}),
|
||||
...placement.configuration
|
||||
});
|
||||
}, [open, placement, widget]);
|
||||
|
||||
const invalidConfiguration = useMemo(
|
||||
() => (widget?.configurationFields ?? []).some((field) => {
|
||||
const value = configuration[field.id];
|
||||
if (field.required && valueIsEmpty(value)) return true;
|
||||
if (field.kind !== "number" || value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return true;
|
||||
if (field.min !== undefined && value < field.min) return true;
|
||||
return field.max !== undefined && value > field.max;
|
||||
}),
|
||||
[configuration, widget]
|
||||
);
|
||||
|
||||
function discardDraft() {
|
||||
if (!widget || !placement) return;
|
||||
setSize(placement.size);
|
||||
setConfiguration({
|
||||
...(widget.defaultConfiguration ?? {}),
|
||||
...placement.configuration
|
||||
});
|
||||
}
|
||||
|
||||
function applyDraft(): boolean {
|
||||
if (!placement || invalidConfiguration) return false;
|
||||
onSave({ ...placement, size, configuration });
|
||||
return true;
|
||||
}
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
title: "i18n:govoplan-dashboard.unsaved_widget_title",
|
||||
message: "i18n:govoplan-dashboard.unsaved_widget_message",
|
||||
onSave: applyDraft,
|
||||
onDiscard: discardDraft
|
||||
});
|
||||
|
||||
function close() {
|
||||
if (dirty) requestDiscard(onClose);
|
||||
else onClose();
|
||||
}
|
||||
|
||||
if (!widget || !placement) return null;
|
||||
const supportedSizes = supportedWidgetSizes(widget);
|
||||
|
||||
function setValue(id: string, value: DashboardWidgetConfigurationValue) {
|
||||
setConfiguration((current) => ({ ...current, [id]: value }));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setSize(defaultWidgetSize(widget));
|
||||
setConfiguration({ ...(widget.defaultConfiguration ?? {}) });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={`Configure ${widget.title}`}
|
||||
onClose={close}
|
||||
className="dashboard-widget-config-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="ghost" onClick={reset}>Reset</Button>
|
||||
<span className="dialog-footer-spacer" />
|
||||
<Button type="button" onClick={close}>Cancel</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={invalidConfiguration}
|
||||
disabledReason={invalidConfiguration ? DASHBOARD_I18N.invalidWidget : undefined}
|
||||
onClick={applyDraft}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />
|
||||
{supportedSizes.length > 1 && (
|
||||
<FormField label="Widget size" documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
|
||||
<SegmentedControl
|
||||
options={supportedSizes.map((candidate) => ({
|
||||
id: candidate,
|
||||
label: sizeLabel(candidate)
|
||||
}))}
|
||||
value={size}
|
||||
onChange={setSize}
|
||||
ariaLabel="Widget size"
|
||||
role="group"
|
||||
size="equal"
|
||||
width="fill"
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{(widget.configurationFields ?? []).map((field) => (
|
||||
<ConfigurationField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={configuration[field.id] ?? widget.defaultConfiguration?.[field.id] ?? null}
|
||||
onChange={(value) => setValue(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigurationField({
|
||||
field,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
field: DashboardWidgetConfigurationField;
|
||||
value: DashboardWidgetConfigurationValue;
|
||||
onChange: (value: DashboardWidgetConfigurationValue) => void;
|
||||
}) {
|
||||
if (field.kind === "boolean") {
|
||||
return (
|
||||
<ToggleSwitch
|
||||
label={field.label}
|
||||
help={field.description}
|
||||
checked={value === true}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "select") {
|
||||
return (
|
||||
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
|
||||
<select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
required={field.required}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="" disabled={field.required}>
|
||||
{field.required ? "Select an option" : "None"}
|
||||
</option>
|
||||
{(field.options ?? []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.kind === "number") {
|
||||
return (
|
||||
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
|
||||
<input
|
||||
type="number"
|
||||
value={typeof value === "number" ? value : ""}
|
||||
required={field.required}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onChange(next === "" ? null : Number(next));
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField label={field.label} help={field.description} documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function valueIsEmpty(value: DashboardWidgetConfigurationValue | undefined): boolean {
|
||||
return value === null || value === undefined || value === "";
|
||||
}
|
||||
|
||||
function sizeLabel(size: DashboardWidgetSize): string {
|
||||
const columns = {
|
||||
small: 1,
|
||||
medium: 2,
|
||||
wide: 3,
|
||||
full: 4
|
||||
}[size];
|
||||
const label = size.charAt(0).toUpperCase() + size.slice(1);
|
||||
return `${label} (${columns} ${columns === 1 ? "column" : "columns"})`;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { DragEvent as ReactDragEvent } from "react";
|
||||
import { GripVertical, Plus, RotateCcw, Search } from "lucide-react";
|
||||
import {
|
||||
IconButton,
|
||||
type DashboardWidgetContribution
|
||||
} from "@govoplan/core-webui";
|
||||
import type { DashboardDragItem } from "./dashboardEditorTypes";
|
||||
|
||||
type WidgetLibraryProps = {
|
||||
widgets: DashboardWidgetContribution[];
|
||||
atCapacity: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onReset: () => void;
|
||||
onAdd: (widget: DashboardWidgetContribution) => void;
|
||||
onDragStart: (
|
||||
event: ReactDragEvent,
|
||||
item: DashboardDragItem
|
||||
) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
export default function WidgetLibrary({
|
||||
widgets,
|
||||
atCapacity,
|
||||
query,
|
||||
onQueryChange,
|
||||
onReset,
|
||||
onAdd,
|
||||
onDragStart,
|
||||
onDragEnd
|
||||
}: WidgetLibraryProps) {
|
||||
return (
|
||||
<aside className="dashboard-widget-library" aria-label="Widget library">
|
||||
<div className="dashboard-widget-library-controls">
|
||||
<div className="dashboard-widget-library-header">
|
||||
<h2>Widget library</h2>
|
||||
</div>
|
||||
<label className="dashboard-widget-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Search widgets"
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="dashboard-widget-library-list">
|
||||
{widgets.map((widget) => (
|
||||
<div
|
||||
key={widget.id}
|
||||
className={`dashboard-widget-library-item${atCapacity ? " is-disabled" : ""}`}
|
||||
draggable={!atCapacity}
|
||||
onDragStart={(event) =>
|
||||
onDragStart(event, { kind: "catalogue", widgetId: widget.id })
|
||||
}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<GripVertical size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{widget.title}</strong>
|
||||
<span>{widget.description ?? widget.category ?? widget.moduleId}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
label={`Add ${widget.title}`}
|
||||
icon={<Plus size={16} />}
|
||||
variant="ghost"
|
||||
disabled={atCapacity}
|
||||
disabledReason={
|
||||
atCapacity
|
||||
? "A Dashboard can contain at most 100 widgets."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onAdd(widget)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{widgets.length === 0 && !atCapacity && (
|
||||
<p className="muted dashboard-widget-library-empty">
|
||||
{query.trim()
|
||||
? "No available widgets match the search."
|
||||
: "All available widgets are already on this Dashboard."}
|
||||
</p>
|
||||
)}
|
||||
{atCapacity && (
|
||||
<p className="muted dashboard-widget-library-empty">
|
||||
This Dashboard has reached the 100-widget limit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IconButton
|
||||
label="Reset to module defaults"
|
||||
icon={<RotateCcw size={16} />}
|
||||
variant="ghost"
|
||||
onClick={onReset}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { DashboardWidgetSize } from "@govoplan/core-webui";
|
||||
import type { DashboardWidgetPlacement } from "./dashboardLayout";
|
||||
|
||||
export type DashboardDragItem =
|
||||
| { kind: "placement"; instanceId: string }
|
||||
| { kind: "catalogue"; widgetId: string };
|
||||
|
||||
export type DashboardDropTarget =
|
||||
| {
|
||||
kind: "placement";
|
||||
instanceId: string;
|
||||
edge: "before" | "after";
|
||||
columnStart: number;
|
||||
}
|
||||
| { kind: "end"; columnStart: number };
|
||||
|
||||
export type DashboardGridPreviewItem =
|
||||
| {
|
||||
kind: "placement";
|
||||
placement: DashboardWidgetPlacement;
|
||||
placeholder: boolean;
|
||||
}
|
||||
| {
|
||||
kind: "catalogue";
|
||||
widgetId: string;
|
||||
size: DashboardWidgetSize;
|
||||
columnStart: number;
|
||||
};
|
||||
|
||||
export function dashboardGridPreview(
|
||||
placements: DashboardWidgetPlacement[],
|
||||
dragItem: DashboardDragItem | null,
|
||||
dropTarget: DashboardDropTarget | null,
|
||||
catalogueSize: DashboardWidgetSize = "medium"
|
||||
): DashboardGridPreviewItem[] {
|
||||
const items: DashboardGridPreviewItem[] = placements.map((placement) => ({
|
||||
kind: "placement",
|
||||
placement,
|
||||
placeholder: false
|
||||
}));
|
||||
if (!dragItem || !dropTarget) return items;
|
||||
|
||||
if (dragItem.kind === "catalogue") {
|
||||
const insertionIndex = previewInsertionIndex(
|
||||
placements,
|
||||
dropTarget,
|
||||
null
|
||||
);
|
||||
items.splice(insertionIndex, 0, {
|
||||
kind: "catalogue",
|
||||
widgetId: dragItem.widgetId,
|
||||
size: catalogueSize,
|
||||
columnStart: dropTarget.columnStart
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
const sourceIndex = placements.findIndex(
|
||||
(placement) => placement.instanceId === dragItem.instanceId
|
||||
);
|
||||
if (sourceIndex < 0) return items;
|
||||
const source = placements[sourceIndex];
|
||||
const remaining = placements.filter(
|
||||
(placement) => placement.instanceId !== dragItem.instanceId
|
||||
);
|
||||
const insertionIndex = previewInsertionIndex(
|
||||
remaining,
|
||||
dropTarget,
|
||||
sourceIndex
|
||||
);
|
||||
const preview: DashboardGridPreviewItem[] = remaining.map((placement) => ({
|
||||
kind: "placement",
|
||||
placement,
|
||||
placeholder: false
|
||||
}));
|
||||
preview.splice(insertionIndex, 0, {
|
||||
kind: "placement",
|
||||
placement: {
|
||||
...source,
|
||||
columnStart: dropTarget.columnStart
|
||||
},
|
||||
placeholder: true
|
||||
});
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function dashboardColumnStartFromPointer(
|
||||
pointerX: number,
|
||||
gridLeft: number,
|
||||
gridWidth: number,
|
||||
columnGap: number,
|
||||
columnSpan: number,
|
||||
columnCount: number
|
||||
): number {
|
||||
const safeColumnCount =
|
||||
Number.isFinite(columnCount) ? Math.max(1, Math.trunc(columnCount)) : 1;
|
||||
const safeWidth =
|
||||
Number.isFinite(gridWidth) && gridWidth > 0 ? gridWidth : 1;
|
||||
const safeGap =
|
||||
Number.isFinite(columnGap) ? Math.max(0, columnGap) : 0;
|
||||
const columnWidth = Math.max(
|
||||
1,
|
||||
(
|
||||
safeWidth
|
||||
- safeGap * (safeColumnCount - 1)
|
||||
) / safeColumnCount
|
||||
);
|
||||
const span =
|
||||
Number.isFinite(columnSpan)
|
||||
? Math.min(safeColumnCount, Math.max(1, Math.trunc(columnSpan)))
|
||||
: 1;
|
||||
const spanWidth = columnWidth * span + safeGap * (span - 1);
|
||||
const stride = columnWidth + safeGap;
|
||||
const maximumStart = safeColumnCount - span + 1;
|
||||
let nearestStart = 1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (let columnStart = 1; columnStart <= maximumStart; columnStart += 1) {
|
||||
const center =
|
||||
gridLeft + (columnStart - 1) * stride + spanWidth / 2;
|
||||
const distance = Math.abs(pointerX - center);
|
||||
if (distance < nearestDistance) {
|
||||
nearestStart = columnStart;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearestStart;
|
||||
}
|
||||
|
||||
function previewInsertionIndex(
|
||||
placements: DashboardWidgetPlacement[],
|
||||
target: DashboardDropTarget,
|
||||
fallbackIndex: number | null
|
||||
): number {
|
||||
if (target.kind === "end") return placements.length;
|
||||
const targetIndex = placements.findIndex(
|
||||
(placement) => placement.instanceId === target.instanceId
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return fallbackIndex === null
|
||||
? placements.length
|
||||
: Math.min(fallbackIndex, placements.length);
|
||||
}
|
||||
return targetIndex + (target.edge === "after" ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import type {
|
||||
DashboardWidgetConfiguration,
|
||||
DashboardWidgetContribution,
|
||||
DashboardWidgetSize
|
||||
} from "@govoplan/core-webui";
|
||||
import type {
|
||||
DashboardLayoutResponse,
|
||||
DashboardLayoutUpdate,
|
||||
DashboardWidgetPlacementResponse
|
||||
} from "../../api/dashboard";
|
||||
|
||||
export type DashboardWidgetPlacement = {
|
||||
instanceId: string;
|
||||
widgetId: string;
|
||||
size: DashboardWidgetSize;
|
||||
columnStart: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
};
|
||||
|
||||
export type DashboardLayoutState = {
|
||||
layoutVersion: 1;
|
||||
revision: number;
|
||||
knownWidgetIds: string[];
|
||||
placements: DashboardWidgetPlacement[];
|
||||
};
|
||||
|
||||
type LegacyDashboardLayout = {
|
||||
visible: string[];
|
||||
known: string[];
|
||||
};
|
||||
|
||||
const SIZES: DashboardWidgetSize[] = ["small", "medium", "wide", "full"];
|
||||
export const DASHBOARD_COLUMN_COUNT = 4;
|
||||
export const MAX_DASHBOARD_WIDGETS = 100;
|
||||
|
||||
export function createWidgetPlacement(
|
||||
widget: DashboardWidgetContribution,
|
||||
columnStart = 1
|
||||
): DashboardWidgetPlacement {
|
||||
const size = defaultWidgetSize(widget);
|
||||
return {
|
||||
instanceId: newInstanceId(),
|
||||
widgetId: widget.id,
|
||||
size,
|
||||
columnStart: normalizeDashboardColumnStart(size, columnStart),
|
||||
configuration: { ...(widget.defaultConfiguration ?? {}) }
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultDashboardLayout(
|
||||
widgets: DashboardWidgetContribution[]
|
||||
): DashboardLayoutState {
|
||||
const placements: DashboardWidgetPlacement[] = [];
|
||||
for (const widget of widgets) {
|
||||
if (
|
||||
widget.defaultVisible === false
|
||||
|| placements.length >= MAX_DASHBOARD_WIDGETS
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const size = defaultWidgetSize(widget);
|
||||
placements.push(
|
||||
createWidgetPlacement(
|
||||
widget,
|
||||
nextDashboardColumnStart(placements, size)
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutVersion: 1,
|
||||
revision: 0,
|
||||
knownWidgetIds: widgets.map((widget) => widget.id),
|
||||
placements
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileDashboardLayout(
|
||||
layout: DashboardLayoutState,
|
||||
widgets: DashboardWidgetContribution[]
|
||||
): DashboardLayoutState {
|
||||
const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
|
||||
const seenInstances = new Set<string>();
|
||||
const seenWidgets = new Set<string>();
|
||||
const placements: DashboardWidgetPlacement[] = [];
|
||||
for (const placement of layout.placements) {
|
||||
if (!placement.instanceId || seenInstances.has(placement.instanceId)) continue;
|
||||
const widget = widgetById.get(placement.widgetId);
|
||||
if (widget && !widget.allowMultiple && seenWidgets.has(widget.id)) continue;
|
||||
seenInstances.add(placement.instanceId);
|
||||
seenWidgets.add(placement.widgetId);
|
||||
placements.push(
|
||||
normalizePlacement(
|
||||
placement,
|
||||
widget,
|
||||
nextDashboardColumnStart(placements, placement.size)
|
||||
)
|
||||
);
|
||||
}
|
||||
const known = new Set(layout.knownWidgetIds);
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (
|
||||
placements.length < MAX_DASHBOARD_WIDGETS
|
||||
&& !known.has(widget.id)
|
||||
&& widget.defaultVisible !== false
|
||||
) {
|
||||
const size = defaultWidgetSize(widget);
|
||||
placements.push(
|
||||
createWidgetPlacement(
|
||||
widget,
|
||||
nextDashboardColumnStart(placements, size)
|
||||
)
|
||||
);
|
||||
}
|
||||
known.add(widget.id);
|
||||
}
|
||||
|
||||
return {
|
||||
layoutVersion: 1,
|
||||
revision: layout.revision,
|
||||
knownWidgetIds: [...known],
|
||||
placements: placements.slice(0, MAX_DASHBOARD_WIDGETS)
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutFromResponse(
|
||||
response: DashboardLayoutResponse
|
||||
): DashboardLayoutState {
|
||||
const placements: DashboardWidgetPlacement[] = [];
|
||||
for (const placement of response.placements) {
|
||||
placements.push(
|
||||
placementFromResponse(
|
||||
placement,
|
||||
nextDashboardColumnStart(placements, placement.size)
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutVersion: 1,
|
||||
revision: response.revision,
|
||||
knownWidgetIds: uniqueStrings(response.known_widget_ids),
|
||||
placements
|
||||
};
|
||||
}
|
||||
|
||||
export function layoutUpdatePayload(
|
||||
layout: DashboardLayoutState
|
||||
): DashboardLayoutUpdate {
|
||||
return {
|
||||
expected_revision: layout.revision,
|
||||
layout_version: 1,
|
||||
placements: layout.placements.map(placementToResponse),
|
||||
known_widget_ids: uniqueStrings(layout.knownWidgetIds)
|
||||
};
|
||||
}
|
||||
|
||||
export function appendPlacement(
|
||||
layout: DashboardLayoutState,
|
||||
widget: DashboardWidgetContribution,
|
||||
columnStart?: number
|
||||
): DashboardLayoutState {
|
||||
if (
|
||||
layout.placements.length >= MAX_DASHBOARD_WIDGETS
|
||||
|| (
|
||||
!widget.allowMultiple
|
||||
&& layout.placements.some((placement) => placement.widgetId === widget.id)
|
||||
)
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
const size = defaultWidgetSize(widget);
|
||||
return {
|
||||
...layout,
|
||||
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]),
|
||||
placements: [
|
||||
...layout.placements,
|
||||
createWidgetPlacement(
|
||||
widget,
|
||||
columnStart ?? nextDashboardColumnStart(layout.placements, size)
|
||||
)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function insertPlacement(
|
||||
layout: DashboardLayoutState,
|
||||
widget: DashboardWidgetContribution,
|
||||
targetInstanceId: string,
|
||||
edge: "before" | "after",
|
||||
columnStart?: number
|
||||
): DashboardLayoutState {
|
||||
if (
|
||||
layout.placements.length >= MAX_DASHBOARD_WIDGETS
|
||||
|| (
|
||||
!widget.allowMultiple
|
||||
&& layout.placements.some((placement) => placement.widgetId === widget.id)
|
||||
)
|
||||
) {
|
||||
return layout;
|
||||
}
|
||||
const targetIndex = layout.placements.findIndex(
|
||||
(placement) => placement.instanceId === targetInstanceId
|
||||
);
|
||||
if (targetIndex < 0) return appendPlacement(layout, widget, columnStart);
|
||||
const insertionIndex = targetIndex + (edge === "after" ? 1 : 0);
|
||||
const size = defaultWidgetSize(widget);
|
||||
const fallbackColumnStart = edge === "before"
|
||||
? layout.placements[targetIndex].columnStart
|
||||
: nextDashboardColumnStart(
|
||||
layout.placements.slice(0, insertionIndex),
|
||||
size
|
||||
);
|
||||
return {
|
||||
...layout,
|
||||
knownWidgetIds: uniqueStrings([...layout.knownWidgetIds, widget.id]),
|
||||
placements: [
|
||||
...layout.placements.slice(0, insertionIndex),
|
||||
createWidgetPlacement(widget, columnStart ?? fallbackColumnStart),
|
||||
...layout.placements.slice(insertionIndex)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function removePlacement(
|
||||
layout: DashboardLayoutState,
|
||||
instanceId: string
|
||||
): DashboardLayoutState {
|
||||
return {
|
||||
...layout,
|
||||
placements: layout.placements.filter(
|
||||
(placement) => placement.instanceId !== instanceId
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function updatePlacement(
|
||||
layout: DashboardLayoutState,
|
||||
nextPlacement: DashboardWidgetPlacement
|
||||
): DashboardLayoutState {
|
||||
const normalized = {
|
||||
...nextPlacement,
|
||||
columnStart: normalizeDashboardColumnStart(
|
||||
nextPlacement.size,
|
||||
nextPlacement.columnStart
|
||||
)
|
||||
};
|
||||
return {
|
||||
...layout,
|
||||
placements: layout.placements.map((placement) =>
|
||||
placement.instanceId === nextPlacement.instanceId
|
||||
? normalized
|
||||
: placement
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderPlacement(
|
||||
layout: DashboardLayoutState,
|
||||
sourceInstanceId: string,
|
||||
targetInstanceId: string,
|
||||
edge: "before" | "after",
|
||||
columnStart?: number
|
||||
): DashboardLayoutState {
|
||||
const source = layout.placements.find(
|
||||
(placement) => placement.instanceId === sourceInstanceId
|
||||
);
|
||||
if (!source) return layout;
|
||||
const movedSource = {
|
||||
...source,
|
||||
columnStart: normalizeDashboardColumnStart(
|
||||
source.size,
|
||||
columnStart ?? source.columnStart
|
||||
)
|
||||
};
|
||||
if (sourceInstanceId === targetInstanceId) {
|
||||
return updatePlacement(layout, movedSource);
|
||||
}
|
||||
const remaining = layout.placements.filter(
|
||||
(placement) => placement.instanceId !== sourceInstanceId
|
||||
);
|
||||
const targetIndex = remaining.findIndex(
|
||||
(placement) => placement.instanceId === targetInstanceId
|
||||
);
|
||||
if (targetIndex < 0) return layout;
|
||||
const insertionIndex = targetIndex + (edge === "after" ? 1 : 0);
|
||||
return {
|
||||
...layout,
|
||||
placements: [
|
||||
...remaining.slice(0, insertionIndex),
|
||||
movedSource,
|
||||
...remaining.slice(insertionIndex)
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function dashboardWidgetColumnSpan(
|
||||
size: DashboardWidgetSize
|
||||
): number {
|
||||
if (size === "small") return 1;
|
||||
if (size === "medium") return 2;
|
||||
if (size === "wide") return 3;
|
||||
return DASHBOARD_COLUMN_COUNT;
|
||||
}
|
||||
|
||||
export function normalizeDashboardColumnStart(
|
||||
size: DashboardWidgetSize,
|
||||
columnStart: unknown,
|
||||
fallback = 1
|
||||
): number {
|
||||
const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1;
|
||||
const candidate =
|
||||
typeof columnStart === "number" && Number.isFinite(columnStart)
|
||||
? Math.trunc(columnStart)
|
||||
: fallback;
|
||||
return Math.min(maximum, Math.max(1, candidate));
|
||||
}
|
||||
|
||||
export function nextDashboardColumnStart(
|
||||
placements: DashboardWidgetPlacement[],
|
||||
size: DashboardWidgetSize
|
||||
): number {
|
||||
const previous = placements.at(-1);
|
||||
if (!previous) return 1;
|
||||
const candidate =
|
||||
previous.columnStart + dashboardWidgetColumnSpan(previous.size);
|
||||
const maximum = DASHBOARD_COLUMN_COUNT - dashboardWidgetColumnSpan(size) + 1;
|
||||
return candidate <= maximum ? candidate : 1;
|
||||
}
|
||||
|
||||
export function dashboardMasonryRowSpan(
|
||||
height: number,
|
||||
rowHeight: number,
|
||||
bottomGap: number
|
||||
): number {
|
||||
const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0;
|
||||
const safeRowHeight =
|
||||
Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : 4;
|
||||
const safeBottomGap =
|
||||
Number.isFinite(bottomGap) ? Math.max(0, bottomGap) : 0;
|
||||
return Math.max(1, Math.ceil((safeHeight + safeBottomGap) / safeRowHeight));
|
||||
}
|
||||
|
||||
export function layoutsEqual(
|
||||
left: DashboardLayoutState,
|
||||
right: DashboardLayoutState
|
||||
): boolean {
|
||||
return JSON.stringify(comparableLayout(left)) === JSON.stringify(comparableLayout(right));
|
||||
}
|
||||
|
||||
export function localLayoutKey(
|
||||
tenantId: string,
|
||||
accountId: string,
|
||||
viewId: string | null
|
||||
): string {
|
||||
return [
|
||||
"govoplan.dashboard.layout.v2",
|
||||
encodeURIComponent(tenantId),
|
||||
encodeURIComponent(accountId),
|
||||
encodeURIComponent(viewId ?? "full")
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function readLocalLayout(
|
||||
storageKey: string,
|
||||
legacyStorageKey: string | null,
|
||||
widgets: DashboardWidgetContribution[]
|
||||
): DashboardLayoutState | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const current = window.localStorage.getItem(storageKey);
|
||||
if (current) return parseStoredLayout(JSON.parse(current));
|
||||
if (!legacyStorageKey) return null;
|
||||
const legacy = window.localStorage.getItem(legacyStorageKey);
|
||||
if (!legacy) return null;
|
||||
return migrateLegacyLayout(JSON.parse(legacy), widgets);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLocalLayout(
|
||||
storageKey: string,
|
||||
layout: DashboardLayoutState
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(layout));
|
||||
} catch {
|
||||
// Browser storage is only a resilience and migration fallback.
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultWidgetSize(
|
||||
widget: DashboardWidgetContribution
|
||||
): DashboardWidgetSize {
|
||||
const supported = supportedWidgetSizes(widget);
|
||||
return supported.includes(widget.defaultSize ?? "medium")
|
||||
? widget.defaultSize ?? "medium"
|
||||
: supported[0];
|
||||
}
|
||||
|
||||
export function supportedWidgetSizes(
|
||||
widget: DashboardWidgetContribution
|
||||
): DashboardWidgetSize[] {
|
||||
const supported = (widget.supportedSizes ?? [widget.defaultSize ?? "medium"])
|
||||
.filter((size): size is DashboardWidgetSize => SIZES.includes(size));
|
||||
return supported.length ? [...new Set(supported)] : ["medium"];
|
||||
}
|
||||
|
||||
export function widgetIsConfigurable(
|
||||
widget: DashboardWidgetContribution
|
||||
): boolean {
|
||||
return Boolean(
|
||||
widget.configurationFields?.length
|
||||
|| supportedWidgetSizes(widget).length > 1
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePlacement(
|
||||
placement: DashboardWidgetPlacement,
|
||||
widget: DashboardWidgetContribution | undefined,
|
||||
fallbackColumnStart: number
|
||||
): DashboardWidgetPlacement {
|
||||
const supported = widget ? supportedWidgetSizes(widget) : SIZES;
|
||||
const size = supported.includes(placement.size)
|
||||
? placement.size
|
||||
: widget
|
||||
? defaultWidgetSize(widget)
|
||||
: "medium";
|
||||
return {
|
||||
...placement,
|
||||
size,
|
||||
columnStart: normalizeDashboardColumnStart(
|
||||
size,
|
||||
placement.columnStart,
|
||||
fallbackColumnStart
|
||||
),
|
||||
configuration: {
|
||||
...(widget?.defaultConfiguration ?? {}),
|
||||
...placement.configuration
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function placementFromResponse(
|
||||
placement: DashboardWidgetPlacementResponse,
|
||||
fallbackColumnStart: number
|
||||
): DashboardWidgetPlacement {
|
||||
return {
|
||||
instanceId: placement.instance_id,
|
||||
widgetId: placement.widget_id,
|
||||
size: placement.size,
|
||||
columnStart: normalizeDashboardColumnStart(
|
||||
placement.size,
|
||||
placement.column_start,
|
||||
fallbackColumnStart
|
||||
),
|
||||
configuration: { ...placement.configuration }
|
||||
};
|
||||
}
|
||||
|
||||
function placementToResponse(
|
||||
placement: DashboardWidgetPlacement
|
||||
): DashboardWidgetPlacementResponse {
|
||||
return {
|
||||
instance_id: placement.instanceId,
|
||||
widget_id: placement.widgetId,
|
||||
size: placement.size,
|
||||
column_start: placement.columnStart,
|
||||
configuration: { ...placement.configuration }
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredLayout(value: unknown): DashboardLayoutState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<DashboardLayoutState>;
|
||||
if (!Array.isArray(candidate.placements) || !Array.isArray(candidate.knownWidgetIds)) {
|
||||
return null;
|
||||
}
|
||||
const placements: DashboardWidgetPlacement[] = [];
|
||||
for (const item of candidate.placements) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const placement = item as Partial<DashboardWidgetPlacement>;
|
||||
if (
|
||||
typeof placement.instanceId !== "string"
|
||||
|| typeof placement.widgetId !== "string"
|
||||
|| !SIZES.includes(placement.size as DashboardWidgetSize)
|
||||
|| !placement.configuration
|
||||
|| typeof placement.configuration !== "object"
|
||||
|| Array.isArray(placement.configuration)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
placements.push({
|
||||
instanceId: placement.instanceId,
|
||||
widgetId: placement.widgetId,
|
||||
size: placement.size as DashboardWidgetSize,
|
||||
columnStart: normalizeDashboardColumnStart(
|
||||
placement.size as DashboardWidgetSize,
|
||||
placement.columnStart,
|
||||
nextDashboardColumnStart(
|
||||
placements,
|
||||
placement.size as DashboardWidgetSize
|
||||
)
|
||||
),
|
||||
configuration: sanitizeConfiguration(placement.configuration)
|
||||
});
|
||||
}
|
||||
return {
|
||||
layoutVersion: 1,
|
||||
revision: typeof candidate.revision === "number" ? candidate.revision : 0,
|
||||
knownWidgetIds: uniqueStrings(candidate.knownWidgetIds),
|
||||
placements
|
||||
};
|
||||
}
|
||||
|
||||
function migrateLegacyLayout(
|
||||
value: unknown,
|
||||
widgets: DashboardWidgetContribution[]
|
||||
): DashboardLayoutState | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<LegacyDashboardLayout>;
|
||||
if (!Array.isArray(candidate.visible) || !Array.isArray(candidate.known)) {
|
||||
return null;
|
||||
}
|
||||
const visible = new Set(uniqueStrings(candidate.visible));
|
||||
const widgetById = new Map(widgets.map((widget) => [widget.id, widget]));
|
||||
const placements: DashboardWidgetPlacement[] = [];
|
||||
for (const widgetId of visible) {
|
||||
const widget = widgetById.get(widgetId);
|
||||
if (!widget) continue;
|
||||
const size = defaultWidgetSize(widget);
|
||||
placements.push(
|
||||
createWidgetPlacement(
|
||||
widget,
|
||||
nextDashboardColumnStart(placements, size)
|
||||
)
|
||||
);
|
||||
}
|
||||
return {
|
||||
layoutVersion: 1,
|
||||
revision: 0,
|
||||
knownWidgetIds: uniqueStrings(candidate.known),
|
||||
placements
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueStrings(values: unknown[]): string[] {
|
||||
return [...new Set(values.filter((value): value is string => typeof value === "string"))];
|
||||
}
|
||||
|
||||
function sanitizeConfiguration(
|
||||
value: Record<string, unknown>
|
||||
): DashboardWidgetConfiguration {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter(([key, item]) =>
|
||||
key.length > 0
|
||||
&& key.length <= 120
|
||||
&& (
|
||||
item === null
|
||||
|| typeof item === "string"
|
||||
|| typeof item === "boolean"
|
||||
|| (typeof item === "number" && Number.isFinite(item))
|
||||
)
|
||||
)
|
||||
) as DashboardWidgetConfiguration;
|
||||
}
|
||||
|
||||
function comparableLayout(layout: DashboardLayoutState) {
|
||||
return {
|
||||
layoutVersion: layout.layoutVersion,
|
||||
knownWidgetIds: layout.knownWidgetIds,
|
||||
placements: layout.placements
|
||||
};
|
||||
}
|
||||
|
||||
function newInstanceId(): string {
|
||||
if (
|
||||
typeof globalThis.crypto !== "undefined"
|
||||
&& typeof globalThis.crypto.randomUUID === "function"
|
||||
) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `widget-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DocumentationHelpReference } from "@govoplan/core-webui";
|
||||
|
||||
export const DASHBOARD_DOCUMENTATION = {
|
||||
topicId: "dashboard.configurable-home",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DASHBOARD_LAYOUT_DOCUMENTATION = {
|
||||
topicId: "dashboard.reference.layout-and-widgets",
|
||||
documentationType: "user"
|
||||
} satisfies DocumentationHelpReference;
|
||||
|
||||
export const DASHBOARD_I18N = {
|
||||
loading: "i18n:govoplan-dashboard.loading_reason",
|
||||
saving: "i18n:govoplan-dashboard.saving_reason",
|
||||
noChanges: "i18n:govoplan-dashboard.no_changes_reason",
|
||||
invalidWidget: "i18n:govoplan-dashboard.invalid_widget_reason"
|
||||
} as const;
|
||||
@@ -1,16 +1,53 @@
|
||||
import { StatusBadge, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import {
|
||||
StatusBadge,
|
||||
type DashboardWidgetConfiguration,
|
||||
type PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export default function InstalledModulesWidget({ modules }: { modules: PlatformWebModule[] }) {
|
||||
if (!modules.length) return <p className="muted">No WebUI modules are active.</p>;
|
||||
export default function InstalledModulesWidget({
|
||||
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 (
|
||||
<dl className="detail-list dashboard-compact-list">
|
||||
{modules.map((module) =>
|
||||
<div key={module.id}>
|
||||
<dt><StatusBadge status="success" label={module.id} /></dt>
|
||||
<dd><strong>{module.label}</strong><span className="muted"> v{module.version}</span></dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>);
|
||||
<>
|
||||
<DescriptionList variant="inline" termWidth="compact">
|
||||
{visibleModules.map((module) =>
|
||||
<div key={module.id}>
|
||||
<dt><StatusBadge status="success" label={module.id} /></dt>
|
||||
<dd>
|
||||
<strong>{module.label}</strong>
|
||||
{showVersions && <span className="muted"> v{module.version}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</DescriptionList>
|
||||
{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)));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,73 @@
|
||||
export const generatedTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard"
|
||||
}
|
||||
import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
const en = {
|
||||
"i18n:govoplan-dashboard.dashboard.3f8b4df2": "Dashboard",
|
||||
"i18n:govoplan-dashboard.summary": "Dashboard summary",
|
||||
"i18n:govoplan-dashboard.library": "Widget library",
|
||||
"i18n:govoplan-dashboard.grid": "Dashboard grid",
|
||||
"i18n:govoplan-dashboard.widget_settings": "Widget settings",
|
||||
"i18n:govoplan-dashboard.installed_modules_widget": "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
@@ -10,13 +10,48 @@ const dashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
widgets: [
|
||||
{
|
||||
id: "dashboard.installed-modules",
|
||||
title: "Installed modules",
|
||||
description: "Enabled WebUI modules in this browser session.",
|
||||
surfaceId: "dashboard.widget.installed-modules",
|
||||
title: "Active interface modules",
|
||||
description: "Tenant-enabled modules with a loaded WebUI in this browser session.",
|
||||
moduleId: "dashboard",
|
||||
category: "Platform",
|
||||
order: 10,
|
||||
defaultSize: "medium",
|
||||
render: ({ modules }) => createElement(InstalledModulesWidget, { modules })
|
||||
supportedSizes: ["small", "medium", "wide"],
|
||||
defaultConfiguration: {
|
||||
maxItems: 8,
|
||||
showVersions: true,
|
||||
sortBy: "module"
|
||||
},
|
||||
configurationFields: [
|
||||
{
|
||||
id: "maxItems",
|
||||
label: "Maximum modules",
|
||||
description: "Limit how many active modules are listed.",
|
||||
kind: "number",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
id: "sortBy",
|
||||
label: "Sort modules by",
|
||||
kind: "select",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "module", label: "Module id" },
|
||||
{ value: "label", label: "Display name" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "showVersions",
|
||||
label: "Show module versions",
|
||||
kind: "boolean"
|
||||
}
|
||||
],
|
||||
render: ({ modules, configuration }) =>
|
||||
createElement(InstalledModulesWidget, { modules, configuration })
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -33,9 +68,63 @@ export const dashboardModule: PlatformWebModule = {
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "dashboard.page",
|
||||
moduleId: "dashboard",
|
||||
kind: "route",
|
||||
label: "i18n:govoplan-dashboard.dashboard.3f8b4df2",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "dashboard.summary",
|
||||
moduleId: "dashboard",
|
||||
kind: "section",
|
||||
label: "i18n:govoplan-dashboard.summary",
|
||||
parentId: "dashboard.page",
|
||||
order: 10
|
||||
},
|
||||
{
|
||||
id: "dashboard.library",
|
||||
moduleId: "dashboard",
|
||||
kind: "section",
|
||||
label: "i18n:govoplan-dashboard.library",
|
||||
parentId: "dashboard.page",
|
||||
order: 20
|
||||
},
|
||||
{
|
||||
id: "dashboard.grid",
|
||||
moduleId: "dashboard",
|
||||
kind: "section",
|
||||
label: "i18n:govoplan-dashboard.grid",
|
||||
parentId: "dashboard.page",
|
||||
order: 30
|
||||
},
|
||||
{
|
||||
id: "dashboard.widget-settings",
|
||||
moduleId: "dashboard",
|
||||
kind: "action",
|
||||
label: "i18n:govoplan-dashboard.widget_settings",
|
||||
parentId: "dashboard.grid",
|
||||
order: 40
|
||||
},
|
||||
{
|
||||
id: "dashboard.widget.installed-modules",
|
||||
moduleId: "dashboard",
|
||||
kind: "section",
|
||||
label: "i18n:govoplan-dashboard.installed_modules_widget",
|
||||
parentId: "dashboard.grid",
|
||||
order: 10
|
||||
}
|
||||
],
|
||||
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }],
|
||||
routes: [
|
||||
{ path: "/dashboard", order: 10, render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) }
|
||||
{
|
||||
path: "/dashboard",
|
||||
order: 10,
|
||||
surfaceId: "dashboard.page",
|
||||
render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": dashboardWidgets
|
||||
@@ -43,4 +132,3 @@ export const dashboardModule: PlatformWebModule = {
|
||||
};
|
||||
|
||||
export default dashboardModule;
|
||||
|
||||
|
||||
+323
-59
@@ -2,7 +2,7 @@
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.dashboard-summary-grid .metric-detail {
|
||||
.dashboard-summary-metrics .metric-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -11,80 +11,345 @@
|
||||
.dashboard-widget-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
grid-auto-flow: row dense;
|
||||
grid-auto-rows: 4px;
|
||||
column-gap: 18px;
|
||||
row-gap: 0;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard-widget {
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
align-self: start;
|
||||
margin-bottom: 18px;
|
||||
grid-column-start: var(--dashboard-column-start, auto);
|
||||
}
|
||||
|
||||
.dashboard-widget-small {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.dashboard-widget-medium {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.dashboard-widget-wide,
|
||||
.dashboard-widget-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dashboard-widget-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-widget-toggle {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--panel-soft);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-widget-toggle input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.dashboard-widget-toggle strong,
|
||||
.dashboard-widget-toggle small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-widget-toggle small {
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.dashboard-compact-list.detail-list div {
|
||||
grid-template-columns: minmax(92px, auto) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-widget-metrics.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
.dashboard-widget > .card {
|
||||
height: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.dashboard-widget-small {
|
||||
grid-column-end: span 1;
|
||||
}
|
||||
|
||||
.dashboard-widget-medium {
|
||||
grid-column-end: span 2;
|
||||
}
|
||||
|
||||
.dashboard-widget-wide {
|
||||
grid-column-end: span 3;
|
||||
}
|
||||
|
||||
.dashboard-widget-full {
|
||||
grid-column-end: span 4;
|
||||
}
|
||||
|
||||
.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: var(--radius-xs);
|
||||
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: 1100px) {
|
||||
.dashboard-widget-grid {
|
||||
grid-template-columns: repeat(2, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-widget {
|
||||
grid-column-start: auto;
|
||||
}
|
||||
|
||||
.dashboard-widget-wide,
|
||||
.dashboard-widget-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.dashboard-widget-library {
|
||||
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr) 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-widget-grid,
|
||||
.dashboard-widget-picker,
|
||||
.dashboard-widget-metrics.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.dashboard-widget-library {
|
||||
grid-template-columns: minmax(0, 1fr) 36px;
|
||||
}
|
||||
|
||||
.dashboard-widget-library-controls {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.dashboard-widget-library-list {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
min-height: 70px;
|
||||
border-top: var(--border-line);
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.dashboard-widget-library-item {
|
||||
flex-basis: min(250px, 72vw);
|
||||
}
|
||||
|
||||
.dashboard-widget-small,
|
||||
@@ -94,4 +359,3 @@
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.");
|
||||
Reference in New Issue
Block a user