Add conditional localized form definitions

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 343a208894
commit e2033640a1
11 changed files with 1346 additions and 33 deletions
+7
View File
@@ -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
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
revision is safe only when the payload is identical. Published definitions may
be retired but not silently returned to draft.
+6 -3
View File
@@ -81,6 +81,9 @@ boundary emits only schema identity, revision, publication state, and field
count; field content remains in the owning database.
The schema vocabulary covers scalar, choice, structured, attachment, signature,
policy-reference, and handoff constraints. Conditional page layout,
localization authoring, and package-fragment tooling remain product depth that
can deepen this owner without changing the runtime boundary.
policy-reference, and handoff constraints. Conditional page/section layout,
cycle-safe predicates, localization authoring, accessibility assessment, and
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.
+29 -6
View File
@@ -69,7 +69,13 @@ manifest = ModuleManifest(
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=("forms_runtime", "portal", "workflow_engine", "cases", "policy"),
optional_dependencies=(
"forms_runtime",
"portal",
"workflow_engine",
"cases",
"policy",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -78,9 +84,21 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="forms.definitions", version="0.1.0"),
),
permissions=(
_permission(READ_SCOPE, "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."),
_permission(
READ_SCOPE,
"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=(
RoleTemplate(
@@ -196,11 +214,16 @@ manifest = ModuleManifest(
documentation_ref="docs/FORMS_BOUNDARY.md",
test_ref="tests/test_forms.py",
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",),
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",),
migration_docs=("docs/FORMS_BOUNDARY.md",),
recovery_docs=("docs/FORMS_BOUNDARY.md",),
+102 -3
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
@@ -11,13 +13,19 @@ from govoplan_forms.backend.schemas import (
FormDefinitionHistoryResponse,
FormDefinitionListResponse,
FormDefinitionWriteRequest,
FormPackageImportRequest,
FormPackageRequest,
)
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
definition_from_mapping,
assess_form_definition_fragment,
export_form_definition_fragment,
form_definition_diagnostics,
form_definition_history,
get_form_definition,
list_form_definitions,
import_form_definition_fragment,
record_form_definition,
)
@@ -32,9 +40,11 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
def _error(exc: Exception) -> HTTPException:
message = str(exc)
code = 409 if any(
word in message.casefold() for word in ("conflict", "already", "stale")
) else 400
code = (
409
if any(word in message.casefold() for word in ("conflict", "already", "stale"))
else 400
)
return HTTPException(status_code=code, detail=message)
@@ -117,6 +127,95 @@ def api_get_form_definition(
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(
"/definitions/{form_id}/history",
response_model=FormDefinitionHistoryResponse,
+17
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
@@ -21,8 +22,24 @@ class FormDefinitionHistoryResponse(BaseModel):
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__ = [
"FormDefinitionHistoryResponse",
"FormDefinitionListResponse",
"FormDefinitionWriteRequest",
"FormPackageImportRequest",
"FormPackageRequest",
]
+448 -3
View File
@@ -1,6 +1,10 @@
from __future__ import annotations
from datetime import UTC, datetime
import hashlib
import hmac
import json
import re
from typing import Any, Mapping, Sequence
import uuid
@@ -16,6 +20,8 @@ from govoplan_core.core.events import (
)
from govoplan_core.core.institutional import (
FormDefinition,
FormConditionExpression,
FormFieldDefinition,
InstitutionalContextError,
InstitutionalReference,
)
@@ -99,9 +105,10 @@ def record_form_definition(
raise FormDefinitionStoreError(
"A Form definition key cannot change across revisions."
)
if definition.publication_state not in _PUBLICATION_TRANSITIONS[
current.publication_state
]:
if (
definition.publication_state
not in _PUBLICATION_TRANSITIONS[current.publication_state]
):
raise FormDefinitionStoreError(
f"Form publication transition {current.publication_state!r} to "
f"{definition.publication_state!r} is not allowed."
@@ -151,6 +158,227 @@ def record_form_definition(
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(
session: Session,
principal: object,
@@ -326,6 +554,223 @@ def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
raise FormDefinitionStoreError(
"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:
+132 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
@@ -9,7 +9,11 @@ from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
FormDefinition,
FormConditionExpression,
FormFieldDefinition,
FormLocalization,
FormPageDefinition,
FormSectionDefinition,
InstitutionalReference,
TemporalRevision,
)
@@ -17,7 +21,11 @@ from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
SqlFormDefinitionProvider,
assess_form_definition_fragment,
export_form_definition_fragment,
form_definition_diagnostics,
form_definition_history,
import_form_definition_fragment,
record_form_definition,
)
@@ -162,6 +170,129 @@ class FormsTests(unittest.TestCase):
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__":
unittest.main()
(assess_form_definition_fragment,)
(export_form_definition_fragment,)
(form_definition_diagnostics,)
(import_form_definition_fragment,)
+82
View File
@@ -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 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 = {
key: string;
label: string;
@@ -12,6 +17,35 @@ export type FormFieldDefinition = {
options: string[];
constraints: Record<string, 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 = {
@@ -40,9 +74,21 @@ export type FormDefinition = {
signature_requirement: "none" | "optional" | "required";
policy_refs: string[];
handoff_kinds: string[];
pages?: FormPageDefinition[];
fallback_locale?: string | null;
localizations?: FormLocalization[];
accessibility?: 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(
settings: ApiSettings,
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 {
Button,
@@ -12,7 +12,10 @@ import {
import {
saveFormDefinition,
type FormDefinition,
type FormCondition,
type FormFieldDefinition,
type FormLocalization,
type FormPageDefinition,
type FormValueType
} from "../../api/forms";
@@ -113,7 +116,10 @@ export default function FormDefinitionDialog({
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
setDraft((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>
<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 className="form-field-editor-heading">
<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
</Button>
</div>
@@ -222,17 +240,27 @@ export default function FormDefinitionDialog({
{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>
}
<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 })} />
<IconButton
label={`Remove ${field.label || "field"}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy || draft.fields.length === 1}
onClick={() => setDraft({ ...draft, fields: draft.fields.filter((_, fieldIndex) => fieldIndex !== index) })}
onClick={() => setDraft(removeField(draft, index))}
/>
</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">
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
</Field>
@@ -262,6 +290,177 @@ function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefin
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 {
if (definition) return structuredClone(definition);
const id = crypto.randomUUID();
@@ -280,6 +479,10 @@ function initialDraft(tenantId: string, definition: FormDefinition | null): Form
signature_requirement: "none",
policy_refs: [],
handoff_kinds: [],
pages: [],
fallback_locale: null,
localizations: [],
accessibility: {},
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[] {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
+84 -6
View File
@@ -1,16 +1,25 @@
import { Pencil, Plus, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import {
Button,
Dialog,
DismissibleAlert,
IconButton,
FormField,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
type PlatformRouteContext
} 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";
@@ -23,6 +32,11 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
const [loading, setLoading] = useState(true);
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 canAdmin = hasScope(auth, "forms:definition:admin");
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
@@ -57,6 +71,52 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
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 (
<main className="forms-page">
<div className="forms-shell">
@@ -76,6 +136,8 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
</select>
</label>
<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>}
<span className="forms-count">{total}</span>
</div>
@@ -93,9 +155,10 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<span>{item.fields.length} fields</span>
<span>Revision {item.reference.version}</span>
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
{mayRevise
? <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />
: <span className="forms-row-action-spacer" />}
<span className="forms-row-actions">
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
{mayRevise && <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />}
</span>
</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>
);
}
+124 -7
View File
@@ -61,7 +61,7 @@
.forms-row {
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;
gap: 14px;
min-height: 64px;
@@ -97,8 +97,25 @@
font-size: 0.8rem;
}
.forms-row-action-spacer {
width: 36px;
.forms-row-actions {
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 {
@@ -198,16 +215,109 @@
.form-field-help,
.form-field-options,
.form-field-constraints {
.form-field-constraints,
.form-field-condition {
grid-column: 2 / -1;
}
.form-field-constraints {
.form-field-constraints,
.form-field-condition {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
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) {
.forms-toolbar {
align-items: stretch;
@@ -243,7 +353,8 @@
.form-field-required,
.form-field-help,
.form-field-options,
.form-field-constraints {
.form-field-constraints,
.form-field-condition {
grid-column: 2;
}
@@ -255,7 +366,13 @@
@media (max-width: 620px) {
.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;
}