21 Commits
Author SHA1 Message Date
zemion 00bec0ed94 fix(packaging): expose immutable WebUI Git package for v0.1.23
Module Package Release / publish-packages (push) Successful in 11s
2026-09-08 02:06:09 +02:00
zemion cfcd723496 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:34 +02:00
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
zemion f9bc774109 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:27 +02:00
zemion 9a8dc9f22c Make package publication retries hash-safe 2026-08-04 14:32:19 +02:00
zemion 34f7dd432b Harden module package publication 2026-08-04 14:02:40 +02:00
zemion 6b2b352009 Add protected package release workflow 2026-08-04 04:14:04 +02:00
zemion e505536e6f Migrate Forms interface patterns 2026-08-03 13:08:43 +02:00
zemion e2033640a1 Add conditional localized form definitions 2026-08-01 20:57:26 +02:00
zemion 343a208894 feat: implement immutable form definitions 2026-08-01 17:48:34 +02:00
zemion 5825c2c8e4 Release v0.1.8 2026-07-11 16:49:04 +02:00
38 changed files with 6146 additions and 20 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+24
View File
@@ -0,0 +1,24 @@
# GovOPlaN Forms Codex Guide
## Scope
This repository owns immutable reusable form definitions and their designer.
Runtime values, drafts, receipts, attachments, signatures, and handoffs belong
to `govoplan-forms-runtime` or the corresponding domain owner.
## Working Rules
- Resolve cross-module behavior through Core contracts and capabilities; do not
import another optional module's tables or WebUI pages.
- Keep definition revisions immutable and tenant-bound. Schema changes create a
new revision and use optimistic concurrency.
- Keep submitted values out of Forms events, logs, and definition storage.
- Every behavior or UI change must update the relevant user/admin documentation
and architecture declaration in the same change.
## Verification
```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
```
+54 -6
View File
@@ -1,11 +1,59 @@
# GovOPlaN Forms # GovOPlaN Forms
`govoplan-forms` owns reusable form definitions, validation rules, and form <!-- govoplan-repository-type:start -->
package fragments. Submission runtime behavior is a separate responsibility **Repository type:** module (domain).
that should live in `govoplan-forms-runtime` when implemented. <!-- govoplan-repository-type:end -->
This repository is currently a tag-only scaffold. It should gain package `govoplan-forms` owns reusable, immutable form definitions, validation rules,
metadata and module manifests only after the first backend or WebUI slice is and form package fragments. Submission runtime behavior remains in
designed. `govoplan-forms-runtime`.
The module persists exact tenant-bound revisions, exposes bounded catalogue,
history, and write APIs, and provides `forms.definitions` for consumers. A
published revision can be used by Forms Runtime without importing Forms tables;
existing submissions keep their exact revision when a new schema is published.
The `/forms` designer uses shared application controls to create, revise,
publish, retire, search, and inspect definitions. It edits field order, types,
choices, validation constraints, draft behavior, attachment and signature
requirements, policy references, and permitted handoff kinds. Saving always
creates an exact immutable revision; it never mutates a published schema in
place.
The designer also supports multi-page/section layout, bounded conditional
visibility expressions, localized labels/help/options/page titles, fallback
locale, and accessibility metadata. Server validation rejects unknown fields,
incompatible operators, placement errors, and condition cycles. Verified
configuration-package fragments can be assessed and imported as a new local
draft with source provenance rather than silently becoming active.
Definition writes use optimistic concurrency. Replaying the same object and
revision is safe only when the payload is identical. Published definitions may
be retired but not silently returned to draft.
Focused verification:
```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
```
See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision. See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision.
## Git-source WebUI package
The repository root exposes `@govoplan/forms-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/forms-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+23 -14
View File
@@ -18,7 +18,7 @@ Forms owns:
- schema contracts that let portal, workflow, cases, and reporting understand a - schema contracts that let portal, workflow, cases, and reporting understand a
form without importing form internals form without importing form internals
`govoplan-forms-runtime` owns, when implemented: `govoplan-forms-runtime` owns:
- public/internal submission sessions, drafts, receipts, submitted values, - public/internal submission sessions, drafts, receipts, submitted values,
validation evidence, attachment references, and submission status validation evidence, attachment references, and submission status
@@ -62,19 +62,28 @@ Form definitions should carry:
- localization keys and fallback text - localization keys and fallback text
- data classification for privacy/retention decisions - data classification for privacy/retention decisions
## Candidate Capabilities ## Capabilities
- `forms.catalog` - `forms.definitions` resolves an exact, tenant-bound immutable definition.
- `forms.schema` - The API provides bounded catalogue and history reads plus OCC-guarded writes.
- `forms.validation` - The designer provides explicit revision, publication, field-order, type,
- `forms.packageFragments` option, constraint, draft, attachment, signature, policy, and handoff editing.
- `forms.runtime` when runtime is installed - Forms Runtime performs value validation against the resolved definition; the
definition owner does not persist submissions.
## First Implementation Slice ## Recovery And Operations
1. Define manifest metadata, permissions, and capability names. Definition revisions are append-only. Recovery restores the database and then
2. Add form definition/version DTOs. verifies that every runtime submission's `form_id` and `form_revision` resolves
3. Add validation metadata for one reusable form schema. to the same payload. Destructive module retirement requires a verified database
4. Add package fragment import/export format. snapshot and is blocked while definition rows remain. The transactional event
5. Add tests that portal/workflow/reporting can detect form capabilities boundary emits only schema identity, revision, publication state, and field
without importing form internals. count; field content remains in the owning database.
The schema vocabulary covers scalar, choice, structured, attachment, signature,
policy-reference, and handoff constraints. Conditional page/section layout,
cycle-safe predicates, localization authoring, accessibility assessment, and
verified package-fragment assessment/import are implemented. Remaining depth is
concrete attachment/signature providers, richer authoring ergonomics, public
identity profiles, and target-produced accessibility evidence; those do not
change the runtime boundary.
+38
View File
@@ -0,0 +1,38 @@
# Forms Interface Pattern Migration
This migration applies the GovOPlaN interface pattern language to the
Forms-owned definition catalogue, package import, and revision editor. Forms
owns immutable reusable schemas; Forms Runtime continues to own values,
submissions, receipts, and handoff execution.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/forms` catalogue | Searchable definition library | Filter, export, import, create, or revise | Shared loading/error/empty/permission/help states and stable row actions |
| Package import dialog | Consequential package review | Create local draft from external provenance | Assessment, required reason, guarded draft, never implicit publish |
| Definition dialog | Versioned definition editor | Create immutable revision | Guarded editor, contextual schema help, explained validation and permission states |
| Publication state | Governed lifecycle selector | Publish or retire exact revision | Admin-only availability and explicit lifecycle confirmation |
| Field/page/localization editors | Structured schema composition | Change future runtime schema | At least one field, stable ordering, preview, localization and accessibility metadata |
## Consequence And Availability Rules
- Every save creates an immutable revision with a reason. Runtime instances
retain the exact revision with which they were created.
- Publishing permits future authorized use. Retirement prevents future use but
does not erase definitions, submissions, receipts, or evidence.
- Imported packages become local drafts and retain source provenance.
- Missing write or administration authority remains visible with an Access
destination; retired definitions show a lifecycle explanation.
- Optional Runtime, Portal, Workflow, Case, and Policy behavior remains behind
declared interfaces and capabilities.
The module uses shared controls, dialogs, blockers, field help, statuses,
loading, empty/error states, disabled reasons, confirmations, and unsaved-draft
guards. The catalogue now composes `WorkspaceFrame`, `ActionToolbar`,
`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.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/forms-webui",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/forms.css": "./webui/src/styles/forms.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+21
View File
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-forms"
version = "0.1.23"
description = "Immutable reusable form definitions for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.35", "govoplan-access>=0.1.18"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_forms = ["py.typed"]
[project.entry-points."govoplan.modules"]
"forms" = "govoplan_forms.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Forms module."""
__version__ = "0.1.23"
+1
View File
@@ -0,0 +1 @@
"""Forms backend package."""
@@ -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",
]
@@ -0,0 +1 @@
"""Forms database models."""
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, Index, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class FormDefinitionRevision(Base, TimestampMixin):
__tablename__ = "form_definition_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"form_id",
"revision",
name="uq_form_definition_revision",
),
Index(
"ix_form_definition_current",
"tenant_id",
"form_id",
"superseded_at",
),
Index(
"ix_form_definition_catalog",
"tenant_id",
"publication_state",
"form_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
form_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
form_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
revision: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
previous_revision_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
publication_state: Mapped[str] = mapped_column(
String(30), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(500), nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
__all__ = ["FormDefinitionRevision"]
+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"]
@@ -0,0 +1,84 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'forms.data-subject-requests': {'consequence_classes': {'exclude_form_semantics': 'Gibt keinen '
'Schema-, Feld- '
'oder '
'Submission-Value-Inhalt '
'zurück.',
'export_definition_attribution': 'Returns '
'minimierten '
'die '
'unveränderliche '
'Revisionsaktivität.'}},
'forms.definitions': {'privacy_notes': ['Definitionskataloge enthalten Schemata und '
'Richtlinienreferenzen, keine eingereichten '
'Formularwerte.',
'Die Paketbewertung gewährt keinen Zugriff auf '
'referenzierte Laufzeiteinreichungen oder externe '
'Anbieter.',
'Veröffentlichte Zugänglichkeits- und '
'Lokalisierungsinhalte sind überall dort sichtbar, wo die '
'genaue Definition autorisiert ist.']},
'forms.reference.fields-and-consequences': {'consequence_classes': {'import_package': 'Erstellt '
'einen '
'lokalen '
'Entwurf '
'und behält '
'die '
'Herkunft '
'des Pakets '
'ohne '
'automatische '
'Veröffentlichung.',
'publish': 'Macht die genaue '
'Revision für '
'zukünftige '
'autorisierte '
'Instanzen '
'verfügbar.',
'retire': 'Stoppt die '
'zukünftige '
'Nutzung, während '
'Definitionen und '
'genaue '
'Laufzeitreferenzen '
'beibehalten '
'werden.',
'save_revision': 'Erstellt '
'eine '
'unveränderliche '
'Definitionsrevision '
'mit einem '
'Änderungsgrund.'},
'limitations': ['Der Paketimport veröffentlicht '
'niemals automatisch eine '
'Formularrevision.',
'Laufzeiteingaben und eingereichte '
'Werte werden niemals als '
'Definitionskonfiguration exportiert.',
'Generische Paket-Rollback erfordert '
'die Voranwendung Datenbank Snapshot '
'beibehalten.'],
'operational_consequences': ['Preserve blockiert eine '
'widersprüchliche lokale '
'Definition; new '
'revision muss explizit '
'überprüft werden.',
'Das erneute Anwenden '
'eines identischen '
'Quell-Digests ist '
'idempotent und erzeugt '
'keine Revision.',
'Importierte '
'Definitionen erfordern '
'eine normale '
'Überprüfung und '
'Veröffentlichung von '
'Formularen vor der '
'Verwendung zur '
'Laufzeit.']}}
+535
View File
@@ -0,0 +1,535 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_forms.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_forms.backend.configuration_provider import FORMS_CONFIGURATION_CAPABILITY
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.semantic_subjects import (
FormsSemanticDocumentationSubjectProvider,
)
MODULE_ID = "forms"
MODULE_NAME = "Forms"
MODULE_VERSION = "0.1.23"
READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin"
OPTIONAL_DEPENDENCIES = (
"forms_runtime",
"portal",
"workflow_engine",
"cases",
"policy",
"docs",
)
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(_context: ModuleContext):
from govoplan_forms.backend.router import router
return router
def _definitions(_context: ModuleContext) -> 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(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
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=(
_permission(
READ_SCOPE,
"View form definitions",
"Read reusable form definitions and exact revisions.",
),
_permission(
WRITE_SCOPE,
"Manage form definitions",
"Create and revise reusable form definitions.",
),
_permission(
ADMIN_SCOPE,
"Publish form definitions",
"Publish and retire form-definition revisions.",
),
),
role_templates=(
RoleTemplate(
slug="forms_designer",
name="Forms designer",
description="Design and publish reusable form definitions.",
permissions=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE),
),
RoleTemplate(
slug="forms_reader",
name="Forms reader",
description="Inspect reusable form definitions.",
permissions=(READ_SCOPE,),
),
),
route_factory=_router,
nav_items=(
NavItem(
path="/forms",
label="Form definitions",
icon="list-tree",
required_any=(READ_SCOPE,),
order=36,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/forms-webui",
routes=(
FrontendRoute(
path="/forms",
component="FormsPage",
required_any=(READ_SCOPE,),
order=36,
),
),
nav_items=(
NavItem(
path="/forms",
label="Form definitions",
icon="list-tree",
required_any=(READ_SCOPE,),
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=(
ViewSurface(
id="forms.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Form definitions navigation",
order=10,
),
ViewSurface(
id="forms.catalogue",
module_id=MODULE_ID,
kind="route",
label="Form definition catalogue",
order=20,
),
),
),
capability_factories={
CAPABILITY_FORM_DEFINITIONS: _definitions,
FORMS_CONFIGURATION_CAPABILITY: _configuration_provider,
FORMS_DSAR_CAPABILITY: _dsar_provider,
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
},
capability_documentation={
CAPABILITY_FORM_DEFINITIONS: CapabilityDocumentation(
label="Immutable form definitions",
summary="Resolves exact tenant-bound form schemas without exposing Forms tables.",
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(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
form_models.FormDefinitionRevision,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement removes immutable form-definition history and requires a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
form_models.FormDefinitionRevision,
label=MODULE_NAME,
),
),
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(
id="forms.definitions",
title="Reusable form definitions",
summary="Create immutable, versioned schemas consumed by Forms Runtime and institutional services.",
body=(
"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. 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",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
conditions=(
DocumentationCondition(
any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)
),
),
links=(
DocumentationLink(
label="Forms boundary and recovery",
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
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={
"kind": "workflow",
"seed": True,
"help_contexts": [
"forms.navigation",
"forms.catalogue",
"forms.state.permission-blocked",
"forms.state.empty",
],
"privacy_notes": [
"Definition catalogues contain schemas and policy references, not submitted Form values.",
"Package assessment does not grant access to referenced runtime submissions or external providers.",
"Published accessibility and localization content is visible wherever the exact definition is authorized.",
],
},
),
DocumentationTopic(
id="forms.reference.fields-and-consequences",
title="Form definition fields and lifecycle consequences",
summary="Schema, publication, localization, policy, evidence, package, and handoff semantics for immutable Form revisions.",
body=(
"The stable key identifies the definition while each save creates a new immutable revision. Field keys, types, "
"constraints, visibility conditions, pages, sections, options, help, localization, and accessibility instructions "
"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 "
"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. "
"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",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Forms boundary and recovery",
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
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={
"seed": True,
"help_contexts": [
"forms.field.publication-state",
"forms.field.signature-requirement",
"forms.field.policy-references",
"forms.field.handoff-kinds",
"forms.field.accessibility",
"forms.field.change-reason",
"forms.action.save-revision",
"forms.action.publish",
"forms.action.retire",
"forms.action.import-package",
],
"consequence_classes": {
"save_revision": "Creates an immutable definition revision with a change reason.",
"publish": "Makes the exact revision available for future authorized instances.",
"retire": "Stops future use while retaining definitions and exact runtime references.",
"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.",
],
},
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/FORMS_BOUNDARY.md",
test_ref="tests/test_forms.py",
known_limits=(
"Concrete attachment/signature providers, anonymous identity profiles, and target-produced accessibility evidence remain product depth.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"form definition",
"form schema",
"form definition revision",
"form semantic subject identity and fingerprint",
),
non_owned_concepts=(
"form submission",
"file content",
"case",
"workflow instance",
),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/FORMS_BOUNDARY.md",),
recovery_docs=("docs/FORMS_BOUNDARY.md",),
security_docs=("docs/FORMS_BOUNDARY.md",),
operations_docs=("docs/FORMS_BOUNDARY.md",),
),
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1 @@
"""Forms Alembic revisions."""
@@ -0,0 +1 @@
"""Forms migration versions."""
@@ -0,0 +1,77 @@
"""v0.1.14 immutable Forms definitions.
Revision ID: e1f2a3b4c5d6
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e1f2a3b4c5d6"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"form_definition_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("form_id", sa.String(length=255), nullable=False),
sa.Column("form_key", sa.String(length=255), nullable=False),
sa.Column("revision", sa.String(length=255), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("publication_state", sa.String(length=30), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_definition_revisions")),
sa.UniqueConstraint(
"tenant_id",
"form_id",
"revision",
name="uq_form_definition_revision",
),
)
for column in (
"tenant_id",
"form_id",
"form_key",
"revision",
"previous_revision_id",
"publication_state",
"recorded_at",
"superseded_at",
"changed_by",
):
op.create_index(
op.f(f"ix_form_definition_revisions_{column}"),
"form_definition_revisions",
[column],
unique=False,
)
op.create_index(
"ix_form_definition_current",
"form_definition_revisions",
["tenant_id", "form_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_form_definition_catalog",
"form_definition_revisions",
["tenant_id", "publication_state", "form_key"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_definition_revisions")
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import InstitutionalContextError
from govoplan_core.db.session import get_session
from govoplan_forms.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_forms.backend.schemas import (
FormDefinitionHistoryResponse,
FormDefinitionListResponse,
FormDefinitionWriteRequest,
FormPackageImportRequest,
FormPackageRequest,
)
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
definition_from_mapping,
assess_form_definition_fragment,
export_form_definition_fragment,
form_definition_diagnostics,
form_definition_history,
get_form_definition,
list_form_definitions,
import_form_definition_fragment,
record_form_definition,
)
router = APIRouter(prefix="/forms", tags=["forms"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
def _error(exc: Exception) -> HTTPException:
message = str(exc)
code = (
409
if any(word in message.casefold() for word in ("conflict", "already", "stale"))
else 400
)
return HTTPException(status_code=code, detail=message)
@router.get("/definitions", response_model=FormDefinitionListResponse)
def api_list_form_definitions(
q: str = Query(default="", max_length=200),
publication_state: list[str] | None = Query(default=None),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormDefinitionListResponse:
_require(principal, READ_SCOPE)
try:
items, total = list_form_definitions(
session,
principal,
query=q,
publication_states=publication_state,
offset=offset,
limit=limit,
)
except FormDefinitionStoreError as exc:
raise _error(exc) from exc
return FormDefinitionListResponse(
definitions=[item.to_dict() for item in items],
total=total,
)
@router.put(
"/definitions/{form_id}",
response_model=dict[str, object],
status_code=status.HTTP_200_OK,
)
def api_record_form_definition(
form_id: str,
payload: FormDefinitionWriteRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
try:
definition = definition_from_mapping(payload.definition)
if definition.reference.object_id != form_id:
raise FormDefinitionStoreError(
"Form definition path and payload IDs must match."
)
if definition.publication_state != "draft":
_require(principal, ADMIN_SCOPE)
stored = record_form_definition(
session,
principal,
definition=definition,
expected_revision=payload.expected_revision,
)
session.commit()
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return stored.to_dict()
@router.get("/definitions/{form_id}", response_model=dict[str, object])
def api_get_form_definition(
form_id: str,
revision: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
item = get_form_definition(
session,
principal,
form_id=form_id,
revision=revision,
)
if item is None:
raise HTTPException(status_code=404, detail="Form definition not found")
return item.to_dict()
@router.get("/definitions/{form_id}/diagnostics", response_model=dict[str, object])
def api_form_definition_diagnostics(
form_id: str,
revision: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
if item is None:
raise HTTPException(status_code=404, detail="Form definition not found")
diagnostics = form_definition_diagnostics(item)
return {
"diagnostics": [dict(value) for value in diagnostics],
"error_count": sum(value.get("severity") == "error" for value in diagnostics),
"warning_count": sum(
value.get("severity") == "warning" for value in diagnostics
),
}
@router.get("/definitions/{form_id}/package", response_model=dict[str, object])
def api_export_form_definition_package(
form_id: str,
revision: str | None = Query(default=None, max_length=255),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, READ_SCOPE)
item = get_form_definition(session, principal, form_id=form_id, revision=revision)
if item is None:
raise HTTPException(status_code=404, detail="Form definition not found")
actor_id = next(
(
str(getattr(principal, name))
for name in ("account_id", "identity_id", "membership_id")
if getattr(principal, name, None)
),
None,
)
return export_form_definition_fragment(
item,
exported_at=datetime.now(UTC),
exported_by=actor_id,
)
@router.post("/packages/assess", response_model=dict[str, object])
def api_assess_form_definition_package(
payload: FormPackageRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
try:
return assess_form_definition_fragment(
session,
principal,
fragment=payload.fragment,
)
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
raise _error(exc) from exc
@router.post("/packages/import", response_model=dict[str, object])
def api_import_form_definition_package(
payload: FormPackageImportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, WRITE_SCOPE)
try:
item = import_form_definition_fragment(
session,
principal,
fragment=payload.fragment,
target_form_id=payload.target_form_id,
target_key=payload.target_key,
expected_revision=payload.expected_revision,
change_reason=payload.change_reason,
recorded_at=payload.recorded_at,
)
session.commit()
except (FormDefinitionStoreError, InstitutionalContextError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get(
"/definitions/{form_id}/history",
response_model=FormDefinitionHistoryResponse,
)
def api_form_definition_history(
form_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormDefinitionHistoryResponse:
_require(principal, READ_SCOPE)
return FormDefinitionHistoryResponse(
revisions=[
item.to_dict()
for item in form_definition_history(
session,
principal,
form_id=form_id,
limit=limit,
)
]
)
__all__ = ["router"]
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class FormDefinitionWriteRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition: dict[str, Any]
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
class FormDefinitionListResponse(BaseModel):
definitions: list[dict[str, Any]]
total: int
class FormDefinitionHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
class FormPackageRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
fragment: dict[str, Any]
class FormPackageImportRequest(FormPackageRequest):
target_form_id: str | None = Field(default=None, min_length=1, max_length=255)
target_key: str | None = Field(default=None, min_length=1, max_length=255)
expected_revision: str | None = Field(default=None, min_length=1, max_length=255)
change_reason: str = Field(min_length=1, max_length=1000)
recorded_at: datetime
__all__ = [
"FormDefinitionHistoryResponse",
"FormDefinitionListResponse",
"FormDefinitionWriteRequest",
"FormPackageImportRequest",
"FormPackageRequest",
]
@@ -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",
]
+825
View File
@@ -0,0 +1,825 @@
from __future__ import annotations
from datetime import UTC, datetime
import hashlib
import hmac
import json
import re
from typing import Any, Mapping, Sequence
import uuid
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.institutional import (
FormDefinition,
FormConditionExpression,
FormFieldDefinition,
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = {
"draft": frozenset({"draft", "published", "retired"}),
"published": frozenset({"published", "retired"}),
"retired": frozenset(),
}
class FormDefinitionStoreError(ValueError):
pass
def definition_from_mapping(value: Mapping[str, object]) -> FormDefinition:
try:
return FormDefinition.from_mapping(value)
except InstitutionalContextError as exc:
raise FormDefinitionStoreError(str(exc)) from exc
def record_form_definition(
session: Session,
principal: object,
*,
definition: FormDefinition,
expected_revision: str | None = None,
) -> FormDefinition:
tenant_id = _principal_tenant(principal)
_validate_definition(definition, tenant_id=tenant_id)
payload = definition.to_dict()
replay = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == definition.reference.object_id,
FormDefinitionRevision.revision == definition.temporal.revision,
)
.one_or_none()
)
if replay is not None:
if replay.payload != payload:
raise FormDefinitionStoreError(
"A different Form definition already uses this revision."
)
return _definition_from_row(replay)
current = _current_row(
session,
tenant_id=tenant_id,
form_id=definition.reference.object_id,
lock=True,
)
if current is None:
if expected_revision is not None:
raise FormDefinitionStoreError(
"Form definition revision conflict: no current revision exists."
)
key_collision = (
session.query(FormDefinitionRevision.id)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_key == definition.key,
FormDefinitionRevision.superseded_at.is_(None),
)
.first()
)
if key_collision is not None:
raise FormDefinitionStoreError(
"Form definition key is already in use in this tenant."
)
else:
if expected_revision != current.revision:
raise FormDefinitionStoreError(
"Form definition revision conflict: the expected revision is stale."
)
if definition.key != current.form_key:
raise FormDefinitionStoreError(
"A Form definition key cannot change across revisions."
)
if (
definition.publication_state
not in _PUBLICATION_TRANSITIONS[current.publication_state]
):
raise FormDefinitionStoreError(
f"Form publication transition {current.publication_state!r} to "
f"{definition.publication_state!r} is not allowed."
)
current.superseded_at = _recorded_at(definition)
row = FormDefinitionRevision(
tenant_id=tenant_id,
form_id=definition.reference.object_id,
form_key=definition.key,
revision=definition.temporal.revision,
previous_revision_id=current.id if current is not None else None,
publication_state=definition.publication_state,
title=definition.title,
recorded_at=_recorded_at(definition),
search_text=f"{definition.key} {definition.title} {definition.description or ''}".casefold(),
payload=payload,
changed_by=_principal_actor(principal),
)
session.add(row)
session.flush()
event_id = str(uuid.uuid4())
emit_platform_event(
session,
PlatformEvent(
event_id=event_id,
type="forms.definition.recorded",
module_id="forms",
payload={
"form_id": row.form_id,
"form_key": row.form_key,
"revision": row.revision,
"publication_state": row.publication_state,
"field_count": len(definition.fields),
},
occurred_at=row.recorded_at,
actor=EventActorRef(type="account", id=_principal_actor(principal)),
tenant=EventTenantRef(id=tenant_id),
resource=EventObjectRef(
type="form_definition",
id=row.form_id,
label=row.title,
),
classification="internal",
),
)
return _definition_from_row(row)
def form_definition_diagnostics(
definition: FormDefinition,
) -> tuple[Mapping[str, object], ...]:
"""Return deterministic, non-blocking authoring diagnostics.
Structural errors are rejected by ``_validate_definition``. Diagnostics are
reserved for useful publication quality feedback such as untranslated text.
"""
diagnostics: list[Mapping[str, object]] = []
field_by_key = {item.key: item for item in definition.fields}
page_keys = {item.key for item in definition.pages}
section_keys = {item.key for page in definition.pages for item in page.sections}
for localization in definition.localizations:
locale = localization.locale
if not localization.title:
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.title_missing",
f"{locale} does not translate the Form title.",
locale=locale,
)
)
for field in definition.fields:
if field.key not in localization.field_labels:
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.field_label_missing",
f"{locale} does not translate field {field.key!r}.",
locale=locale,
subject=field.key,
)
)
if field.help_text and field.key not in localization.field_help_texts:
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.field_help_missing",
f"{locale} does not translate help for field {field.key!r}.",
locale=locale,
subject=field.key,
)
)
translated_options = localization.option_labels.get(field.key, {})
for option in field.options:
if option not in translated_options:
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.option_missing",
f"{locale} does not translate option {option!r} of field {field.key!r}.",
locale=locale,
subject=f"{field.key}:{option}",
)
)
for key in sorted(page_keys - set(localization.page_titles)):
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.page_title_missing",
f"{locale} does not translate page {key!r}.",
locale=locale,
subject=key,
)
)
for key in sorted(section_keys - set(localization.section_titles)):
diagnostics.append(
_definition_diagnostic(
"warning",
"translation.section_title_missing",
f"{locale} does not translate section {key!r}.",
locale=locale,
subject=key,
)
)
# These sets are validated structurally. Keeping the lookup here makes
# diagnostics stable if future compatible readers retain unknown keys.
_ = field_by_key
return tuple(diagnostics)
def export_form_definition_fragment(
definition: FormDefinition,
*,
exported_at: datetime,
exported_by: str | None,
) -> dict[str, object]:
if exported_at.tzinfo is None or exported_at.utcoffset() is None:
raise FormDefinitionStoreError("Package exported_at must include a timezone.")
definition_payload = definition.to_dict()
digest = _payload_sha256(definition_payload)
return {
"kind": "govoplan.forms.definition",
"contract_version": "0.1.0",
"definition": definition_payload,
"definition_sha256": digest,
"provenance": {
"owner_module": "forms",
"tenant_id": definition.reference.tenant_id,
"form_id": definition.reference.object_id,
"revision": definition.reference.version,
"exported_at": exported_at.isoformat(),
"exported_by": exported_by,
},
}
def assess_form_definition_fragment(
session: Session,
principal: object,
*,
fragment: Mapping[str, object],
) -> dict[str, object]:
source = _definition_from_fragment(fragment)
tenant_id = _principal_tenant(principal)
current = get_form_definition(
session,
principal,
form_id=source.reference.object_id,
)
key_collision = (
session.query(FormDefinitionRevision.form_id)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_key == source.key,
FormDefinitionRevision.superseded_at.is_(None),
)
.first()
)
same_tenant = source.reference.tenant_id == tenant_id
if (
same_tenant
and current is not None
and current.reference.version == source.reference.version
):
outcome = (
"replay" if current.to_dict() == source.to_dict() else "revision_conflict"
)
elif current is not None:
outcome = "new_revision_required"
elif key_collision is not None:
outcome = "key_conflict"
else:
outcome = "create"
return {
"outcome": outcome,
"portable": True,
"same_tenant": same_tenant,
"source": {
"tenant_id": source.reference.tenant_id,
"form_id": source.reference.object_id,
"key": source.key,
"revision": source.reference.version,
},
"current_revision": current.reference.version if current else None,
"requires_remap": not same_tenant,
}
def import_form_definition_fragment(
session: Session,
principal: object,
*,
fragment: Mapping[str, object],
target_form_id: str | None,
target_key: str | None,
expected_revision: str | None,
change_reason: str,
recorded_at: datetime,
) -> FormDefinition:
source = _definition_from_fragment(fragment)
tenant_id = _principal_tenant(principal)
if recorded_at.tzinfo is None or recorded_at.utcoffset() is None:
raise FormDefinitionStoreError(
"Package import recorded_at must include a timezone."
)
clean_reason = str(change_reason or "").strip()
if not clean_reason or len(clean_reason) > 1000:
raise FormDefinitionStoreError(
"Package import requires a change reason of at most 1000 characters."
)
resolved_id = str(target_form_id or source.reference.object_id).strip()
resolved_key = str(target_key or source.key).strip()
current = get_form_definition(session, principal, form_id=resolved_id)
revision = str(uuid.uuid4())
payload = source.to_dict()
payload["reference"] = {
**dict(payload["reference"]),
"object_id": resolved_id,
"tenant_id": tenant_id,
"version": revision,
}
payload["key"] = current.key if current is not None else resolved_key
payload["temporal"] = {
"revision": revision,
"valid_from": recorded_at.isoformat(),
"valid_to": None,
"recorded_at": recorded_at.isoformat(),
"superseded_at": None,
"change_reason": clean_reason,
}
payload["publication_state"] = "draft"
metadata = dict(source.metadata)
metadata["package_import"] = {
"source_tenant_id": source.reference.tenant_id,
"source_form_id": source.reference.object_id,
"source_revision": source.reference.version,
"source_sha256": str(fragment.get("definition_sha256") or ""),
}
payload["metadata"] = metadata
imported = definition_from_mapping(payload)
return record_form_definition(
session,
principal,
definition=imported,
expected_revision=expected_revision,
)
def get_form_definition(
session: Session,
principal: object,
*,
form_id: str,
revision: str | None = None,
) -> FormDefinition | None:
tenant_id = _principal_tenant(principal)
query = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
)
if revision is None:
query = query.filter(FormDefinitionRevision.superseded_at.is_(None))
else:
query = query.filter(FormDefinitionRevision.revision == revision)
row = query.order_by(FormDefinitionRevision.recorded_at.desc()).first()
return _definition_from_row(row) if row is not None else None
def list_form_definitions(
session: Session,
principal: object,
*,
query: str = "",
publication_states: Sequence[str] | None = None,
offset: int = 0,
limit: int = 100,
) -> tuple[tuple[FormDefinition, ...], int]:
tenant_id = _principal_tenant(principal)
if offset < 0 or not 1 <= limit <= 200:
raise FormDefinitionStoreError(
"Form definition offset must be non-negative and limit between 1 and 200."
)
statement = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.superseded_at.is_(None),
)
if publication_states:
statement = statement.filter(
FormDefinitionRevision.publication_state.in_(tuple(publication_states))
)
clean_query = query.strip().casefold()
if clean_query:
statement = statement.filter(
FormDefinitionRevision.search_text.contains(clean_query)
)
total = int(statement.with_entities(func.count()).scalar() or 0)
rows = (
statement.order_by(
FormDefinitionRevision.form_key.asc(),
FormDefinitionRevision.recorded_at.desc(),
)
.offset(offset)
.limit(limit)
.all()
)
return tuple(_definition_from_row(row) for row in rows), total
def form_definition_history(
session: Session,
principal: object,
*,
form_id: str,
limit: int = 100,
) -> tuple[FormDefinition, ...]:
tenant_id = _principal_tenant(principal)
if not 1 <= limit <= 200:
raise FormDefinitionStoreError("Form history limit must be between 1 and 200.")
rows = (
session.query(FormDefinitionRevision)
.filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
)
.order_by(FormDefinitionRevision.recorded_at.desc())
.limit(limit)
.all()
)
return tuple(_definition_from_row(row) for row in rows)
class SqlFormDefinitionProvider:
def get_form_definition(
self,
session: object,
principal: object,
*,
reference: InstitutionalReference,
effective_at: datetime | None = None,
) -> FormDefinition | None:
tenant_id = _principal_tenant(principal)
if (
reference.kind != "form"
or reference.owner_module != "forms"
or reference.tenant_id != tenant_id
or not reference.version
):
raise InstitutionalContextError(
"Form definition lookup requires an exact same-tenant Forms reference."
)
definition = get_form_definition(
_session(session),
principal,
form_id=reference.object_id,
revision=reference.version,
)
if definition is None or (
effective_at is not None
and not definition.temporal.effective_at(effective_at)
):
return None
return definition
def list_form_definitions(
self,
session: object,
principal: object,
*,
tenant_id: str,
query: str = "",
limit: int = 100,
) -> Sequence[FormDefinition]:
if tenant_id != _principal_tenant(principal):
raise InstitutionalContextError(
"Form definition catalogue lookup cannot cross tenants."
)
items, _ = list_form_definitions(
_session(session),
principal,
query=query,
publication_states=("published",),
limit=limit,
)
return items
def _current_row(
session: Session,
*,
tenant_id: str,
form_id: str,
lock: bool,
) -> FormDefinitionRevision | None:
query = session.query(FormDefinitionRevision).filter(
FormDefinitionRevision.tenant_id == tenant_id,
FormDefinitionRevision.form_id == form_id,
FormDefinitionRevision.superseded_at.is_(None),
)
if lock:
query = query.with_for_update()
return query.one_or_none()
def _definition_from_row(row: FormDefinitionRevision) -> FormDefinition:
payload: dict[str, Any] = dict(row.payload)
temporal = dict(payload.get("temporal") or {})
temporal["superseded_at"] = _datetime_text(row.superseded_at)
payload["temporal"] = temporal
return FormDefinition.from_mapping(payload)
def _validate_definition(definition: FormDefinition, *, tenant_id: str) -> None:
if definition.reference.owner_module != "forms":
raise FormDefinitionStoreError("Form definitions must be owned by Forms.")
if definition.reference.tenant_id != tenant_id:
raise FormDefinitionStoreError("Form definitions cannot cross tenants.")
if definition.temporal.superseded_at is not None:
raise FormDefinitionStoreError("Clients cannot set Form superseded_at.")
_recorded_at(definition)
if not str(definition.temporal.change_reason or "").strip():
raise FormDefinitionStoreError(
"A Form definition revision requires a change reason."
)
_validate_form_composition(definition)
def _validate_form_composition(definition: FormDefinition) -> None:
fields = {item.key: item for item in definition.fields}
if definition.pages:
field_occurrences = [
field_key
for page in definition.pages
for section in page.sections
for field_key in section.field_keys
]
unknown = sorted(set(field_occurrences) - set(fields))
if unknown:
raise FormDefinitionStoreError(
f"Form pages reference unknown fields: {', '.join(unknown)}."
)
duplicates = sorted(
key for key in set(field_occurrences) if field_occurrences.count(key) > 1
)
if duplicates:
raise FormDefinitionStoreError(
f"Form pages place fields more than once: {', '.join(duplicates)}."
)
missing = sorted(set(fields) - set(field_occurrences))
if missing:
raise FormDefinitionStoreError(
f"Form pages do not place fields: {', '.join(missing)}."
)
conditions: list[tuple[str, FormConditionExpression]] = []
for field in definition.fields:
if field.visibility_condition is not None:
conditions.append((f"field:{field.key}", field.visibility_condition))
for page in definition.pages:
if page.visibility_condition is not None:
conditions.append((f"page:{page.key}", page.visibility_condition))
for section in page.sections:
if section.visibility_condition is not None:
conditions.append(
(f"section:{section.key}", section.visibility_condition)
)
for subject, condition in conditions:
_validate_condition(condition, fields=fields, subject=subject)
graph = {
field.key: set(field.visibility_condition.referenced_fields)
if field.visibility_condition is not None
else set()
for field in definition.fields
}
_reject_condition_cycles(graph)
page_keys = {item.key for item in definition.pages}
section_keys = {item.key for page in definition.pages for item in page.sections}
for localization in definition.localizations:
unknown_fields = (
set(localization.field_labels)
| set(localization.field_help_texts)
| set(localization.option_labels)
) - set(fields)
if unknown_fields:
raise FormDefinitionStoreError(
f"Form localization {localization.locale!r} references unknown fields: "
f"{', '.join(sorted(unknown_fields))}."
)
if set(localization.page_titles) - page_keys:
raise FormDefinitionStoreError(
f"Form localization {localization.locale!r} references unknown pages."
)
if set(localization.section_titles) - section_keys:
raise FormDefinitionStoreError(
f"Form localization {localization.locale!r} references unknown sections."
)
for field_key, labels in localization.option_labels.items():
field = fields[field_key]
if set(labels) - set(field.options):
raise FormDefinitionStoreError(
f"Form localization {localization.locale!r} translates unknown "
f"options for field {field_key!r}."
)
def _validate_condition(
condition: FormConditionExpression,
*,
fields: Mapping[str, FormFieldDefinition],
subject: str,
) -> None:
if condition.kind != "predicate":
for child in condition.conditions:
_validate_condition(child, fields=fields, subject=subject)
return
field_key = str(condition.field_key)
field = fields.get(field_key)
if field is None:
raise FormDefinitionStoreError(
f"Form condition on {subject} references unknown field {field_key!r}."
)
operator = str(condition.operator)
if operator in {"lt", "lte", "gt", "gte"} and field.value_type not in {
"integer",
"number",
"date",
"datetime",
}:
raise FormDefinitionStoreError(
f"Form condition operator {operator!r} is incompatible with "
f"field {field_key!r} ({field.value_type})."
)
if operator == "contains" and field.value_type not in {
"text",
"multiline_text",
"email",
"multi_choice",
"list",
}:
raise FormDefinitionStoreError(
f"Form condition operator 'contains' is incompatible with field {field_key!r}."
)
if operator in {"in", "not_in"} and (
not isinstance(condition.value, Sequence)
or isinstance(condition.value, (str, bytes))
):
raise FormDefinitionStoreError(
f"Form condition operator {operator!r} requires a list value."
)
if operator not in {"is_empty", "is_not_empty", "in", "not_in"}:
if not _condition_value_matches(field, condition.value):
raise FormDefinitionStoreError(
f"Form condition value is incompatible with field {field_key!r} "
f"({field.value_type})."
)
def _condition_value_matches(field: FormFieldDefinition, value: object) -> bool:
if value is None:
return True
if field.value_type == "boolean":
return isinstance(value, bool)
if field.value_type == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if field.value_type == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if field.value_type in {"object"}:
return isinstance(value, Mapping)
if field.value_type in {"list", "multi_choice"}:
return isinstance(value, Sequence) and not isinstance(value, (str, bytes))
return isinstance(value, str)
def _reject_condition_cycles(graph: Mapping[str, set[str]]) -> None:
visiting: set[str] = set()
visited: set[str] = set()
def visit(key: str, path: tuple[str, ...]) -> None:
if key in visiting:
cycle = " -> ".join((*path, key))
raise FormDefinitionStoreError(
f"Form visibility conditions contain a dependency cycle: {cycle}."
)
if key in visited:
return
visiting.add(key)
for dependency in sorted(graph.get(key, set())):
if dependency in graph:
visit(dependency, (*path, key))
visiting.remove(key)
visited.add(key)
for key in sorted(graph):
visit(key, ())
def _definition_from_fragment(fragment: Mapping[str, object]) -> FormDefinition:
if fragment.get("kind") != "govoplan.forms.definition":
raise FormDefinitionStoreError("Unsupported Forms package fragment kind.")
if fragment.get("contract_version") != "0.1.0":
raise FormDefinitionStoreError("Unsupported Forms package contract version.")
payload = fragment.get("definition")
if not isinstance(payload, Mapping):
raise FormDefinitionStoreError("Forms package definition must be an object.")
expected = str(fragment.get("definition_sha256") or "")
if not re.fullmatch(r"[0-9a-f]{64}", expected) or not _constant_time_equal(
expected,
_payload_sha256(payload),
):
raise FormDefinitionStoreError("Forms package definition digest is invalid.")
return definition_from_mapping(payload)
def _payload_sha256(value: Mapping[str, object]) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _constant_time_equal(left: str, right: str) -> bool:
return hmac.compare_digest(left, right)
def _definition_diagnostic(
severity: str,
code: str,
message: str,
*,
locale: str | None = None,
subject: str | None = None,
) -> Mapping[str, object]:
return {
"severity": severity,
"code": code,
"message": message,
"locale": locale,
"subject": subject,
}
def _recorded_at(definition: FormDefinition) -> datetime:
if definition.temporal.recorded_at is None:
raise FormDefinitionStoreError(
"A Form definition revision requires recorded_at."
)
return definition.temporal.recorded_at
def _principal_tenant(principal: object) -> str:
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
if not tenant_id:
raise InstitutionalContextError(
"Form definition operations require a tenant-bound principal."
)
return tenant_id
def _principal_actor(principal: object) -> str | None:
for name in ("account_id", "identity_id", "membership_id"):
value = str(getattr(principal, name, "") or "").strip()
if value:
return value
return None
def _session(value: object) -> Session:
if not hasattr(value, "query"):
raise InstitutionalContextError(
"Form definition provider requires a database session."
)
return value # type: ignore[return-value]
def _datetime_text(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value.isoformat()
__all__ = [
"FormDefinitionStoreError",
"SqlFormDefinitionProvider",
"definition_from_mapping",
"form_definition_history",
"get_form_definition",
"list_form_definitions",
"record_form_definition",
]
+1
View File
@@ -0,0 +1 @@
+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()
+298
View File
@@ -0,0 +1,298 @@
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,
FormConditionExpression,
FormFieldDefinition,
FormLocalization,
FormPageDefinition,
FormSectionDefinition,
InstitutionalReference,
TemporalRevision,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
SqlFormDefinitionProvider,
assess_form_definition_fragment,
export_form_definition_fragment,
form_definition_diagnostics,
form_definition_history,
import_form_definition_fragment,
record_form_definition,
)
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
def definition(
*,
revision: str = "1",
state: str = "published",
tenant_id: str = "tenant-1",
) -> FormDefinition:
recorded_at = NOW + timedelta(minutes=int(revision) - 1)
return FormDefinition(
reference=InstitutionalReference(
kind="form",
owner_module="forms",
object_id="permit-application",
tenant_id=tenant_id,
version=revision,
),
key="permit-application",
temporal=TemporalRevision(
revision=revision,
recorded_at=recorded_at,
change_reason="Initial schema." if revision == "1" else "Revise schema.",
),
title="Permit application",
fields=(
FormFieldDefinition(
key="name",
label="Name",
required=True,
constraints={"min_length": 2, "max_length": 200},
),
FormFieldDefinition(
key="delivery",
label="Delivery",
value_type="choice",
options=("portal", "mail"),
),
),
publication_state=state, # type: ignore[arg-type]
allow_drafts=True,
max_attachments=2,
handoff_kinds=("case",),
)
class FormsTests(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()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_provider_returns_exact_published_revision_and_history(self) -> None:
first = record_form_definition(
self.session,
self.principal,
definition=definition(),
)
provider = SqlFormDefinitionProvider()
self.assertEqual(
(first,),
tuple(
provider.list_form_definitions(
self.session,
self.principal,
tenant_id="tenant-1",
)
),
)
exact = provider.get_form_definition(
self.session,
self.principal,
reference=first.reference,
effective_at=NOW,
)
self.assertEqual("1", exact.temporal.revision if exact else None)
record_form_definition(
self.session,
self.principal,
definition=definition(revision="2"),
expected_revision="1",
)
self.assertEqual(
["2", "1"],
[
item.temporal.revision
for item in form_definition_history(
self.session,
self.principal,
form_id="permit-application",
)
],
)
def test_replay_occ_and_tenant_boundaries_fail_closed(self) -> None:
first = record_form_definition(
self.session,
self.principal,
definition=definition(),
)
replay = record_form_definition(
self.session,
self.principal,
definition=definition(),
)
self.assertEqual(first, replay)
with self.assertRaisesRegex(FormDefinitionStoreError, "stale"):
record_form_definition(
self.session,
self.principal,
definition=definition(revision="2"),
expected_revision="0",
)
with self.assertRaisesRegex(FormDefinitionStoreError, "cross tenants"):
record_form_definition(
self.session,
self.principal,
definition=definition(tenant_id="tenant-2"),
)
with self.assertRaisesRegex(Exception, "cross tenants"):
SqlFormDefinitionProvider().list_form_definitions(
self.session,
Principal("tenant-2"),
tenant_id="tenant-1",
)
def test_pages_conditions_and_localization_are_validated(self) -> None:
item = definition()
composed = replace(
item,
fields=(
item.fields[0],
replace(
item.fields[1],
visibility_condition=FormConditionExpression(
kind="predicate",
field_key="name",
operator="is_not_empty",
),
),
),
pages=(
FormPageDefinition(
key="application",
title="Application",
sections=(
FormSectionDefinition(
key="details",
title="Details",
field_keys=("name", "delivery"),
),
),
),
),
fallback_locale="de",
localizations=(
FormLocalization(
locale="de",
title="Antrag",
field_labels={"name": "Name", "delivery": "Zustellung"},
option_labels={"delivery": {"portal": "Portal", "mail": "Post"}},
page_titles={"application": "Antrag"},
section_titles={"details": "Angaben"},
),
),
)
stored = record_form_definition(
self.session,
self.principal,
definition=composed,
)
self.assertEqual("application", stored.pages[0].key)
self.assertEqual((), form_definition_diagnostics(stored))
cyclic = replace(
definition(revision="2"),
fields=(
replace(
item.fields[0],
visibility_condition=FormConditionExpression(
kind="predicate",
field_key="delivery",
operator="eq",
value="portal",
),
),
replace(
item.fields[1],
visibility_condition=FormConditionExpression(
kind="predicate",
field_key="name",
operator="is_not_empty",
),
),
),
)
with self.assertRaisesRegex(FormDefinitionStoreError, "dependency cycle"):
record_form_definition(
self.session,
self.principal,
definition=cyclic,
expected_revision="1",
)
def test_package_fragment_is_verified_assessed_and_imported_as_draft(self) -> None:
source = definition(tenant_id="tenant-source")
fragment = export_form_definition_fragment(
source,
exported_at=NOW,
exported_by="source-account",
)
assessment = assess_form_definition_fragment(
self.session,
self.principal,
fragment=fragment,
)
self.assertEqual("create", assessment["outcome"])
self.assertTrue(assessment["requires_remap"])
imported = import_form_definition_fragment(
self.session,
self.principal,
fragment=fragment,
target_form_id="local-permit",
target_key="local-permit",
expected_revision=None,
change_reason="Import reviewed package.",
recorded_at=NOW,
)
self.assertEqual("tenant-1", imported.reference.tenant_id)
self.assertEqual("draft", imported.publication_state)
self.assertEqual(
"tenant-source",
imported.metadata["package_import"]["source_tenant_id"],
)
tampered = dict(fragment)
tampered["definition"] = {**fragment["definition"], "title": "Tampered"}
with self.assertRaisesRegex(FormDefinitionStoreError, "digest"):
assess_form_definition_fragment(
self.session,
self.principal,
fragment=tampered,
)
if __name__ == "__main__":
unittest.main()
(assess_form_definition_fragment,)
(export_form_definition_fragment,)
(form_definition_diagnostics,)
(import_form_definition_fragment,)
@@ -0,0 +1,69 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_forms.backend.manifest import manifest
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
class FormsInterfaceDocumentationContractTests(unittest.TestCase):
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:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
self.assertEqual({"/forms"}, {item.path for item in frontend.routes}) # type: ignore[union-attr]
self.assertEqual(
{"forms.navigation", "forms.catalogue"},
{item.id for item in frontend.view_surfaces}, # type: ignore[union-attr]
)
def test_help_privacy_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
guide = topics["forms.definitions"]
reference = topics["forms.reference.fields-and-consequences"]
self.assertIn("forms.catalogue", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
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("import_package", reference.metadata["consequence_classes"])
def test_semantic_subject_provider_and_static_baseline_are_declared(self) -> None:
capability = semantic_documentation_subject_capability("forms")
self.assertIn("docs", manifest.optional_dependencies)
self.assertNotIn("docs", manifest.dependencies)
self.assertIn(capability, manifest.capability_factories)
self.assertEqual(
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
manifest.capability_documentation[capability].contract_version,
)
topics = {topic.id: topic for topic in manifest.documentation}
semantic = topics["forms.semantic-documentation"]
self.assertEqual({"admin", "user"}, set(semantic.documentation_types))
self.assertIn("cannot override", semantic.body)
def test_builder_links_semantic_help_without_closing_unsaved_dialog(self) -> None:
source = (
Path(__file__).parents[1]
/ "webui/src/features/forms/FormDefinitionDialog.tsx"
).read_text(encoding="utf-8")
self.assertIn('target="_blank"', source)
self.assertIn("semanticFieldDocumentation", source)
self.assertIn("/docs/semantic?", source)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_forms.backend.manifest import get_manifest
class FormsMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_definition_store_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-forms-migration-") as directory:
url = f"sqlite:///{Path(directory) / 'forms.db'}"
migrate_database(
database_url=url,
enabled_modules=("forms",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
self.assertIn(
"form_definition_revisions",
inspect(engine).get_table_names(),
)
with engine.connect() as connection:
self.assertIn(
"e1f2a3b4c5d6",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__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()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/forms-webui",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/forms.css": "./src/styles/forms.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+153
View File
@@ -0,0 +1,153 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
export type FormCondition =
| { kind: "predicate"; field_key: string; operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "is_empty" | "is_not_empty"; value?: unknown }
| { kind: "all" | "any"; conditions: FormCondition[] }
| { kind: "not"; conditions: [FormCondition] };
export type FormFieldDefinition = {
key: string;
label: string;
value_type: FormValueType;
required: boolean;
help_text?: string | null;
options: string[];
constraints: Record<string, unknown>;
default_value?: unknown;
visibility_condition?: FormCondition | null;
accessibility?: Record<string, unknown>;
};
export type FormSectionDefinition = {
key: string;
title: string;
description?: string | null;
field_keys: string[];
visibility_condition?: FormCondition | null;
};
export type FormPageDefinition = {
key: string;
title: string;
description?: string | null;
sections: FormSectionDefinition[];
visibility_condition?: FormCondition | null;
};
export type FormLocalization = {
locale: string;
title?: string | null;
description?: string | null;
field_labels: Record<string, string>;
field_help_texts: Record<string, string>;
option_labels: Record<string, Record<string, string>>;
page_titles: Record<string, string>;
section_titles: Record<string, string>;
};
export type FormDefinition = {
reference: {
kind: "form";
owner_module: "forms";
object_id: string;
tenant_id: string;
version: string;
};
key: string;
temporal: {
revision: string;
valid_from?: string | null;
valid_to?: string | null;
recorded_at: string;
superseded_at?: string | null;
change_reason: string;
};
title: string;
description?: string | null;
fields: FormFieldDefinition[];
publication_state: "draft" | "published" | "retired";
allow_drafts: boolean;
max_attachments: number;
signature_requirement: "none" | "optional" | "required";
policy_refs: string[];
handoff_kinds: string[];
pages?: FormPageDefinition[];
fallback_locale?: string | null;
localizations?: FormLocalization[];
accessibility?: Record<string, unknown>;
metadata: Record<string, unknown>;
};
export type FormPackageFragment = {
kind: "govoplan.forms.definition";
contract_version: "0.1.0";
definition: FormDefinition;
definition_sha256: string;
provenance: Record<string, unknown>;
};
export function listFormDefinitions(
settings: ApiSettings,
options: { query?: string; states?: string[]; offset?: number; limit?: number } = {},
signal?: AbortSignal
): Promise<{ definitions: FormDefinition[]; total: number }> {
return apiFetch(settings, apiPath("/api/v1/forms/definitions", {
q: options.query,
publication_state: options.states,
offset: options.offset ?? 0,
limit: options.limit ?? 200
}), { signal });
}
export function saveFormDefinition(
settings: ApiSettings,
definition: FormDefinition,
expectedRevision?: string | null
): Promise<FormDefinition> {
return apiFetch(settings, `/api/v1/forms/definitions/${encodeURIComponent(definition.reference.object_id)}`, {
method: "PUT",
body: JSON.stringify({
definition,
expected_revision: expectedRevision ?? null
})
});
}
export function exportFormDefinitionPackage(
settings: ApiSettings,
formId: string,
revision: string
): Promise<FormPackageFragment> {
return apiFetch(settings, apiPath(`/api/v1/forms/definitions/${encodeURIComponent(formId)}/package`, { revision }));
}
export function assessFormDefinitionPackage(
settings: ApiSettings,
fragment: FormPackageFragment
): Promise<{ outcome: string; requires_remap: boolean; current_revision?: string | null }> {
return apiFetch(settings, "/api/v1/forms/packages/assess", {
method: "POST",
body: JSON.stringify({ fragment })
});
}
export function importFormDefinitionPackage(
settings: ApiSettings,
fragment: FormPackageFragment,
options: { targetFormId?: string; targetKey?: string; expectedRevision?: string; changeReason: string }
): Promise<FormDefinition> {
return apiFetch(settings, "/api/v1/forms/packages/import", {
method: "POST",
body: JSON.stringify({
fragment,
target_form_id: options.targetFormId || null,
target_key: options.targetKey || null,
expected_revision: options.expectedRevision || null,
change_reason: options.changeReason,
recorded_at: new Date().toISOString()
})
});
}
@@ -0,0 +1,775 @@
import { ArrowDown, ArrowUp, BookOpen, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { FormGrid,
ActionToolbar,
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField as Field,
IconButton,
ToggleSwitch,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings,
type DocumentationHelpReference
} from "@govoplan/core-webui";
import {
saveFormDefinition,
type FormDefinition,
type FormCondition,
type FormFieldDefinition,
type FormLocalization,
type FormPageDefinition,
type FormValueType
} from "../../api/forms";
import { FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
const VALUE_TYPES: Array<{ value: FormValueType; label: string }> = [
{ value: "text", label: "Text" },
{ value: "multiline_text", label: "Long text" },
{ value: "email", label: "Email" },
{ value: "integer", label: "Integer" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Yes / no" },
{ value: "date", label: "Date" },
{ value: "datetime", label: "Date and time" },
{ value: "choice", label: "Single choice" },
{ value: "multi_choice", label: "Multiple choice" },
{ value: "object", label: "Structured object" },
{ value: "list", label: "Structured list" }
];
export default function FormDefinitionDialog({
open,
settings,
tenantId,
definition,
canPublish,
onClose,
onSaved
}: {
open: boolean;
settings: ApiSettings;
tenantId: string;
definition: FormDefinition | null;
canPublish: boolean;
onClose: () => void;
onSaved: (definition: FormDefinition) => void;
}) {
const { translateText } = usePlatformLanguage();
const [baseline, setBaseline] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
const [draft, setDraft] = useState<FormDefinition>(baseline);
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [confirmLifecycle, setConfirmLifecycle] = useState(false);
const { requestDiscard } = useUnsavedChanges();
useEffect(() => {
if (!open) return;
const next = initialDraft(tenantId, definition);
setDraft(next);
setBaseline(next);
setChangeReason("");
setBusy(false);
setError("");
setConfirmLifecycle(false);
}, [definition, open, tenantId]);
useEffect(() => {
if (!open || !definition || !window.location.hash) return;
const targetId = decodeURIComponent(window.location.hash.slice(1));
window.requestAnimationFrame(() => {
document.getElementById(targetId)?.scrollIntoView({ block: "center" });
});
}, [definition, open]);
const valid = useMemo(() => Boolean(
draft.title.trim()
&& draft.key.trim()
&& draft.fields.length > 0
&& draft.fields.every((field) => field.key.trim() && field.label.trim())
&& changeReason.trim()
), [changeReason, draft]);
const dirty = useMemo(
() => Boolean(changeReason || JSON.stringify(draft) !== JSON.stringify(baseline)),
[baseline, changeReason, draft]
);
async function save(): Promise<boolean> {
if (!valid) return false;
setBusy(true);
setError("");
const revision = crypto.randomUUID();
const recordedAt = new Date().toISOString();
const payload: FormDefinition = {
...draft,
reference: {
...draft.reference,
version: revision
},
temporal: {
...draft.temporal,
revision,
recorded_at: recordedAt,
superseded_at: null,
change_reason: changeReason.trim()
},
title: draft.title.trim(),
key: draft.key.trim(),
description: draft.description?.trim() || null,
fields: draft.fields.map(normalizeField),
policy_refs: draft.policy_refs.map((item) => item.trim()).filter(Boolean),
metadata: { ...draft.metadata }
};
try {
const saved = await saveFormDefinition(
settings,
payload,
definition?.reference.version
);
onSaved(saved);
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form definition could not be saved.");
return false;
} finally {
setBusy(false);
}
}
useUnsavedDraftGuard({
dirty: open && dirty,
onSave: save,
onDiscard: () => {
setDraft(baseline);
setChangeReason("");
},
title: "i18n:govoplan-forms.unsaved_title",
message: "i18n:govoplan-forms.unsaved_message"
});
function requestClose() {
if (busy) return;
if (dirty) requestDiscard(onClose);
else onClose();
}
function requestSave() {
const previousState = definition?.publication_state ?? "draft";
if (draft.publication_state !== "draft" && draft.publication_state !== previousState) {
setConfirmLifecycle(true);
return;
}
void save();
}
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
setDraft((current) => ({
...current,
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field),
pages: patch.key && patch.key !== current.fields[index].key
? remapPageField(current.pages ?? [], current.fields[index].key, patch.key)
: current.pages
}));
}
function moveField(index: number, delta: -1 | 1) {
setDraft((current) => {
const target = index + delta;
if (target < 0 || target >= current.fields.length) return current;
const fields = [...current.fields];
[fields[index], fields[target]] = [fields[target], fields[index]];
return { ...current, fields };
});
}
return (
<Dialog
open={open}
title={definition ? `Revise ${definition.title}` : "New Form definition"}
onClose={requestClose}
closeDisabled={busy}
portal
className="form-definition-dialog"
footer={
<>
<Button onClick={requestClose} disabled={busy} disabledReason={busy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={requestSave} disabled={busy || !valid} disabledReason={busy ? FORMS_I18N.busy : !valid ? FORMS_I18N.incomplete : undefined}>
{busy ? "Saving" : "Save revision"}
</Button>
</>
}>
<div className="form-definition-editor">
<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>}
<FormGrid columns={2} gap="small" collapseAt="narrow">
<Field label="Title">
<input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} />
</Field>
<Field label="Key">
<input value={draft.key} disabled={busy || Boolean(definition)} onChange={(event) => setDraft({ ...draft, key: event.target.value })} />
</Field>
<Field label="Description" className="form-definition-wide">
<textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} />
</Field>
<Field label="Publication state" help={!canPublish ? FORMS_I18N.adminReason : undefined} documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.publication_state} disabled={busy} onChange={(event) => setDraft({ ...draft, publication_state: event.target.value as FormDefinition["publication_state"] })}>
{definition?.publication_state !== "published" && <option value="draft">Draft</option>}
{canPublish && <option value="published">Published</option>}
{canPublish && <option value="retired">Retired</option>}
</select>
</Field>
<Field label="Signature" documentation={FORMS_FIELD_DOCUMENTATION}>
<select value={draft.signature_requirement} disabled={busy} onChange={(event) => setDraft({ ...draft, signature_requirement: event.target.value as FormDefinition["signature_requirement"] })}>
<option value="none">Not used</option>
<option value="optional">Optional</option>
<option value="required">Required</option>
</select>
</Field>
<Field label="Maximum attachments">
<input type="number" min={0} max={1000} value={draft.max_attachments} disabled={busy} onChange={(event) => setDraft({ ...draft, max_attachments: Number(event.target.value) })} />
</Field>
<div className="form-definition-toggle">
<ToggleSwitch label="Draft saving" checked={draft.allow_drafts} disabled={busy} onChange={(allow_drafts) => setDraft({ ...draft, allow_drafts })} />
</div>
<Field label="Policy references" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={draft.policy_refs.join(", ")} disabled={busy} placeholder="policy:permit-intake" onChange={(event) => setDraft({ ...draft, policy_refs: splitValues(event.target.value) })} />
</Field>
<div className="form-definition-handoffs form-definition-wide">
<span>Permitted handoffs</span>
{(["case", "workflow", "record"] as const).map((kind) =>
<ToggleSwitch
key={kind}
label={humanize(kind)}
checked={draft.handoff_kinds.includes(kind)}
disabled={busy}
onChange={(checked) => setDraft({
...draft,
handoff_kinds: checked
? [...draft.handoff_kinds, kind]
: draft.handoff_kinds.filter((item) => item !== kind)
})}
/>
)}
</div>
<Field label="Accessibility instructions" className="form-definition-wide" documentation={FORMS_FIELD_DOCUMENTATION}>
<textarea
rows={2}
value={String(draft.accessibility?.instructions ?? "")}
disabled={busy}
onChange={(event) => setDraft({
...draft,
accessibility: patchOptionalText(draft.accessibility ?? {}, "instructions", event.target.value)
})}
placeholder="Optional instructions announced before the Form"
/>
</Field>
</FormGrid>
<ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Fields</h3>
<Button onClick={() => setDraft(addField(draft))} disabled={busy}>
<Plus size={16} aria-hidden="true" />Add field
</Button>
</ActionToolbar>
<div className="form-field-editor-list">
{draft.fields.map((field, index) =>
<div className="form-field-editor-row" id={`field-${field.key}`} key={`${index}:${field.key}`}>
<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"} down`} icon={<ArrowDown size={15} />} disabled={busy || index === draft.fields.length - 1} onClick={() => moveField(index, 1)} />
</div>
<Field label="Key"><input value={field.key} disabled={busy} onChange={(event) => patchField(index, { key: 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">
<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>)}
</select>
</Field>
<div className="form-field-required"><ToggleSwitch label="Required" checked={field.required} disabled={busy} onChange={(required) => patchField(index, { required })} /></div>
<Field label="Help text" className="form-field-help"><input value={field.help_text ?? ""} disabled={busy} onChange={(event) => patchField(index, { help_text: event.target.value })} /></Field>
{isChoice(field.value_type) &&
<Field label="Options" className="form-field-options"><input value={field.options.join(", ")} disabled={busy} onChange={(event) => patchField(index, { options: splitValues(event.target.value) })} /></Field>
}
<ConditionFields
condition={field.visibility_condition ?? null}
fields={draft.fields}
currentKey={field.key}
disabled={busy}
onChange={(visibility_condition) => patchField(index, { visibility_condition })}
/>
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
<IconButton
label={`Remove ${field.label || "field"}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy || draft.fields.length === 1}
disabledReason={busy ? FORMS_I18N.busy : draft.fields.length === 1 ? FORMS_I18N.oneField : undefined}
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>
<PageEditor draft={draft} disabled={busy} onChange={setDraft} />
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
<DefinitionPreview definition={draft} previous={definition} />
<Field label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}>
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
</Field>
</div>
<ConfirmDialog
open={confirmLifecycle}
title="i18n:govoplan-forms.lifecycle_title"
message={i18nMessage("i18n:govoplan-forms.lifecycle_message", { state: translateText(humanize(draft.publication_state)) })}
confirmLabel="Save revision"
tone={draft.publication_state === "retired" ? "danger" : "default"}
busy={busy}
onCancel={() => setConfirmLifecycle(false)}
onConfirm={() => {
setConfirmLifecycle(false);
void save();
}}
/>
</Dialog>
);
}
function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefinition; disabled: boolean; onChange: (value: Record<string, unknown>) => void }) {
if (["text", "multiline_text", "email"].includes(field.value_type)) {
return (
<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="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>
</FormGrid>
);
}
if (["integer", "number"].includes(field.value_type)) {
return (
<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="Maximum"><input type="number" value={constraintValue(field.constraints.maximum)} disabled={disabled} onChange={(event) => onChange(patchConstraint(field.constraints, "maximum", event.target.value))} /></Field>
</FormGrid>
);
}
return null;
}
function ConditionFields({
condition,
fields,
currentKey,
disabled,
onChange
}: {
condition: FormCondition | null;
fields: FormFieldDefinition[];
currentKey: string;
disabled: boolean;
onChange: (value: FormCondition | null) => void;
}) {
const predicate = condition?.kind === "predicate" ? condition : null;
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
return (
<FormGrid columns={3} gap="compact" collapseAt="narrow" className="form-field-condition">
<Field label="Visible when">
<select
value={predicate?.field_key ?? ""}
disabled={disabled || candidates.length === 0}
onChange={(event) => onChange(event.target.value
? { kind: "predicate", field_key: event.target.value, operator: "eq", value: true }
: null)}>
<option value="">Always visible</option>
{candidates.map((item) => <option key={item.key} value={item.key}>{item.label || item.key}</option>)}
</select>
</Field>
{predicate && <>
<Field label="Condition">
<select
value={predicate.operator}
disabled={disabled}
onChange={(event) => onChange({
...predicate,
operator: event.target.value as Extract<FormCondition, { kind: "predicate" }>["operator"],
...(event.target.value === "is_empty" || event.target.value === "is_not_empty" ? { value: undefined } : {})
})}>
<option value="eq">Equals</option>
<option value="neq">Does not equal</option>
<option value="is_empty">Is empty</option>
<option value="is_not_empty">Is not empty</option>
<option value="contains">Contains</option>
</select>
</Field>
{!(["is_empty", "is_not_empty"] as string[]).includes(predicate.operator) &&
<Field label="Value">
<input
value={conditionInputValue(predicate.value)}
disabled={disabled}
onChange={(event) => onChange({ ...predicate, value: parseConditionValue(event.target.value, fields.find((item) => item.key === predicate.field_key)?.value_type) })}
/>
</Field>
}
</>}
</FormGrid>
);
}
function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
const pages = draft.pages ?? [];
function updatePages(next: FormPageDefinition[]) {
onChange({ ...draft, pages: next });
}
return (
<section className="form-composition-section">
<ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3>Pages and sections</h3>
{pages.length === 0
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
</ActionToolbar>
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
{pages.map((page, pageIndex) =>
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
<div className="form-page-editor-heading">
<Field label="Page key"><input value={page.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, key: event.target.value }))} /></Field>
<Field label="Page title"><input value={page.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, title: event.target.value }))} /></Field>
<IconButton label={`Remove page ${page.title || page.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => updatePages(pages.filter((_, index) => index !== pageIndex))} />
</div>
{page.sections.map((section, sectionIndex) =>
<div className="form-section-editor" key={`${sectionIndex}:${section.key}`}>
<Field label="Section key"><input value={section.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, key: event.target.value }) }))} /></Field>
<Field label="Section title"><input value={section.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, title: event.target.value }) }))} /></Field>
<Field label="Fields">
<select
multiple
value={section.field_keys}
disabled={disabled}
onChange={(event) => updatePages(replaceAt(pages, pageIndex, {
...page,
sections: replaceAt(page.sections, sectionIndex, {
...section,
field_keys: Array.from(event.currentTarget.selectedOptions, (option) => option.value)
})
}))}>
{draft.fields.map((field) => <option key={field.key} value={field.key}>{field.label || field.key}</option>)}
</select>
</Field>
<IconButton label={`Remove section ${section.title || section.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled || page.sections.length === 1} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: page.sections.filter((_, index) => index !== sectionIndex) }))} />
</div>
)}
<Button disabled={disabled} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: [...page.sections, emptySection(page.sections.length + 1)] }))}><Plus size={15} aria-hidden="true" />Add section</Button>
</div>
)}
</section>
);
}
function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
const localizations = draft.localizations ?? [];
function update(items: FormLocalization[]) {
const fallback = items.some((item) => item.locale === draft.fallback_locale)
? draft.fallback_locale
: items[0]?.locale ?? null;
onChange({ ...draft, localizations: items, fallback_locale: fallback });
}
return (
<section className="form-composition-section">
<ActionToolbar surface="section-header" className="form-field-editor-heading">
<h3><Languages size={17} aria-hidden="true" />Localizations</h3>
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
</ActionToolbar>
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
{localizations.map((localization, index) =>
<div className="form-localization-editor" key={`${index}:${localization.locale}`}>
<Field label="Locale"><input value={localization.locale} disabled={disabled} placeholder="de" onChange={(event) => update(replaceAt(localizations, index, { ...localization, locale: event.target.value }))} /></Field>
<Field label="Localized title"><input value={localization.title ?? ""} disabled={disabled} onChange={(event) => update(replaceAt(localizations, index, { ...localization, title: event.target.value }))} /></Field>
<label className="form-localization-fallback"><input type="radio" checked={draft.fallback_locale === localization.locale} disabled={disabled || !localization.locale} onChange={() => onChange({ ...draft, fallback_locale: localization.locale })} />Fallback</label>
<IconButton label={`Remove locale ${localization.locale || index + 1}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => update(localizations.filter((_, itemIndex) => itemIndex !== index))} />
<div className="form-localization-fields">
{draft.fields.map((field) =>
<Field key={field.key} label={`${field.label || field.key} label`}>
<input
value={localization.field_labels[field.key] ?? ""}
disabled={disabled}
onChange={(event) => update(replaceAt(localizations, index, {
...localization,
field_labels: patchOptionalText(localization.field_labels, field.key, event.target.value)
}))}
/>
</Field>
)}
</div>
</div>
)}
</section>
);
}
function DefinitionPreview({ definition, previous }: { definition: FormDefinition; previous: FormDefinition | null }) {
const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
return (
<section className="form-composition-section form-definition-preview">
<ActionToolbar surface="section-header" className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></ActionToolbar>
<FormGrid columns={2} collapseAt="narrow" className="form-preview-grid">
<div>
<strong>{definition.title || "Untitled Form"}</strong>
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
<div key={page.key} className="form-preview-page">
<span>{page.title}</span>
{page.sections.map((section) => <small key={section.key}>{section.title}: {section.field_keys.join(", ") || "No fields"}</small>)}
</div>
)}
</div>
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
</FormGrid>
</section>
);
}
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
if (definition) return structuredClone(definition);
const id = crypto.randomUUID();
const revision = crypto.randomUUID();
const now = new Date().toISOString();
return {
reference: { kind: "form", owner_module: "forms", object_id: id, tenant_id: tenantId, version: revision },
key: "",
temporal: { revision, recorded_at: now, change_reason: "" },
title: "",
description: "",
fields: [emptyField(1)],
publication_state: "draft",
allow_drafts: true,
max_attachments: 0,
signature_requirement: "none",
policy_refs: [],
handoff_kinds: [],
pages: [],
fallback_locale: null,
localizations: [],
accessibility: {},
metadata: {}
};
}
function emptyField(index: number): FormFieldDefinition {
return { key: `field-${index}`, label: "", value_type: "text", required: false, help_text: "", options: [], constraints: {} };
}
function normalizeField(field: FormFieldDefinition): FormFieldDefinition {
return {
...field,
key: field.key.trim(),
label: field.label.trim(),
help_text: field.help_text?.trim() || null,
options: isChoice(field.value_type) ? field.options.map((item) => item.trim()).filter(Boolean) : [],
constraints: Object.fromEntries(Object.entries(field.constraints).filter(([, value]) => value !== "" && value !== null && value !== undefined))
};
}
function addField(definition: FormDefinition): FormDefinition {
const field = emptyField(definition.fields.length + 1);
const pages = definition.pages ?? [];
if (pages.length === 0) return { ...definition, fields: [...definition.fields, field] };
const firstPage = pages[0];
const firstSection = firstPage.sections[0];
return {
...definition,
fields: [...definition.fields, field],
pages: replaceAt(pages, 0, {
...firstPage,
sections: replaceAt(firstPage.sections, 0, {
...firstSection,
field_keys: [...firstSection.field_keys, field.key]
})
})
};
}
function removeField(definition: FormDefinition, index: number): FormDefinition {
const key = definition.fields[index].key;
return {
...definition,
fields: definition.fields.filter((_, fieldIndex) => fieldIndex !== index),
pages: (definition.pages ?? []).map((page) => ({
...page,
sections: page.sections.map((section) => ({
...section,
field_keys: section.field_keys.filter((fieldKey) => fieldKey !== key)
}))
}))
};
}
function remapPageField(pages: FormPageDefinition[], previous: string, next: string): FormPageDefinition[] {
return pages.map((page) => ({
...page,
sections: page.sections.map((section) => ({
...section,
field_keys: section.field_keys.map((key) => key === previous ? next : key)
}))
}));
}
function defaultPage(fields: FormFieldDefinition[]): FormPageDefinition {
return {
key: "page-1",
title: "Form",
sections: [{ key: "section-1", title: "Details", field_keys: fields.map((item) => item.key) }]
};
}
function emptyPage(index: number): FormPageDefinition {
return { key: `page-${index}`, title: `Page ${index}`, sections: [emptySection(1)] };
}
function emptySection(index: number) {
return { key: `section-${index}`, title: `Section ${index}`, field_keys: [] as string[] };
}
function emptyLocalization(): FormLocalization {
return {
locale: "",
title: "",
description: "",
field_labels: {},
field_help_texts: {},
option_labels: {},
page_titles: {},
section_titles: {}
};
}
function replaceAt<T>(items: T[], index: number, value: T): T[] {
return items.map((item, itemIndex) => itemIndex === index ? value : item);
}
function patchOptionalText<T extends Record<string, unknown>>(current: T, key: string, value: string): T {
const next = { ...current };
if (value.trim()) next[key as keyof T] = value as T[keyof T];
else delete next[key as keyof T];
return next;
}
function conditionInputValue(value: unknown): string {
if (typeof value === "string") return value;
if (value === undefined || value === null) return "";
return JSON.stringify(value);
}
function parseConditionValue(value: string, type?: FormValueType): unknown {
if (type === "boolean") return value.trim().toLowerCase() === "true";
if (type === "integer") return Number.parseInt(value, 10);
if (type === "number") return Number(value);
return value;
}
function definitionChanges(previous: FormDefinition, current: FormDefinition): string[] {
const changes: string[] = [];
if (previous.title !== current.title) changes.push("Title changed");
if (previous.publication_state !== current.publication_state) changes.push(`State: ${previous.publication_state} -> ${current.publication_state}`);
if (previous.fields.length !== current.fields.length) changes.push(`Fields: ${previous.fields.length} -> ${current.fields.length}`);
if ((previous.pages?.length ?? 0) !== (current.pages?.length ?? 0)) changes.push(`Pages: ${previous.pages?.length ?? 0} -> ${current.pages?.length ?? 0}`);
if ((previous.localizations?.length ?? 0) !== (current.localizations?.length ?? 0)) changes.push(`Locales: ${previous.localizations?.length ?? 0} -> ${current.localizations?.length ?? 0}`);
if (changes.length === 0 && JSON.stringify(previous) !== JSON.stringify(current)) changes.push("Definition details changed");
return changes.length ? changes : ["No unsaved changes"];
}
function splitValues(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean);
}
function isChoice(value: string): boolean {
return value === "choice" || value === "multi_choice";
}
function patchConstraint(current: Record<string, unknown>, key: string, value: string): Record<string, unknown> {
const next = { ...current };
if (!value.trim()) delete next[key];
else next[key] = Number(value);
return next;
}
function patchTextConstraint(current: Record<string, unknown>, key: string, value: string): Record<string, unknown> {
const next = { ...current };
if (!value) delete next[key];
else next[key] = value;
return next;
}
function constraintValue(value: unknown): number | "" {
return typeof value === "number" ? value : "";
}
function humanize(value: string): string {
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}`;
}
+253
View File
@@ -0,0 +1,253 @@
import { Download, Pencil, Plus, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router";
import { ActionBlockerHint,
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
FormField,
FilterBar,
LoadingIndicator,
PageScrollViewport,
StatePanel,
StatusBadge,
hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
WorkspaceActionBar,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
assessFormDefinitionPackage,
exportFormDefinitionPackage,
importFormDefinitionPackage,
listFormDefinitions,
type FormDefinition,
type FormPackageFragment
} from "../../api/forms";
import FormDefinitionDialog from "./FormDefinitionDialog";
import { FORMS_DOCUMENTATION, FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
export default function FormsPage({ settings, auth }: PlatformRouteContext) {
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [state, setState] = useState("");
const [items, setItems] = useState<FormDefinition[]>([]);
const [total, setTotal] = useState(0);
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [importing, setImporting] = useState<FormPackageFragment | null>(null);
const [importReason, setImportReason] = useState("");
const [importAssessment, setImportAssessment] = useState("");
const [importBusy, setImportBusy] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const importInput = useRef<HTMLInputElement>(null);
const canWrite = hasScope(auth, "forms:definition:write");
const canAdmin = hasScope(auth, "forms:definition:admin");
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return listFormDefinitions(settings, {
query: submittedQuery,
states: state ? [state] : undefined,
limit: 200
}, signal).
then((result) => {
setItems(result.definitions);
setTotal(result.total);
}).
finally(() => setLoading(false));
}, [settings, state, submittedQuery]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Form definitions could not be loaded.");
}
});
return () => controller.abort();
}, [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) {
event.preventDefault();
setSubmittedQuery(query.trim());
}
async function choosePackage(file?: File) {
if (!file) return;
setError("");
try {
const fragment = JSON.parse(await file.text()) as FormPackageFragment;
const assessment = await assessFormDefinitionPackage(settings, fragment);
setImporting(fragment);
setImportAssessment(assessment.outcome);
setImportReason("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form package could not be read.");
} finally {
if (importInput.current) importInput.current.value = "";
}
}
async function importPackage(): Promise<boolean> {
if (!importing || !importReason.trim()) return false;
setImportBusy(true);
setError("");
try {
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
setImporting(null);
await load();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
return false;
} finally {
setImportBusy(false);
}
}
useUnsavedDraftGuard({
dirty: Boolean(importing && importReason),
onSave: importPackage,
onDiscard: () => {
setImporting(null);
setImportReason("");
},
title: "i18n:govoplan-forms.unsaved_title",
message: "i18n:govoplan-forms.unsaved_message"
});
function closeImport() {
if (importBusy) return;
if (importing && importReason) requestDiscard(() => setImporting(null));
else setImporting(null);
}
async function downloadPackage(item: FormDefinition) {
setError("");
try {
const fragment = await exportFormDefinitionPackage(settings, item.reference.object_id, item.reference.version);
const href = URL.createObjectURL(new Blob([JSON.stringify(fragment, null, 2)], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `${item.key}-${item.reference.version}.govoplan-form.json`;
anchor.click();
URL.revokeObjectURL(href);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form package could not be exported.");
}
}
return (
<main className="forms-page">
<WorkspaceFrame className="forms-shell" label="Form definitions" interfaceId="forms.catalogue" helpContextId="forms.page.catalogue" helpModuleId="forms">
<WorkspaceActionBar
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" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search definitions" aria-label="Search Form definitions" />
<Button type="submit">Search</Button>
</FilterBar>
<label>
<span>State</span>
<select value={state} onChange={(event) => setState(event.target.value)}>
<option value="">All</option>
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="retired">Retired</option>
</select>
</label>
<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])} />
<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>}
helpAction={<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />}
/>
<PageScrollViewport className="forms-list-viewport">
{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} />}
{loading && <LoadingIndicator label="Loading Form definitions" />}
{!loading && !error && items.length === 0 && <StatePanel size="compact" description="No matching definitions." />}
{!loading && items.length > 0 &&
<div className="forms-list" role="list">
{items.map((item) => {
const mayRevise = canWrite && (item.publication_state === "draft" || canAdmin) && item.publication_state !== "retired";
return (
<div className="forms-row" role="listitem" key={item.reference.object_id}>
<span><strong>{item.title}</strong><small>{item.key}</small></span>
<span>{item.fields.length} fields</span>
<span>Revision {item.reference.version}</span>
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
<span className="forms-row-actions">
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
<IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} disabled={!mayRevise} disabledReason={!canWrite ? FORMS_I18N.writeReason : item.publication_state === "retired" || (item.publication_state === "published" && !canAdmin) ? FORMS_I18N.lifecycleReason : undefined} onClick={() => setEditing(item)} />
</span>
</div>
);
})}
</div>
}
</PageScrollViewport>
</WorkspaceFrame>
{editing &&
<FormDefinitionDialog
open
settings={settings}
tenantId={tenantId}
definition={editing === "new" ? null : editing}
canPublish={canAdmin}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void load().catch((reason) => setError(reason instanceof Error ? reason.message : "Definitions could not be reloaded."));
}}
/>
}
<Dialog
open={Boolean(importing)}
title="Import Form package"
onClose={closeImport}
closeDisabled={importBusy}
portal
footer={<>
<Button onClick={closeImport} disabled={importBusy} disabledReason={importBusy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()} disabledReason={importBusy ? FORMS_I18N.busy : !importReason.trim() ? FORMS_I18N.incomplete : undefined}>{importBusy ? "Importing" : "Import as draft"}</Button>
</>}>
<div className="forms-package-import">
<p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p>
<FormField label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
</div>
</Dialog>
</main>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,27 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const FORMS_DOCUMENTATION = {
topicId: "forms.definitions",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const FORMS_FIELD_DOCUMENTATION = {
topicId: "forms.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const FORMS_I18N = {
loading: "i18n:govoplan-forms.loading_reason",
busy: "i18n:govoplan-forms.busy_reason",
writeReason: "i18n:govoplan-forms.write_permission_reason",
adminReason: "i18n:govoplan-forms.admin_permission_reason",
lifecycleReason: "i18n:govoplan-forms.lifecycle_reason",
incomplete: "i18n:govoplan-forms.incomplete_reason",
oneField: "i18n:govoplan-forms.one_field_reason",
requiredAction: "i18n:govoplan-forms.required_action",
actor: "i18n:govoplan-forms.responsible_actor",
destination: "i18n:govoplan-forms.destination",
permissionAction: "i18n:govoplan-forms.permission_action",
permissionActor: "i18n:govoplan-forms.permission_actor",
permissionDestination: "i18n:govoplan-forms.permission_destination"
} as const;
+149
View File
@@ -0,0 +1,149 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-forms.form_definitions": "Form definitions",
"i18n:govoplan-forms.loading_reason": "Form definitions are still loading.",
"i18n:govoplan-forms.busy_reason": "Another Form definition action is still running.",
"i18n:govoplan-forms.write_permission_reason": "Your account may not create, import, or revise Form definitions.",
"i18n:govoplan-forms.admin_permission_reason": "Publishing and retirement require Form administration permission.",
"i18n:govoplan-forms.lifecycle_reason": "Retired definitions cannot be revised; published definitions require administrative authority.",
"i18n:govoplan-forms.incomplete_reason": "Complete the title, key, fields, and change reason first.",
"i18n:govoplan-forms.one_field_reason": "A Form definition must retain at least one field.",
"i18n:govoplan-forms.required_action": "Required action",
"i18n:govoplan-forms.responsible_actor": "Responsible actor",
"i18n:govoplan-forms.destination": "Destination",
"i18n:govoplan-forms.permission_action": "Ask for the corresponding Form definition permission.",
"i18n:govoplan-forms.permission_actor": "An Access or tenant administrator",
"i18n:govoplan-forms.permission_destination": "Access role assignments",
"i18n:govoplan-forms.unsaved_title": "Unsaved Form definition",
"i18n:govoplan-forms.unsaved_message": "Save or discard the Form definition draft before leaving this surface.",
"i18n:govoplan-forms.lifecycle_title": "Confirm definition lifecycle",
"i18n:govoplan-forms.lifecycle_message": "Save this revision as {state}? Existing submissions keep their exact definition revision.",
"Form definitions": "Form definitions",
"Search definitions": "Search definitions",
"Search Form definitions": "Search Form definitions",
"Search": "Search",
"State": "State",
"All": "All",
"Draft": "Draft",
"Published": "Published",
"Retired": "Retired",
"Refresh definitions": "Refresh definitions",
"Import Form package": "Import Form package",
"New definition": "New definition",
"Loading Form definitions": "Loading Form definitions",
"No matching definitions.": "No matching definitions.",
"Assessment": "Assessment",
"Cancel": "Cancel",
"Importing": "Importing",
"Import as draft": "Import as draft",
"Change reason": "Change reason",
"New Form definition": "New Form definition",
"Saving": "Saving",
"Save revision": "Save revision",
"Title": "Title",
"Key": "Key",
"Description": "Description",
"Publication state": "Publication state",
"Signature": "Signature",
"Not used": "Not used",
"Optional": "Optional",
"Required": "Required",
"Maximum attachments": "Maximum attachments",
"Draft saving": "Draft saving",
"Policy references": "Policy references",
"Permitted handoffs": "Permitted handoffs",
"Case": "Case",
"Workflow": "Workflow",
"Record": "Record",
"Accessibility instructions": "Accessibility instructions",
"Fields": "Fields",
"Add field": "Add field",
"Label": "Label",
"Type": "Type",
"Help text": "Help text",
"Options": "Options",
"Pages and sections": "Pages and sections",
"Enable pages": "Enable pages",
"Add page": "Add page",
"Add section": "Add section",
"Localizations": "Localizations",
"Add locale": "Add locale",
"Preview and revision changes": "Preview and revision changes",
"No Form management permission": "No Form management permission"
} as const;
const de: Record<keyof typeof en, string> = {
"i18n:govoplan-forms.form_definitions": "Formulardefinitionen",
"i18n:govoplan-forms.loading_reason": "Formulardefinitionen werden noch geladen.",
"i18n:govoplan-forms.busy_reason": "Eine andere Aktion für Formulardefinitionen läuft noch.",
"i18n:govoplan-forms.write_permission_reason": "Ihr Konto darf Formulardefinitionen nicht erstellen, importieren oder überarbeiten.",
"i18n:govoplan-forms.admin_permission_reason": "Veröffentlichung und Stilllegung erfordern die Formularadministration.",
"i18n:govoplan-forms.lifecycle_reason": "Stillgelegte Definitionen können nicht überarbeitet werden; veröffentlichte Definitionen erfordern administrative Berechtigung.",
"i18n:govoplan-forms.incomplete_reason": "Füllen Sie zuerst Titel, Schlüssel, Felder und Änderungsgrund aus.",
"i18n:govoplan-forms.one_field_reason": "Eine Formulardefinition muss mindestens ein Feld behalten.",
"i18n:govoplan-forms.required_action": "Erforderliche Aktion",
"i18n:govoplan-forms.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-forms.destination": "Ziel",
"i18n:govoplan-forms.permission_action": "Fordern Sie die entsprechende Berechtigung für Formulardefinitionen an.",
"i18n:govoplan-forms.permission_actor": "Eine Zugriffs- oder Mandantenadministration",
"i18n:govoplan-forms.permission_destination": "Zugriff und Rollenzuweisungen",
"i18n:govoplan-forms.unsaved_title": "Ungespeicherte Formulardefinition",
"i18n:govoplan-forms.unsaved_message": "Speichern oder verwerfen Sie den Entwurf der Formulardefinition, bevor Sie diese Oberfläche verlassen.",
"i18n:govoplan-forms.lifecycle_title": "Definitionslebenszyklus bestätigen",
"i18n:govoplan-forms.lifecycle_message": "Diese Revision als {state} speichern? Bestehende Einreichungen behalten ihre exakte Definitionsrevision.",
"Form definitions": "Formulardefinitionen",
"Search definitions": "Definitionen suchen",
"Search Form definitions": "Formulardefinitionen durchsuchen",
"Search": "Suchen",
"State": "Status",
"All": "Alle",
"Draft": "Entwurf",
"Published": "Veröffentlicht",
"Retired": "Stillgelegt",
"Refresh definitions": "Definitionen aktualisieren",
"Import Form package": "Formularpaket importieren",
"New definition": "Neue Definition",
"Loading Form definitions": "Formulardefinitionen werden geladen",
"No matching definitions.": "Keine passenden Definitionen.",
"Assessment": "Bewertung",
"Cancel": "Abbrechen",
"Importing": "Importieren",
"Import as draft": "Als Entwurf importieren",
"Change reason": "Änderungsgrund",
"New Form definition": "Neue Formulardefinition",
"Saving": "Speichern",
"Save revision": "Revision speichern",
"Title": "Titel",
"Key": "Schlüssel",
"Description": "Beschreibung",
"Publication state": "Veröffentlichungsstatus",
"Signature": "Signatur",
"Not used": "Nicht verwendet",
"Optional": "Optional",
"Required": "Erforderlich",
"Maximum attachments": "Maximale Anhänge",
"Draft saving": "Entwurfsspeicherung",
"Policy references": "Richtlinienreferenzen",
"Permitted handoffs": "Zulässige Übergaben",
"Case": "Fall",
"Workflow": "Workflow",
"Record": "Datensatz",
"Accessibility instructions": "Hinweise zur Barrierefreiheit",
"Fields": "Felder",
"Add field": "Feld hinzufügen",
"Label": "Bezeichnung",
"Type": "Typ",
"Help text": "Hilfetext",
"Options": "Optionen",
"Pages and sections": "Seiten und Abschnitte",
"Enable pages": "Seiten aktivieren",
"Add page": "Seite hinzufügen",
"Add section": "Abschnitt hinzufügen",
"Localizations": "Lokalisierungen",
"Add locale": "Sprache hinzufügen",
"Preview and revision changes": "Vorschau und Revisionsänderungen",
"No Form management permission": "Keine Berechtigung zur Formularverwaltung"
};
export const generatedTranslations: PlatformTranslations = { en, de };
+2
View File
@@ -0,0 +1,2 @@
export { default, formsModule } from "./module";
export * from "./api/forms";
+41
View File
@@ -0,0 +1,41 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/forms.css";
const FormsPage = lazy(() => import("./features/forms/FormsPage"));
export const formsModule: PlatformWebModule = {
id: "forms",
label: "i18n:govoplan-forms.form_definitions",
version: "0.1.21",
dependencies: ["access"],
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy", "docs"],
translations: generatedTranslations,
routes: [
{
path: "/forms",
anyOf: ["forms:definition:read"],
order: 36,
surfaceId: "forms.catalogue",
render: (context) => createElement(FormsPage, context)
}
],
navItems: [
{
to: "/forms",
label: "i18n:govoplan-forms.form_definitions",
iconName: "list-tree",
anyOf: ["forms:definition:read"],
order: 36,
surfaceId: "forms.navigation"
}
],
viewSurfaces: [
{ id: "forms.navigation", moduleId: "forms", kind: "navigation", label: "Form definitions navigation", order: 10 },
{ id: "forms.catalogue", moduleId: "forms", kind: "route", label: "Form definition catalogue", order: 20 }
]
};
export default formsModule;
+319
View File
@@ -0,0 +1,319 @@
.forms-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
.forms-search {
flex: 1 1 520px;
margin-right: auto;
}
.forms-toolbar > label {
display: flex;
align-items: center;
gap: 7px;
}
.forms-toolbar > label > span,
.forms-count {
color: var(--text-soft);
font-size: 0.82rem;
}
.forms-list-viewport {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.forms-list-viewport > .action-blocker-hint {
margin-bottom: 16px;
}
.forms-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-compact);
background: var(--surface-raised);
}
.forms-row {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto auto;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 9px 12px;
border-bottom: 1px solid var(--border);
}
.forms-row:last-child {
border-bottom: 0;
}
.forms-row:hover {
background: var(--hover-bg);
}
.forms-row > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.forms-row > span:first-child strong,
.forms-row > span:first-child small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forms-row > span:not(:first-child, .status-badge),
.forms-row small {
color: var(--text-soft);
font-size: 0.8rem;
}
.forms-row-actions {
display: flex;
align-items: center;
gap: 4px;
}
.forms-hidden-input {
display: none;
}
.forms-package-import {
display: flex;
flex-direction: column;
gap: 12px;
}
.forms-package-import p {
margin: 0;
color: var(--text-soft);
}
.form-definition-dialog {
width: min(1120px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
}
.form-definition-editor {
display: flex;
min-height: 0;
flex-direction: column;
gap: 18px;
}
.form-definition-help {
display: flex;
justify-content: flex-end;
}
.form-definition-wide {
grid-column: 1 / -1;
}
.form-definition-toggle {
display: flex;
align-items: end;
min-height: 58px;
}
.form-definition-handoffs {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px 18px;
padding: 10px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
}
.form-definition-handoffs > span {
margin-right: auto;
font-size: 0.84rem;
font-weight: 600;
}
.form-field-editor-list {
border-top: 1px solid var(--border);
}
.form-field-editor-row {
display: grid;
grid-template-columns: 36px minmax(120px, 0.8fr) minmax(160px, 1.1fr) minmax(130px, 0.8fr) minmax(100px, auto) 36px;
align-items: end;
gap: 9px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.form-field-order {
display: flex;
align-self: stretch;
flex-direction: column;
justify-content: center;
gap: 3px;
}
.form-field-order .icon-button {
width: 32px;
height: 32px;
}
.form-field-required {
display: flex;
align-items: center;
min-height: 38px;
}
.form-field-help,
.form-field-options,
.form-field-constraints,
.form-field-condition {
grid-column: 2 / -1;
}
.form-composition-section {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.form-field-editor-heading h3 {
display: inline-flex;
align-items: center;
gap: 7px;
}
.form-section-note {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.form-page-editor,
.form-localization-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.form-page-editor-heading,
.form-section-editor,
.form-localization-editor {
display: grid;
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1fr) auto;
align-items: end;
gap: 9px;
}
.form-section-editor {
grid-template-columns: minmax(120px, 0.7fr) minmax(160px, 1fr) minmax(220px, 1.5fr) auto;
padding-left: 18px;
}
.form-section-editor select[multiple] {
min-height: 84px;
}
.form-localization-editor {
grid-template-columns: minmax(100px, 0.5fr) minmax(200px, 1fr) auto auto;
}
.form-localization-fallback {
display: flex;
align-items: center;
gap: 6px;
min-height: 38px;
}
.form-localization-fields {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.form-preview-grid {
min-width: 0;
}
.form-preview-grid > div {
min-width: 0;
}
.form-preview-grid ul {
margin: 7px 0 0;
padding-left: 18px;
}
.form-preview-page {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 8px;
}
.form-preview-page small {
color: var(--text-soft);
}
@media (max-width: 900px) {
.forms-row {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.forms-row > span:nth-child(2),
.forms-row > span:nth-child(3) {
display: none;
}
.form-field-editor-row {
grid-template-columns: 36px minmax(0, 1fr) 36px;
}
.form-field-editor-row > .form-field {
grid-column: 2;
}
.form-field-order {
grid-column: 1;
grid-row: 1 / span 4;
}
.form-field-required,
.form-field-help,
.form-field-options,
.form-field-constraints,
.form-field-condition {
grid-column: 2;
}
.form-field-editor-row > .icon-button:last-child {
grid-column: 3;
grid-row: 1;
}
}
@media (max-width: 680px) {
.form-page-editor-heading,
.form-section-editor,
.form-localization-editor,
.form-localization-fields {
grid-template-columns: 1fr;
}
.form-definition-wide {
grid-column: auto;
}
}