2 Commits
Author SHA1 Message Date
zemion 944401d53b fix(ui): keep dashboard configuration exit available and align heading help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:36 +02:00
zemion ec240ed219 feat: require explicit dashboard read permission
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:42 +02:00
15 changed files with 219 additions and 25 deletions
+4
View File
@@ -9,6 +9,10 @@ Configurable dashboard module for GovOPlaN.
The module owns the `/dashboard` route when installed. Core keeps only a minimal The module owns the `/dashboard` route when installed. Core keeps only a minimal
fallback home for installations where this module is absent. fallback home for installations where this module is absent.
Opening the route and using its personal-layout API requires the grantable
`dashboard:dashboard:read` tenant permission. The `dashboard_user` role template
contains this permission; widgets retain their own provider-specific access checks.
Modules contribute widgets through the `dashboard.widgets` WebUI capability. Modules contribute widgets through the `dashboard.widgets` WebUI capability.
Personal widget layouts are stored by the backend for each tenant, account, and Personal widget layouts are stored by the backend for each tenant, account, and
active View. Configure mode supports adding, removing, ordering, sizing, and active View. Configure mode supports adding, removing, ordering, sizing, and
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/dashboard-webui", "name": "@govoplan/dashboard-webui",
"version": "0.1.19", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-dashboard" name = "govoplan-dashboard"
version = "0.1.19" version = "0.1.20"
description = "GovOPlaN configurable dashboard module." description = "GovOPlaN configurable dashboard module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.19" __version__ = "0.1.20"
+103 -8
View File
@@ -9,6 +9,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
@@ -17,6 +18,8 @@ from govoplan_core.core.modules import (
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition,
RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
@@ -26,6 +29,21 @@ from govoplan_dashboard.backend.dsar_provider import (
DASHBOARD_DSAR_CAPABILITY, DASHBOARD_DSAR_CAPABILITY,
DashboardDsarProvider, DashboardDsarProvider,
) )
from govoplan_dashboard.backend.permissions import READ_SCOPE
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Dashboard",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _dashboard_router(_context): def _dashboard_router(_context):
@@ -51,7 +69,7 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
manifest = ModuleManifest( manifest = ModuleManifest(
id="dashboard", id="dashboard",
name="Dashboard", name="Dashboard",
version="0.1.19", version="0.1.20",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"), optional_dependencies=("ops", "campaigns", "files", "mail", "tasks", "notifications", "reporting"),
provides_interfaces=( provides_interfaces=(
@@ -70,6 +88,21 @@ manifest = ModuleManifest(
), ),
}, },
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
permissions=(
_permission(
READ_SCOPE,
"View dashboard",
"Open the Dashboard and read, save, reset, or remove the current user's personal layout.",
),
),
role_templates=(
RoleTemplate(
slug="dashboard_user",
name="Dashboard user",
description="Open and personalize the Dashboard.",
permissions=(READ_SCOPE,),
),
),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id="dashboard", module_id="dashboard",
metadata=Base.metadata, metadata=Base.metadata,
@@ -90,12 +123,35 @@ manifest = ModuleManifest(
label="Dashboard", label="Dashboard",
), ),
), ),
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), nav_items=(
NavItem(
path="/dashboard",
label="Dashboard",
icon="dashboard",
required_all=(READ_SCOPE,),
order=10,
),
),
frontend=FrontendModule( frontend=FrontendModule(
module_id="dashboard", module_id="dashboard",
package_name="@govoplan/dashboard-webui", package_name="@govoplan/dashboard-webui",
routes=(FrontendRoute(path="/dashboard", component="DashboardPage", order=10),), routes=(
nav_items=(NavItem(path="/dashboard", label="Dashboard", icon="dashboard", order=10),), FrontendRoute(
path="/dashboard",
component="DashboardPage",
required_all=(READ_SCOPE,),
order=10,
),
),
nav_items=(
NavItem(
path="/dashboard",
label="Dashboard",
icon="dashboard",
required_all=(READ_SCOPE,),
order=10,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="dashboard.page", id="dashboard.page",
@@ -217,6 +273,9 @@ manifest = ModuleManifest(
title="Configurable user dashboard", title="Configurable user dashboard",
summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.", summary="The dashboard module owns the configurable home surface. Feature modules expose widgets through a narrow dashboard.widgets capability.",
body=( body=(
"The user documentation book sits immediately to the right of Dashboard in both display and "
"configuration mode. Widget configuration help sits beside its dialog title. "
"A widget's contributed documentation book sits beside its existing card title, not in the widget footer. "
"Core only provides a minimal fallback home when the dashboard module is absent. " "Core only provides a minimal fallback home when the dashboard module is absent. "
"Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. " "Dashboard widgets must be contributed through core contracts, not by importing sibling module components directly. "
"Personal layouts are stored per tenant, account, and active View. The active interface-module count includes only " "Personal layouts are stored per tenant, account, and active View. The active interface-module count includes only "
@@ -228,7 +287,9 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator"), audience=("user", "tenant_admin", "operator"),
related_modules=("core", "ops"), related_modules=("core", "ops"),
order=20, order=20,
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
metadata={ metadata={
"kind": "workflow",
"help_contexts": [ "help_contexts": [
"dashboard.page", "dashboard.page",
"dashboard.summary", "dashboard.summary",
@@ -237,6 +298,17 @@ manifest = ModuleManifest(
"dashboard.state.browser-fallback", "dashboard.state.browser-fallback",
"dashboard.state.view-specific", "dashboard.state.view-specific",
], ],
"prerequisites": [
"Your tenant has enabled the Dashboard module.",
"Your role grants the dashboard read permission.",
],
"steps": [
"Open Dashboard from the main navigation.",
"Add, configure, resize, or remove widgets for the active View.",
"Save the layout after reviewing the unsaved-change indicator.",
],
"outcome": "The personal layout is available for the active tenant, account, and View.",
"verification": "Reload the Dashboard and confirm that the saved widget arrangement returns.",
}, },
translations={ translations={
"de": { "de": {
@@ -245,6 +317,9 @@ manifest = ModuleManifest(
"Das Dashboard-Modul führt die konfigurierbare Startoberfläche; Fachmodule stellen Widgets über die enge Fähigkeit dashboard.widgets bereit." "Das Dashboard-Modul führt die konfigurierbare Startoberfläche; Fachmodule stellen Widgets über die enge Fähigkeit dashboard.widgets bereit."
), ),
"body": ( "body": (
"Das Buch für die Benutzerdokumentation steht im Anzeige- und Konfigurationsmodus unmittelbar "
"rechts neben Übersicht (Dashboard). Die Hilfe zur Widget-Konfiguration steht neben ihrem Dialogtitel. "
"Das beigetragene Dokumentationsbuch eines Widgets steht neben seinem vorhandenen Kartentitel, nicht in der Fußzeile. "
"Core stellt nur dann eine minimale Ersatzstartseite bereit, wenn das Dashboard-Modul fehlt. Dashboard-Widgets " "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. " "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 " "Persönliche Layouts werden je Mandant, Konto und aktivem View gespeichert. Die Anzahl aktiver Oberflächenmodule "
@@ -253,6 +328,22 @@ manifest = ModuleManifest(
), ),
} }
}, },
structured_translation_version="1",
structured_translations={
"de": {
"prerequisites": [
"Ihr Mandant hat das Dashboard-Modul aktiviert.",
"Ihre Rolle gewährt die Dashboard-Leseberechtigung.",
],
"steps": [
"Öffnen Sie das Dashboard über die Hauptnavigation.",
"Fügen Sie Widgets für den aktiven View hinzu, konfigurieren oder skalieren Sie sie oder entfernen Sie Widgets.",
"Speichern Sie das Layout, nachdem Sie die Anzeige ungespeicherter Änderungen geprüft haben.",
],
"outcome": "Das persönliche Layout ist für den aktiven Mandanten, das Konto und den View verfügbar.",
"verification": "Laden Sie das Dashboard neu und prüfen Sie, ob die gespeicherte Widget-Anordnung wiederhergestellt wird.",
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="dashboard.reference.layout-and-widgets", id="dashboard.reference.layout-and-widgets",
@@ -263,8 +354,10 @@ manifest = ModuleManifest(
"Configuring changes only a local draft until Save layout is selected; Cancel or " "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 " "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 " "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 " "positions. Cancel exits configuration even when nothing has changed; Save is disabled until "
"dirty invokes the shared save-or-discard guard. Widget removal removes only the placement, " "there are changes. Cancel asks before discarding an edited draft. Both actions are temporarily "
"disabled while a save is in progress. 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 " "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 " "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 " "its module, focused View surface, and permission contract are available. Widgets never grant "
@@ -304,8 +397,10 @@ manifest = ModuleManifest(
"Ein Dashboard-Layout gehört zum aktiven Mandanten, Konto und fokussierten View. Konfigurationen ändern " "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 " "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, " "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 " "ungespeichert oder in Speicherung ist; Speichern und Abbrechen bleiben an stabilen Positionen. Abbrechen beendet "
"Erklärung deaktiviert, wenn keine Änderung vorliegt. Beim Verlassen eines geänderten Entwurfs greift die gemeinsame " "die Konfiguration auch ohne Änderungen; Speichern wird erst bei Änderungen verfügbar. Bei einem geänderten "
"Entwurf fragt Abbrechen vor dem Verwerfen nach. Nur während einer laufenden Speicherung sind beide Aktionen "
"vorübergehend deaktiviert. Beim Verlassen eines geänderten Entwurfs greift die gemeinsame "
"Speichern-oder-Verwerfen-Sicherung. Das Entfernen eines Widgets entfernt nur seine Platzierung, nicht die dargestellten " "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 " "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 " "Speichern umkehrbar. Ein Widget wird nur angeboten, wenn Modul, fokussierte View-Oberfläche und Berechtigungsvertrag "
@@ -0,0 +1,6 @@
"""Dashboard permission scope constants."""
READ_SCOPE = "dashboard:dashboard:read"
__all__ = ["READ_SCOPE"]
+13 -1
View File
@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.permissions import READ_SCOPE
from govoplan_dashboard.backend.schemas import ( from govoplan_dashboard.backend.schemas import (
DashboardLayoutResponse, DashboardLayoutResponse,
DashboardLayoutUpdateRequest, DashboardLayoutUpdateRequest,
@@ -24,6 +25,14 @@ from govoplan_dashboard.backend.service import (
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _require_read(principal: ApiPrincipal) -> None:
if not has_scope(principal, READ_SCOPE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope: {READ_SCOPE}",
)
def _response( def _response(
layout: DashboardLayout | None, layout: DashboardLayout | None,
*, *,
@@ -51,6 +60,7 @@ def api_get_dashboard_layout(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse: ) -> DashboardLayoutResponse:
_require_read(principal)
return _response( return _response(
get_dashboard_layout( get_dashboard_layout(
session, session,
@@ -69,6 +79,7 @@ def api_save_dashboard_layout(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> DashboardLayoutResponse: ) -> DashboardLayoutResponse:
_require_read(principal)
try: try:
layout = save_dashboard_layout( layout = save_dashboard_layout(
session, session,
@@ -111,6 +122,7 @@ def api_delete_dashboard_layout(
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> None: ) -> None:
_require_read(principal)
delete_dashboard_layout( delete_dashboard_layout(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
+28 -1
View File
@@ -12,6 +12,7 @@ from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_dashboard.backend.db.models import DashboardLayout from govoplan_dashboard.backend.db.models import DashboardLayout
from govoplan_dashboard.backend.manifest import READ_SCOPE
from govoplan_dashboard.backend.router import router from govoplan_dashboard.backend.router import router
@@ -19,13 +20,14 @@ def principal(
*, *,
tenant_id: str = "tenant-1", tenant_id: str = "tenant-1",
account_id: str = "account-1", account_id: str = "account-1",
scopes: frozenset[str] = frozenset({READ_SCOPE}),
) -> ApiPrincipal: ) -> ApiPrincipal:
return ApiPrincipal( return ApiPrincipal(
principal=PrincipalRef( principal=PrincipalRef(
account_id=account_id, account_id=account_id,
membership_id=f"membership:{tenant_id}:{account_id}", membership_id=f"membership:{tenant_id}:{account_id}",
tenant_id=tenant_id, tenant_id=tenant_id,
scopes=frozenset(), scopes=scopes,
group_ids=frozenset(), group_ids=frozenset(),
), ),
account=object(), account=object(),
@@ -67,6 +69,31 @@ class DashboardLayoutApiTests(unittest.TestCase):
self.client.close() self.client.close()
self.engine.dispose() self.engine.dispose()
def test_layout_endpoints_require_dashboard_read_permission(self) -> None:
self.active_principal = principal(scopes=frozenset())
for method in ("get", "put", "delete"):
response = getattr(self.client, method)(
"/api/v1/dashboard/layout",
**(
{
"json": {
"expected_revision": 0,
"layout_version": 1,
"placements": [],
"known_widget_ids": [],
}
}
if method == "put"
else {}
),
)
self.assertEqual(403, response.status_code)
self.assertEqual(
f"Missing required scope: {READ_SCOPE}",
response.json()["detail"],
)
def test_layouts_are_isolated_by_account_and_view(self) -> None: def test_layouts_are_isolated_by_account_and_view(self) -> None:
initial = self.client.get("/api/v1/dashboard/layout") initial = self.client.get("/api/v1/dashboard/layout")
self.assertEqual(200, initial.status_code) self.assertEqual(200, initial.status_code)
+36 -3
View File
@@ -7,13 +7,28 @@ from govoplan_core.core.modules import (
documentation_structured_translation_issues, documentation_structured_translation_issues,
localizable_documentation_metadata_keys, localizable_documentation_metadata_keys,
) )
from govoplan_dashboard.backend.manifest import get_manifest from govoplan_dashboard.backend.manifest import READ_SCOPE, get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
class DashboardInterfaceDocumentationContractTests(unittest.TestCase): class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
def test_dashboard_surface_requires_explicit_read_permission(self) -> None:
manifest = get_manifest()
self.assertEqual({READ_SCOPE}, {item.scope for item in manifest.permissions})
self.assertIn(
READ_SCOPE,
next(
item.permissions
for item in manifest.role_templates
if item.slug == "dashboard_user"
),
)
self.assertEqual((READ_SCOPE,), manifest.nav_items[0].required_all)
self.assertEqual((READ_SCOPE,), manifest.frontend.routes[0].required_all) # type: ignore[union-attr]
self.assertEqual((READ_SCOPE,), manifest.frontend.nav_items[0].required_all) # type: ignore[union-attr]
def test_surface_hierarchy_remains_declared(self) -> None: def test_surface_hierarchy_remains_declared(self) -> None:
frontend = get_manifest().frontend frontend = get_manifest().frontend
self.assertIsNotNone(frontend) self.assertIsNotNone(frontend)
@@ -39,6 +54,8 @@ class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
reference = topics["dashboard.reference.layout-and-widgets"] reference = topics["dashboard.reference.layout-and-widgets"]
self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"]) self.assertIn("dashboard.state.browser-fallback", home.metadata["help_contexts"])
self.assertEqual("workflow", home.metadata["kind"])
self.assertEqual((READ_SCOPE,), home.conditions[0].required_scopes)
self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"]) self.assertIn("dashboard.field.widget-size", reference.metadata["help_contexts"])
self.assertIn("save_layout", reference.metadata["consequence_classes"]) self.assertIn("save_layout", reference.metadata["consequence_classes"])
self.assertIn("remove_widget", reference.metadata["consequence_classes"]) self.assertIn("remove_widget", reference.metadata["consequence_classes"])
@@ -75,12 +92,28 @@ class DashboardInterfaceDocumentationContractTests(unittest.TestCase):
REPO_ROOT / "webui/src/features/dashboard/WidgetLibrary.tsx" REPO_ROOT / "webui/src/features/dashboard/WidgetLibrary.tsx"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertIn("DocumentationHelpLink", page) self.assertEqual(
1,
page.count("titleHelp={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}"),
)
self.assertNotIn("helpAction=", page)
self.assertIn("useUnsavedDraftGuard", page) self.assertIn("useUnsavedDraftGuard", page)
self.assertIn("useUnsavedDraftGuard", dialog) self.assertIn("useUnsavedDraftGuard", dialog)
self.assertIn("DASHBOARD_LAYOUT_DOCUMENTATION", dialog) self.assertIn(
"titleHelp={<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />}",
dialog,
)
self.assertIn("disabled={atCapacity}", library) self.assertIn("disabled={atCapacity}", library)
def test_widget_help_uses_provider_reference_at_the_existing_title(self) -> None:
grid = (REPO_ROOT / "webui/src/features/dashboard/DashboardGrid.tsx").read_text(encoding="utf-8")
self.assertEqual(
2,
grid.count("titleHelp={widget.documentation && <DocumentationHelpLink reference={widget.documentation} />}"),
"Regular cards and height-preserving drag cards retain provider documentation beside their existing title.",
)
self.assertNotIn("helpAction=", grid)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/dashboard-webui", "name": "@govoplan/dashboard-webui",
"version": "0.1.19", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -15,6 +15,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
Card, Card,
DocumentationHelpLink,
IconButton, IconButton,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
@@ -154,7 +155,7 @@ export default function DashboardGrid({
onDragOver={(event) => event.preventDefault()} onDragOver={(event) => event.preventDefault()}
onDrop={onDropPreview} onDrop={onDropPreview}
> >
<Card title={widget.title}> <Card title={widget.title} titleHelp={widget.documentation && <DocumentationHelpLink reference={widget.documentation} />}>
<DashboardWidgetContent <DashboardWidgetContent
widget={widget} widget={widget}
placement={placement} placement={placement}
@@ -186,6 +187,7 @@ export default function DashboardGrid({
> >
<Card <Card
title={widget.title} title={widget.title}
titleHelp={widget.documentation && <DocumentationHelpLink reference={widget.documentation} />}
collapsible={!configuring} collapsible={!configuring}
collapseKey={`dashboard-widget:${placement.instanceId}`} collapseKey={`dashboard-widget:${placement.instanceId}`}
actions={ actions={
+13 -4
View File
@@ -6,7 +6,6 @@ import {
type DragEvent as ReactDragEvent type DragEvent as ReactDragEvent
} from "react"; } from "react";
import { import {
RefreshCw,
Save, Save,
SlidersHorizontal, SlidersHorizontal,
X X
@@ -24,6 +23,7 @@ import {
isApiError, isApiError,
useEffectiveView, useEffectiveView,
usePlatformModules, usePlatformModules,
useUnsavedChanges,
useUnsavedDraftGuard, useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
type AuthInfo, type AuthInfo,
@@ -185,6 +185,7 @@ export default function DashboardPage({
]); ]);
const dirty = configuring && !layoutsEqual(savedLayout, draftLayout); const dirty = configuring && !layoutsEqual(savedLayout, draftLayout);
const { requestDiscard } = useUnsavedChanges();
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty, dirty,
onSave: persistLayout, onSave: persistLayout,
@@ -253,12 +254,21 @@ export default function DashboardPage({
function discardChanges() { function discardChanges() {
setDraftLayout(savedLayout); setDraftLayout(savedLayout);
exitConfiguration();
}
function exitConfiguration() {
setConfiguring(false); setConfiguring(false);
setEditingInstanceId(null); setEditingInstanceId(null);
setDragItem(null); setDragItem(null);
setDropTarget(null); setDropTarget(null);
} }
function cancelConfiguration() {
if (dirty) requestDiscard(exitConfiguration);
else exitConfiguration();
}
function beginConfiguration() { function beginConfiguration() {
setDraftLayout(savedLayout); setDraftLayout(savedLayout);
setConfiguring(true); setConfiguring(true);
@@ -447,6 +457,7 @@ export default function DashboardPage({
archetype={configuring ? "editor" : "overview"} archetype={configuring ? "editor" : "overview"}
className="dashboard-page" className="dashboard-page"
title="i18n:govoplan-dashboard.dashboard.3f8b4df2" title="i18n:govoplan-dashboard.dashboard.3f8b4df2"
titleHelp={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
description="Personal workspace assembled from installed module widgets." description="Personal workspace assembled from installed module widgets."
error={error} error={error}
success={error ? "" : notice} success={error ? "" : notice}
@@ -454,8 +465,7 @@ export default function DashboardPage({
<PageActionBar <PageActionBar
variant="editor" variant="editor"
state={saving ? "saving" : dirty ? "dirty" : "clean"} state={saving ? "saving" : dirty ? "dirty" : "clean"}
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />} discardAction={{ label: <><X size={16} /> Cancel</>, behavior: "exit", onClick: cancelConfiguration }}
discardAction={{ label: <><X size={16} /> Cancel</>, onClick: discardChanges }}
saveAction={{ label: <><Save size={16} /> Save layout</>, onClick: () => void persistLayout() }} saveAction={{ label: <><Save size={16} /> Save layout</>, onClick: () => void persistLayout() }}
/> />
) : ( ) : (
@@ -467,7 +477,6 @@ export default function DashboardPage({
loading, loading,
disabledReason: loading ? DASHBOARD_I18N.loading : undefined disabledReason: loading ? DASHBOARD_I18N.loading : undefined
}} }}
helpAction={<DocumentationHelpLink reference={DASHBOARD_DOCUMENTATION} />}
primaryActions={( primaryActions={(
<Button <Button
onClick={beginConfiguration} onClick={beginConfiguration}
@@ -118,6 +118,7 @@ export default function WidgetConfigurationDialog({
} }
function reset() { function reset() {
if (!widget) return;
setSize(defaultWidgetSize(widget)); setSize(defaultWidgetSize(widget));
setConfiguration({ ...(widget.defaultConfiguration ?? {}) }); setConfiguration({ ...(widget.defaultConfiguration ?? {}) });
} }
@@ -126,6 +127,7 @@ export default function WidgetConfigurationDialog({
<Dialog <Dialog
open={open} open={open}
title={`Configure ${widget.title}`} title={`Configure ${widget.title}`}
titleHelp={<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />}
onClose={close} onClose={close}
className="dashboard-widget-config-dialog" className="dashboard-widget-config-dialog"
footer={ footer={
@@ -145,7 +147,6 @@ export default function WidgetConfigurationDialog({
</> </>
} }
> >
<DocumentationHelpLink reference={DASHBOARD_LAYOUT_DOCUMENTATION} />
{supportedSizes.length > 1 && ( {supportedSizes.length > 1 && (
<FormField label="Widget size" documentation={DASHBOARD_LAYOUT_DOCUMENTATION}> <FormField label="Widget size" documentation={DASHBOARD_LAYOUT_DOCUMENTATION}>
<SegmentedControl <SegmentedControl
+3 -1
View File
@@ -5,6 +5,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/dashboard.css"; import "./styles/dashboard.css";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const dashboardRead = ["dashboard:dashboard:read"];
const dashboardWidgets: DashboardWidgetsUiCapability = { const dashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [ widgets: [
@@ -117,10 +118,11 @@ export const dashboardModule: PlatformWebModule = {
order: 10 order: 10
} }
], ],
navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", order: 10 }], navItems: [{ to: "/dashboard", label: "i18n:govoplan-dashboard.dashboard.3f8b4df2", iconName: "dashboard", anyOf: dashboardRead, order: 10 }],
routes: [ routes: [
{ {
path: "/dashboard", path: "/dashboard",
anyOf: dashboardRead,
order: 10, order: 10,
surfaceId: "dashboard.page", surfaceId: "dashboard.page",
render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth }) render: ({ settings, auth }) => createElement(DashboardPage, { settings, auth })
+4 -1
View File
@@ -186,10 +186,13 @@
padding: 12px 16px; padding: 12px 16px;
} }
.dashboard-widget-grid.is-configuring .card-header > h2 { .dashboard-widget-grid.is-configuring .card-header > .card-title-with-help {
order: 2; order: 2;
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
}
.dashboard-widget-grid.is-configuring .card-title-with-help > h2 {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;