feat: expose form semantic documentation subjects
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-forms"
|
name = "govoplan-forms"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
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"
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ from govoplan_core.core.modules import (
|
|||||||
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.db import models as form_models
|
from govoplan_forms.backend.db import models as form_models
|
||||||
@@ -35,11 +39,14 @@ from govoplan_forms.backend.dsar_provider import (
|
|||||||
FormsDsarProvider,
|
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.18"
|
MODULE_VERSION = "0.1.19"
|
||||||
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"
|
||||||
@@ -49,7 +56,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:
|
||||||
@@ -80,6 +89,12 @@ def _dsar_provider(_context: ModuleContext) -> FormsDsarProvider:
|
|||||||
return FormsDsarProvider()
|
return FormsDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_subjects(
|
||||||
|
_context: ModuleContext,
|
||||||
|
) -> FormsSemanticDocumentationSubjectProvider:
|
||||||
|
return FormsSemanticDocumentationSubjectProvider()
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id=MODULE_ID,
|
id=MODULE_ID,
|
||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
@@ -93,6 +108,10 @@ 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_DSAR_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(
|
||||||
@@ -186,6 +205,7 @@ manifest = ModuleManifest(
|
|||||||
capability_factories={
|
capability_factories={
|
||||||
CAPABILITY_FORM_DEFINITIONS: _definitions,
|
CAPABILITY_FORM_DEFINITIONS: _definitions,
|
||||||
FORMS_DSAR_CAPABILITY: _dsar_provider,
|
FORMS_DSAR_CAPABILITY: _dsar_provider,
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
|
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
|
||||||
@@ -201,6 +221,15 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
contract_version="0.1.0",
|
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,
|
||||||
@@ -220,6 +249,38 @@ 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["forms.semantic-documentation"],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="forms.data-subject-requests",
|
id="forms.data-subject-requests",
|
||||||
title="Form-definition data-subject requests",
|
title="Form-definition data-subject requests",
|
||||||
@@ -336,10 +397,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
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):
|
||||||
@@ -25,6 +30,29 @@ class FormsInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
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()
|
||||||
|
|||||||
@@ -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()
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/forms-webui",
|
"name": "@govoplan/forms-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
"@govoplan/core-webui": "^0.1.18",
|
"@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,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 { useEffect, useMemo, useState } from "react";
|
||||||
import { FormGrid,
|
import { FormGrid,
|
||||||
ActionToolbar,
|
ActionToolbar,
|
||||||
@@ -14,7 +14,8 @@ import { FormGrid,
|
|||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
useUnsavedDraftGuard,
|
useUnsavedDraftGuard,
|
||||||
type ApiSettings
|
type ApiSettings,
|
||||||
|
type DocumentationHelpReference
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
saveFormDefinition,
|
saveFormDefinition,
|
||||||
@@ -80,6 +81,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()
|
||||||
@@ -197,7 +206,15 @@ 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">
|
||||||
|
<DocumentationHelpLink reference={FORMS_FIELD_DOCUMENTATION} />
|
||||||
|
{definition && <>
|
||||||
|
<DocumentationHelpLink reference={semanticFormDocumentation(definition)} />
|
||||||
|
<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>}
|
||||||
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
<FormGrid columns={2} gap="small" collapseAt="narrow">
|
||||||
<Field label="Title">
|
<Field label="Title">
|
||||||
@@ -271,13 +288,18 @@ export default function FormDefinitionDialog({
|
|||||||
</ActionToolbar>
|
</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>)}
|
||||||
@@ -304,6 +326,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>
|
||||||
@@ -701,3 +733,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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Download, Pencil, Plus, 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 { useSearchParams } from "react-router";
|
||||||
import { ActionBlockerHint,
|
import { ActionBlockerHint,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,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("");
|
||||||
@@ -75,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());
|
||||||
|
|||||||
+2
-2
@@ -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.19",
|
||||||
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: [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user