Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944401d53b | ||
|
|
ec240ed219 |
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.19"
|
__version__ = "0.1.20"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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={
|
||||||
|
|||||||
@@ -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
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user