10 Commits
Author SHA1 Message Date
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
zemion ad847cc075 Add shared GovOPlaN gitignore 2026-07-10 21:57:26 +02:00
zemion d5231b85a3 chore: sync GovOPlaN module split state 2026-07-10 12:51:22 +02:00
31 changed files with 4476 additions and 0 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
+348
View File
@@ -0,0 +1,348 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/
+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
```
+41
View File
@@ -0,0 +1,41 @@
# GovOPlaN Forms
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`govoplan-forms` owns reusable, immutable form definitions, validation rules,
and form package fragments. Submission runtime behavior remains in
`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.
+89
View File
@@ -0,0 +1,89 @@
# Forms Module Boundary
`govoplan-forms` owns reusable form definitions and validation metadata.
Runtime submissions and workflow handoff are separate behavior.
The core boundary decision register is in
`/mnt/DATA/git/govoplan-core/docs/MODULE_ARCHITECTURE.md`.
## Ownership
Forms owns:
- form definitions, field schemas, sections, validation rules, and visibility
rules
- localization, accessibility metadata, versioning, draft/published lifecycle,
and admin editing
- form import/export and configuration-package fragments
- schema contracts that let portal, workflow, cases, and reporting understand a
form without importing form internals
`govoplan-forms-runtime` owns:
- public/internal submission sessions, drafts, receipts, submitted values,
validation evidence, attachment references, and submission status
- submission handoff events to workflow, cases, tasks, mail, reporting, files,
DMS, or portal
## Boundaries
Forms does not own:
- public portal identity and anonymous/authenticated public entry points
- file storage and file permissions for uploaded attachments
- cases, workflow transitions, tasks, or assignment state
- DMS document lifecycle and generated record storage
- reporting aggregation or dataflow transformation
## Submission Status Model
Candidate runtime statuses:
- `draft`
- `submitted`
- `validated`
- `needs_review`
- `accepted`
- `rejected`
- `handed_off`
- `archived`
The definition module can declare valid transitions and required evidence, but
runtime state belongs to the runtime module.
## Accessibility And Validation Requirements
Form definitions should carry:
- labels, descriptions, required markers, and help text
- machine-readable validation rules
- field grouping and ordering
- keyboard and screen-reader metadata where needed
- localization keys and fallback text
- data classification for privacy/retention decisions
## Capabilities
- `forms.definitions` resolves an exact, tenant-bound immutable definition.
- The API provides bounded catalogue and history reads plus OCC-guarded writes.
- The designer provides explicit revision, publication, field-order, type,
option, constraint, draft, attachment, signature, policy, and handoff editing.
- Forms Runtime performs value validation against the resolved definition; the
definition owner does not persist submissions.
## Recovery And Operations
Definition revisions are append-only. Recovery restores the database and then
verifies that every runtime submission's `form_id` and `form_revision` resolves
to the same payload. Destructive module retirement requires a verified database
snapshot and is blocked while definition rows remain. The transactional event
boundary emits only schema identity, revision, publication state, and field
count; field content remains in the owning database.
The schema vocabulary covers scalar, choice, structured, attachment, signature,
policy-reference, and handoff constraints. Conditional page/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.
+33
View File
@@ -0,0 +1,33 @@
# 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. Existing responsive list/editor layouts remain bounded. English and
German catalogues cover the owned route and editor vocabulary.
+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.15"
description = "Immutable reusable form definitions for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.15", "govoplan-access>=0.1.15"]
[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.15"
+1
View File
@@ -0,0 +1 @@
"""Forms backend package."""
@@ -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"]
+297
View File
@@ -0,0 +1,297 @@
from __future__ import annotations
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,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_forms.backend.db import models as form_models
from govoplan_forms.backend.service import SqlFormDefinitionProvider
MODULE_ID = "forms"
MODULE_NAME = "Forms"
MODULE_VERSION = "0.1.15"
READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin"
OPTIONAL_DEPENDENCIES = (
"forms_runtime",
"portal",
"workflow_engine",
"cases",
"policy",
)
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()
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"),
),
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,
),
),
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},
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",
)
},
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.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."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
links=(
DocumentationLink(
label="Forms boundary and recovery",
href="govoplan-forms/docs/FORMS_BOUNDARY.md",
kind="repository",
),
),
metadata={
"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."
),
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",
),
),
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.",
},
},
),
),
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, richer authoring ergonomics, and target-produced accessibility evidence remain product depth.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=("form definition", "form schema", "form definition revision"),
non_owned_concepts=(
"form submission",
"file content",
"case",
"workflow instance",
),
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",),
),
)
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",
]
+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 @@
+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,30 @@
from __future__ import annotations
import unittest
from govoplan_forms.backend.manifest import manifest
class FormsInterfaceDocumentationContractTests(unittest.TestCase):
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.assertIn("forms.field.publication-state", reference.metadata["help_contexts"])
self.assertIn("publish", reference.metadata["consequence_classes"])
self.assertIn("import_package", reference.metadata["consequence_classes"])
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()
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@govoplan/forms-webui",
"version": "0.1.15",
"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.15",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"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,702 @@
import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField as Field,
IconButton,
ToggleSwitch,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type ApiSettings
} 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]);
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} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="form-definition-grid">
<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>
</div>
<div className="form-field-editor-heading">
<h3>Fields</h3>
<Button onClick={() => setDraft(addField(draft))} disabled={busy}>
<Plus size={16} aria-hidden="true" />Add field
</Button>
</div>
<div className="form-field-editor-list">
{draft.fields.map((field, index) =>
<div className="form-field-editor-row" 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"><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))}
/>
</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 (
<div 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>
</div>
);
}
if (["integer", "number"].includes(field.value_type)) {
return (
<div 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>
</div>
);
}
return null;
}
function ConditionFields({
condition,
fields,
currentKey,
disabled,
onChange
}: {
condition: FormCondition | null;
fields: FormFieldDefinition[];
currentKey: string;
disabled: boolean;
onChange: (value: FormCondition | null) => void;
}) {
const predicate = condition?.kind === "predicate" ? condition : null;
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
return (
<div className="form-field-condition">
<Field label="Visible when">
<select
value={predicate?.field_key ?? ""}
disabled={disabled || candidates.length === 0}
onChange={(event) => onChange(event.target.value
? { kind: "predicate", field_key: event.target.value, operator: "eq", value: true }
: null)}>
<option value="">Always visible</option>
{candidates.map((item) => <option key={item.key} value={item.key}>{item.label || item.key}</option>)}
</select>
</Field>
{predicate && <>
<Field label="Condition">
<select
value={predicate.operator}
disabled={disabled}
onChange={(event) => onChange({
...predicate,
operator: event.target.value as Extract<FormCondition, { kind: "predicate" }>["operator"],
...(event.target.value === "is_empty" || event.target.value === "is_not_empty" ? { value: undefined } : {})
})}>
<option value="eq">Equals</option>
<option value="neq">Does not equal</option>
<option value="is_empty">Is empty</option>
<option value="is_not_empty">Is not empty</option>
<option value="contains">Contains</option>
</select>
</Field>
{!(["is_empty", "is_not_empty"] as string[]).includes(predicate.operator) &&
<Field label="Value">
<input
value={conditionInputValue(predicate.value)}
disabled={disabled}
onChange={(event) => onChange({ ...predicate, value: parseConditionValue(event.target.value, fields.find((item) => item.key === predicate.field_key)?.value_type) })}
/>
</Field>
}
</>}
</div>
);
}
function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
const pages = draft.pages ?? [];
function updatePages(next: FormPageDefinition[]) {
onChange({ ...draft, pages: next });
}
return (
<section className="form-composition-section">
<div className="form-field-editor-heading">
<h3>Pages and sections</h3>
{pages.length === 0
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
</div>
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
{pages.map((page, pageIndex) =>
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
<div className="form-page-editor-heading">
<Field label="Page key"><input value={page.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, key: event.target.value }))} /></Field>
<Field label="Page title"><input value={page.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, title: event.target.value }))} /></Field>
<IconButton label={`Remove page ${page.title || page.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => updatePages(pages.filter((_, index) => index !== pageIndex))} />
</div>
{page.sections.map((section, sectionIndex) =>
<div className="form-section-editor" key={`${sectionIndex}:${section.key}`}>
<Field label="Section key"><input value={section.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, key: event.target.value }) }))} /></Field>
<Field label="Section title"><input value={section.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, title: event.target.value }) }))} /></Field>
<Field label="Fields">
<select
multiple
value={section.field_keys}
disabled={disabled}
onChange={(event) => updatePages(replaceAt(pages, pageIndex, {
...page,
sections: replaceAt(page.sections, sectionIndex, {
...section,
field_keys: Array.from(event.currentTarget.selectedOptions, (option) => option.value)
})
}))}>
{draft.fields.map((field) => <option key={field.key} value={field.key}>{field.label || field.key}</option>)}
</select>
</Field>
<IconButton label={`Remove section ${section.title || section.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled || page.sections.length === 1} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: page.sections.filter((_, index) => index !== sectionIndex) }))} />
</div>
)}
<Button disabled={disabled} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: [...page.sections, emptySection(page.sections.length + 1)] }))}><Plus size={15} aria-hidden="true" />Add section</Button>
</div>
)}
</section>
);
}
function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
const localizations = draft.localizations ?? [];
function update(items: FormLocalization[]) {
const fallback = items.some((item) => item.locale === draft.fallback_locale)
? draft.fallback_locale
: items[0]?.locale ?? null;
onChange({ ...draft, localizations: items, fallback_locale: fallback });
}
return (
<section className="form-composition-section">
<div className="form-field-editor-heading">
<h3><Languages size={17} aria-hidden="true" />Localizations</h3>
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
</div>
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
{localizations.map((localization, index) =>
<div className="form-localization-editor" key={`${index}:${localization.locale}`}>
<Field label="Locale"><input value={localization.locale} disabled={disabled} placeholder="de" onChange={(event) => update(replaceAt(localizations, index, { ...localization, locale: event.target.value }))} /></Field>
<Field label="Localized title"><input value={localization.title ?? ""} disabled={disabled} onChange={(event) => update(replaceAt(localizations, index, { ...localization, title: event.target.value }))} /></Field>
<label className="form-localization-fallback"><input type="radio" checked={draft.fallback_locale === localization.locale} disabled={disabled || !localization.locale} onChange={() => onChange({ ...draft, fallback_locale: localization.locale })} />Fallback</label>
<IconButton label={`Remove locale ${localization.locale || index + 1}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => update(localizations.filter((_, itemIndex) => itemIndex !== index))} />
<div className="form-localization-fields">
{draft.fields.map((field) =>
<Field key={field.key} label={`${field.label || field.key} label`}>
<input
value={localization.field_labels[field.key] ?? ""}
disabled={disabled}
onChange={(event) => update(replaceAt(localizations, index, {
...localization,
field_labels: patchOptionalText(localization.field_labels, field.key, event.target.value)
}))}
/>
</Field>
)}
</div>
</div>
)}
</section>
);
}
function DefinitionPreview({ definition, previous }: { definition: FormDefinition; previous: FormDefinition | null }) {
const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
return (
<section className="form-composition-section form-definition-preview">
<div className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></div>
<div className="form-preview-grid">
<div>
<strong>{definition.title || "Untitled Form"}</strong>
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
<div key={page.key} className="form-preview-page">
<span>{page.title}</span>
{page.sections.map((section) => <small key={section.key}>{section.title}: {section.field_keys.join(", ") || "No fields"}</small>)}
</div>
)}
</div>
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
</div>
</section>
);
}
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
if (definition) return structuredClone(definition);
const id = crypto.randomUUID();
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());
}
+231
View File
@@ -0,0 +1,231 @@
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import {
ActionBlockerHint,
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
FormField,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
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 [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]);
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">
<div className="forms-shell">
<div className="forms-toolbar">
<form 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>
</form>
<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="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_I18N.loading : undefined} />
<IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} />
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>
<span className="forms-count">{total}</span>
<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />
</div>
<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 && <div className="forms-empty">No matching definitions.</div>}
{!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>
</div>
{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.14",
dependencies: ["access"],
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy"],
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;
+391
View File
@@ -0,0 +1,391 @@
.forms-page,
.forms-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.forms-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.forms-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.forms-search {
display: flex;
align-items: center;
gap: 8px;
width: min(520px, 100%);
margin-right: auto;
}
.forms-search input {
min-width: 120px;
flex: 1;
}
.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: 6px;
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);
}
.forms-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.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-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.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-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.form-field-editor-heading h3 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-field-editor-list {
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-field-constraints,
.form-field-condition {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
.form-composition-section {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.form-field-editor-heading h3 {
display: inline-flex;
align-items: center;
gap: 7px;
}
.form-section-note {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.form-page-editor,
.form-localization-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.form-page-editor-heading,
.form-section-editor,
.form-localization-editor {
display: grid;
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1fr) auto;
align-items: end;
gap: 9px;
}
.form-section-editor {
grid-template-columns: minmax(120px, 0.7fr) minmax(160px, 1fr) minmax(220px, 1.5fr) auto;
padding-left: 18px;
}
.form-section-editor select[multiple] {
min-height: 84px;
}
.form-localization-editor {
grid-template-columns: minmax(100px, 0.5fr) minmax(200px, 1fr) auto auto;
}
.form-localization-fallback {
display: flex;
align-items: center;
gap: 6px;
min-height: 38px;
}
.form-localization-fields {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.form-preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.form-preview-grid > div {
min-width: 0;
}
.form-preview-grid ul {
margin: 7px 0 0;
padding-left: 18px;
}
.form-preview-page {
display: flex;
flex-direction: column;
gap: 3px;
margin-top: 8px;
}
.form-preview-page small {
color: var(--text-soft);
}
@media (max-width: 900px) {
.forms-toolbar {
align-items: stretch;
flex-wrap: wrap;
}
.forms-search {
width: 100%;
}
.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: 620px) {
.form-definition-grid,
.form-field-constraints,
.form-field-condition,
.form-page-editor-heading,
.form-section-editor,
.form-localization-editor,
.form-localization-fields,
.form-preview-grid {
grid-template-columns: 1fr;
}
.form-definition-wide {
grid-column: auto;
}
}