14 Commits
Author SHA1 Message Date
zemion 0a7bd5b3b4 fix(ui): align contextual documentation with headings
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:46 +02:00
zemion 00bec0ed94 fix(packaging): expose immutable WebUI Git package for v0.1.23
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:09 +02:00
zemion cfcd723496 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:34 +02:00
zemion 06afa79795 docs(forms): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 20:27:25 +02:00
zemion 727c70f756 Release Forms v0.1.20 configuration package provider
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 18:04:34 +02:00
zemion b9132d076d feat: expose form semantic documentation subjects
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 15:44:42 +02:00
zemion 5bc651a8e7 feat(forms): add governed DSAR coverage 2026-08-21 13:20:28 +02:00
zemion 0d7d18c486 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 178d08c729 feat: align forms with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 50e0172f65 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion abf35d75f3 Adopt shared WebUI layout primitives 2026-08-18 10:42:52 +02:00
zemion cf7da905a7 Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:51 +02:00
zemion a80e2fa870 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:34:13 +02:00
zemion b3cd080e5a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:29 +02:00
21 changed files with 2246 additions and 138 deletions
+18
View File
@@ -39,3 +39,21 @@ PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
``` ```
See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision. See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision.
## Git-source WebUI package
The repository root exposes `@govoplan/forms-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/forms-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+4
View File
@@ -70,6 +70,10 @@ Form definitions should carry:
option, constraint, draft, attachment, signature, policy, and handoff editing. option, constraint, draft, attachment, signature, policy, and handoff editing.
- Forms Runtime performs value validation against the resolved definition; the - Forms Runtime performs value validation against the resolved definition; the
definition owner does not persist submissions. definition owner does not persist submissions.
- Forms consumes Core's title-help and text-help layout contracts for its
catalogue and revision dialog. Workflow books sit beside their heading;
semantic documentation stays beside the Form meaning label for the exact
definition. The placement contract does not move content ownership to Core.
## Recovery And Operations ## Recovery And Operations
+13 -2
View File
@@ -29,5 +29,16 @@ submissions, receipts, and handoff execution.
The module uses shared controls, dialogs, blockers, field help, statuses, The module uses shared controls, dialogs, blockers, field help, statuses,
loading, empty/error states, disabled reasons, confirmations, and unsaved-draft loading, empty/error states, disabled reasons, confirmations, and unsaved-draft
guards. Existing responsive list/editor layouts remain bounded. English and guards. The catalogue now composes `WorkspaceFrame`, `ActionToolbar`,
German catalogues cover the owned route and editor vocabulary. `FilterBar`, and `StatePanel`; equal-column definition/editor groups use
`FormGrid`, while field, page, localization, and preview headings use the
shared section-header toolbar surface. Core therefore owns viewport, search,
state, heading, and narrow-layout geometry. These contracts do not change
filter scope, permissions, revision semantics, or publication consequences.
English and German catalogues cover the owned route and editor vocabulary.
Documentation books belong immediately beside their contextual text. The
catalogue uses Core's workspace title and `titleHelp`; the revision dialog uses
its title help, and the separate Form meaning label carries semantic help for
that exact definition. Field help stays beside its label. This uses Core layout
contracts and does not change revision, publication, or runtime ownership.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/forms-webui",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/forms.css": "./webui/src/styles/forms.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-forms" name = "govoplan-forms"
version = "0.1.15" version = "0.1.23"
description = "Immutable reusable form definitions for GovOPlaN." description = "Immutable reusable form definitions for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.15", "govoplan-access>=0.1.15"] dependencies = ["govoplan-core>=0.1.35", "govoplan-access>=0.1.18"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Forms module.""" """GovOPlaN Forms module."""
__version__ = "0.1.15" __version__ = "0.1.23"
@@ -0,0 +1,359 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult,
ConfigurationDiagnostic,
ConfigurationExportResult,
ConfigurationExportSelection,
ConfigurationPackageFragment,
ConfigurationPlanItem,
ConfigurationPreflightContext,
ConfigurationPreflightResult,
ConfigurationProvider,
ConfigurationProviderDescription,
)
from govoplan_core.db.session import get_database
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
assess_form_definition_fragment,
export_form_definition_fragment,
get_form_definition,
import_form_definition_fragment,
list_form_definitions,
)
FORMS_CONFIGURATION_CAPABILITY = "forms.configuration"
_WRITE_SCOPES = frozenset(
{
"forms:definition:write",
"admin:settings:write",
"system:settings:write",
"system:governance:write",
}
)
@dataclass(frozen=True, slots=True)
class _ConfigurationPrincipal:
tenant_id: str
account_id: str | None = None
class SqlFormsConfigurationProvider(ConfigurationProvider):
module_id = "forms"
def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription(
module_id=self.module_id,
fragment_types=("definition",),
schema_refs={
"definition": "govoplan/forms/configuration/definition.v1",
},
exported_scopes=("tenant",),
)
def preflight(
self,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> ConfigurationPreflightResult:
with get_database().session() as session:
return _preflight_definition(session, fragment, context)
def apply(
self,
fragment: ConfigurationPackageFragment,
supplied_data: Mapping[str, Any],
context: ConfigurationPreflightContext,
) -> ConfigurationApplyResult:
del supplied_data
with get_database().session() as session:
result = _apply_definition(session, fragment, context)
if not any(item.severity == "blocker" for item in result.diagnostics):
session.commit()
return result
def export(
self,
selection: ConfigurationExportSelection,
context: ConfigurationPreflightContext,
) -> ConfigurationExportResult:
tenant_id = selection.tenant_id or context.tenant_id
if not tenant_id:
return ConfigurationExportResult(diagnostics=(_tenant_required(),))
principal = _ConfigurationPrincipal(
tenant_id=tenant_id,
account_id=context.operator_user_id,
)
selected_ids = {
item.removeprefix("form:")
for item in selection.object_refs
if item.startswith("form:")
}
with get_database().session() as session:
definitions, _total = list_form_definitions(
session,
principal,
limit=200,
)
fragments = tuple(
ConfigurationPackageFragment(
module_id=self.module_id,
fragment_type="definition",
fragment_id=definition.reference.object_id,
payload={
"fragment": export_form_definition_fragment(
definition,
exported_at=datetime.now(UTC),
exported_by=context.operator_user_id,
),
"on_conflict": "new_revision",
},
)
for definition in definitions
if not selected_ids
or definition.reference.object_id in selected_ids
)
return ConfigurationExportResult(fragments=fragments)
def health(
self,
import_result: ConfigurationApplyResult,
context: ConfigurationPreflightContext,
) -> tuple[ConfigurationDiagnostic, ...]:
del context
return tuple(
item for item in import_result.diagnostics if item.severity == "blocker"
)
def _preflight_definition(
session: Any,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> ConfigurationPreflightResult:
if fragment.fragment_type != "definition":
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
if not context.tenant_id:
return ConfigurationPreflightResult(
diagnostics=(_tenant_required(fragment),),
plan=(_plan("blocked", fragment, "Select a target tenant."),),
)
if not (_WRITE_SCOPES & context.operator_scopes):
return ConfigurationPreflightResult(
diagnostics=(_write_scope_required(fragment),),
plan=(_plan("blocked", fragment, "Forms write authority is missing."),),
)
principal = _ConfigurationPrincipal(
tenant_id=context.tenant_id,
account_id=context.operator_user_id,
)
try:
parsed = _definition_payload(fragment)
assessment = assess_form_definition_fragment(
session,
principal,
fragment=parsed["fragment"],
)
except (FormDefinitionStoreError, ValueError) as exc:
return ConfigurationPreflightResult(
diagnostics=(_invalid(fragment, str(exc)),),
plan=(_plan("blocked", fragment, "Forms fragment is invalid."),),
)
target_form_id = parsed["target_form_id"] or str(
assessment["source"]["form_id"]
)
current = get_form_definition(
session,
principal,
form_id=target_form_id,
)
if current is not None and _is_replay(current.metadata, parsed["fragment"]):
return ConfigurationPreflightResult(
plan=(_plan("noop", fragment, "The same source definition is already imported."),)
)
if current is not None and parsed["on_conflict"] != "new_revision":
return ConfigurationPreflightResult(
diagnostics=(_conflict(fragment, target_form_id),),
plan=(_plan("blocked", fragment, "Existing definition is preserved by package policy."),),
)
return ConfigurationPreflightResult(
plan=(_plan(
"update" if current is not None else "create",
fragment,
f"{'Create a new revision of' if current is not None else 'Create'} Form definition {target_form_id} as a draft.",
),)
)
def _apply_definition(
session: Any,
fragment: ConfigurationPackageFragment,
context: ConfigurationPreflightContext,
) -> ConfigurationApplyResult:
preflight = _preflight_definition(session, fragment, context)
if any(item.severity == "blocker" for item in preflight.diagnostics):
return ConfigurationApplyResult(diagnostics=preflight.diagnostics)
parsed = _definition_payload(fragment)
assert context.tenant_id is not None
principal = _ConfigurationPrincipal(
tenant_id=context.tenant_id,
account_id=context.operator_user_id,
)
source = parsed["fragment"].get("provenance")
source_form_id = (
str(source.get("form_id") or "").strip()
if isinstance(source, Mapping)
else ""
)
target_form_id = parsed["target_form_id"] or source_form_id
current = get_form_definition(session, principal, form_id=target_form_id)
if current is not None and _is_replay(current.metadata, parsed["fragment"]):
return ConfigurationApplyResult()
imported = import_form_definition_fragment(
session,
principal,
fragment=parsed["fragment"],
target_form_id=target_form_id,
target_key=parsed["target_key"],
expected_revision=current.reference.version if current is not None else None,
change_reason=parsed["change_reason"],
recorded_at=datetime.now(UTC),
)
reference = f"form:{imported.reference.object_id}:{imported.reference.version}"
key = fragment.fragment_id or imported.reference.object_id
if current is None:
return ConfigurationApplyResult(created_refs={key: reference})
return ConfigurationApplyResult(updated_refs={key: reference})
def _definition_payload(fragment: ConfigurationPackageFragment) -> dict[str, Any]:
allowed = {
"fragment",
"target_form_id",
"target_key",
"change_reason",
"on_conflict",
}
unknown = sorted(set(fragment.payload) - allowed)
if unknown:
raise FormDefinitionStoreError(
f"Forms configuration payload contains unsupported fields: {', '.join(unknown)}."
)
source = fragment.payload.get("fragment")
if not isinstance(source, Mapping):
raise FormDefinitionStoreError(
"Forms configuration definition requires a fragment object."
)
on_conflict = str(fragment.payload.get("on_conflict") or "preserve").strip().casefold()
if on_conflict not in {"preserve", "new_revision"}:
raise FormDefinitionStoreError(
"Forms configuration on_conflict must be preserve or new_revision."
)
return {
"fragment": dict(source),
"target_form_id": _optional_text(fragment.payload.get("target_form_id")),
"target_key": _optional_text(fragment.payload.get("target_key")),
"change_reason": _optional_text(fragment.payload.get("change_reason"))
or "Imported through a reviewed configuration package.",
"on_conflict": on_conflict,
}
def _is_replay(metadata: Mapping[str, Any], source: Mapping[str, Any]) -> bool:
package_import = metadata.get("package_import")
if not isinstance(package_import, Mapping):
return False
return bool(source.get("definition_sha256")) and (
str(package_import.get("source_sha256") or "")
== str(source.get("definition_sha256") or "")
)
def _plan(
action: str,
fragment: ConfigurationPackageFragment,
summary: str,
) -> ConfigurationPlanItem:
return ConfigurationPlanItem(
action=action, # type: ignore[arg-type]
module_id=fragment.module_id,
fragment_type=fragment.fragment_type,
fragment_id=fragment.fragment_id,
summary=summary,
)
def _tenant_required(
fragment: ConfigurationPackageFragment | None = None,
) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="tenant_required",
message="Forms configuration import and export require a target tenant.",
module_id="forms",
object_ref=(fragment.fragment_id or fragment.fragment_type) if fragment else None,
resolution="Select a tenant before continuing.",
)
def _write_scope_required(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="forms_configuration_write_scope_required",
message="The operator may not import Form definitions for this tenant.",
module_id="forms",
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Use an approved Forms designer or system configuration administrator.",
)
def _conflict(fragment: ConfigurationPackageFragment, form_id: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="forms_configuration_conflict",
message=f"Form definition {form_id!r} already exists and preserve is selected.",
module_id="forms",
object_ref=form_id,
resolution="Keep the local definition, choose a different target id, or review a package that explicitly creates a new revision.",
)
def _invalid(fragment: ConfigurationPackageFragment, message: str) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="forms_configuration_payload_invalid",
message=message,
module_id="forms",
object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Use a Forms definition fragment compatible with the installed provider schema.",
)
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
return ConfigurationDiagnostic(
severity="blocker",
code="fragment_type_unsupported",
message=f"Forms configuration does not support fragment type {fragment.fragment_type!r}.",
module_id="forms",
object_ref=fragment.fragment_id or fragment.fragment_type,
)
def _optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
__all__ = [
"FORMS_CONFIGURATION_CAPABILITY",
"SqlFormsConfigurationProvider",
]
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
FORMS_DSAR_CAPABILITY = dsar_capability_name("forms")
_MAX_RECORDS = 5_000
_CONFLICT = object()
class FormsDsarProvider:
provider_id = "forms"
module_id = "forms"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _selectors(subject)
if selectors is None:
return ()
account_id, form_id, revision_id = selectors
query = db.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.changed_by == account_id,
)
if form_id:
query = query.filter(FormDefinitionRevision.form_id == form_id)
if revision_id:
query = query.filter(FormDefinitionRevision.id == revision_id)
rows = (
query.order_by(
FormDefinitionRevision.recorded_at,
FormDefinitionRevision.id,
)
.limit(_MAX_RECORDS + 1)
.all()
)
if len(rows) > _MAX_RECORDS:
raise ValueError("Forms DSAR result limit exceeded; narrow selectors.")
return tuple(_record(row) for row in rows)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Forms DSAR subject selectors conflict.")
actions = []
for record in records:
_validate_record(record)
actions.append(
DsarErasureActionRef(
action_id=f"forms:retain:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=f"Retain {record.title}",
rationale=(
record.retention_reason
or "Form-definition attribution remains governance evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _selectors(subject) is None:
raise ValueError("Forms DSAR subject selectors conflict.")
results = []
for action in actions:
_validate_action(action)
if action.executable or action.kind != "retain":
raise ValueError("Forms DSAR publishes retain actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Form-definition attribution remains governance evidence.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _selectors(subject: DsarSubjectRef) -> tuple[str, str | None, str | None] | None:
references = subject.external_references
account = _coalesce(
subject.account_id,
references.get("forms.account"),
references.get("access.account"),
)
form_id = _coalesce(references.get("forms.form"), references.get("forms.form_id"))
revision_id = _coalesce(
references.get("forms.revision"), references.get("forms.revision_id")
)
if account is _CONFLICT or form_id is _CONFLICT or revision_id is _CONFLICT:
return None
if not isinstance(account, str) or not account:
return None
return (
account,
form_id if isinstance(form_id, str) else None,
revision_id if isinstance(revision_id, str) else None,
)
def _record(row: FormDefinitionRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="forms",
module_id="forms",
resource_type="form_definition_actor_attribution",
resource_id=row.id,
category="form_definition_governance_attribution",
title="Form-definition actor attribution",
data={
"form_id": row.form_id,
"revision_id": row.id,
"revision": row.revision,
"publication_state": row.publication_state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_form_definition_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason=(
"Form-definition author attribution is retained with immutable schema history."
),
)
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Forms DSAR requires a SQLAlchemy Session.")
return value
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "forms" or record.module_id != "forms":
raise ValueError("Forms DSAR cannot plan a foreign provider record.")
if (
record.resource_type != "form_definition_actor_attribution"
or not record.resource_id
):
raise ValueError("Forms DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "forms" or action.module_id != "forms":
raise ValueError("Forms DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("forms:retain:"):
raise ValueError("Forms DSAR action identity is invalid.")
__all__ = ["FORMS_DSAR_CAPABILITY", "FormsDsarProvider"]
@@ -0,0 +1,84 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'forms.data-subject-requests': {'consequence_classes': {'exclude_form_semantics': 'Gibt keinen '
'Schema-, Feld- '
'oder '
'Submission-Value-Inhalt '
'zurück.',
'export_definition_attribution': 'Returns '
'minimierten '
'die '
'unveränderliche '
'Revisionsaktivität.'}},
'forms.definitions': {'privacy_notes': ['Definitionskataloge enthalten Schemata und '
'Richtlinienreferenzen, keine eingereichten '
'Formularwerte.',
'Die Paketbewertung gewährt keinen Zugriff auf '
'referenzierte Laufzeiteinreichungen oder externe '
'Anbieter.',
'Veröffentlichte Zugänglichkeits- und '
'Lokalisierungsinhalte sind überall dort sichtbar, wo die '
'genaue Definition autorisiert ist.']},
'forms.reference.fields-and-consequences': {'consequence_classes': {'import_package': 'Erstellt '
'einen '
'lokalen '
'Entwurf '
'und behält '
'die '
'Herkunft '
'des Pakets '
'ohne '
'automatische '
'Veröffentlichung.',
'publish': 'Macht die genaue '
'Revision für '
'zukünftige '
'autorisierte '
'Instanzen '
'verfügbar.',
'retire': 'Stoppt die '
'zukünftige '
'Nutzung, während '
'Definitionen und '
'genaue '
'Laufzeitreferenzen '
'beibehalten '
'werden.',
'save_revision': 'Erstellt '
'eine '
'unveränderliche '
'Definitionsrevision '
'mit einem '
'Änderungsgrund.'},
'limitations': ['Der Paketimport veröffentlicht '
'niemals automatisch eine '
'Formularrevision.',
'Laufzeiteingaben und eingereichte '
'Werte werden niemals als '
'Definitionskonfiguration exportiert.',
'Generische Paket-Rollback erfordert '
'die Voranwendung Datenbank Snapshot '
'beibehalten.'],
'operational_consequences': ['Preserve blockiert eine '
'widersprüchliche lokale '
'Definition; new '
'revision muss explizit '
'überprüft werden.',
'Das erneute Anwenden '
'eines identischen '
'Quell-Digests ist '
'idempotent und erzeugt '
'keine Revision.',
'Importierte '
'Definitionen erfordern '
'eine normale '
'Überprüfung und '
'Veröffentlichung von '
'Formularen vor der '
'Verwendung zur '
'Laufzeit.']}}
+251 -8
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_forms.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path from pathlib import Path
from govoplan_core.core.access import ( from govoplan_core.core.access import (
@@ -13,6 +16,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -23,18 +27,31 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, 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.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_forms.backend.configuration_provider import FORMS_CONFIGURATION_CAPABILITY
from govoplan_forms.backend.db import models as form_models from govoplan_forms.backend.db import models as form_models
from govoplan_forms.backend.dsar_provider import (
FORMS_DSAR_CAPABILITY,
FormsDsarProvider,
)
from govoplan_forms.backend.service import SqlFormDefinitionProvider from govoplan_forms.backend.service import SqlFormDefinitionProvider
from govoplan_forms.backend.semantic_subjects import (
FormsSemanticDocumentationSubjectProvider,
)
MODULE_ID = "forms" MODULE_ID = "forms"
MODULE_NAME = "Forms" MODULE_NAME = "Forms"
MODULE_VERSION = "0.1.15" MODULE_VERSION = "0.1.23"
READ_SCOPE = "forms:definition:read" READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write" WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin" ADMIN_SCOPE = "forms:definition:admin"
@@ -44,7 +61,9 @@ OPTIONAL_DEPENDENCIES = (
"workflow_engine", "workflow_engine",
"cases", "cases",
"policy", "policy",
"docs",
) )
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -71,6 +90,24 @@ def _definitions(_context: ModuleContext) -> SqlFormDefinitionProvider:
return SqlFormDefinitionProvider() return SqlFormDefinitionProvider()
def _dsar_provider(_context: ModuleContext) -> FormsDsarProvider:
return FormsDsarProvider()
def _semantic_subjects(
_context: ModuleContext,
) -> FormsSemanticDocumentationSubjectProvider:
return FormsSemanticDocumentationSubjectProvider()
def _configuration_provider(_context: ModuleContext):
from govoplan_forms.backend.configuration_provider import (
SqlFormsConfigurationProvider,
)
return SqlFormsConfigurationProvider()
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -83,6 +120,12 @@ manifest = ModuleManifest(
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"), ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
ModuleInterfaceProvider(name=FORMS_CONFIGURATION_CAPABILITY, version="0.1.0"),
ModuleInterfaceProvider(name=FORMS_DSAR_CAPABILITY, version="0.1.0"),
ModuleInterfaceProvider(
name=SEMANTIC_SUBJECT_CAPABILITY,
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
),
), ),
permissions=( permissions=(
_permission( _permission(
@@ -145,6 +188,17 @@ manifest = ModuleManifest(
order=36, order=36,
), ),
), ),
product_areas=(
ProductAreaContribution(
id="services-cases",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.services_cases",
icon="landmark",
description="i18n:govoplan-core.product_area.services_cases_description",
surface_ids=("forms.nav.forms", "forms.route.forms"),
order=20,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="forms.navigation", id="forms.navigation",
@@ -162,13 +216,42 @@ manifest = ModuleManifest(
), ),
), ),
), ),
capability_factories={CAPABILITY_FORM_DEFINITIONS: _definitions}, capability_factories={
CAPABILITY_FORM_DEFINITIONS: _definitions,
FORMS_CONFIGURATION_CAPABILITY: _configuration_provider,
FORMS_DSAR_CAPABILITY: _dsar_provider,
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
},
capability_documentation={ capability_documentation={
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation( CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
label="Immutable form definitions", label="Immutable form definitions",
summary="Resolves exact tenant-bound form schemas without exposing Forms tables.", summary="Resolves exact tenant-bound form schemas without exposing Forms tables.",
contract_version="0.1.0", contract_version="0.1.0",
) ),
FORMS_CONFIGURATION_CAPABILITY: CapabilityDocumentation(
label="Forms configuration-package provider",
summary="Preflights, imports, and exports immutable Form definition fragments as tenant-local drafts with source provenance.",
contract_version="0.1.0",
documentation_types=("admin",),
audience=("forms_designer", "system_admin", "operator"),
),
FORMS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Forms data-subject request provider",
summary=(
"Exports minimized form-definition author attribution without schema "
"or semantic content."
),
contract_version="0.1.0",
),
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
label="Form semantic-documentation subjects",
summary=(
"Lists currently authorized form definitions, fields, and sections "
"using stable lineage identities and review fingerprints."
),
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
documentation_types=("admin", "user"),
),
}, },
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
@@ -188,17 +271,121 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( documentation=(
DocumentationTopic(
id="forms.semantic-documentation",
title="Document configured form and field meaning",
summary="Attach tenant-owned semantic guidance to an authorized form, field, or section without changing its schema.",
body=(
"When Docs is installed, Forms supplies documentation-safe subjects for each accessible current definition and its stable fields and sections. "
"The subject identity survives label and ordering changes. A deleted and later recreated key receives a new lineage identity, so old documentation remains explicitly orphaned instead of attaching silently. "
"Fingerprints change only when the relevant form, field, localization, hierarchy, validation, or visibility semantics change and request editorial review; they never publish or invalidate Docs content automatically. "
"Semantic prose can explain meaning, collection purpose, interpretation, intended and non-intended use, and examples, but cannot override field type, requiredness, constraints, validation, options, policy, or submitted values. "
"Forms rechecks tenant and read authority for discovery, direct resolution, contextual help, search, and Docs projection. If Docs is absent, form authoring and static help continue normally."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "form_designer", "information_owner", "module_admin"),
related_modules=("docs", "forms_runtime"),
links=(
DocumentationLink(
label="Semantic documentation authoring",
href="/docs/semantic",
kind="runtime",
),
DocumentationLink(
label="Forms boundary and recovery",
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
kind="repository",
),
),
translations={
"de": {
"title": "Konfigurierte Bedeutung von Formularen und Feldern dokumentieren",
"summary": (
"Mandanteneigene semantische Erläuterungen an ein berechtigtes Formular, Feld oder einen Abschnitt anfügen, ohne das "
"Schema zu verändern."
),
"body": (
"Ist Docs installiert, liefert Forms dokumentationssichere Subjekte für jede zugängliche aktuelle Definition sowie ihre "
"stabilen Felder und Abschnitte. Die Subjektidentität übersteht Änderungen an Bezeichnung und Reihenfolge. Ein gelöschter "
"und später neu angelegter Schlüssel erhält eine neue Abstammungsidentität; ältere Dokumentation bleibt ausdrücklich "
"verwaist, statt stillschweigend neu angefügt zu werden. Fingerabdrücke ändern sich nur, wenn sich relevante Formular-, "
"Feld-, Lokalisierungs-, Hierarchie-, Validierungs- oder Sichtbarkeitssemantik ändert, und fordern dann eine redaktionelle "
"Prüfung an; Docs-Inhalte werden niemals automatisch veröffentlicht oder entwertet. Semantischer Text darf Bedeutung, "
"Erhebungszweck, Interpretation, beabsichtigte und nicht beabsichtigte Verwendung sowie Beispiele erläutern, aber weder "
"Feldtyp, Pflichtstatus, Einschränkungen, Validierung, Optionen, Richtlinie noch übermittelte Werte überstimmen. Forms prüft "
"Mandant und Leseberechtigung für Ermittlung, direkte Auflösung, Kontexthilfe, Suche und Docs-Projektion erneut. Fehlt Docs, "
"funktionieren Formularerstellung und statische Hilfe unverändert weiter."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["forms.semantic-documentation"],
},
),
DocumentationTopic(
id="forms.data-subject-requests",
title="Form-definition data-subject requests",
summary=(
"Export definition-author activity without treating schemas as submitted values."
),
body=(
"Forms correlates only an exact tenant account identifier and can narrow "
"an already verified search to one form or definition revision. It "
"returns the immutable revision identifier, lifecycle state, and timing "
"of the subject's definition work. Titles, search text, schema payloads, "
"field semantics, policy references, and change-reason content are not "
"included. Forms stores no submitted values; Forms Runtime and the "
"owning service export those records separately. Definition attribution "
"is retained with immutable schema history."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "forms_runtime", "docs", "audit"),
translations={
"de": {
"title": "Betroffenenanfragen für Formulardefinitionen",
"summary": "Aktivität der Definitionsautoren exportieren, ohne Schemata als übermittelte Werte zu behandeln.",
"body": (
"Forms gleicht nur eine exakte mandantenbezogene Kontokennung ab und kann eine bereits verifizierte Suche auf ein "
"Formular oder eine Definitionsrevision begrenzen. Ausgegeben werden unveränderliche Revisionskennung, "
"Lebenszykluszustand und Zeitpunkte der Definitionsarbeit der betroffenen Person. Titel, Suchtext, Schemanutzdaten, "
"Feldsemantik, Richtlinienverweise und Inhalte von Änderungsgründen sind nicht enthalten. Forms speichert keine "
"übermittelten Werte; Forms Runtime und der zuständige Service exportieren diese Datensätze getrennt. Die Zuordnung der "
"Definitionsarbeit bleibt mit der unveränderlichen Schemahistorie erhalten."
),
}
},
metadata={
"help_contexts": ["forms.catalogue", "privacy.data-subject-requests"],
"consequence_classes": {
"export_definition_attribution": "Returns minimized immutable revision activity.",
"exclude_form_semantics": "Does not return schema, field, or submitted-value content.",
},
},
),
DocumentationTopic( DocumentationTopic(
id="forms.definitions", id="forms.definitions",
title="Reusable form definitions", title="Reusable form definitions",
summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.", summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.",
body=( body=(
"Documentation books sit beside Form definitions and the revision dialog title. The separate "
"Form meaning label carries the exact definitions semantic documentation book. "
"Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. " "Each revision fixes field types, options, constraints, draft, attachment, signature, policy, and handoff requirements. "
"Publishing is explicit; existing submissions continue to retain their exact revision." "Publishing is explicit; existing submissions continue to retain their exact revision. The catalogue, filter, empty states, "
"section headings, and definition field groups use the shared responsive layout language and reflow at narrow widths without "
"changing filter scope, permissions, or lifecycle effects."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"), audience=("user", "operator", "module_admin", "product_owner"),
conditions=(
DocumentationCondition(
any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
),
),
links=( links=(
DocumentationLink( DocumentationLink(
label="Forms boundary and recovery", label="Forms boundary and recovery",
@@ -206,7 +393,26 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
translations={
"de": {
"title": "Wiederverwendbare Formulardefinitionen erstellen",
"summary": (
"Unveränderliche versionierte Schemata erstellen, die Forms Runtime und institutionelle Services verwenden."
),
"body": (
"Dokumentationsbücher stehen neben Formulardefinitionen und dem Titel des Revisionsdialogs. "
"Die separate Bezeichnung Formularbedeutung trägt das Buch zur semantischen Dokumentation der "
"genauen Definition. "
"Jede Revision legt Feldtypen, Optionen, Einschränkungen sowie Anforderungen an Entwurf, Anlagen, Signaturen, "
"Richtlinien und Übergaben fest. Die Veröffentlichung erfolgt ausdrücklich; vorhandene Einreichungen behalten ihre "
"exakte Revision. Katalog, Filter, Leerzustände, Abschnittsüberschriften und Feldgruppen der Definition verwenden die "
"gemeinsame responsive Layoutsprache und ordnen sich bei geringer Breite neu an, ohne Filterumfang, Berechtigungen oder "
"Lebenszykluswirkungen zu verändern."
),
}
},
metadata={ metadata={
"kind": "workflow",
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
"forms.navigation", "forms.navigation",
@@ -231,7 +437,8 @@ manifest = ModuleManifest(
"become the exact runtime schema. Attachment, signature, policy, draft, and permitted-handoff settings are enforced " "become the exact runtime schema. Attachment, signature, policy, draft, and permitted-handoff settings are enforced "
"by Forms Runtime when that module is present. Publishing makes a revision available for new instances; existing " "by Forms Runtime when that module is present. Publishing makes a revision available for new instances; existing "
"instances retain their prior exact revision. Retirement prevents future use without deleting definitions or submissions. " "instances retain their prior exact revision. Retirement prevents future use without deleting definitions or submissions. "
"Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision." "Package import always creates a local draft and retains source provenance; it never silently publishes an imported revision. "
"The Forms configuration provider validates the digest-bound source fragment, target tenant, operator authority, local conflicts, and replay provenance during preflight. The conservative conflict policy preserves an existing definition unless the reviewed package explicitly requests a new revision. Reapplying the same source digest is a no-op. Export emits only definition configuration and provenance; runtime submissions and submitted values remain outside the package."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -244,6 +451,22 @@ manifest = ModuleManifest(
kind="repository", kind="repository",
), ),
), ),
translations={
"de": {
"title": "Felder und Lebenszyklusfolgen von Formulardefinitionen",
"summary": "Schema, Veröffentlichung, Lokalisierung, Richtlinien, Nachweise, Pakete und Übergaben unveränderlicher Formularrevisionen verstehen.",
"body": (
"Der stabile Schlüssel kennzeichnet die Definition; jedes Speichern erzeugt eine neue unveränderliche Revision. "
"Feldschlüssel, Typen, Einschränkungen, Sichtbarkeitsbedingungen, Seiten, Abschnitte, Auswahlwerte, Hilfen, Übersetzungen und Barrierefreiheitshinweise bilden das exakte Laufzeitschema. "
"Anhangs-, Signatur-, Richtlinien-, Entwurfs- und Übergabevorgaben werden durch Forms Runtime erzwungen, sofern das Modul vorhanden ist. "
"Die Veröffentlichung stellt eine Revision für neue Instanzen bereit; bestehende Instanzen behalten ihre genaue frühere Revision. Die Stilllegung verhindert künftige Nutzung, ohne Definitionen oder Einreichungen zu löschen. "
"Ein Paketimport erzeugt immer einen lokalen Entwurf, bewahrt die Herkunft und veröffentlicht niemals stillschweigend. "
"Der Forms-Konfigurationsprovider prüft vorab das digest-gebundene Quellfragment, den Zielmandanten, die Berechtigung, lokale Konflikte und Wiederholungsnachweise. "
"Die vorsichtige Konfliktregel erhält eine vorhandene Definition, sofern das geprüfte Paket nicht ausdrücklich eine neue Revision verlangt. Dieselbe Quelldigest erneut anzuwenden ist ein Leerlauf. "
"Der Export enthält ausschließlich Definitionskonfiguration und Herkunft; Laufzeiteinreichungen und eingegebene Werte bleiben außerhalb des Pakets."
),
}
},
metadata={ metadata={
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
@@ -264,6 +487,16 @@ manifest = ModuleManifest(
"retire": "Stops future use while retaining definitions and exact runtime references.", "retire": "Stops future use while retaining definitions and exact runtime references.",
"import_package": "Creates a local draft and retains package provenance without automatic publication.", "import_package": "Creates a local draft and retains package provenance without automatic publication.",
}, },
"limitations": [
"Package import never publishes a Form revision automatically.",
"Runtime submissions and submitted values are never exported as definition configuration.",
"Generic package rollback requires the retained pre-apply database snapshot.",
],
"operational_consequences": [
"Preserve blocks a conflicting local definition; new_revision must be explicitly reviewed.",
"Reapplying an identical source digest is idempotent and creates no revision.",
"Imported definitions require normal Forms review and publication before runtime use.",
],
}, },
), ),
), ),
@@ -274,10 +507,15 @@ manifest = ModuleManifest(
documentation_ref="docs/FORMS_BOUNDARY.md", documentation_ref="docs/FORMS_BOUNDARY.md",
test_ref="tests/test_forms.py", test_ref="tests/test_forms.py",
known_limits=( known_limits=(
"Concrete attachment/signature providers, anonymous identity profiles, richer authoring ergonomics, and target-produced accessibility evidence remain product depth.", "Concrete attachment/signature providers, anonymous identity profiles, and target-produced accessibility evidence remain product depth.",
), ),
supported_authority_modes=("native_authoritative",), supported_authority_modes=("native_authoritative",),
owned_concepts=("form definition", "form schema", "form definition revision"), owned_concepts=(
"form definition",
"form schema",
"form definition revision",
"form semantic subject identity and fingerprint",
),
non_owned_concepts=( non_owned_concepts=(
"form submission", "form submission",
"file content", "file content",
@@ -285,7 +523,7 @@ manifest = ModuleManifest(
"workflow instance", "workflow instance",
), ),
reference_packages=("product.service-to-decision",), reference_packages=("product.service-to-decision",),
migration_docs=("docs/FORMS_BOUNDARY.md",), migration_docs=("docs/FORMS_BOUNDARY.md", "docs/INTERFACE_PATTERN_MIGRATION.md"),
recovery_docs=("docs/FORMS_BOUNDARY.md",), recovery_docs=("docs/FORMS_BOUNDARY.md",),
security_docs=("docs/FORMS_BOUNDARY.md",), security_docs=("docs/FORMS_BOUNDARY.md",),
operations_docs=("docs/FORMS_BOUNDARY.md",), operations_docs=("docs/FORMS_BOUNDARY.md",),
@@ -293,5 +531,10 @@ manifest = ModuleManifest(
) )
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest
@@ -0,0 +1,503 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
SemanticDocumentationBreadcrumb,
SemanticDocumentationSubjectAnchor,
SemanticDocumentationSubjectDescriptor,
SemanticDocumentationSubjectPage,
SemanticDocumentationSubjectQuery,
SemanticDocumentationSubjectReference,
SemanticDocumentationSubjectResolution,
semantic_documentation_fingerprint,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import definition_from_mapping
SUBJECT_KIND = "form_definition"
READ_SCOPE = "forms:definition:read"
_MAX_SUBJECTS = 20_000
@dataclass(frozen=True, slots=True)
class _IdentityState:
field_ids: Mapping[str, str]
section_ids: Mapping[tuple[str, str], str]
historical_field_ids: frozenset[str]
historical_section_ids: frozenset[str]
class FormsSemanticDocumentationSubjectProvider:
provider_id = "forms.semantic_subjects"
module_id = "forms"
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage:
if not _authorized(principal, request.tenant_id):
return SemanticDocumentationSubjectPage()
db = _session(session)
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
return SemanticDocumentationSubjectPage()
rows = (
db.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == request.tenant_id,
FormDefinitionRevision.superseded_at.is_(None),
)
.order_by(FormDefinitionRevision.form_key, FormDefinitionRevision.form_id)
.all()
)
subjects: list[SemanticDocumentationSubjectDescriptor] = []
for row in rows:
definition = definition_from_mapping(row.payload)
identities = _identity_state(db, row)
subjects.extend(_descriptors(definition, identities))
if len(subjects) > _MAX_SUBJECTS:
raise ValueError(
"Forms semantic subject limit exceeded; narrow the query."
)
query = request.query.casefold().strip()
if query:
subjects = [
item
for item in subjects
if query in _descriptor_search_text(item).casefold()
]
offset = _cursor_offset(request.cursor)
selected = tuple(subjects[offset : offset + request.limit])
next_offset = offset + len(selected)
has_more = next_offset < len(subjects)
return SemanticDocumentationSubjectPage(
subjects=selected,
next_cursor=str(next_offset) if has_more else None,
has_more=has_more,
)
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
if (
reference.module_id != self.module_id
or reference.subject_kind != SUBJECT_KIND
or not _authorized(principal, reference.tenant_id)
):
return None
db = _session(session)
row = (
db.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == reference.tenant_id,
FormDefinitionRevision.form_id == reference.subject_id,
FormDefinitionRevision.superseded_at.is_(None),
)
.one_or_none()
)
if row is None:
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code="form_missing",
)
definition = definition_from_mapping(row.payload)
identities = _identity_state(db, row)
descriptor = next(
(
item
for item in _descriptors(definition, identities)
if item.reference.stable_key == reference.stable_key
),
None,
)
if descriptor is None:
anchor = reference.anchor
reason = "subject_missing"
if anchor is not None and anchor.kind == "field":
reason = (
"field_deleted"
if anchor.id in identities.historical_field_ids
else "field_missing"
)
elif anchor is not None and anchor.kind == "section":
reason = (
"section_deleted"
if anchor.id in identities.historical_section_ids
else "section_missing"
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code=reason,
)
changed = any(
expected is not None and expected != actual
for expected, actual in (
(reference.observed_revision, descriptor.reference.observed_revision),
(
reference.observed_fingerprint,
descriptor.reference.observed_fingerprint,
),
)
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="changed" if changed else "available",
subject=descriptor,
)
def _descriptors(definition, identities: _IdentityState):
form_reference = _reference(
definition,
revision=definition.temporal.revision,
fingerprint=_form_fingerprint(definition),
)
route = f"/forms?formId={quote(definition.reference.object_id, safe='')}"
form_labels = _form_labels(definition)
form_descriptions = _form_descriptions(definition)
result = [
SemanticDocumentationSubjectDescriptor(
reference=form_reference,
labels=form_labels,
descriptions=form_descriptions,
route=route,
required_scopes=(READ_SCOPE,),
)
]
field_locations = _field_locations(definition)
for field in definition.fields:
identity = identities.field_ids[field.key]
page, section = field_locations.get(field.key, (None, None))
labels = _field_labels(definition, field.key, field.label)
descriptions = _field_descriptions(definition, field.key, field.help_text)
fingerprint = _field_fingerprint(definition, field, page, section, labels)
breadcrumbs = [
SemanticDocumentationBreadcrumb(
label=_label(form_labels),
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
)
]
if page is not None:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=page.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
)
)
if section is not None:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=section.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
anchor=SemanticDocumentationSubjectAnchor(
kind="section",
id=identities.section_ids[(page.key, section.key)],
),
)
)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
anchor=SemanticDocumentationSubjectAnchor(
kind="field", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels=labels,
descriptions=descriptions,
breadcrumbs=tuple(breadcrumbs),
route=route,
route_anchor=f"field-{field.key}",
required_scopes=(READ_SCOPE,),
)
)
for page in definition.pages:
for section in page.sections:
identity = identities.section_ids[(page.key, section.key)]
labels = _section_labels(definition, section.key, section.title)
fingerprint = _section_fingerprint(definition, page, section, labels)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
anchor=SemanticDocumentationSubjectAnchor(
kind="section", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels=labels,
descriptions=(
{_fallback_locale(definition): section.description}
if section.description
else {}
),
breadcrumbs=(
SemanticDocumentationBreadcrumb(
label=_label(form_labels),
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
),
SemanticDocumentationBreadcrumb(
label=page.title,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
),
),
route=route,
route_anchor=f"section-{page.key}-{section.key}",
required_scopes=(READ_SCOPE,),
)
)
return tuple(result)
def _reference(
definition,
*,
revision: str,
fingerprint: str,
anchor: SemanticDocumentationSubjectAnchor | None = None,
) -> SemanticDocumentationSubjectReference:
return SemanticDocumentationSubjectReference(
module_id="forms",
tenant_id=definition.reference.tenant_id,
subject_kind=SUBJECT_KIND,
subject_id=definition.reference.object_id,
anchor=anchor,
observed_revision=revision,
observed_fingerprint=fingerprint,
)
def _identity_state(
session: Session,
current: FormDefinitionRevision,
) -> _IdentityState:
rows = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == current.tenant_id,
FormDefinitionRevision.form_id == current.form_id,
)
.order_by(FormDefinitionRevision.recorded_at, FormDefinitionRevision.id)
.all()
)
active_fields: dict[str, str] = {}
active_sections: dict[tuple[str, str], str] = {}
historical_fields: set[str] = set()
historical_sections: set[str] = set()
for row in rows:
definition = definition_from_mapping(row.payload)
field_keys = {field.key for field in definition.fields}
section_keys = {
(page.key, section.key)
for page in definition.pages
for section in page.sections
}
active_fields = {
key: value for key, value in active_fields.items() if key in field_keys
}
active_sections = {
key: value for key, value in active_sections.items() if key in section_keys
}
for key in sorted(field_keys):
active_fields.setdefault(key, _lineage_id("field", row.id, key))
historical_fields.add(active_fields[key])
for page_key, section_key in sorted(section_keys):
key = (page_key, section_key)
active_sections.setdefault(
key,
_lineage_id("section", row.id, page_key, section_key),
)
historical_sections.add(active_sections[key])
if row.id == current.id:
break
return _IdentityState(
field_ids=active_fields,
section_ids=active_sections,
historical_field_ids=frozenset(historical_fields),
historical_section_ids=frozenset(historical_sections),
)
def _lineage_id(kind: str, *parts: str) -> str:
value = "\x1f".join((kind, *parts)).encode()
return f"{kind}-{hashlib.sha256(value).hexdigest()[:40]}"
def _form_fingerprint(definition) -> str:
return semantic_documentation_fingerprint(
{
"revision": definition.temporal.revision,
"publication_state": definition.publication_state,
}
)
def _field_fingerprint(definition, field, page, section, labels) -> str:
return semantic_documentation_fingerprint(
{
"canonical_label": field.label,
"canonical_help": field.help_text,
"labels": labels,
"help": _field_descriptions(definition, field.key, field.help_text),
"value_type": field.value_type,
"required": field.required,
"options": list(field.options),
"constraints": dict(field.constraints),
"visibility": (
field.visibility_condition.to_dict()
if field.visibility_condition is not None
else None
),
"page": page.key if page else None,
"section": section.key if section else None,
}
)
def _section_fingerprint(definition, page, section, labels) -> str:
return semantic_documentation_fingerprint(
{
"labels": labels,
"description": section.description,
"page": page.key,
"field_keys": list(section.field_keys),
"visibility": (
section.visibility_condition.to_dict()
if section.visibility_condition is not None
else None
),
}
)
def _form_labels(definition) -> dict[str, str]:
labels = {_fallback_locale(definition): definition.title}
for localization in definition.localizations:
if localization.title:
labels[localization.locale] = localization.title
return labels
def _form_descriptions(definition) -> dict[str, str]:
descriptions = (
{_fallback_locale(definition): definition.description}
if definition.description
else {}
)
for localization in definition.localizations:
if localization.description:
descriptions[localization.locale] = localization.description
return descriptions
def _field_labels(definition, key: str, canonical: str) -> dict[str, str]:
labels = {_fallback_locale(definition): canonical}
for localization in definition.localizations:
label = localization.field_labels.get(key)
if label:
labels[localization.locale] = label
return labels
def _field_descriptions(
definition, key: str, canonical: str | None
) -> dict[str, str]:
descriptions = (
{_fallback_locale(definition): canonical} if canonical else {}
)
for localization in definition.localizations:
value = localization.field_help_texts.get(key)
if value:
descriptions[localization.locale] = value
return descriptions
def _section_labels(definition, key: str, canonical: str) -> dict[str, str]:
labels = {_fallback_locale(definition): canonical}
for localization in definition.localizations:
label = localization.section_titles.get(key)
if label:
labels[localization.locale] = label
return labels
def _fallback_locale(definition) -> str:
return definition.fallback_locale or "en"
def _field_locations(definition) -> dict[str, tuple[object, object]]:
return {
field_key: (page, section)
for page in definition.pages
for section in page.sections
for field_key in section.field_keys
}
def _label(labels: Mapping[str, str]) -> str:
return labels.get("de") or labels.get("en") or next(iter(labels.values()))
def _descriptor_search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
return " ".join(
(
item.reference.subject_id,
*(item.labels.values()),
*(item.descriptions.values()),
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
)
)
def _authorized(principal: object, tenant_id: str) -> bool:
if str(getattr(principal, "tenant_id", "") or "") != tenant_id:
return False
checker = getattr(principal, "has", None)
if callable(checker):
return bool(checker(READ_SCOPE))
return READ_SCOPE in getattr(principal, "scopes", ())
def _cursor_offset(value: str | None) -> int:
if value is None:
return 0
if not value.isdigit() or int(value) < 0:
raise ValueError("Forms semantic subject cursor is invalid.")
return int(value)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Forms semantic subjects require a SQLAlchemy session.")
return value
__all__ = [
"FormsSemanticDocumentationSubjectProvider",
"SUBJECT_KIND",
]
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.configuration_packages import (
ConfigurationPackageFragment,
ConfigurationPreflightContext,
ConfigurationProvider,
)
from govoplan_core.core.institutional import (
FormDefinition,
FormFieldDefinition,
FormLocalization,
FormPageDefinition,
FormSectionDefinition,
InstitutionalReference,
TemporalRevision,
)
from govoplan_forms.backend.configuration_provider import (
FORMS_CONFIGURATION_CAPABILITY,
SqlFormsConfigurationProvider,
_apply_definition,
_preflight_definition,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.manifest import get_manifest
from govoplan_forms.backend.service import (
export_form_definition_fragment,
get_form_definition,
)
@dataclass(frozen=True)
class Principal:
tenant_id: str
account_id: str = "operator-1"
def source_definition(title: str = "Resident parking permit") -> FormDefinition:
return FormDefinition(
reference=InstitutionalReference(
kind="form",
owner_module="forms",
object_id="resident-parking-permit-application",
tenant_id="reference-package",
version="3",
),
key="resident-parking-permit-application",
temporal=TemporalRevision(
revision="3",
recorded_at=datetime(2026, 8, 22, tzinfo=UTC),
change_reason="Reference package revision.",
),
title=title,
description="Apply for a resident parking permit through a digital or assisted channel.",
fields=(
FormFieldDefinition(key="applicant_name", label="Name", required=True, constraints={"min_length": 2, "max_length": 200}),
FormFieldDefinition(key="applicant_email", label="Email", required=True, constraints={"format": "email"}),
FormFieldDefinition(key="residence_address", label="Primary residence", required=True, constraints={"max_length": 500}),
FormFieldDefinition(key="licence_plate", label="Licence plate", required=True, constraints={"max_length": 20}),
),
publication_state="published",
max_attachments=4,
policy_refs=("law:resident-parking-permit", "records:resident-parking-permit"),
handoff_kinds=("case", "workflow"),
pages=(
FormPageDefinition(
key="application",
title="Application",
sections=(
FormSectionDefinition(
key="applicant-and-vehicle",
title="Applicant and vehicle",
field_keys=("applicant_name", "applicant_email", "residence_address", "licence_plate"),
),
),
),
),
fallback_locale="de",
localizations=(
FormLocalization(
locale="de",
title="Anwohnerparkausweis beantragen",
description="Einen Anwohnerparkausweis digital oder mit Unterstützung beantragen.",
field_labels={
"applicant_name": "Name",
"applicant_email": "E-Mail-Adresse",
"residence_address": "Hauptwohnsitz",
"licence_plate": "Kennzeichen",
},
page_titles={"application": "Antrag"},
section_titles={"applicant-and-vehicle": "Antragstellende Person und Fahrzeug"},
),
),
)
class FormsConfigurationProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
FormDefinitionRevision.__table__.create(self.engine)
self.session = Session(self.engine)
self.context = ConfigurationPreflightContext(
tenant_id="tenant-1",
operator_user_id="operator-1",
operator_scopes=frozenset({"system:governance:write"}),
installed_modules={"forms": "0.1.20"},
capabilities=frozenset({FORMS_CONFIGURATION_CAPABILITY}),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def fragment(self, definition: FormDefinition | None = None, *, on_conflict: str = "new_revision") -> ConfigurationPackageFragment:
return ConfigurationPackageFragment(
module_id="forms",
fragment_type="definition",
fragment_id="resident-parking-permit-application",
payload={
"fragment": export_form_definition_fragment(
definition or source_definition(),
exported_at=datetime(2026, 8, 22, 12, tzinfo=UTC),
exported_by="package-author",
),
"on_conflict": on_conflict,
"change_reason": "Install the reviewed resident parking permit reference form.",
},
)
def test_provider_is_registered_and_runtime_checkable(self) -> None:
provider = get_manifest().capability_factories[FORMS_CONFIGURATION_CAPABILITY](None) # type: ignore[arg-type]
self.assertIsInstance(provider, ConfigurationProvider)
self.assertIsInstance(provider, SqlFormsConfigurationProvider)
self.assertEqual(("definition",), provider.describe().fragment_types)
def test_import_is_tenant_local_draft_and_same_source_replay_is_noop(self) -> None:
fragment = self.fragment()
preflight = _preflight_definition(self.session, fragment, self.context)
applied = _apply_definition(self.session, fragment, self.context)
self.session.commit()
replay_preflight = _preflight_definition(self.session, fragment, self.context)
replay = _apply_definition(self.session, fragment, self.context)
self.assertEqual("create", preflight.plan[0].action)
self.assertEqual(1, len(applied.created_refs))
imported = get_form_definition(
self.session,
Principal("tenant-1"),
form_id="resident-parking-permit-application",
)
assert imported is not None
self.assertEqual("tenant-1", imported.reference.tenant_id)
self.assertEqual("draft", imported.publication_state)
self.assertEqual(
"reference-package",
imported.metadata["package_import"]["source_tenant_id"],
)
self.assertEqual("noop", replay_preflight.plan[0].action)
self.assertEqual({}, replay.created_refs)
self.assertEqual({}, replay.updated_refs)
def test_preserve_reports_conflict_and_missing_authority_blocks(self) -> None:
first = self.fragment()
_apply_definition(self.session, first, self.context)
self.session.commit()
changed = self.fragment(source_definition("Changed reference"), on_conflict="preserve")
unauthorized = ConfigurationPreflightContext(
tenant_id="tenant-1",
operator_user_id="operator-2",
)
conflict = _preflight_definition(self.session, changed, self.context)
denied = _preflight_definition(self.session, changed, unauthorized)
self.assertIn(
"forms_configuration_conflict",
{item.code for item in conflict.diagnostics},
)
self.assertIn(
"forms_configuration_write_scope_required",
{item.code for item in denied.diagnostics},
)
if __name__ == "__main__":
unittest.main()
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.dsar_provider import (
FORMS_DSAR_CAPABILITY,
FormsDsarProvider,
)
from govoplan_forms.backend.manifest import manifest
NOW = datetime(2026, 8, 22, 12, 0, tzinfo=UTC)
class FormsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = FormsDsarProvider()
self.session.add_all(
(
FormDefinitionRevision(
id="revision-1",
tenant_id="tenant-1",
form_id="form-1",
form_key="secret-form-key-do-not-export",
revision="2",
publication_state="published",
title="Sensitive semantic title do not export",
recorded_at=NOW,
search_text="search-content-do-not-export",
payload={"secret": "schema-payload-do-not-export"},
changed_by="account-1",
),
FormDefinitionRevision(
id="revision-other",
tenant_id="tenant-2",
form_id="form-other",
form_key="other",
revision="1",
publication_state="draft",
title="Other tenant",
recorded_at=NOW,
search_text="other",
payload={},
changed_by="account-1",
),
)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_search_is_minimized_tenant_safe_and_narrowable(self) -> None:
self.assertIsInstance(self.provider, DsarProvider)
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
self.assertEqual(["revision-1"], [record.resource_id for record in records])
exported = json.dumps([record.to_dict() for record in records])
for excluded in (
"secret-form-key-do-not-export",
"Sensitive semantic title do not export",
"search-content-do-not-export",
"schema-payload-do-not-export",
):
self.assertNotIn(excluded, exported)
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"forms.form": "form-1"},
),
)
self.assertEqual(1, len(narrowed))
def test_requires_account_and_retains_definition_history(self) -> None:
self.assertEqual(
(),
self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(email="designer@example.test"),
),
)
subject = DsarSubjectRef(account_id="account-1")
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=subject,
records=records,
)
self.assertTrue(all(action.kind == "retain" for action in actions))
def test_manifest_registers_provider_and_documentation(self) -> None:
self.assertIn(FORMS_DSAR_CAPABILITY, manifest.capability_factories)
self.assertIn(
"forms.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
if __name__ == "__main__":
unittest.main()
+40 -1
View File
@@ -1,11 +1,24 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import unittest import unittest
from govoplan_forms.backend.manifest import manifest from govoplan_forms.backend.manifest import manifest
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
class FormsInterfaceDocumentationContractTests(unittest.TestCase): class FormsInterfaceDocumentationContractTests(unittest.TestCase):
def test_all_static_topics_have_complete_german_content(self) -> None:
for topic in manifest.documentation:
german = (topic.translations or {}).get("de", {})
self.assertEqual({"title", "summary", "body"}, set(german), topic.id)
self.assertTrue(
all(str(value).strip() for value in german.values()), topic.id
)
def test_route_and_surfaces_remain_declared(self) -> None: def test_route_and_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend frontend = manifest.frontend
self.assertIsNotNone(frontend) self.assertIsNotNone(frontend)
@@ -21,10 +34,36 @@ class FormsInterfaceDocumentationContractTests(unittest.TestCase):
reference = topics["forms.reference.fields-and-consequences"] reference = topics["forms.reference.fields-and-consequences"]
self.assertIn("forms.catalogue", guide.metadata["help_contexts"]) self.assertIn("forms.catalogue", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
self.assertIn("forms.field.publication-state", reference.metadata["help_contexts"]) self.assertEqual("workflow", guide.metadata["kind"])
self.assertIn(
"forms.field.publication-state", reference.metadata["help_contexts"]
)
self.assertIn("publish", reference.metadata["consequence_classes"]) self.assertIn("publish", reference.metadata["consequence_classes"])
self.assertIn("import_package", reference.metadata["consequence_classes"]) self.assertIn("import_package", reference.metadata["consequence_classes"])
def test_semantic_subject_provider_and_static_baseline_are_declared(self) -> None:
capability = semantic_documentation_subject_capability("forms")
self.assertIn("docs", manifest.optional_dependencies)
self.assertNotIn("docs", manifest.dependencies)
self.assertIn(capability, manifest.capability_factories)
self.assertEqual(
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
manifest.capability_documentation[capability].contract_version,
)
topics = {topic.id: topic for topic in manifest.documentation}
semantic = topics["forms.semantic-documentation"]
self.assertEqual({"admin", "user"}, set(semantic.documentation_types))
self.assertIn("cannot override", semantic.body)
def test_builder_links_semantic_help_without_closing_unsaved_dialog(self) -> None:
source = (
Path(__file__).parents[1]
/ "webui/src/features/forms/FormDefinitionDialog.tsx"
).read_text(encoding="utf-8")
self.assertIn('target="_blank"', source)
self.assertIn("semanticFieldDocumentation", source)
self.assertIn("/docs/semantic?", source)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
FormDefinition,
FormFieldDefinition,
FormLocalization,
FormPageDefinition,
FormSectionDefinition,
InstitutionalReference,
TemporalRevision,
)
from govoplan_core.core.semantic_documentation import (
SemanticDocumentationSubjectQuery,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.semantic_subjects import (
FormsSemanticDocumentationSubjectProvider,
)
from govoplan_forms.backend.service import record_form_definition
NOW = datetime(2026, 8, 21, 8, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "author-1"
scopes: frozenset[str] = frozenset({"forms:definition:read"})
def has(self, scope: str) -> bool:
return scope in self.scopes
def definition(
revision: int,
*,
fields: tuple[FormFieldDefinition, ...] | None = None,
) -> FormDefinition:
resolved_fields = fields or (
FormFieldDefinition(key="name", label="Name", required=True),
FormFieldDefinition(key="delivery", label="Delivery method"),
)
return FormDefinition(
reference=InstitutionalReference(
kind="form",
owner_module="forms",
object_id="resident-permit",
tenant_id="tenant-1",
version=str(revision),
),
key="resident-permit",
temporal=TemporalRevision(
revision=str(revision),
recorded_at=NOW + timedelta(minutes=revision),
change_reason=f"Revision {revision}",
),
title="Resident permit",
fields=resolved_fields,
publication_state="published",
pages=(
FormPageDefinition(
key="application",
title="Application",
sections=(
FormSectionDefinition(
key="details",
title="Applicant details",
field_keys=tuple(field.key for field in resolved_fields),
),
),
),
),
fallback_locale="de",
localizations=(
FormLocalization(
locale="de",
title="Anwohnerparkausweis",
field_labels={field.key: field.label for field in resolved_fields},
page_titles={"application": "Antrag"},
section_titles={"details": "Angaben"},
),
),
)
class FormsSemanticSubjectTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
FormDefinitionRevision.__table__.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
self.provider = FormsSemanticDocumentationSubjectProvider()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def subjects(self):
return self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1", limit=200),
).subjects
def test_exposes_safe_form_field_and_section_descriptors(self) -> None:
record_form_definition(
self.session,
self.principal,
definition=definition(1),
)
subjects = self.subjects()
self.assertEqual(4, len(subjects))
form = next(item for item in subjects if item.reference.anchor is None)
field = next(
item
for item in subjects
if item.reference.anchor and item.reference.anchor.kind == "field"
)
self.assertEqual("Anwohnerparkausweis", form.labels["de"])
self.assertEqual("forms:definition:read", field.required_scopes[0])
self.assertTrue(field.route.startswith("/forms?formId="))
self.assertTrue(field.route_anchor.startswith("field-"))
self.assertNotIn("constraints", field.to_dict())
def test_identity_survives_reorder_and_label_change_but_fingerprint_changes(self) -> None:
first = definition(1)
record_form_definition(self.session, self.principal, definition=first)
before = {
item.route_anchor: item.reference
for item in self.subjects()
if item.reference.anchor and item.reference.anchor.kind == "field"
}
revised = replace(
definition(2),
fields=(
first.fields[1],
replace(first.fields[0], label="Full legal name"),
),
pages=(
FormPageDefinition(
key="application",
title="Application",
sections=(
FormSectionDefinition(
key="details",
title="Applicant details",
field_keys=("delivery", "name"),
),
),
),
),
)
record_form_definition(
self.session,
self.principal,
definition=revised,
expected_revision="1",
)
after = {
item.route_anchor: item.reference
for item in self.subjects()
if item.reference.anchor and item.reference.anchor.kind == "field"
}
self.assertEqual(
before["field-name"].stable_key,
after["field-name"].stable_key,
)
self.assertNotEqual(
before["field-name"].observed_fingerprint,
after["field-name"].observed_fingerprint,
)
resolution = self.provider.resolve_subject(
self.session,
self.principal,
reference=before["field-name"],
)
self.assertEqual("changed", resolution.availability)
def test_deleted_and_recreated_key_gets_new_lineage(self) -> None:
first = definition(1)
record_form_definition(self.session, self.principal, definition=first)
old = next(
item.reference
for item in self.subjects()
if item.route_anchor == "field-delivery"
)
record_form_definition(
self.session,
self.principal,
definition=definition(2, fields=(first.fields[0],)),
expected_revision="1",
)
deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("missing", deleted.availability)
self.assertEqual("field_deleted", deleted.reason_code)
recreated_field = replace(first.fields[1], label="Recreated delivery")
record_form_definition(
self.session,
self.principal,
definition=definition(3, fields=(first.fields[0], recreated_field)),
expected_revision="2",
)
recreated = next(
item.reference
for item in self.subjects()
if item.route_anchor == "field-delivery"
)
self.assertNotEqual(old.stable_key, recreated.stable_key)
still_deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("field_deleted", still_deleted.reason_code)
def test_resolution_denies_cross_tenant_and_missing_scope(self) -> None:
record_form_definition(
self.session,
self.principal,
definition=definition(1),
)
reference = self.subjects()[0].reference
denied = replace(self.principal, scopes=frozenset())
self.assertIsNone(
self.provider.resolve_subject(
self.session,
denied,
reference=reference,
)
)
foreign = replace(self.principal, tenant_id="tenant-2")
self.assertIsNone(
self.provider.resolve_subject(
self.session,
foreign,
reference=reference,
)
)
self.assertEqual(
(),
self.provider.list_subjects(
self.session,
denied,
request=SemanticDocumentationSubjectQuery(tenant_id="tenant-1"),
).subjects,
)
if __name__ == "__main__":
unittest.main()
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/forms-webui", "name": "@govoplan/forms-webui",
"version": "0.1.15", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,10 +14,11 @@
"./styles/forms.css": "./src/styles/forms.css" "./styles/forms.css": "./src/styles/forms.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20" "react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -1,10 +1,12 @@
import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react"; import { ArrowDown, ArrowUp, BookOpen, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import { FormGrid,
ActionToolbar,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
TextWithHelp,
DismissibleAlert, DismissibleAlert,
FormField as Field, FormField as Field,
IconButton, IconButton,
@@ -13,7 +15,8 @@ import {
usePlatformLanguage, usePlatformLanguage,
useUnsavedChanges, useUnsavedChanges,
useUnsavedDraftGuard, useUnsavedDraftGuard,
type ApiSettings type ApiSettings,
type DocumentationHelpReference
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
saveFormDefinition, saveFormDefinition,
@@ -79,6 +82,14 @@ export default function FormDefinitionDialog({
setConfirmLifecycle(false); setConfirmLifecycle(false);
}, [definition, open, tenantId]); }, [definition, open, tenantId]);
useEffect(() => {
if (!open || !definition || !window.location.hash) return;
const targetId = decodeURIComponent(window.location.hash.slice(1));
window.requestAnimationFrame(() => {
document.getElementById(targetId)?.scrollIntoView({ block: "center" });
});
}, [definition, open]);
const valid = useMemo(() => Boolean( const valid = useMemo(() => Boolean(
draft.title.trim() draft.title.trim()
&& draft.key.trim() && draft.key.trim()
@@ -183,6 +194,7 @@ export default function FormDefinitionDialog({
<Dialog <Dialog
open={open} open={open}
title={definition ? `Revise ${definition.title}` : "New Form definition"} title={definition ? `Revise ${definition.title}` : "New Form definition"}
titleHelp={<DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} />}
onClose={requestClose} onClose={requestClose}
closeDisabled={busy} closeDisabled={busy}
portal portal
@@ -196,9 +208,16 @@ export default function FormDefinitionDialog({
</> </>
}> }>
<div className="form-definition-editor"> <div className="form-definition-editor">
<div className="form-definition-help"><DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} /></div> <div className="form-definition-help">
{definition && <>
<TextWithHelp help={<DocumentationHelpLink reference={semanticFormDocumentation(definition)} />}>Form meaning</TextWithHelp>
<a className="btn btn-secondary" href={semanticAuthoringHref(definition)} target="_blank" rel="noreferrer">
<BookOpen size={16} aria-hidden="true" /> Document form meaning
</a>
</>}
</div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="form-definition-grid"> <FormGrid columns={2} gap="small" collapseAt="narrow">
<Field label="Title"> <Field label="Title">
<input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /> <input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
</Field> </Field>
@@ -260,23 +279,28 @@ export default function FormDefinitionDialog({
placeholder="Optional instructions announced before the Form" placeholder="Optional instructions announced before the Form"
/> />
</Field> </Field>
</div> </FormGrid>
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Fields</h3> <h3>Fields</h3>
<Button onClick={() => setDraft(addField(draft))} disabled={busy}> <Button onClick={() => setDraft(addField(draft))} disabled={busy}>
<Plus size={16} aria-hidden="true" />Add field <Plus size={16} aria-hidden="true" />Add field
</Button> </Button>
</div> </ActionToolbar>
<div className="form-field-editor-list"> <div className="form-field-editor-list">
{draft.fields.map((field, index) => {draft.fields.map((field, index) =>
<div className="form-field-editor-row" key={`${index}:${field.key}`}> <div className="form-field-editor-row" id={`field-${field.key}`} key={`${index}:${field.key}`}>
<div className="form-field-order"> <div className="form-field-order">
<IconButton label={`Move ${field.label || "field"} up`} icon={<ArrowUp size={15} />} disabled={busy || index === 0} onClick={() => moveField(index, -1)} /> <IconButton label={`Move ${field.label || "field"} up`} icon={<ArrowUp size={15} />} disabled={busy || index === 0} onClick={() => moveField(index, -1)} />
<IconButton label={`Move ${field.label || "field"} down`} icon={<ArrowDown size={15} />} disabled={busy || index === draft.fields.length - 1} onClick={() => moveField(index, 1)} /> <IconButton label={`Move ${field.label || "field"} down`} icon={<ArrowDown size={15} />} disabled={busy || index === draft.fields.length - 1} onClick={() => moveField(index, 1)} />
</div> </div>
<Field label="Key"><input value={field.key} disabled={busy} onChange={(event) => patchField(index, { key: event.target.value })} /></Field> <Field label="Key"><input value={field.key} disabled={busy} onChange={(event) => patchField(index, { key: event.target.value })} /></Field>
<Field label="Label"><input value={field.label} disabled={busy} onChange={(event) => patchField(index, { label: event.target.value })} /></Field> <Field
label="Label"
documentation={definition && definition.fields.some((item) => item.key === field.key) ? semanticFieldDocumentation(definition, field.key) : FORMS_FIELD_DOCUMENTATION}
>
<input value={field.label} disabled={busy} onChange={(event) => patchField(index, { label: event.target.value })} />
</Field>
<Field label="Type"> <Field label="Type">
<select value={field.value_type} disabled={busy} onChange={(event) => patchField(index, { value_type: event.target.value as FormValueType, options: isChoice(event.target.value) ? field.options : [] })}> <select value={field.value_type} disabled={busy} onChange={(event) => patchField(index, { value_type: event.target.value as FormValueType, options: isChoice(event.target.value) ? field.options : [] })}>
{VALUE_TYPES.map((type) => <option key={type.value} value={type.value}>{type.label}</option>)} {VALUE_TYPES.map((type) => <option key={type.value} value={type.value}>{type.label}</option>)}
@@ -303,6 +327,16 @@ export default function FormDefinitionDialog({
disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined} disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined}
onClick={() => setDraft(removeField(draft, index))} onClick={() => setDraft(removeField(draft, index))}
/> />
{definition && definition.fields.some((item) => item.key === field.key) && (
<a
className="btn btn-secondary"
href={semanticAuthoringHref(definition, `field-${field.key}`)}
target="_blank"
rel="noreferrer"
>
<BookOpen size={15} aria-hidden="true" /> Document meaning
</a>
)}
</div> </div>
)} )}
</div> </div>
@@ -333,19 +367,19 @@ export default function FormDefinitionDialog({
function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefinition; disabled: boolean; onChange: (value: Record<string, unknown>) => void }) { function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefinition; disabled: boolean; onChange: (value: Record<string, unknown>) => void }) {
if (["text", "multiline_text", "email"].includes(field.value_type)) { if (["text", "multiline_text", "email"].includes(field.value_type)) {
return ( return (
<div className="form-field-constraints"> <FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-constraints">
<Field label="Minimum length"><input type="number" min={0} value={constraintValue(field.constraints.min_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "min_length", event.target.value))} /></Field> <Field label="Minimum length"><input type="number" min={0} value={constraintValue(field.constraints.min_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "min_length", event.target.value))} /></Field>
<Field label="Maximum length"><input type="number" min={0} value={constraintValue(field.constraints.max_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "max_length", event.target.value))} /></Field> <Field label="Maximum length"><input type="number" min={0} value={constraintValue(field.constraints.max_length)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "max_length", event.target.value))} /></Field>
<Field label="Pattern"><input value={String(field.constraints.pattern ?? "")} disabled={disabled} onChange={(event) => onChange(patchTextConstraint(field.constraints, "pattern", event.target.value))} /></Field> <Field label="Pattern"><input value={String(field.constraints.pattern ?? "")} disabled={disabled} onChange={(event) => onChange(patchTextConstraint(field.constraints, "pattern", event.target.value))} /></Field>
</div> </FormGrid>
); );
} }
if (["integer", "number"].includes(field.value_type)) { if (["integer", "number"].includes(field.value_type)) {
return ( return (
<div className="form-field-constraints"> <FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-constraints">
<Field label="Minimum"><input type="number" value={constraintValue(field.constraints.minimum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "minimum", event.target.value))} /></Field> <Field label="Minimum"><input type="number" value={constraintValue(field.constraints.minimum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "minimum", event.target.value))} /></Field>
<Field label="Maximum"><input type="number" value={constraintValue(field.constraints.maximum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "maximum", event.target.value))} /></Field> <Field label="Maximum"><input type="number" value={constraintValue(field.constraints.maximum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "maximum", event.target.value))} /></Field>
</div> </FormGrid>
); );
} }
return null; return null;
@@ -367,7 +401,7 @@ function ConditionFields({
const predicate = condition?.kind === "predicate" ? condition : null; const predicate = condition?.kind === "predicate" ? condition : null;
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim()); const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
return ( return (
<div className="form-field-condition"> <FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-condition">
<Field label="Visible when"> <Field label="Visible when">
<select <select
value={predicate?.field_key ?? ""} value={predicate?.field_key ?? ""}
@@ -406,7 +440,7 @@ function ConditionFields({
</Field> </Field>
} }
</>} </>}
</div> </FormGrid>
); );
} }
@@ -417,12 +451,12 @@ function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disa
} }
return ( return (
<section className="form-composition-section"> <section className="form-composition-section">
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Pages and sections</h3> <h3>Pages and sections</h3>
{pages.length === 0 {pages.length === 0
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button> ? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>} : <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
</div> </ActionToolbar>
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>} {pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
{pages.map((page, pageIndex) => {pages.map((page, pageIndex) =>
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}> <div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
@@ -470,10 +504,10 @@ function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefiniti
} }
return ( return (
<section className="form-composition-section"> <section className="form-composition-section">
<div className="form-field-editor-heading"> <ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3><Languages size={17} aria-hidden="true" />Localizations</h3> <h3><Languages size={17} aria-hidden="true" />Localizations</h3>
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button> <Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
</div> </ActionToolbar>
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>} {localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
{localizations.map((localization, index) => {localizations.map((localization, index) =>
<div className="form-localization-editor" key={`${index}:${localization.locale}`}> <div className="form-localization-editor" key={`${index}:${localization.locale}`}>
@@ -505,8 +539,8 @@ function DefinitionPreview({ definition, previous }: { definition: FormDefinitio
const changed = previous ? definitionChanges(previous, definition) : ["New definition"]; const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
return ( return (
<section className="form-composition-section form-definition-preview"> <section className="form-composition-section form-definition-preview">
<div className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></div> <ActionToolbar surface="section-header" className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></ActionToolbar>
<div className="form-preview-grid"> <FormGrid columns={2} collapseAt="narrow" className="form-preview-grid">
<div> <div>
<strong>{definition.title || "Untitled Form"}</strong> <strong>{definition.title || "Untitled Form"}</strong>
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) => {(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
@@ -517,7 +551,7 @@ function DefinitionPreview({ definition, previous }: { definition: FormDefinitio
)} )}
</div> </div>
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div> <div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
</div> </FormGrid>
</section> </section>
); );
} }
@@ -700,3 +734,43 @@ function constraintValue(value: unknown): number | "" {
function humanize(value: string): string { function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
} }
function semanticHelpContext(definition: FormDefinition, routeAnchor?: string): string {
return [
"semantic",
"forms",
"form_definition",
definition.reference.object_id,
routeAnchor
].filter(Boolean).join(".");
}
function semanticFormDocumentation(definition: FormDefinition): DocumentationHelpReference {
return {
contextId: semanticHelpContext(definition),
documentationType: "user"
};
}
function semanticFieldDocumentation(
definition: FormDefinition,
fieldKey: string
): DocumentationHelpReference {
return {
contextId: semanticHelpContext(definition, `field-${fieldKey}`),
documentationType: "user"
};
}
function semanticAuthoringHref(
definition: FormDefinition,
routeAnchor?: string
): string {
const params = new URLSearchParams({
module: "forms",
subjectKind: "form_definition",
subjectId: definition.reference.object_id
});
if (routeAnchor) params.set("routeAnchor", routeAnchor);
return `/docs/semantic?${params}`;
}
+41 -18
View File
@@ -1,19 +1,23 @@
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react"; import { Download, Pencil, Plus, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { import { useSearchParams } from "react-router";
ActionBlockerHint, import { ActionBlockerHint,
Button, Button,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
IconButton, IconButton,
FormField, FormField,
FilterBar,
LoadingIndicator, LoadingIndicator,
PageScrollViewport, PageScrollViewport,
StatePanel,
StatusBadge, StatusBadge,
hasScope, hasScope,
useUnsavedChanges, useUnsavedChanges,
useUnsavedDraftGuard, useUnsavedDraftGuard,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext type PlatformRouteContext
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
@@ -29,6 +33,7 @@ import { FORMS_DOCUMENTATION, FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./in
export default function FormsPage({ settings, auth }: PlatformRouteContext) { export default function FormsPage({ settings, auth }: PlatformRouteContext) {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState(""); const [submittedQuery, setSubmittedQuery] = useState("");
const [state, setState] = useState(""); const [state, setState] = useState("");
@@ -72,6 +77,17 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
return () => controller.abort(); return () => controller.abort();
}, [load]); }, [load]);
useEffect(() => {
const formId = searchParams.get("formId");
if (!formId || editing || loading) return;
const requested = items.find((item) => item.reference.object_id === formId);
if (!requested) return;
setEditing(requested);
const next = new URLSearchParams(searchParams);
next.delete("formId");
setSearchParams(next, { replace: true });
}, [editing, items, loading, searchParams, setSearchParams]);
function search(event: FormEvent) { function search(event: FormEvent) {
event.preventDefault(); event.preventDefault();
setSubmittedQuery(query.trim()); setSubmittedQuery(query.trim());
@@ -144,14 +160,22 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
return ( return (
<main className="forms-page"> <main className="forms-page">
<div className="forms-shell"> <WorkspaceFrame className="forms-shell" label="Form definitions" interfaceId="forms.catalogue" helpContextId="forms.page.catalogue" helpModuleId="forms">
<div className="forms-toolbar"> <WorkspaceActionBar
<form onSubmit={search} className="forms-search"> title="Form definitions"
titleHelp={<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />}
scope="workspace"
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(), loading, label: "Refresh definitions" }}
className="forms-toolbar"
contextActions={<>
<FilterBar as="form" surface="control" wrap="never" width="default" onSubmit={search} className="forms-search">
<Search size={17} aria-hidden="true" /> <Search size={17} aria-hidden="true" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" />
<Button type="submit">Search</Button> <Button type="submit">Search</Button>
</form> </FilterBar>
<label> <label>
<span>State</span> <span>State</span>
<select value={state} onChange={(event) => setState(event.target.value)}> <select value={state} onChange={(event) => setState(event.target.value)}>
<option value="">All</option> <option value="">All</option>
@@ -159,19 +183,18 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<option value="published">Published</option> <option value="published">Published</option>
<option value="retired">Retired</option> <option value="retired">Retired</option>
</select> </select>
</label> </label>
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_I18N.loading : undefined} /> <IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} />
<IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} /> <input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} /> <span className="forms-count">{total}</span>
<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button> </>}
<span className="forms-count">{total}</span> createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
<DocumentationHelpLink reference={FORMS_DOCUMENTATION} /> />
</div>
<PageScrollViewport className="forms-list-viewport"> <PageScrollViewport className="forms-list-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />} {!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />}
{loading && <LoadingIndicator label="Loading Form definitions" />} {loading && <LoadingIndicator label="Loading Form definitions" />}
{!loading && !error && items.length === 0 && <div className="forms-empty">No matching definitions.</div>} {!loading && !error && items.length === 0 && <StatePanel size="compact" description="No matching definitions." />}
{!loading && items.length > 0 && {!loading && items.length > 0 &&
<div className="forms-list" role="list"> <div className="forms-list" role="list">
{items.map((item) => { {items.map((item) => {
@@ -192,7 +215,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
</div> </div>
} }
</PageScrollViewport> </PageScrollViewport>
</div> </WorkspaceFrame>
{editing && {editing &&
<FormDefinitionDialog <FormDefinitionDialog
open open
+2
View File
@@ -1,6 +1,7 @@
import type { PlatformTranslations } from "@govoplan/core-webui"; import type { PlatformTranslations } from "@govoplan/core-webui";
const en = { const en = {
"Form meaning": "Form meaning",
"i18n:govoplan-forms.form_definitions": "Form definitions", "i18n:govoplan-forms.form_definitions": "Form definitions",
"i18n:govoplan-forms.loading_reason": "Form definitions are still loading.", "i18n:govoplan-forms.loading_reason": "Form definitions are still loading.",
"i18n:govoplan-forms.busy_reason": "Another Form definition action is still running.", "i18n:govoplan-forms.busy_reason": "Another Form definition action is still running.",
@@ -74,6 +75,7 @@ const en = {
} as const; } as const;
const de: Record<keyof typeof en, string> = { const de: Record<keyof typeof en, string> = {
"Form meaning": "Formularbedeutung",
"i18n:govoplan-forms.form_definitions": "Formulardefinitionen", "i18n:govoplan-forms.form_definitions": "Formulardefinitionen",
"i18n:govoplan-forms.loading_reason": "Formulardefinitionen werden noch geladen.", "i18n:govoplan-forms.loading_reason": "Formulardefinitionen werden noch geladen.",
"i18n:govoplan-forms.busy_reason": "Eine andere Aktion für Formulardefinitionen läuft noch.", "i18n:govoplan-forms.busy_reason": "Eine andere Aktion für Formulardefinitionen läuft noch.",
+2 -2
View File
@@ -9,9 +9,9 @@ const FormsPage = lazy(() => import("./features/forms/FormsPage"));
export const formsModule: PlatformWebModule = { export const formsModule: PlatformWebModule = {
id: "forms", id: "forms",
label: "i18n:govoplan-forms.form_definitions", label: "i18n:govoplan-forms.form_definitions",
version: "0.1.14", version: "0.1.21",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy"], optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy", "docs"],
translations: generatedTranslations, translations: generatedTranslations,
routes: [ routes: [
{ {
+6 -78
View File
@@ -1,39 +1,14 @@
.forms-page, .forms-page {
.forms-shell {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.forms-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.forms-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.forms-search { .forms-search {
display: flex; flex: 1 1 520px;
align-items: center;
gap: 8px;
width: min(520px, 100%);
margin-right: auto; margin-right: auto;
} }
.forms-search input {
min-width: 120px;
flex: 1;
}
.forms-toolbar > label { .forms-toolbar > label {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -59,7 +34,7 @@
.forms-list { .forms-list {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--surface-raised); background: var(--surface-raised);
} }
@@ -122,12 +97,6 @@
color: var(--text-soft); color: var(--text-soft);
} }
.forms-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.form-definition-dialog { .form-definition-dialog {
width: min(1120px, calc(100vw - 32px)); width: min(1120px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px)); height: min(860px, calc(100vh - 32px));
@@ -145,12 +114,6 @@
justify-content: flex-end; justify-content: flex-end;
} }
.form-definition-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.form-definition-wide { .form-definition-wide {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -177,19 +140,6 @@
font-weight: 600; font-weight: 600;
} }
.form-field-editor-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-field-editor-heading h3 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-field-editor-list { .form-field-editor-list {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
@@ -229,13 +179,6 @@
grid-column: 2 / -1; grid-column: 2 / -1;
} }
.form-field-constraints,
.form-field-condition {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.form-composition-section { .form-composition-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -302,9 +245,7 @@
} }
.form-preview-grid { .form-preview-grid {
display: grid; min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
} }
.form-preview-grid > div { .form-preview-grid > div {
@@ -328,15 +269,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.forms-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.forms-search {
width: 100%;
}
.forms-row { .forms-row {
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto;
} }
@@ -373,15 +305,11 @@
} }
} }
@media (max-width: 620px) { @media (max-width: 680px) {
.form-definition-grid,
.form-field-constraints,
.form-field-condition,
.form-page-editor-heading, .form-page-editor-heading,
.form-section-editor, .form-section-editor,
.form-localization-editor, .form-localization-editor,
.form-localization-fields, .form-localization-fields {
.form-preview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }