Compare commits

..
4 Commits
Author SHA1 Message Date
zemion 142ccbc587 feat(core): define managed tabular file contract
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 19:29:48 +02:00
zemion f75ad48d78 test(core): isolate entitlement navigation fixture 2026-08-21 19:26:00 +02:00
zemion 5a9e8f79f9 test(webui): cover contextual help in the browser 2026-08-21 17:36:06 +02:00
zemion fbea74a74b feat(core): define durable datasource artifact contract 2026-08-21 17:36:05 +02:00
17 changed files with 446 additions and 21 deletions
+21
View File
@@ -48,6 +48,12 @@ module and server restrictions, lower-scope visibility, activation, save, and
irreversible deletion. This ensures F1 explains secret custody and the effect on irreversible deletion. This ensures F1 explains secret custody and the effect on
dependent connections from system, tenant, group, user, and personal surfaces. dependent connections from system, tenant, group, user, and personal surfaces.
The source inventory treats literal `helpContextId` and
`data-help-context-id` declarations as authored help associations, including a
native control nested in `FormField`. Dynamic context expressions remain
separate evidence and generic derived fallbacks remain in the richer-help
candidate queue.
The generated `help_review_candidates` list is therefore a content-depth queue, The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize: not a list of controls on which F1 cannot work. It should prioritize:
@@ -57,6 +63,14 @@ not a list of controls on which F1 cannot work. It should prioritize:
4. provider authority, synchronization, conflict, and outcome unknown; 4. provider authority, synchronization, conflict, and outcome unknown;
5. fields whose consequences are not evident from their label. 5. fields whose consequences are not evident from their label.
The shared browser conformance journey mounts the production Help menu and
resolver. It proves that F1 uses the focused control rather than only the page,
maps an exact retention action to Policy-owned administrator documentation,
retains the page context as fallback for derived actions, exposes an accessible
modal at narrow widths, closes with Escape, and restores focus to the triggering
control. Module journeys should add their own exact high-risk mappings; they do
not need to reimplement the keyboard or dialog mechanics.
## Verification ## Verification
```bash ```bash
@@ -74,3 +88,10 @@ The check must report:
- no duplicate stable IDs; - no duplicate stable IDs;
- no undeclared public WebUI surface; - no undeclared public WebUI surface;
- no stale runtime route or endpoint declaration. - no stale runtime route or endpoint declaration.
Browser acceptance is part of the focused workspace gate and can be run alone:
```bash
cd /mnt/DATA/git/govoplan-core/webui
npm run test:conformance
```
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.18" version = "0.1.19"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+91 -2
View File
@@ -23,6 +23,7 @@ CAPABILITY_DATASOURCE_CATALOGUE = "datasources.catalogue"
CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle" CAPABILITY_DATASOURCE_LIFECYCLE = "datasources.lifecycle"
CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication" CAPABILITY_DATASOURCE_PUBLICATION = "datasources.publication"
CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins" CAPABILITY_DATASOURCE_ORIGINS = "connectors.datasourceOrigins"
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS = "datasources.artifactBackends"
DatasourceMode = Literal["live", "cached", "static"] DatasourceMode = Literal["live", "cached", "static"]
DatasourceKind = Literal[ DatasourceKind = Literal[
@@ -37,6 +38,11 @@ DatasourceKind = Literal[
] ]
DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"] DatasourceShape = Literal["tabular", "document", "binary", "directory", "stream"]
DatasourceConsistency = Literal["current", "live", "frozen"] DatasourceConsistency = Literal["current", "live", "frozen"]
DatasourcePublicationStatus = Literal[
"published",
"published_with_warnings",
"review_required",
]
class DatasourceError(ValueError): class DatasourceError(ValueError):
@@ -309,12 +315,73 @@ class DatasourceStageInput:
governance: DatasourceGovernance | None = None governance: DatasourceGovernance | None = None
@dataclass(frozen=True, slots=True)
class DatasourceArtifactReference:
"""Immutable provider-neutral reference to a durable tabular payload.
The producer owns creation of the payload. Datasources pins its locator,
checksum and declared shape without importing the artifact-owning module;
a configured payload backend verifies integrity and provides bounded reads.
"""
backend: str
locator: str
checksum: str
row_count: int
byte_count: int
schema: tuple[DatasourceField, ...]
fingerprint: str
media_type: str = "application/x-ndjson"
checkpoint: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
validation: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DatasourceArtifactBackend(Protocol):
"""Storage-module boundary for immutable artifact-backed tabular data."""
backend: str
def verify(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
def read_rows(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
offset: int,
limit: int,
) -> Sequence[Mapping[str, object]]: ...
def delete(
self,
session: object,
*,
tenant_id: str,
artifact: DatasourceArtifactReference,
) -> None: ...
@runtime_checkable
class DatasourceArtifactBackendProvider(Protocol):
def artifact_backends(self) -> Sequence[DatasourceArtifactBackend]: ...
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DatasourcePublicationRequest: class DatasourcePublicationRequest:
producer_module: str producer_module: str
producer_run_ref: str producer_run_ref: str
idempotency_key: str idempotency_key: str
rows: tuple[Mapping[str, object], ...] rows: tuple[Mapping[str, object], ...] | None = None
artifact: DatasourceArtifactReference | None = None
target_datasource_ref: str | None = None target_datasource_ref: str | None = None
name: str | None = None name: str | None = None
source_name: str | None = None source_name: str | None = None
@@ -331,7 +398,7 @@ class DatasourcePublicationRequest:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DatasourcePublicationResult: class DatasourcePublicationResult:
ref: str ref: str
status: str status: DatasourcePublicationStatus
datasource: DatasourceDescriptor datasource: DatasourceDescriptor
materialization: DatasourceMaterialization materialization: DatasourceMaterialization
replayed: bool = False replayed: bool = False
@@ -564,6 +631,17 @@ def datasource_catalogue(registry: object | None) -> DatasourceCatalogueProvider
return capability if isinstance(capability, DatasourceCatalogueProvider) else None return capability if isinstance(capability, DatasourceCatalogueProvider) else None
def datasource_artifact_backend_provider(
registry: object | None,
) -> DatasourceArtifactBackendProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS)
return (
capability
if isinstance(capability, DatasourceArtifactBackendProvider)
else None
)
def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None: def datasource_lifecycle(registry: object | None) -> DatasourceLifecycleProvider | None:
capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE) capability = _capability(registry, CAPABILITY_DATASOURCE_LIFECYCLE)
return capability if isinstance(capability, DatasourceLifecycleProvider) else None return capability if isinstance(capability, DatasourceLifecycleProvider) else None
@@ -613,9 +691,14 @@ def _governance_mapping(value: object) -> Mapping[str, object]:
__all__ = [ __all__ = [
"CAPABILITY_DATASOURCE_CATALOGUE", "CAPABILITY_DATASOURCE_CATALOGUE",
"CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS",
"CAPABILITY_DATASOURCE_LIFECYCLE", "CAPABILITY_DATASOURCE_LIFECYCLE",
"CAPABILITY_DATASOURCE_ORIGINS", "CAPABILITY_DATASOURCE_ORIGINS",
"CAPABILITY_DATASOURCE_PUBLICATION",
"DatasourceAccessError", "DatasourceAccessError",
"DatasourceArtifactReference",
"DatasourceArtifactBackend",
"DatasourceArtifactBackendProvider",
"DatasourceCatalogueProvider", "DatasourceCatalogueProvider",
"DatasourceConsistency", "DatasourceConsistency",
"DatasourceDescriptor", "DatasourceDescriptor",
@@ -631,6 +714,10 @@ __all__ = [
"DatasourceOriginProvider", "DatasourceOriginProvider",
"DatasourceOriginReadRequest", "DatasourceOriginReadRequest",
"DatasourceOriginReadResult", "DatasourceOriginReadResult",
"DatasourcePublicationProvider",
"DatasourcePublicationRequest",
"DatasourcePublicationResult",
"DatasourcePublicationStatus",
"DatasourceReadRequest", "DatasourceReadRequest",
"DatasourceReadResult", "DatasourceReadResult",
"DatasourceShape", "DatasourceShape",
@@ -639,6 +726,8 @@ __all__ = [
"DatasourceUnavailableError", "DatasourceUnavailableError",
"DatasourceValidationError", "DatasourceValidationError",
"datasource_catalogue", "datasource_catalogue",
"datasource_artifact_backend_provider",
"datasource_lifecycle", "datasource_lifecycle",
"datasource_origins", "datasource_origins",
"datasource_publication",
] ]
+119
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
from govoplan_core.core.access import ResourceAccessExplanationProvider from govoplan_core.core.access import ResourceAccessExplanationProvider
@@ -10,6 +11,48 @@ from govoplan_core.core.access import ResourceAccessExplanationProvider
CAPABILITY_FILES_ACCESS = "files.access" CAPABILITY_FILES_ACCESS = "files.access"
CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store" CAPABILITY_FILES_ARTIFACT_STORE = "files.artifact_store"
CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references" CAPABILITY_FILES_POSTBOX_REFERENCES = "files.postbox_references"
CAPABILITY_FILES_TABULAR_CONTENT = "files.tabular_content"
class ManagedTabularFileError(ValueError):
"""Stable base error for exact-version managed tabular file access."""
class ManagedTabularFileNotFoundError(ManagedTabularFileError):
pass
class ManagedTabularFileAccessError(ManagedTabularFileError):
pass
class ManagedTabularFileUnavailableError(ManagedTabularFileError):
pass
class ManagedTabularFileValidationError(ManagedTabularFileError):
pass
@dataclass(frozen=True, slots=True)
class ManagedTabularFile:
"""Authorized metadata for one immutable managed file version."""
file_asset_id: str
file_version_id: str
filename: str
display_path: str
content_type: str | None
size_bytes: int
sha256: str
updated_at: datetime | None = None
current_version: bool = True
@dataclass(frozen=True, slots=True)
class ManagedTabularFileContent:
file: ManagedTabularFile
payload: bytes
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -91,6 +134,39 @@ class PostboxFileReferenceProvider(Protocol):
) -> tuple[PostboxFileReferenceRef, ...]: ... ) -> tuple[PostboxFileReferenceRef, ...]: ...
@runtime_checkable
class ManagedTabularFileProvider(Protocol):
"""List and open authorized CSV/XLSX content without exposing Files internals."""
def list_tabular_files(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[ManagedTabularFile, ...]: ...
def get_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str | None = None,
) -> ManagedTabularFile | None: ...
def read_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str,
max_bytes: int,
) -> ManagedTabularFileContent: ...
def postbox_file_reference_provider( def postbox_file_reference_provider(
registry: object | None, registry: object | None,
) -> PostboxFileReferenceProvider | None: ) -> PostboxFileReferenceProvider | None:
@@ -107,3 +183,46 @@ def postbox_file_reference_provider(
"PostboxFileReferenceProvider" "PostboxFileReferenceProvider"
) )
return provider return provider
def managed_tabular_file_provider(
registry: object | None,
) -> ManagedTabularFileProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_FILES_TABULAR_CONTENT)
):
return None
provider = registry.require_capability(CAPABILITY_FILES_TABULAR_CONTENT)
if not isinstance(provider, ManagedTabularFileProvider):
raise TypeError(
"files.tabular_content provider does not implement "
"ManagedTabularFileProvider"
)
return provider
__all__ = [
"CAPABILITY_FILES_ACCESS",
"CAPABILITY_FILES_ARTIFACT_STORE",
"CAPABILITY_FILES_POSTBOX_REFERENCES",
"CAPABILITY_FILES_TABULAR_CONTENT",
"FileAccessProvider",
"ManagedArtifactRef",
"ManagedArtifactStore",
"ManagedArtifactWriteRequest",
"ManagedTabularFile",
"ManagedTabularFileAccessError",
"ManagedTabularFileContent",
"ManagedTabularFileError",
"ManagedTabularFileNotFoundError",
"ManagedTabularFileProvider",
"ManagedTabularFileUnavailableError",
"ManagedTabularFileValidationError",
"PostboxFileReferenceProvider",
"PostboxFileReferenceRef",
"PostboxFileReferenceRequest",
"managed_tabular_file_provider",
"postbox_file_reference_provider",
]
+9
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.datasources import ( from govoplan_core.core.datasources import (
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS,
CAPABILITY_DATASOURCE_CATALOGUE, CAPABILITY_DATASOURCE_CATALOGUE,
CAPABILITY_DATASOURCE_LIFECYCLE, CAPABILITY_DATASOURCE_LIFECYCLE,
CAPABILITY_DATASOURCE_ORIGINS, CAPABILITY_DATASOURCE_ORIGINS,
CAPABILITY_DATASOURCE_PUBLICATION, CAPABILITY_DATASOURCE_PUBLICATION,
DatasourceCatalogueProvider, DatasourceCatalogueProvider,
DatasourceArtifactBackendProvider,
DatasourceDescriptor, DatasourceDescriptor,
DatasourceField, DatasourceField,
DatasourceLifecycleProvider, DatasourceLifecycleProvider,
@@ -20,6 +22,7 @@ from govoplan_core.core.datasources import (
DatasourceReadResult, DatasourceReadResult,
DatasourceStage, DatasourceStage,
datasource_catalogue, datasource_catalogue,
datasource_artifact_backend_provider,
datasource_lifecycle, datasource_lifecycle,
datasource_origins, datasource_origins,
datasource_publication, datasource_publication,
@@ -155,6 +158,9 @@ class _Provider:
truncated=False, truncated=False,
) )
def artifact_backends(self):
return ()
class DatasourceContractTests(unittest.TestCase): class DatasourceContractTests(unittest.TestCase):
def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None: def test_capabilities_are_runtime_checkable_and_resolved_without_modules(self) -> None:
@@ -163,6 +169,7 @@ class DatasourceContractTests(unittest.TestCase):
self.assertIsInstance(provider, DatasourceLifecycleProvider) self.assertIsInstance(provider, DatasourceLifecycleProvider)
self.assertIsInstance(provider, DatasourcePublicationProvider) self.assertIsInstance(provider, DatasourcePublicationProvider)
self.assertIsInstance(provider, DatasourceOriginProvider) self.assertIsInstance(provider, DatasourceOriginProvider)
self.assertIsInstance(provider, DatasourceArtifactBackendProvider)
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register( registry.register(
ModuleManifest( ModuleManifest(
@@ -174,6 +181,7 @@ class DatasourceContractTests(unittest.TestCase):
CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider, CAPABILITY_DATASOURCE_LIFECYCLE: lambda context: provider,
CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider, CAPABILITY_DATASOURCE_PUBLICATION: lambda context: provider,
CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider, CAPABILITY_DATASOURCE_ORIGINS: lambda context: provider,
CAPABILITY_DATASOURCE_ARTIFACT_BACKENDS: lambda context: provider,
}, },
) )
) )
@@ -183,6 +191,7 @@ class DatasourceContractTests(unittest.TestCase):
self.assertIs(provider, datasource_lifecycle(registry)) self.assertIs(provider, datasource_lifecycle(registry))
self.assertIs(provider, datasource_publication(registry)) self.assertIs(provider, datasource_publication(registry))
self.assertIs(provider, datasource_origins(registry)) self.assertIs(provider, datasource_origins(registry))
self.assertIs(provider, datasource_artifact_backend_provider(registry))
self.assertIsNone(datasource_catalogue(PlatformRegistry())) self.assertIsNone(datasource_catalogue(PlatformRegistry()))
def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None: def test_descriptor_distinguishes_mode_kind_shape_and_materialization(self) -> None:
+90
View File
@@ -0,0 +1,90 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from govoplan_core.core.files import (
CAPABILITY_FILES_TABULAR_CONTENT,
ManagedTabularFile,
ManagedTabularFileContent,
ManagedTabularFileProvider,
managed_tabular_file_provider,
)
class _Provider:
def list_tabular_files(self, session, principal, *, query="", limit=100):
del session, principal, query, limit
return ()
def get_tabular_file(
self,
session,
principal,
*,
file_asset_id,
file_version_id=None,
):
del session, principal, file_asset_id, file_version_id
return None
def read_tabular_file(
self,
session,
principal,
*,
file_asset_id,
file_version_id,
max_bytes,
):
del session, principal, file_asset_id, file_version_id, max_bytes
file = ManagedTabularFile(
file_asset_id="asset-1",
file_version_id="version-1",
filename="source.csv",
display_path="Imports/source.csv",
content_type="text/csv",
size_bytes=8,
sha256="a" * 64,
updated_at=datetime(2026, 8, 21, tzinfo=UTC),
)
return ManagedTabularFileContent(file=file, payload=b"id\n1\n")
class _Registry:
def __init__(self, provider):
self.provider = provider
def has_capability(self, name):
return name == CAPABILITY_FILES_TABULAR_CONTENT
def require_capability(self, name):
if not self.has_capability(name):
raise KeyError(name)
return self.provider
class ManagedTabularFileContractTests(unittest.TestCase):
def test_runtime_protocol_and_registry_resolution(self) -> None:
provider = _Provider()
self.assertIsInstance(provider, ManagedTabularFileProvider)
self.assertIs(provider, managed_tabular_file_provider(_Registry(provider)))
self.assertIsNone(managed_tabular_file_provider(None))
def test_exact_version_content_retains_safe_metadata(self) -> None:
result = _Provider().read_tabular_file(
object(),
object(),
file_asset_id="asset-1",
file_version_id="version-1",
max_bytes=100,
)
self.assertEqual("version-1", result.file.file_version_id)
self.assertEqual("a" * 64, result.file.sha256)
self.assertEqual(b"id\n1\n", result.payload)
if __name__ == "__main__":
unittest.main()
+6
View File
@@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.admin.models import SystemSettings
from govoplan_core.celery_app import _run_tenant_worker_batches from govoplan_core.celery_app import _run_tenant_worker_batches
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.lifecycle import require_module_active from govoplan_core.core.lifecycle import require_module_active
@@ -26,6 +27,7 @@ from govoplan_core.core.module_entitlements import (
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database, get_database from govoplan_core.db.session import configure_database, get_database
from govoplan_core.db.base import Base
from govoplan_core.server.platform import create_platform_router from govoplan_core.server.platform import create_platform_router
from govoplan_core.tenancy.scope import Tenant, create_scope_tables from govoplan_core.tenancy.scope import Tenant, create_scope_tables
@@ -257,6 +259,10 @@ class TenantModuleEntitlementRouteTests(unittest.TestCase):
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-")) root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
configure_database(f"sqlite:///{root / 'test.db'}") configure_database(f"sqlite:///{root / 'test.db'}")
create_scope_tables(get_database().engine) create_scope_tables(get_database().engine)
Base.metadata.create_all(
bind=get_database().engine,
tables=[SystemSettings.__table__],
)
self.manifests = ( self.manifests = (
ModuleManifest(id="access", name="Access", version="test"), ModuleManifest(id="access", name="Access", version="test"),
ModuleManifest( ModuleManifest(
+28
View File
@@ -26,6 +26,7 @@ import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
@@ -149,6 +150,7 @@ export default function ConformanceApp() {
</section> </section>
<LaunchContextScenario /> <LaunchContextScenario />
{new URLSearchParams(location.search).has("help") ? <HelpConformanceScenario /> : null}
</PageLayout> </PageLayout>
<Dialog <Dialog
@@ -170,6 +172,32 @@ export default function ConformanceApp() {
); );
} }
function HelpConformanceScenario() {
return (
<section className="conformance-section" aria-labelledby="help-heading">
<h2 id="help-heading">Kontextsensitive Hilfe</h2>
<p>F1 löst den Hilfekontext des fokussierten Bedienelements auf und kehrt nach dem Schließen dorthin zurück.</p>
<ActionToolbar surface="subtle">
<Button
variant="danger"
data-testid="f1-retention-action"
helpContextId="policy.retention.action.apply"
helpModuleId="policy"
>
Aufbewahrungsregeln anwenden
</Button>
<Button
data-testid="f1-fallback-action"
interfaceId="core.conformance.action.review"
>
Allgemeine Aktion prüfen
</Button>
<HelpMenu auth={null} />
</ActionToolbar>
</section>
);
}
function QuickAccessScenario() { function QuickAccessScenario() {
const location = useLocation(); const location = useLocation();
const mode = new URLSearchParams(location.search).get("quick-access"); const mode = new URLSearchParams(location.search).get("quick-access");
+3
View File
@@ -0,0 +1,3 @@
const installedModules: Array<never> = [];
export default installedModules;
+22 -3
View File
@@ -3,6 +3,9 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter } from "react-router";
import ConformanceApp from "./ConformanceApp"; import ConformanceApp from "./ConformanceApp";
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { PlatformWebModule } from "../src/types";
import "../src/styles/tokens.css"; import "../src/styles/tokens.css";
import "../src/styles/layout.css"; import "../src/styles/layout.css";
import "../src/styles/forms.css"; import "../src/styles/forms.css";
@@ -16,12 +19,28 @@ import "./conformance.css";
const theme = new URLSearchParams(window.location.search).get("theme"); const theme = new URLSearchParams(window.location.search).get("theme");
if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme; if (theme === "dark" || theme === "light") document.documentElement.dataset.theme = theme;
const CONFORMANCE_MODULES: PlatformWebModule[] = [{
id: "policy",
label: "Richtlinien",
version: "1",
helpContexts: [{
id: "policy.retention.action.apply",
topic_id: "policy.admin.retention-and-disposition",
title: "Aufbewahrungsregeln sicher anwenden",
documentation_types: ["admin"]
}]
}];
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<UnsavedChangesProvider> <PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<ConformanceApp /> <PlatformLanguageProvider preferredLanguageCode="de">
</UnsavedChangesProvider> <UnsavedChangesProvider>
<ConformanceApp />
</UnsavedChangesProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );
@@ -110,6 +110,44 @@ test("Quick Access preserves focus and adapts to a narrow viewport", async ({ pa
await expect(filesTrigger).toBeFocused(); await expect(filesTrigger).toBeFocused();
}); });
test("F1 resolves focused high-risk help and restores focus accessibly", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light&help=1");
const retentionAction = page.getByTestId("f1-retention-action");
await retentionAction.focus();
await page.keyboard.press("F1");
const dialog = page.getByRole("dialog", { name: "Kontexthilfe" });
await expect(dialog).toBeVisible();
await expect(dialog.locator("[data-help-context]"))
.toHaveAttribute("data-help-context", "policy.retention.action.apply");
await expect(dialog.getByRole("heading", { level: 3 }))
.toHaveText("Aufbewahrungsregeln anwenden");
await expect(dialog).toContainText("topic=policy.admin.retention-and-disposition");
await expect(dialog.getByRole("button", { name: "Administrator-Dokumentation öffnen" }))
.toBeVisible();
await expectNoAccessibilityViolations(page);
const bounds = await dialog.boundingBox();
expect(bounds?.x).toBeGreaterThanOrEqual(0);
expect((bounds?.x ?? 0) + (bounds?.width ?? 0)).toBeLessThanOrEqual(390);
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(retentionAction).toBeFocused();
const fallbackAction = page.getByTestId("f1-fallback-action");
await fallbackAction.focus();
await page.keyboard.press("F1");
const fallbackDialog = page.getByRole("dialog", { name: "Kontexthilfe" });
await expect(fallbackDialog.locator("[data-help-context]"))
.toHaveAttribute("data-help-context", "core.conformance.action.review");
await expect(fallbackDialog).toContainText("fallback_context=campaigns.list");
await page.keyboard.press("Escape");
await expect(fallbackAction).toBeFocused();
});
test("View focus has a deliberate permission-derived all-tools escape", async ({ page }) => { test("View focus has a deliberate permission-derived all-tools escape", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => { await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({ await route.fulfill({
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.18", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.18",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+11 -9
View File
@@ -26,6 +26,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.restore_safe_defaults": "Restore safe defaults", "i18n:govoplan-core.restore_safe_defaults": "Restore safe defaults",
"i18n:govoplan-core.import_overrides": "Import JSON", "i18n:govoplan-core.import_overrides": "Import JSON",
"i18n:govoplan-core.export_overrides": "Export JSON", "i18n:govoplan-core.export_overrides": "Export JSON",
"i18n:govoplan-core.explanation_subject.6ff46d22": "Explanation subject",
"i18n:govoplan-core.remove_overrides": "Remove overrides", "i18n:govoplan-core.remove_overrides": "Remove overrides",
"i18n:govoplan-core.appearance_override_not_configured_help": "No personal token overrides are stored. Preset and policy inheritance remain active.", "i18n:govoplan-core.appearance_override_not_configured_help": "No personal token overrides are stored. Preset and policy inheritance remain active.",
"i18n:govoplan-core.appearance_override_policy_disabled": "The governing appearance policy currently blocks creating or editing personal overrides. Existing overrides may still be exported or removed.", "i18n:govoplan-core.appearance_override_policy_disabled": "The governing appearance policy currently blocks creating or editing personal overrides. Existing overrides may still be exported or removed.",
@@ -761,6 +762,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.restore_safe_defaults": "Sichere Standardwerte wiederherstellen", "i18n:govoplan-core.restore_safe_defaults": "Sichere Standardwerte wiederherstellen",
"i18n:govoplan-core.import_overrides": "JSON importieren", "i18n:govoplan-core.import_overrides": "JSON importieren",
"i18n:govoplan-core.export_overrides": "JSON exportieren", "i18n:govoplan-core.export_overrides": "JSON exportieren",
"i18n:govoplan-core.explanation_subject.6ff46d22": "Person für die Zugriffserklärung",
"i18n:govoplan-core.remove_overrides": "Anpassungen entfernen", "i18n:govoplan-core.remove_overrides": "Anpassungen entfernen",
"i18n:govoplan-core.appearance_override_not_configured_help": "Es sind keine persönlichen Token-Anpassungen gespeichert. Vorgaben und Richtlinienvererbung bleiben aktiv.", "i18n:govoplan-core.appearance_override_not_configured_help": "Es sind keine persönlichen Token-Anpassungen gespeichert. Vorgaben und Richtlinienvererbung bleiben aktiv.",
"i18n:govoplan-core.appearance_override_policy_disabled": "Die geltende Darstellungsrichtlinie sperrt derzeit das Anlegen oder Bearbeiten persönlicher Anpassungen. Bestehende Anpassungen können weiterhin exportiert oder entfernt werden.", "i18n:govoplan-core.appearance_override_policy_disabled": "Die geltende Darstellungsrichtlinie sperrt derzeit das Anlegen oder Bearbeiten persönlicher Anpassungen. Bestehende Anpassungen können weiterhin exportiert oder entfernt werden.",
@@ -936,7 +938,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.choose_date.e7877fd9": "Choose date", "i18n:govoplan-core.choose_date.e7877fd9": "Choose date",
"i18n:govoplan-core.clear_filter.b667d6f9": "Clear filter", "i18n:govoplan-core.clear_filter.b667d6f9": "Clear filter",
"i18n:govoplan-core.close_filter.3a281c3f": "Close filter", "i18n:govoplan-core.close_filter.3a281c3f": "Close filter",
"i18n:govoplan-core.close.bbfa773e": "Close", "i18n:govoplan-core.close.bbfa773e": "Schließen",
"i18n:govoplan-core.collapse.9cf188d3": "Collapse", "i18n:govoplan-core.collapse.9cf188d3": "Collapse",
"i18n:govoplan-core.color_picker.91dee962": "Color picker", "i18n:govoplan-core.color_picker.91dee962": "Color picker",
"i18n:govoplan-core.comfortable.2313707a": "Comfortable", "i18n:govoplan-core.comfortable.2313707a": "Comfortable",
@@ -950,7 +952,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.connection_security_mode_expected_by_the_server_.4348e12e": "Connection security mode expected by the server: plain, TLS or STARTTLS.", "i18n:govoplan-core.connection_security_mode_expected_by_the_server_.4348e12e": "Connection security mode expected by the server: plain, TLS or STARTTLS.",
"i18n:govoplan-core.connection_successful_the_backend_health_endpoin.14eb9295": "Connection successful. The backend health endpoint responded.", "i18n:govoplan-core.connection_successful_the_backend_health_endpoin.14eb9295": "Connection successful. The backend health endpoint responded.",
"i18n:govoplan-core.contains.33e15d00": "Contains", "i18n:govoplan-core.contains.33e15d00": "Contains",
"i18n:govoplan-core.context_help.7fc44ea1": "Context help", "i18n:govoplan-core.context_help.7fc44ea1": "Kontexthilfe",
"i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4": "Controls contextual UI help markers once persisted user preferences are available.", "i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4": "Controls contextual UI help markers once persisted user preferences are available.",
"i18n:govoplan-core.core_only.d46ce7d9": "Core only", "i18n:govoplan-core.core_only.d46ce7d9": "Core only",
"i18n:govoplan-core.core_shell_only.4949b5d5": "Core shell only", "i18n:govoplan-core.core_shell_only.4949b5d5": "Core shell only",
@@ -1047,9 +1049,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.group.171a0606": "Group", "i18n:govoplan-core.group.171a0606": "Group",
"i18n:govoplan-core.groups": "Gruppen", "i18n:govoplan-core.groups": "Gruppen",
"i18n:govoplan-core.headers.520de744": "Headers", "i18n:govoplan-core.headers.520de744": "Headers",
"i18n:govoplan-core.help_context.61aed3b9": "Help context:", "i18n:govoplan-core.help_context.61aed3b9": "Hilfekontext:",
"i18n:govoplan-core.help_f1": "Hilfe (F1)", "i18n:govoplan-core.help_f1": "Hilfe (F1)",
"i18n:govoplan-core.help.c47ae153": "Help", "i18n:govoplan-core.help.c47ae153": "Hilfe",
"i18n:govoplan-core.hh_mm.a4c7ee9b": "HH:MM", "i18n:govoplan-core.hh_mm.a4c7ee9b": "HH:MM",
"i18n:govoplan-core.hide_password.e40123b4": "Hide password", "i18n:govoplan-core.hide_password.e40123b4": "Hide password",
"i18n:govoplan-core.high_level_campaign_pattern_used_by_guided_setup.c5ec6652": "High-level campaign pattern used by guided setup to prefill sensible defaults.", "i18n:govoplan-core.high_level_campaign_pattern_used_by_guided_setup.c5ec6652": "High-level campaign pattern used by guided setup to prefill sensible defaults.",
@@ -1174,9 +1176,9 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.on.e0049a66": "On", "i18n:govoplan-core.on.e0049a66": "On",
"i18n:govoplan-core.only_users_with_maintenance_access_can_use_the_s.5566264f": "Only users with maintenance access can use the system right now.", "i18n:govoplan-core.only_users_with_maintenance_access_can_use_the_s.5566264f": "Only users with maintenance access can use the system right now.",
"i18n:govoplan-core.open_address_form.f8ee560f": "Open address form", "i18n:govoplan-core.open_address_form.f8ee560f": "Open address form",
"i18n:govoplan-core.open_admin_documentation.6adbdae3": "Open admin documentation", "i18n:govoplan-core.open_admin_documentation.6adbdae3": "Administrator-Dokumentation öffnen",
"i18n:govoplan-core.open_maintenance_mode_settings.99b41249": "Open maintenance mode settings", "i18n:govoplan-core.open_maintenance_mode_settings.99b41249": "Open maintenance mode settings",
"i18n:govoplan-core.open_user_documentation.084af515": "Open user documentation", "i18n:govoplan-core.open_user_documentation.084af515": "Benutzerdokumentation öffnen",
"i18n:govoplan-core.optional_backend_url_leave_empty_to_use_the_curr.52bd2bb4": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.", "i18n:govoplan-core.optional_backend_url_leave_empty_to_use_the_curr.52bd2bb4": "Optional backend URL. Leave empty to use the current origin and the configured Vite/backend proxy.",
"i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.", "i18n:govoplan-core.optional_html_version_of_the_email_body_for_rich.3f01db97": "Optional HTML version of the email body for richer formatting.",
"i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.", "i18n:govoplan-core.optional_local_alias_for_the_active_tenant_it_do.edbb7b14": "Optional local alias for the active tenant. It does not replace the global account name in the title bar.",
@@ -1184,7 +1186,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.owner.89ff3122": "Eigentümer", "i18n:govoplan-core.owner.89ff3122": "Eigentümer",
"i18n:govoplan-core.owner_policy.1e8df143": "Owner policy", "i18n:govoplan-core.owner_policy.1e8df143": "Owner policy",
"i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>", "i18n:govoplan-core.p_no_html_body_content_p.c305bfc6": "<p>No HTML body content.</p>",
"i18n:govoplan-core.page.fb06270f": "Page", "i18n:govoplan-core.page.fb06270f": "Seite",
"i18n:govoplan-core.password_for_the_selected_account_or_mail_server.d9681f16": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.", "i18n:govoplan-core.password_for_the_selected_account_or_mail_server.d9681f16": "Password for the selected account or mail server. Stored and transmitted according to the configured backend flow.",
"i18n:govoplan-core.password_protected.09d9e174": "Password protected", "i18n:govoplan-core.password_protected.09d9e174": "Password protected",
"i18n:govoplan-core.password.8be3c943": "Passwort", "i18n:govoplan-core.password.8be3c943": "Passwort",
@@ -1346,12 +1348,12 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.theme_and_language.60a8cf59": "Design und Sprache", "i18n:govoplan-core.theme_and_language.60a8cf59": "Design und Sprache",
"i18n:govoplan-core.theme.a797e309": "Theme", "i18n:govoplan-core.theme.a797e309": "Theme",
"i18n:govoplan-core.this_address_is_already_listed.0f6d0ff2": "This address is already listed.", "i18n:govoplan-core.this_address_is_already_listed.0f6d0ff2": "This address is already listed.",
"i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877": "This area is prepared for context-sensitive help. Future help content can use this context identifier, or the equivalent help query parameter", "i18n:govoplan-core.this_area_is_prepared_for_context_sensitive_help.57665877": "Das fokussierte Bedienelement verweist auf den folgenden dokumentierten Hilfekontext:",
"i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e": "This global name is shown in the title bar and follows you across tenant memberships.", "i18n:govoplan-core.this_global_name_is_shown_in_the_title_bar_and_f.1d46240e": "This global name is shown in the title bar and follows you across tenant memberships.",
"i18n:govoplan-core.this_module_is_prepared_but_not_implemented_yet.6e6449ee": "Dieses Modul ist vorbereitet, aber noch nicht umgesetzt.", "i18n:govoplan-core.this_module_is_prepared_but_not_implemented_yet.6e6449ee": "Dieses Modul ist vorbereitet, aber noch nicht umgesetzt.",
"i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b": "This page has unsaved changes. Save them before leaving, or discard the changes and continue.", "i18n:govoplan-core.this_page_has_unsaved_changes_save_them_before_l.419a9d8b": "This page has unsaved changes. Save them before leaving, or discard the changes and continue.",
"i18n:govoplan-core.timeout_seconds.0bfc6553": "Timeout seconds", "i18n:govoplan-core.timeout_seconds.0bfc6553": "Timeout seconds",
"i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2": "to open the right page or section.", "i18n:govoplan-core.to_open_the_right_page_or_section.5ecf4fd2": "Öffnen Sie die verknüpfte Dokumentation für Berechtigungen, Folgen und Hinweise zur Wiederherstellung.",
"i18n:govoplan-core.to.ae79ea1e": "To", "i18n:govoplan-core.to.ae79ea1e": "To",
"i18n:govoplan-core.tu.b44892b7": "Tu", "i18n:govoplan-core.tu.b44892b7": "Tu",
"i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0": "Type a name and email address, then press Enter", "i18n:govoplan-core.type_a_name_and_email_address_then_press_enter.7c8d43f0": "Type a name and email address, then press Enter",
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@govoplan/core-webui": resolve(import.meta.dirname, "conformance/QuickAccessCoreFacade.ts"), "@govoplan/core-webui": resolve(import.meta.dirname, "conformance/QuickAccessCoreFacade.ts"),
"virtual:govoplan-installed-modules": resolve(import.meta.dirname, "conformance/installedModules.ts"),
react: resolve(import.meta.dirname, "node_modules/react"), react: resolve(import.meta.dirname, "node_modules/react"),
"react-router": resolve(import.meta.dirname, "node_modules/react-router"), "react-router": resolve(import.meta.dirname, "node_modules/react-router"),
"lucide-react": resolve(import.meta.dirname, "node_modules/lucide-react") "lucide-react": resolve(import.meta.dirname, "node_modules/lucide-react")