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