Add conditional localized form definitions
This commit is contained in:
@@ -20,6 +20,13 @@ requirements, policy references, and permitted handoff kinds. Saving always
|
|||||||
creates an exact immutable revision; it never mutates a published schema in
|
creates an exact immutable revision; it never mutates a published schema in
|
||||||
place.
|
place.
|
||||||
|
|
||||||
|
The designer also supports multi-page/section layout, bounded conditional
|
||||||
|
visibility expressions, localized labels/help/options/page titles, fallback
|
||||||
|
locale, and accessibility metadata. Server validation rejects unknown fields,
|
||||||
|
incompatible operators, placement errors, and condition cycles. Verified
|
||||||
|
configuration-package fragments can be assessed and imported as a new local
|
||||||
|
draft with source provenance rather than silently becoming active.
|
||||||
|
|
||||||
Definition writes use optimistic concurrency. Replaying the same object and
|
Definition writes use optimistic concurrency. Replaying the same object and
|
||||||
revision is safe only when the payload is identical. Published definitions may
|
revision is safe only when the payload is identical. Published definitions may
|
||||||
be retired but not silently returned to draft.
|
be retired but not silently returned to draft.
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ boundary emits only schema identity, revision, publication state, and field
|
|||||||
count; field content remains in the owning database.
|
count; field content remains in the owning database.
|
||||||
|
|
||||||
The schema vocabulary covers scalar, choice, structured, attachment, signature,
|
The schema vocabulary covers scalar, choice, structured, attachment, signature,
|
||||||
policy-reference, and handoff constraints. Conditional page layout,
|
policy-reference, and handoff constraints. Conditional page/section layout,
|
||||||
localization authoring, and package-fragment tooling remain product depth that
|
cycle-safe predicates, localization authoring, accessibility assessment, and
|
||||||
can deepen this owner without changing the runtime boundary.
|
verified package-fragment assessment/import are implemented. Remaining depth is
|
||||||
|
concrete attachment/signature providers, richer authoring ergonomics, public
|
||||||
|
identity profiles, and target-produced accessibility evidence; those do not
|
||||||
|
change the runtime boundary.
|
||||||
|
|||||||
@@ -69,7 +69,13 @@ manifest = ModuleManifest(
|
|||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("access",),
|
dependencies=("access",),
|
||||||
optional_dependencies=("forms_runtime", "portal", "workflow_engine", "cases", "policy"),
|
optional_dependencies=(
|
||||||
|
"forms_runtime",
|
||||||
|
"portal",
|
||||||
|
"workflow_engine",
|
||||||
|
"cases",
|
||||||
|
"policy",
|
||||||
|
),
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -78,9 +84,21 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
|
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
|
||||||
),
|
),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(READ_SCOPE, "View form definitions", "Read reusable form definitions and exact revisions."),
|
_permission(
|
||||||
_permission(WRITE_SCOPE, "Manage form definitions", "Create and revise reusable form definitions."),
|
READ_SCOPE,
|
||||||
_permission(ADMIN_SCOPE, "Publish form definitions", "Publish and retire form-definition revisions."),
|
"View form definitions",
|
||||||
|
"Read reusable form definitions and exact revisions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
WRITE_SCOPE,
|
||||||
|
"Manage form definitions",
|
||||||
|
"Create and revise reusable form definitions.",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
ADMIN_SCOPE,
|
||||||
|
"Publish form definitions",
|
||||||
|
"Publish and retire form-definition revisions.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
role_templates=(
|
role_templates=(
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -196,11 +214,16 @@ 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=(
|
||||||
"Conditional multi-page layout, localization authoring, and package-fragment tooling remain product depth.",
|
"Concrete attachment/signature providers, anonymous identity profiles, richer authoring ergonomics, 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"),
|
||||||
non_owned_concepts=("form submission", "file content", "case", "workflow instance"),
|
non_owned_concepts=(
|
||||||
|
"form submission",
|
||||||
|
"file content",
|
||||||
|
"case",
|
||||||
|
"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",),
|
||||||
recovery_docs=("docs/FORMS_BOUNDARY.md",),
|
recovery_docs=("docs/FORMS_BOUNDARY.md",),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -11,13 +13,19 @@ from govoplan_forms.backend.schemas import (
|
|||||||
FormDefinitionHistoryResponse,
|
FormDefinitionHistoryResponse,
|
||||||
FormDefinitionListResponse,
|
FormDefinitionListResponse,
|
||||||
FormDefinitionWriteRequest,
|
FormDefinitionWriteRequest,
|
||||||
|
FormPackageImportRequest,
|
||||||
|
FormPackageRequest,
|
||||||
)
|
)
|
||||||
from govoplan_forms.backend.service import (
|
from govoplan_forms.backend.service import (
|
||||||
FormDefinitionStoreError,
|
FormDefinitionStoreError,
|
||||||
definition_from_mapping,
|
definition_from_mapping,
|
||||||
|
assess_form_definition_fragment,
|
||||||
|
export_form_definition_fragment,
|
||||||
|
form_definition_diagnostics,
|
||||||
form_definition_history,
|
form_definition_history,
|
||||||
get_form_definition,
|
get_form_definition,
|
||||||
list_form_definitions,
|
list_form_definitions,
|
||||||
|
import_form_definition_fragment,
|
||||||
record_form_definition,
|
record_form_definition,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,9 +40,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
|||||||
|
|
||||||
def _error(exc: Exception) -> HTTPException:
|
def _error(exc: Exception) -> HTTPException:
|
||||||
message = str(exc)
|
message = str(exc)
|
||||||
code = 409 if any(
|
code = (
|
||||||
word in message.casefold() for word in ("conflict", "already", "stale")
|
409
|
||||||
) else 400
|
if any(word in message.casefold() for word in ("conflict", "already", "stale"))
|
||||||
|
else 400
|
||||||
|
)
|
||||||
return HTTPException(status_code=code, detail=message)
|
return HTTPException(status_code=code, detail=message)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +127,95 @@ def api_get_form_definition(
|
|||||||
return item.to_dict()
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions/{form_id}/diagnostics", response_model=dict[str, object])
|
||||||
|
def api_form_definition_diagnostics(
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = Query(default=None, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||||
|
diagnostics = form_definition_diagnostics(item)
|
||||||
|
return {
|
||||||
|
"diagnostics": [dict(value) for value in diagnostics],
|
||||||
|
"error_count": sum(value.get("severity") == "error" for value in diagnostics),
|
||||||
|
"warning_count": sum(
|
||||||
|
value.get("severity") == "warning" for value in diagnostics
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions/{form_id}/package", response_model=dict[str, object])
|
||||||
|
def api_export_form_definition_package(
|
||||||
|
form_id: str,
|
||||||
|
revision: str | None = Query(default=None, max_length=255),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, READ_SCOPE)
|
||||||
|
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Form definition not found")
|
||||||
|
actor_id = next(
|
||||||
|
(
|
||||||
|
str(getattr(principal, name))
|
||||||
|
for name in ("account_id", "identity_id", "membership_id")
|
||||||
|
if getattr(principal, name, None)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return export_form_definition_fragment(
|
||||||
|
item,
|
||||||
|
exported_at=datetime.now(UTC),
|
||||||
|
exported_by=actor_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/packages/assess", response_model=dict[str, object])
|
||||||
|
def api_assess_form_definition_package(
|
||||||
|
payload: FormPackageRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
return assess_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=payload.fragment,
|
||||||
|
)
|
||||||
|
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||||
|
raise _error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/packages/import", response_model=dict[str, object])
|
||||||
|
def api_import_form_definition_package(
|
||||||
|
payload: FormPackageImportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
_require(principal, WRITE_SCOPE)
|
||||||
|
try:
|
||||||
|
item = import_form_definition_fragment(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
fragment=payload.fragment,
|
||||||
|
target_form_id=payload.target_form_id,
|
||||||
|
target_key=payload.target_key,
|
||||||
|
expected_revision=payload.expected_revision,
|
||||||
|
change_reason=payload.change_reason,
|
||||||
|
recorded_at=payload.recorded_at,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise _error(exc) from exc
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/definitions/{form_id}/history",
|
"/definitions/{form_id}/history",
|
||||||
response_model=FormDefinitionHistoryResponse,
|
response_model=FormDefinitionHistoryResponse,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -21,8 +22,24 @@ class FormDefinitionHistoryResponse(BaseModel):
|
|||||||
revisions: list[dict[str, Any]]
|
revisions: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class FormPackageRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
fragment: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class FormPackageImportRequest(FormPackageRequest):
|
||||||
|
target_form_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
target_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
change_reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
recorded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"FormDefinitionHistoryResponse",
|
"FormDefinitionHistoryResponse",
|
||||||
"FormDefinitionListResponse",
|
"FormDefinitionListResponse",
|
||||||
"FormDefinitionWriteRequest",
|
"FormDefinitionWriteRequest",
|
||||||
|
"FormPackageImportRequest",
|
||||||
|
"FormPackageRequest",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -16,6 +20,8 @@ from govoplan_core.core.events import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
FormDefinition,
|
FormDefinition,
|
||||||
|
FormConditionExpression,
|
||||||
|
FormFieldDefinition,
|
||||||
InstitutionalContextError,
|
InstitutionalContextError,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
)
|
)
|
||||||
@@ -99,9 +105,10 @@ def record_form_definition(
|
|||||||
raise FormDefinitionStoreError(
|
raise FormDefinitionStoreError(
|
||||||
"A Form definition key cannot change across revisions."
|
"A Form definition key cannot change across revisions."
|
||||||
)
|
)
|
||||||
if definition.publication_state not in _PUBLICATION_TRANSITIONS[
|
if (
|
||||||
current.publication_state
|
definition.publication_state
|
||||||
]:
|
not in _PUBLICATION_TRANSITIONS[current.publication_state]
|
||||||
|
):
|
||||||
raise FormDefinitionStoreError(
|
raise FormDefinitionStoreError(
|
||||||
f"Form publication transition {current.publication_state!r} to "
|
f"Form publication transition {current.publication_state!r} to "
|
||||||
f"{definition.publication_state!r} is not allowed."
|
f"{definition.publication_state!r} is not allowed."
|
||||||
@@ -151,6 +158,227 @@ def record_form_definition(
|
|||||||
return _definition_from_row(row)
|
return _definition_from_row(row)
|
||||||
|
|
||||||
|
|
||||||
|
def form_definition_diagnostics(
|
||||||
|
definition: FormDefinition,
|
||||||
|
) -> tuple[Mapping[str, object], ...]:
|
||||||
|
"""Return deterministic, non-blocking authoring diagnostics.
|
||||||
|
|
||||||
|
Structural errors are rejected by ``_validate_definition``. Diagnostics are
|
||||||
|
reserved for useful publication quality feedback such as untranslated text.
|
||||||
|
"""
|
||||||
|
|
||||||
|
diagnostics: list[Mapping[str, object]] = []
|
||||||
|
field_by_key = {item.key: item for item in definition.fields}
|
||||||
|
page_keys = {item.key for item in definition.pages}
|
||||||
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
locale = localization.locale
|
||||||
|
if not localization.title:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.title_missing",
|
||||||
|
f"{locale} does not translate the Form title.",
|
||||||
|
locale=locale,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for field in definition.fields:
|
||||||
|
if field.key not in localization.field_labels:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.field_label_missing",
|
||||||
|
f"{locale} does not translate field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=field.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if field.help_text and field.key not in localization.field_help_texts:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.field_help_missing",
|
||||||
|
f"{locale} does not translate help for field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=field.key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
translated_options = localization.option_labels.get(field.key, {})
|
||||||
|
for option in field.options:
|
||||||
|
if option not in translated_options:
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.option_missing",
|
||||||
|
f"{locale} does not translate option {option!r} of field {field.key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=f"{field.key}:{option}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(page_keys - set(localization.page_titles)):
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.page_title_missing",
|
||||||
|
f"{locale} does not translate page {key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for key in sorted(section_keys - set(localization.section_titles)):
|
||||||
|
diagnostics.append(
|
||||||
|
_definition_diagnostic(
|
||||||
|
"warning",
|
||||||
|
"translation.section_title_missing",
|
||||||
|
f"{locale} does not translate section {key!r}.",
|
||||||
|
locale=locale,
|
||||||
|
subject=key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# These sets are validated structurally. Keeping the lookup here makes
|
||||||
|
# diagnostics stable if future compatible readers retain unknown keys.
|
||||||
|
_ = field_by_key
|
||||||
|
return tuple(diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def export_form_definition_fragment(
|
||||||
|
definition: FormDefinition,
|
||||||
|
*,
|
||||||
|
exported_at: datetime,
|
||||||
|
exported_by: str | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if exported_at.tzinfo is None or exported_at.utcoffset() is None:
|
||||||
|
raise FormDefinitionStoreError("Package exported_at must include a timezone.")
|
||||||
|
definition_payload = definition.to_dict()
|
||||||
|
digest = _payload_sha256(definition_payload)
|
||||||
|
return {
|
||||||
|
"kind": "govoplan.forms.definition",
|
||||||
|
"contract_version": "0.1.0",
|
||||||
|
"definition": definition_payload,
|
||||||
|
"definition_sha256": digest,
|
||||||
|
"provenance": {
|
||||||
|
"owner_module": "forms",
|
||||||
|
"tenant_id": definition.reference.tenant_id,
|
||||||
|
"form_id": definition.reference.object_id,
|
||||||
|
"revision": definition.reference.version,
|
||||||
|
"exported_at": exported_at.isoformat(),
|
||||||
|
"exported_by": exported_by,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_form_definition_fragment(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
fragment: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
source = _definition_from_fragment(fragment)
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
current = get_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
form_id=source.reference.object_id,
|
||||||
|
)
|
||||||
|
key_collision = (
|
||||||
|
session.query(FormDefinitionRevision.form_id)
|
||||||
|
.filter(
|
||||||
|
FormDefinitionRevision.tenant_id == tenant_id,
|
||||||
|
FormDefinitionRevision.form_key == source.key,
|
||||||
|
FormDefinitionRevision.superseded_at.is_(None),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
same_tenant = source.reference.tenant_id == tenant_id
|
||||||
|
if (
|
||||||
|
same_tenant
|
||||||
|
and current is not None
|
||||||
|
and current.reference.version == source.reference.version
|
||||||
|
):
|
||||||
|
outcome = (
|
||||||
|
"replay" if current.to_dict() == source.to_dict() else "revision_conflict"
|
||||||
|
)
|
||||||
|
elif current is not None:
|
||||||
|
outcome = "new_revision_required"
|
||||||
|
elif key_collision is not None:
|
||||||
|
outcome = "key_conflict"
|
||||||
|
else:
|
||||||
|
outcome = "create"
|
||||||
|
return {
|
||||||
|
"outcome": outcome,
|
||||||
|
"portable": True,
|
||||||
|
"same_tenant": same_tenant,
|
||||||
|
"source": {
|
||||||
|
"tenant_id": source.reference.tenant_id,
|
||||||
|
"form_id": source.reference.object_id,
|
||||||
|
"key": source.key,
|
||||||
|
"revision": source.reference.version,
|
||||||
|
},
|
||||||
|
"current_revision": current.reference.version if current else None,
|
||||||
|
"requires_remap": not same_tenant,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def import_form_definition_fragment(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
fragment: Mapping[str, object],
|
||||||
|
target_form_id: str | None,
|
||||||
|
target_key: str | None,
|
||||||
|
expected_revision: str | None,
|
||||||
|
change_reason: str,
|
||||||
|
recorded_at: datetime,
|
||||||
|
) -> FormDefinition:
|
||||||
|
source = _definition_from_fragment(fragment)
|
||||||
|
tenant_id = _principal_tenant(principal)
|
||||||
|
if recorded_at.tzinfo is None or recorded_at.utcoffset() is None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Package import recorded_at must include a timezone."
|
||||||
|
)
|
||||||
|
clean_reason = str(change_reason or "").strip()
|
||||||
|
if not clean_reason or len(clean_reason) > 1000:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
"Package import requires a change reason of at most 1000 characters."
|
||||||
|
)
|
||||||
|
resolved_id = str(target_form_id or source.reference.object_id).strip()
|
||||||
|
resolved_key = str(target_key or source.key).strip()
|
||||||
|
current = get_form_definition(session, principal, form_id=resolved_id)
|
||||||
|
revision = str(uuid.uuid4())
|
||||||
|
payload = source.to_dict()
|
||||||
|
payload["reference"] = {
|
||||||
|
**dict(payload["reference"]),
|
||||||
|
"object_id": resolved_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"version": revision,
|
||||||
|
}
|
||||||
|
payload["key"] = current.key if current is not None else resolved_key
|
||||||
|
payload["temporal"] = {
|
||||||
|
"revision": revision,
|
||||||
|
"valid_from": recorded_at.isoformat(),
|
||||||
|
"valid_to": None,
|
||||||
|
"recorded_at": recorded_at.isoformat(),
|
||||||
|
"superseded_at": None,
|
||||||
|
"change_reason": clean_reason,
|
||||||
|
}
|
||||||
|
payload["publication_state"] = "draft"
|
||||||
|
metadata = dict(source.metadata)
|
||||||
|
metadata["package_import"] = {
|
||||||
|
"source_tenant_id": source.reference.tenant_id,
|
||||||
|
"source_form_id": source.reference.object_id,
|
||||||
|
"source_revision": source.reference.version,
|
||||||
|
"source_sha256": str(fragment.get("definition_sha256") or ""),
|
||||||
|
}
|
||||||
|
payload["metadata"] = metadata
|
||||||
|
imported = definition_from_mapping(payload)
|
||||||
|
return record_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=imported,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_form_definition(
|
def get_form_definition(
|
||||||
session: Session,
|
session: Session,
|
||||||
principal: object,
|
principal: object,
|
||||||
@@ -326,6 +554,223 @@ def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
|
|||||||
raise FormDefinitionStoreError(
|
raise FormDefinitionStoreError(
|
||||||
"A Form definition revision requires a change reason."
|
"A Form definition revision requires a change reason."
|
||||||
)
|
)
|
||||||
|
_validate_form_composition(definition)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_form_composition(definition: FormDefinition) -> None:
|
||||||
|
fields = {item.key: item for item in definition.fields}
|
||||||
|
if definition.pages:
|
||||||
|
field_occurrences = [
|
||||||
|
field_key
|
||||||
|
for page in definition.pages
|
||||||
|
for section in page.sections
|
||||||
|
for field_key in section.field_keys
|
||||||
|
]
|
||||||
|
unknown = sorted(set(field_occurrences) - set(fields))
|
||||||
|
if unknown:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages reference unknown fields: {', '.join(unknown)}."
|
||||||
|
)
|
||||||
|
duplicates = sorted(
|
||||||
|
key for key in set(field_occurrences) if field_occurrences.count(key) > 1
|
||||||
|
)
|
||||||
|
if duplicates:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages place fields more than once: {', '.join(duplicates)}."
|
||||||
|
)
|
||||||
|
missing = sorted(set(fields) - set(field_occurrences))
|
||||||
|
if missing:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form pages do not place fields: {', '.join(missing)}."
|
||||||
|
)
|
||||||
|
conditions: list[tuple[str, FormConditionExpression]] = []
|
||||||
|
for field in definition.fields:
|
||||||
|
if field.visibility_condition is not None:
|
||||||
|
conditions.append((f"field:{field.key}", field.visibility_condition))
|
||||||
|
for page in definition.pages:
|
||||||
|
if page.visibility_condition is not None:
|
||||||
|
conditions.append((f"page:{page.key}", page.visibility_condition))
|
||||||
|
for section in page.sections:
|
||||||
|
if section.visibility_condition is not None:
|
||||||
|
conditions.append(
|
||||||
|
(f"section:{section.key}", section.visibility_condition)
|
||||||
|
)
|
||||||
|
for subject, condition in conditions:
|
||||||
|
_validate_condition(condition, fields=fields, subject=subject)
|
||||||
|
graph = {
|
||||||
|
field.key: set(field.visibility_condition.referenced_fields)
|
||||||
|
if field.visibility_condition is not None
|
||||||
|
else set()
|
||||||
|
for field in definition.fields
|
||||||
|
}
|
||||||
|
_reject_condition_cycles(graph)
|
||||||
|
page_keys = {item.key for item in definition.pages}
|
||||||
|
section_keys = {item.key for page in definition.pages for item in page.sections}
|
||||||
|
for localization in definition.localizations:
|
||||||
|
unknown_fields = (
|
||||||
|
set(localization.field_labels)
|
||||||
|
| set(localization.field_help_texts)
|
||||||
|
| set(localization.option_labels)
|
||||||
|
) - set(fields)
|
||||||
|
if unknown_fields:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown fields: "
|
||||||
|
f"{', '.join(sorted(unknown_fields))}."
|
||||||
|
)
|
||||||
|
if set(localization.page_titles) - page_keys:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown pages."
|
||||||
|
)
|
||||||
|
if set(localization.section_titles) - section_keys:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} references unknown sections."
|
||||||
|
)
|
||||||
|
for field_key, labels in localization.option_labels.items():
|
||||||
|
field = fields[field_key]
|
||||||
|
if set(labels) - set(field.options):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form localization {localization.locale!r} translates unknown "
|
||||||
|
f"options for field {field_key!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_condition(
|
||||||
|
condition: FormConditionExpression,
|
||||||
|
*,
|
||||||
|
fields: Mapping[str, FormFieldDefinition],
|
||||||
|
subject: str,
|
||||||
|
) -> None:
|
||||||
|
if condition.kind != "predicate":
|
||||||
|
for child in condition.conditions:
|
||||||
|
_validate_condition(child, fields=fields, subject=subject)
|
||||||
|
return
|
||||||
|
field_key = str(condition.field_key)
|
||||||
|
field = fields.get(field_key)
|
||||||
|
if field is None:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition on {subject} references unknown field {field_key!r}."
|
||||||
|
)
|
||||||
|
operator = str(condition.operator)
|
||||||
|
if operator in {"lt", "lte", "gt", "gte"} and field.value_type not in {
|
||||||
|
"integer",
|
||||||
|
"number",
|
||||||
|
"date",
|
||||||
|
"datetime",
|
||||||
|
}:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator {operator!r} is incompatible with "
|
||||||
|
f"field {field_key!r} ({field.value_type})."
|
||||||
|
)
|
||||||
|
if operator == "contains" and field.value_type not in {
|
||||||
|
"text",
|
||||||
|
"multiline_text",
|
||||||
|
"email",
|
||||||
|
"multi_choice",
|
||||||
|
"list",
|
||||||
|
}:
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator 'contains' is incompatible with field {field_key!r}."
|
||||||
|
)
|
||||||
|
if operator in {"in", "not_in"} and (
|
||||||
|
not isinstance(condition.value, Sequence)
|
||||||
|
or isinstance(condition.value, (str, bytes))
|
||||||
|
):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition operator {operator!r} requires a list value."
|
||||||
|
)
|
||||||
|
if operator not in {"is_empty", "is_not_empty", "in", "not_in"}:
|
||||||
|
if not _condition_value_matches(field, condition.value):
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form condition value is incompatible with field {field_key!r} "
|
||||||
|
f"({field.value_type})."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_value_matches(field: FormFieldDefinition, value: object) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
if field.value_type == "boolean":
|
||||||
|
return isinstance(value, bool)
|
||||||
|
if field.value_type == "integer":
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
if field.value_type == "number":
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
if field.value_type in {"object"}:
|
||||||
|
return isinstance(value, Mapping)
|
||||||
|
if field.value_type in {"list", "multi_choice"}:
|
||||||
|
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
|
||||||
|
return isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_condition_cycles(graph: Mapping[str, set[str]]) -> None:
|
||||||
|
visiting: set[str] = set()
|
||||||
|
visited: set[str] = set()
|
||||||
|
|
||||||
|
def visit(key: str, path: tuple[str, ...]) -> None:
|
||||||
|
if key in visiting:
|
||||||
|
cycle = " -> ".join((*path, key))
|
||||||
|
raise FormDefinitionStoreError(
|
||||||
|
f"Form visibility conditions contain a dependency cycle: {cycle}."
|
||||||
|
)
|
||||||
|
if key in visited:
|
||||||
|
return
|
||||||
|
visiting.add(key)
|
||||||
|
for dependency in sorted(graph.get(key, set())):
|
||||||
|
if dependency in graph:
|
||||||
|
visit(dependency, (*path, key))
|
||||||
|
visiting.remove(key)
|
||||||
|
visited.add(key)
|
||||||
|
|
||||||
|
for key in sorted(graph):
|
||||||
|
visit(key, ())
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_from_fragment(fragment: Mapping[str, object]) -> FormDefinition:
|
||||||
|
if fragment.get("kind") != "govoplan.forms.definition":
|
||||||
|
raise FormDefinitionStoreError("Unsupported Forms package fragment kind.")
|
||||||
|
if fragment.get("contract_version") != "0.1.0":
|
||||||
|
raise FormDefinitionStoreError("Unsupported Forms package contract version.")
|
||||||
|
payload = fragment.get("definition")
|
||||||
|
if not isinstance(payload, Mapping):
|
||||||
|
raise FormDefinitionStoreError("Forms package definition must be an object.")
|
||||||
|
expected = str(fragment.get("definition_sha256") or "")
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{64}", expected) or not _constant_time_equal(
|
||||||
|
expected,
|
||||||
|
_payload_sha256(payload),
|
||||||
|
):
|
||||||
|
raise FormDefinitionStoreError("Forms package definition digest is invalid.")
|
||||||
|
return definition_from_mapping(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_sha256(value: Mapping[str, object]) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _constant_time_equal(left: str, right: str) -> bool:
|
||||||
|
return hmac.compare_digest(left, right)
|
||||||
|
|
||||||
|
|
||||||
|
def _definition_diagnostic(
|
||||||
|
severity: str,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
locale: str | None = None,
|
||||||
|
subject: str | None = None,
|
||||||
|
) -> Mapping[str, object]:
|
||||||
|
return {
|
||||||
|
"severity": severity,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"locale": locale,
|
||||||
|
"subject": subject,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _recorded_at(definition: FormDefinition) -> datetime:
|
def _recorded_at(definition: FormDefinition) -> datetime:
|
||||||
|
|||||||
+132
-1
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -9,7 +9,11 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
FormDefinition,
|
FormDefinition,
|
||||||
|
FormConditionExpression,
|
||||||
FormFieldDefinition,
|
FormFieldDefinition,
|
||||||
|
FormLocalization,
|
||||||
|
FormPageDefinition,
|
||||||
|
FormSectionDefinition,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
)
|
)
|
||||||
@@ -17,7 +21,11 @@ from govoplan_forms.backend.db.models import FormDefinitionRevision
|
|||||||
from govoplan_forms.backend.service import (
|
from govoplan_forms.backend.service import (
|
||||||
FormDefinitionStoreError,
|
FormDefinitionStoreError,
|
||||||
SqlFormDefinitionProvider,
|
SqlFormDefinitionProvider,
|
||||||
|
assess_form_definition_fragment,
|
||||||
|
export_form_definition_fragment,
|
||||||
|
form_definition_diagnostics,
|
||||||
form_definition_history,
|
form_definition_history,
|
||||||
|
import_form_definition_fragment,
|
||||||
record_form_definition,
|
record_form_definition,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,6 +170,129 @@ class FormsTests(unittest.TestCase):
|
|||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_pages_conditions_and_localization_are_validated(self) -> None:
|
||||||
|
item = definition()
|
||||||
|
composed = replace(
|
||||||
|
item,
|
||||||
|
fields=(
|
||||||
|
item.fields[0],
|
||||||
|
replace(
|
||||||
|
item.fields[1],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="name",
|
||||||
|
operator="is_not_empty",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pages=(
|
||||||
|
FormPageDefinition(
|
||||||
|
key="application",
|
||||||
|
title="Application",
|
||||||
|
sections=(
|
||||||
|
FormSectionDefinition(
|
||||||
|
key="details",
|
||||||
|
title="Details",
|
||||||
|
field_keys=("name", "delivery"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
fallback_locale="de",
|
||||||
|
localizations=(
|
||||||
|
FormLocalization(
|
||||||
|
locale="de",
|
||||||
|
title="Antrag",
|
||||||
|
field_labels={"name": "Name", "delivery": "Zustellung"},
|
||||||
|
option_labels={"delivery": {"portal": "Portal", "mail": "Post"}},
|
||||||
|
page_titles={"application": "Antrag"},
|
||||||
|
section_titles={"details": "Angaben"},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
stored = record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=composed,
|
||||||
|
)
|
||||||
|
self.assertEqual("application", stored.pages[0].key)
|
||||||
|
self.assertEqual((), form_definition_diagnostics(stored))
|
||||||
|
|
||||||
|
cyclic = replace(
|
||||||
|
definition(revision="2"),
|
||||||
|
fields=(
|
||||||
|
replace(
|
||||||
|
item.fields[0],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="delivery",
|
||||||
|
operator="eq",
|
||||||
|
value="portal",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
replace(
|
||||||
|
item.fields[1],
|
||||||
|
visibility_condition=FormConditionExpression(
|
||||||
|
kind="predicate",
|
||||||
|
field_key="name",
|
||||||
|
operator="is_not_empty",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "dependency cycle"):
|
||||||
|
record_form_definition(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
definition=cyclic,
|
||||||
|
expected_revision="1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_package_fragment_is_verified_assessed_and_imported_as_draft(self) -> None:
|
||||||
|
source = definition(tenant_id="tenant-source")
|
||||||
|
fragment = export_form_definition_fragment(
|
||||||
|
source,
|
||||||
|
exported_at=NOW,
|
||||||
|
exported_by="source-account",
|
||||||
|
)
|
||||||
|
assessment = assess_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=fragment,
|
||||||
|
)
|
||||||
|
self.assertEqual("create", assessment["outcome"])
|
||||||
|
self.assertTrue(assessment["requires_remap"])
|
||||||
|
|
||||||
|
imported = import_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=fragment,
|
||||||
|
target_form_id="local-permit",
|
||||||
|
target_key="local-permit",
|
||||||
|
expected_revision=None,
|
||||||
|
change_reason="Import reviewed package.",
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
self.assertEqual("tenant-1", imported.reference.tenant_id)
|
||||||
|
self.assertEqual("draft", imported.publication_state)
|
||||||
|
self.assertEqual(
|
||||||
|
"tenant-source",
|
||||||
|
imported.metadata["package_import"]["source_tenant_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
tampered = dict(fragment)
|
||||||
|
tampered["definition"] = {**fragment["definition"], "title": "Tampered"}
|
||||||
|
with self.assertRaisesRegex(FormDefinitionStoreError, "digest"):
|
||||||
|
assess_form_definition_fragment(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
fragment=tampered,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
(assess_form_definition_fragment,)
|
||||||
|
(export_form_definition_fragment,)
|
||||||
|
(form_definition_diagnostics,)
|
||||||
|
(import_form_definition_fragment,)
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
||||||
|
|
||||||
|
export type FormCondition =
|
||||||
|
| { kind: "predicate"; field_key: string; operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "is_empty" | "is_not_empty"; value?: unknown }
|
||||||
|
| { kind: "all" | "any"; conditions: FormCondition[] }
|
||||||
|
| { kind: "not"; conditions: [FormCondition] };
|
||||||
|
|
||||||
export type FormFieldDefinition = {
|
export type FormFieldDefinition = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -12,6 +17,35 @@ export type FormFieldDefinition = {
|
|||||||
options: string[];
|
options: string[];
|
||||||
constraints: Record<string, unknown>;
|
constraints: Record<string, unknown>;
|
||||||
default_value?: unknown;
|
default_value?: unknown;
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
accessibility?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormSectionDefinition = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
field_keys: string[];
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormPageDefinition = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
sections: FormSectionDefinition[];
|
||||||
|
visibility_condition?: FormCondition | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormLocalization = {
|
||||||
|
locale: string;
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
field_labels: Record<string, string>;
|
||||||
|
field_help_texts: Record<string, string>;
|
||||||
|
option_labels: Record<string, Record<string, string>>;
|
||||||
|
page_titles: Record<string, string>;
|
||||||
|
section_titles: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FormDefinition = {
|
export type FormDefinition = {
|
||||||
@@ -40,9 +74,21 @@ export type FormDefinition = {
|
|||||||
signature_requirement: "none" | "optional" | "required";
|
signature_requirement: "none" | "optional" | "required";
|
||||||
policy_refs: string[];
|
policy_refs: string[];
|
||||||
handoff_kinds: string[];
|
handoff_kinds: string[];
|
||||||
|
pages?: FormPageDefinition[];
|
||||||
|
fallback_locale?: string | null;
|
||||||
|
localizations?: FormLocalization[];
|
||||||
|
accessibility?: Record<string, unknown>;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FormPackageFragment = {
|
||||||
|
kind: "govoplan.forms.definition";
|
||||||
|
contract_version: "0.1.0";
|
||||||
|
definition: FormDefinition;
|
||||||
|
definition_sha256: string;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export function listFormDefinitions(
|
export function listFormDefinitions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: { query?: string; states?: string[]; offset?: number; limit?: number } = {},
|
options: { query?: string; states?: string[]; offset?: number; limit?: number } = {},
|
||||||
@@ -69,3 +115,39 @@ export function saveFormDefinition(
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
formId: string,
|
||||||
|
revision: string
|
||||||
|
): Promise<FormPackageFragment> {
|
||||||
|
return apiFetch(settings, apiPath(`/api/v1/forms/definitions/${encodeURIComponent(formId)}/package`, { revision }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assessFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
fragment: FormPackageFragment
|
||||||
|
): Promise<{ outcome: string; requires_remap: boolean; current_revision?: string | null }> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms/packages/assess", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ fragment })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importFormDefinitionPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
fragment: FormPackageFragment,
|
||||||
|
options: { targetFormId?: string; targetKey?: string; expectedRevision?: string; changeReason: string }
|
||||||
|
): Promise<FormDefinition> {
|
||||||
|
return apiFetch(settings, "/api/v1/forms/packages/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
fragment,
|
||||||
|
target_form_id: options.targetFormId || null,
|
||||||
|
target_key: options.targetKey || null,
|
||||||
|
expected_revision: options.expectedRevision || null,
|
||||||
|
change_reason: options.changeReason,
|
||||||
|
recorded_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
saveFormDefinition,
|
saveFormDefinition,
|
||||||
type FormDefinition,
|
type FormDefinition,
|
||||||
|
type FormCondition,
|
||||||
type FormFieldDefinition,
|
type FormFieldDefinition,
|
||||||
|
type FormLocalization,
|
||||||
|
type FormPageDefinition,
|
||||||
type FormValueType
|
type FormValueType
|
||||||
} from "../../api/forms";
|
} from "../../api/forms";
|
||||||
|
|
||||||
@@ -113,7 +116,10 @@ export default function FormDefinitionDialog({
|
|||||||
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
|
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field)
|
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field),
|
||||||
|
pages: patch.key && patch.key !== current.fields[index].key
|
||||||
|
? remapPageField(current.pages ?? [], current.fields[index].key, patch.key)
|
||||||
|
: current.pages
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,11 +201,23 @@ export default function FormDefinitionDialog({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Field label="Accessibility instructions" className="form-definition-wide">
|
||||||
|
<textarea
|
||||||
|
rows={2}
|
||||||
|
value={String(draft.accessibility?.instructions ?? "")}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => setDraft({
|
||||||
|
...draft,
|
||||||
|
accessibility: patchOptionalText(draft.accessibility ?? {}, "instructions", event.target.value)
|
||||||
|
})}
|
||||||
|
placeholder="Optional instructions announced before the Form"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-field-editor-heading">
|
<div className="form-field-editor-heading">
|
||||||
<h3>Fields</h3>
|
<h3>Fields</h3>
|
||||||
<Button onClick={() => setDraft({ ...draft, fields: [...draft.fields, emptyField(draft.fields.length + 1)] })} 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>
|
</div>
|
||||||
@@ -222,17 +240,27 @@ export default function FormDefinitionDialog({
|
|||||||
{isChoice(field.value_type) &&
|
{isChoice(field.value_type) &&
|
||||||
<Field label="Options" className="form-field-options"><input value={field.options.join(", ")} disabled={busy} onChange={(event) => patchField(index, { options: splitValues(event.target.value) })} /></Field>
|
<Field label="Options" className="form-field-options"><input value={field.options.join(", ")} disabled={busy} onChange={(event) => patchField(index, { options: splitValues(event.target.value) })} /></Field>
|
||||||
}
|
}
|
||||||
|
<ConditionFields
|
||||||
|
condition={field.visibility_condition ?? null}
|
||||||
|
fields={draft.fields}
|
||||||
|
currentKey={field.key}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(visibility_condition) => patchField(index, { visibility_condition })}
|
||||||
|
/>
|
||||||
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
|
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
|
||||||
<IconButton
|
<IconButton
|
||||||
label={`Remove ${field.label || "field"}`}
|
label={`Remove ${field.label || "field"}`}
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={busy || draft.fields.length === 1}
|
disabled={busy || draft.fields.length === 1}
|
||||||
onClick={() => setDraft({ ...draft, fields: draft.fields.filter((_, fieldIndex) => fieldIndex !== index) })}
|
onClick={() => setDraft(removeField(draft, index))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<PageEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||||
|
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||||
|
<DefinitionPreview definition={draft} previous={definition} />
|
||||||
<Field label="Change reason">
|
<Field label="Change reason">
|
||||||
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
|
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
|
||||||
</Field>
|
</Field>
|
||||||
@@ -262,6 +290,177 @@ function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConditionFields({
|
||||||
|
condition,
|
||||||
|
fields,
|
||||||
|
currentKey,
|
||||||
|
disabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
condition: FormCondition | null;
|
||||||
|
fields: FormFieldDefinition[];
|
||||||
|
currentKey: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (value: FormCondition | null) => void;
|
||||||
|
}) {
|
||||||
|
const predicate = condition?.kind === "predicate" ? condition : null;
|
||||||
|
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
|
||||||
|
return (
|
||||||
|
<div className="form-field-condition">
|
||||||
|
<Field label="Visible when">
|
||||||
|
<select
|
||||||
|
value={predicate?.field_key ?? ""}
|
||||||
|
disabled={disabled || candidates.length === 0}
|
||||||
|
onChange={(event) => onChange(event.target.value
|
||||||
|
? { kind: "predicate", field_key: event.target.value, operator: "eq", value: true }
|
||||||
|
: null)}>
|
||||||
|
<option value="">Always visible</option>
|
||||||
|
{candidates.map((item) => <option key={item.key} value={item.key}>{item.label || item.key}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{predicate && <>
|
||||||
|
<Field label="Condition">
|
||||||
|
<select
|
||||||
|
value={predicate.operator}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange({
|
||||||
|
...predicate,
|
||||||
|
operator: event.target.value as Extract<FormCondition, { kind: "predicate" }>["operator"],
|
||||||
|
...(event.target.value === "is_empty" || event.target.value === "is_not_empty" ? { value: undefined } : {})
|
||||||
|
})}>
|
||||||
|
<option value="eq">Equals</option>
|
||||||
|
<option value="neq">Does not equal</option>
|
||||||
|
<option value="is_empty">Is empty</option>
|
||||||
|
<option value="is_not_empty">Is not empty</option>
|
||||||
|
<option value="contains">Contains</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{!(["is_empty", "is_not_empty"] as string[]).includes(predicate.operator) &&
|
||||||
|
<Field label="Value">
|
||||||
|
<input
|
||||||
|
value={conditionInputValue(predicate.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange({ ...predicate, value: parseConditionValue(event.target.value, fields.find((item) => item.key === predicate.field_key)?.value_type) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
}
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||||
|
const pages = draft.pages ?? [];
|
||||||
|
function updatePages(next: FormPageDefinition[]) {
|
||||||
|
onChange({ ...draft, pages: next });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="form-composition-section">
|
||||||
|
<div className="form-field-editor-heading">
|
||||||
|
<h3>Pages and sections</h3>
|
||||||
|
{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([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
|
||||||
|
</div>
|
||||||
|
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
|
||||||
|
{pages.map((page, pageIndex) =>
|
||||||
|
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
|
||||||
|
<div className="form-page-editor-heading">
|
||||||
|
<Field label="Page key"><input value={page.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, key: event.target.value }))} /></Field>
|
||||||
|
<Field label="Page title"><input value={page.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, title: event.target.value }))} /></Field>
|
||||||
|
<IconButton label={`Remove page ${page.title || page.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => updatePages(pages.filter((_, index) => index !== pageIndex))} />
|
||||||
|
</div>
|
||||||
|
{page.sections.map((section, sectionIndex) =>
|
||||||
|
<div className="form-section-editor" key={`${sectionIndex}:${section.key}`}>
|
||||||
|
<Field label="Section key"><input value={section.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, key: event.target.value }) }))} /></Field>
|
||||||
|
<Field label="Section title"><input value={section.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, title: event.target.value }) }))} /></Field>
|
||||||
|
<Field label="Fields">
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
value={section.field_keys}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => updatePages(replaceAt(pages, pageIndex, {
|
||||||
|
...page,
|
||||||
|
sections: replaceAt(page.sections, sectionIndex, {
|
||||||
|
...section,
|
||||||
|
field_keys: Array.from(event.currentTarget.selectedOptions, (option) => option.value)
|
||||||
|
})
|
||||||
|
}))}>
|
||||||
|
{draft.fields.map((field) => <option key={field.key} value={field.key}>{field.label || field.key}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<IconButton label={`Remove section ${section.title || section.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled || page.sections.length === 1} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: page.sections.filter((_, index) => index !== sectionIndex) }))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button disabled={disabled} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: [...page.sections, emptySection(page.sections.length + 1)] }))}><Plus size={15} aria-hidden="true" />Add section</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||||
|
const localizations = draft.localizations ?? [];
|
||||||
|
function update(items: FormLocalization[]) {
|
||||||
|
const fallback = items.some((item) => item.locale === draft.fallback_locale)
|
||||||
|
? draft.fallback_locale
|
||||||
|
: items[0]?.locale ?? null;
|
||||||
|
onChange({ ...draft, localizations: items, fallback_locale: fallback });
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="form-composition-section">
|
||||||
|
<div className="form-field-editor-heading">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
|
||||||
|
{localizations.map((localization, index) =>
|
||||||
|
<div className="form-localization-editor" key={`${index}:${localization.locale}`}>
|
||||||
|
<Field label="Locale"><input value={localization.locale} disabled={disabled} placeholder="de" onChange={(event) => update(replaceAt(localizations, index, { ...localization, locale: event.target.value }))} /></Field>
|
||||||
|
<Field label="Localized title"><input value={localization.title ?? ""} disabled={disabled} onChange={(event) => update(replaceAt(localizations, index, { ...localization, title: event.target.value }))} /></Field>
|
||||||
|
<label className="form-localization-fallback"><input type="radio" checked={draft.fallback_locale === localization.locale} disabled={disabled || !localization.locale} onChange={() => onChange({ ...draft, fallback_locale: localization.locale })} />Fallback</label>
|
||||||
|
<IconButton label={`Remove locale ${localization.locale || index + 1}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => update(localizations.filter((_, itemIndex) => itemIndex !== index))} />
|
||||||
|
<div className="form-localization-fields">
|
||||||
|
{draft.fields.map((field) =>
|
||||||
|
<Field key={field.key} label={`${field.label || field.key} label`}>
|
||||||
|
<input
|
||||||
|
value={localization.field_labels[field.key] ?? ""}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => update(replaceAt(localizations, index, {
|
||||||
|
...localization,
|
||||||
|
field_labels: patchOptionalText(localization.field_labels, field.key, event.target.value)
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DefinitionPreview({ definition, previous }: { definition: FormDefinition; previous: FormDefinition | null }) {
|
||||||
|
const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<div className="form-preview-grid">
|
||||||
|
<div>
|
||||||
|
<strong>{definition.title || "Untitled Form"}</strong>
|
||||||
|
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
|
||||||
|
<div key={page.key} className="form-preview-page">
|
||||||
|
<span>{page.title}</span>
|
||||||
|
{page.sections.map((section) => <small key={section.key}>{section.title}: {section.field_keys.join(", ") || "No fields"}</small>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
|
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
|
||||||
if (definition) return structuredClone(definition);
|
if (definition) return structuredClone(definition);
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
@@ -280,6 +479,10 @@ function initialDraft(tenantId: string, definition: FormDefinition | null): Form
|
|||||||
signature_requirement: "none",
|
signature_requirement: "none",
|
||||||
policy_refs: [],
|
policy_refs: [],
|
||||||
handoff_kinds: [],
|
handoff_kinds: [],
|
||||||
|
pages: [],
|
||||||
|
fallback_locale: null,
|
||||||
|
localizations: [],
|
||||||
|
accessibility: {},
|
||||||
metadata: {}
|
metadata: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -299,6 +502,114 @@ function normalizeField(field: FormFieldDefinition): FormFieldDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addField(definition: FormDefinition): FormDefinition {
|
||||||
|
const field = emptyField(definition.fields.length + 1);
|
||||||
|
const pages = definition.pages ?? [];
|
||||||
|
if (pages.length === 0) return { ...definition, fields: [...definition.fields, field] };
|
||||||
|
const firstPage = pages[0];
|
||||||
|
const firstSection = firstPage.sections[0];
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fields: [...definition.fields, field],
|
||||||
|
pages: replaceAt(pages, 0, {
|
||||||
|
...firstPage,
|
||||||
|
sections: replaceAt(firstPage.sections, 0, {
|
||||||
|
...firstSection,
|
||||||
|
field_keys: [...firstSection.field_keys, field.key]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(definition: FormDefinition, index: number): FormDefinition {
|
||||||
|
const key = definition.fields[index].key;
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
fields: definition.fields.filter((_, fieldIndex) => fieldIndex !== index),
|
||||||
|
pages: (definition.pages ?? []).map((page) => ({
|
||||||
|
...page,
|
||||||
|
sections: page.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
field_keys: section.field_keys.filter((fieldKey) => fieldKey !== key)
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapPageField(pages: FormPageDefinition[], previous: string, next: string): FormPageDefinition[] {
|
||||||
|
return pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
sections: page.sections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
field_keys: section.field_keys.map((key) => key === previous ? next : key)
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPage(fields: FormFieldDefinition[]): FormPageDefinition {
|
||||||
|
return {
|
||||||
|
key: "page-1",
|
||||||
|
title: "Form",
|
||||||
|
sections: [{ key: "section-1", title: "Details", field_keys: fields.map((item) => item.key) }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPage(index: number): FormPageDefinition {
|
||||||
|
return { key: `page-${index}`, title: `Page ${index}`, sections: [emptySection(1)] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptySection(index: number) {
|
||||||
|
return { key: `section-${index}`, title: `Section ${index}`, field_keys: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyLocalization(): FormLocalization {
|
||||||
|
return {
|
||||||
|
locale: "",
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
field_labels: {},
|
||||||
|
field_help_texts: {},
|
||||||
|
option_labels: {},
|
||||||
|
page_titles: {},
|
||||||
|
section_titles: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAt<T>(items: T[], index: number, value: T): T[] {
|
||||||
|
return items.map((item, itemIndex) => itemIndex === index ? value : item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchOptionalText<T extends Record<string, unknown>>(current: T, key: string, value: string): T {
|
||||||
|
const next = { ...current };
|
||||||
|
if (value.trim()) next[key as keyof T] = value as T[keyof T];
|
||||||
|
else delete next[key as keyof T];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conditionInputValue(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value === undefined || value === null) return "";
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConditionValue(value: string, type?: FormValueType): unknown {
|
||||||
|
if (type === "boolean") return value.trim().toLowerCase() === "true";
|
||||||
|
if (type === "integer") return Number.parseInt(value, 10);
|
||||||
|
if (type === "number") return Number(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function definitionChanges(previous: FormDefinition, current: FormDefinition): string[] {
|
||||||
|
const changes: string[] = [];
|
||||||
|
if (previous.title !== current.title) changes.push("Title changed");
|
||||||
|
if (previous.publication_state !== current.publication_state) changes.push(`State: ${previous.publication_state} -> ${current.publication_state}`);
|
||||||
|
if (previous.fields.length !== current.fields.length) changes.push(`Fields: ${previous.fields.length} -> ${current.fields.length}`);
|
||||||
|
if ((previous.pages?.length ?? 0) !== (current.pages?.length ?? 0)) changes.push(`Pages: ${previous.pages?.length ?? 0} -> ${current.pages?.length ?? 0}`);
|
||||||
|
if ((previous.localizations?.length ?? 0) !== (current.localizations?.length ?? 0)) changes.push(`Locales: ${previous.localizations?.length ?? 0} -> ${current.localizations?.length ?? 0}`);
|
||||||
|
if (changes.length === 0 && JSON.stringify(previous) !== JSON.stringify(current)) changes.push("Definition details changed");
|
||||||
|
return changes.length ? changes : ["No unsaved changes"];
|
||||||
|
}
|
||||||
|
|
||||||
function splitValues(value: string): string[] {
|
function splitValues(value: string): string[] {
|
||||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Pencil, Plus, RefreshCw, Search } from "lucide-react";
|
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
FormField,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
PageScrollViewport,
|
PageScrollViewport,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
hasScope,
|
hasScope,
|
||||||
type PlatformRouteContext
|
type PlatformRouteContext
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { listFormDefinitions, type FormDefinition } from "../../api/forms";
|
import {
|
||||||
|
assessFormDefinitionPackage,
|
||||||
|
exportFormDefinitionPackage,
|
||||||
|
importFormDefinitionPackage,
|
||||||
|
listFormDefinitions,
|
||||||
|
type FormDefinition,
|
||||||
|
type FormPackageFragment
|
||||||
|
} from "../../api/forms";
|
||||||
import FormDefinitionDialog from "./FormDefinitionDialog";
|
import FormDefinitionDialog from "./FormDefinitionDialog";
|
||||||
|
|
||||||
|
|
||||||
@@ -23,6 +32,11 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
|
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [importing, setImporting] = useState<FormPackageFragment | null>(null);
|
||||||
|
const [importReason, setImportReason] = useState("");
|
||||||
|
const [importAssessment, setImportAssessment] = useState("");
|
||||||
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
|
const importInput = useRef<HTMLInputElement>(null);
|
||||||
const canWrite = hasScope(auth, "forms:definition:write");
|
const canWrite = hasScope(auth, "forms:definition:write");
|
||||||
const canAdmin = hasScope(auth, "forms:definition:admin");
|
const canAdmin = hasScope(auth, "forms:definition:admin");
|
||||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||||
@@ -57,6 +71,52 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
setSubmittedQuery(query.trim());
|
setSubmittedQuery(query.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function choosePackage(file?: File) {
|
||||||
|
if (!file) return;
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const fragment = JSON.parse(await file.text()) as FormPackageFragment;
|
||||||
|
const assessment = await assessFormDefinitionPackage(settings, fragment);
|
||||||
|
setImporting(fragment);
|
||||||
|
setImportAssessment(assessment.outcome);
|
||||||
|
setImportReason("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be read.");
|
||||||
|
} finally {
|
||||||
|
if (importInput.current) importInput.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPackage() {
|
||||||
|
if (!importing || !importReason.trim()) return;
|
||||||
|
setImportBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
|
||||||
|
setImporting(null);
|
||||||
|
await load();
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPackage(item: FormDefinition) {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const fragment = await exportFormDefinitionPackage(settings, item.reference.object_id, item.reference.version);
|
||||||
|
const href = URL.createObjectURL(new Blob([JSON.stringify(fragment, null, 2)], { type: "application/json" }));
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = `${item.key}-${item.reference.version}.govoplan-form.json`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (reason) {
|
||||||
|
setError(reason instanceof Error ? reason.message : "The Form package could not be exported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="forms-page">
|
<main className="forms-page">
|
||||||
<div className="forms-shell">
|
<div className="forms-shell">
|
||||||
@@ -76,6 +136,8 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} />
|
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} />
|
||||||
|
{canWrite && <IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading} />}
|
||||||
|
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
|
||||||
{canWrite && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
|
{canWrite && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
|
||||||
<span className="forms-count">{total}</span>
|
<span className="forms-count">{total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,9 +155,10 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
<span>{item.fields.length} fields</span>
|
<span>{item.fields.length} fields</span>
|
||||||
<span>Revision {item.reference.version}</span>
|
<span>Revision {item.reference.version}</span>
|
||||||
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
|
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
|
||||||
{mayRevise
|
<span className="forms-row-actions">
|
||||||
? <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />
|
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
|
||||||
: <span className="forms-row-action-spacer" />}
|
{mayRevise && <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -117,6 +180,21 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(importing)}
|
||||||
|
title="Import Form package"
|
||||||
|
onClose={() => setImporting(null)}
|
||||||
|
closeDisabled={importBusy}
|
||||||
|
portal
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setImporting(null)} disabled={importBusy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()}>{importBusy ? "Importing" : "Import as draft"}</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="forms-package-import">
|
||||||
|
<p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p>
|
||||||
|
<FormField label="Change reason"><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-7
@@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
.forms-row {
|
.forms-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto 36px;
|
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
@@ -97,8 +97,25 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-row-action-spacer {
|
.forms-row-actions {
|
||||||
width: 36px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-hidden-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-package-import {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forms-package-import p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.forms-empty {
|
.forms-empty {
|
||||||
@@ -198,16 +215,109 @@
|
|||||||
|
|
||||||
.form-field-help,
|
.form-field-help,
|
||||||
.form-field-options,
|
.form-field-options,
|
||||||
.form-field-constraints {
|
.form-field-constraints,
|
||||||
|
.form-field-condition {
|
||||||
grid-column: 2 / -1;
|
grid-column: 2 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field-constraints {
|
.form-field-constraints,
|
||||||
|
.form-field-condition {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-composition-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field-editor-heading h3 {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-page-editor,
|
||||||
|
.form-localization-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-page-editor-heading,
|
||||||
|
.form-section-editor,
|
||||||
|
.form-localization-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1fr) auto;
|
||||||
|
align-items: end;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-editor {
|
||||||
|
grid-template-columns: minmax(120px, 0.7fr) minmax(160px, 1fr) minmax(220px, 1.5fr) auto;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section-editor select[multiple] {
|
||||||
|
min-height: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-editor {
|
||||||
|
grid-template-columns: minmax(100px, 0.5fr) minmax(200px, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-localization-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-grid ul {
|
||||||
|
margin: 7px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-preview-page small {
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.forms-toolbar {
|
.forms-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -243,7 +353,8 @@
|
|||||||
.form-field-required,
|
.form-field-required,
|
||||||
.form-field-help,
|
.form-field-help,
|
||||||
.form-field-options,
|
.form-field-options,
|
||||||
.form-field-constraints {
|
.form-field-constraints,
|
||||||
|
.form-field-condition {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +366,13 @@
|
|||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.form-definition-grid,
|
.form-definition-grid,
|
||||||
.form-field-constraints {
|
.form-field-constraints,
|
||||||
|
.form-field-condition,
|
||||||
|
.form-page-editor-heading,
|
||||||
|
.form-section-editor,
|
||||||
|
.form-localization-editor,
|
||||||
|
.form-localization-fields,
|
||||||
|
.form-preview-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user