From b9132d076d9e313a02cb6e143ac8df57438d15bd Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 15:44:42 +0200 Subject: [PATCH] feat: expose form semantic documentation subjects --- pyproject.toml | 2 +- src/govoplan_forms/backend/manifest.py | 72 ++- .../backend/semantic_subjects.py | 503 ++++++++++++++++++ .../test_interface_documentation_contract.py | 28 + tests/test_semantic_subjects.py | 263 +++++++++ webui/package.json | 5 +- .../features/forms/FormDefinitionDialog.tsx | 82 ++- webui/src/features/forms/FormsPage.tsx | 13 + webui/src/module.ts | 4 +- 9 files changed, 959 insertions(+), 13 deletions(-) create mode 100644 src/govoplan_forms/backend/semantic_subjects.py create mode 100644 tests/test_semantic_subjects.py diff --git a/pyproject.toml b/pyproject.toml index d2e76ef..7a6836b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-forms" -version = "0.1.18" +version = "0.1.19" description = "Immutable reusable form definitions for GovOPlaN." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_forms/backend/manifest.py b/src/govoplan_forms/backend/manifest.py index 52588cd..ebdf08c 100644 --- a/src/govoplan_forms/backend/manifest.py +++ b/src/govoplan_forms/backend/manifest.py @@ -27,6 +27,10 @@ from govoplan_core.core.modules import ( RoleTemplate, ) 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.db.base import Base from govoplan_forms.backend.db import models as form_models @@ -35,11 +39,14 @@ from govoplan_forms.backend.dsar_provider import ( FormsDsarProvider, ) from govoplan_forms.backend.service import SqlFormDefinitionProvider +from govoplan_forms.backend.semantic_subjects import ( + FormsSemanticDocumentationSubjectProvider, +) MODULE_ID = "forms" MODULE_NAME = "Forms" -MODULE_VERSION = "0.1.18" +MODULE_VERSION = "0.1.19" READ_SCOPE = "forms:definition:read" WRITE_SCOPE = "forms:definition:write" ADMIN_SCOPE = "forms:definition:admin" @@ -49,7 +56,9 @@ OPTIONAL_DEPENDENCIES = ( "workflow_engine", "cases", "policy", + "docs", ) +SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID) def _permission(scope: str, label: str, description: str) -> PermissionDefinition: @@ -80,6 +89,12 @@ def _dsar_provider(_context: ModuleContext) -> FormsDsarProvider: return FormsDsarProvider() +def _semantic_subjects( + _context: ModuleContext, +) -> FormsSemanticDocumentationSubjectProvider: + return FormsSemanticDocumentationSubjectProvider() + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -93,6 +108,10 @@ manifest = ModuleManifest( provides_interfaces=( ModuleInterfaceProvider(name="forms.definitions", 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=( _permission( @@ -186,6 +205,7 @@ manifest = ModuleManifest( capability_factories={ CAPABILITY_FORM_DEFINITIONS: _definitions, FORMS_DSAR_CAPABILITY: _dsar_provider, + SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects, }, capability_documentation={ CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation( @@ -201,6 +221,15 @@ manifest = ModuleManifest( ), 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( module_id=MODULE_ID, @@ -220,6 +249,38 @@ manifest = ModuleManifest( ), ), 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", + ), + ), + metadata={ + "kind": "reference", + "help_contexts": ["forms.semantic-documentation"], + }, + ), DocumentationTopic( id="forms.data-subject-requests", title="Form-definition data-subject requests", @@ -336,10 +397,15 @@ manifest = ModuleManifest( documentation_ref="docs/FORMS_BOUNDARY.md", test_ref="tests/test_forms.py", 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",), - 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=( "form submission", "file content", diff --git a/src/govoplan_forms/backend/semantic_subjects.py b/src/govoplan_forms/backend/semantic_subjects.py new file mode 100644 index 0000000..aef0428 --- /dev/null +++ b/src/govoplan_forms/backend/semantic_subjects.py @@ -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", +] diff --git a/tests/test_interface_documentation_contract.py b/tests/test_interface_documentation_contract.py index cd02653..9f692fd 100644 --- a/tests/test_interface_documentation_contract.py +++ b/tests/test_interface_documentation_contract.py @@ -1,8 +1,13 @@ from __future__ import annotations +from pathlib import Path import unittest 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): @@ -25,6 +30,29 @@ class FormsInterfaceDocumentationContractTests(unittest.TestCase): self.assertIn("publish", 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__": unittest.main() diff --git a/tests/test_semantic_subjects.py b/tests/test_semantic_subjects.py new file mode 100644 index 0000000..378e67b --- /dev/null +++ b/tests/test_semantic_subjects.py @@ -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() diff --git a/webui/package.json b/webui/package.json index 5a79a61..fc44acd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/forms-webui", - "version": "0.1.18", + "version": "0.1.19", "private": true, "type": "module", "main": "src/index.ts", @@ -17,7 +17,8 @@ "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", - "react-dom": ">=19.2.7 <20" + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" }, "peerDependenciesMeta": { "@govoplan/core-webui": { diff --git a/webui/src/features/forms/FormDefinitionDialog.tsx b/webui/src/features/forms/FormDefinitionDialog.tsx index 03415df..cd6a326 100644 --- a/webui/src/features/forms/FormDefinitionDialog.tsx +++ b/webui/src/features/forms/FormDefinitionDialog.tsx @@ -1,4 +1,4 @@ -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 { FormGrid, ActionToolbar, @@ -14,7 +14,8 @@ import { FormGrid, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, - type ApiSettings + type ApiSettings, + type DocumentationHelpReference } from "@govoplan/core-webui"; import { saveFormDefinition, @@ -80,6 +81,14 @@ export default function FormDefinitionDialog({ setConfirmLifecycle(false); }, [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( draft.title.trim() && draft.key.trim() @@ -197,7 +206,15 @@ export default function FormDefinitionDialog({ }>
-
+
+ + {definition && <> + + + + } +
{error && {error}} @@ -271,13 +288,18 @@ export default function FormDefinitionDialog({
{draft.fields.map((field, index) => -
+
} disabled={busy || index === 0} onClick={() => moveField(index, -1)} /> } disabled={busy || index === draft.fields.length - 1} onClick={() => moveField(index, 1)} />
patchField(index, { key: event.target.value })} /> - patchField(index, { label: event.target.value })} /> + item.key === field.key) ? semanticFieldDocumentation(definition, field.key) : FORMS_FIELD_DOCUMENTATION} + > + patchField(index, { label: event.target.value })} /> +