feat: implement immutable form definitions

This commit is contained in:
2026-08-01 17:48:34 +02:00
parent 5825c2c8e4
commit 343a208894
25 changed files with 2072 additions and 20 deletions
+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
```
+25 -6
View File
@@ -4,12 +4,31 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-forms` owns reusable form definitions, validation rules, and form `govoplan-forms` owns reusable, immutable form definitions, validation rules,
package fragments. Submission runtime behavior is a separate responsibility and form package fragments. Submission runtime behavior remains in
that should live in `govoplan-forms-runtime` when implemented. `govoplan-forms-runtime`.
This repository is currently a tag-only scaffold. It should gain package The module persists exact tenant-bound revisions, exposes bounded catalogue,
metadata and module manifests only after the first backend or WebUI slice is history, and write APIs, and provides `forms.definitions` for consumers. A
designed. 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.
Definition writes use optimistic concurrency. Replaying the same object and
revision is safe only when the payload is identical. Published definitions may
be retired but not silently returned to draft.
Focused verification:
```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
```
See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision. See [docs/FORMS_BOUNDARY.md](docs/FORMS_BOUNDARY.md) for the boundary decision.
+20 -14
View File
@@ -18,7 +18,7 @@ Forms owns:
- schema contracts that let portal, workflow, cases, and reporting understand a - schema contracts that let portal, workflow, cases, and reporting understand a
form without importing form internals form without importing form internals
`govoplan-forms-runtime` owns, when implemented: `govoplan-forms-runtime` owns:
- public/internal submission sessions, drafts, receipts, submitted values, - public/internal submission sessions, drafts, receipts, submitted values,
validation evidence, attachment references, and submission status validation evidence, attachment references, and submission status
@@ -62,19 +62,25 @@ Form definitions should carry:
- localization keys and fallback text - localization keys and fallback text
- data classification for privacy/retention decisions - data classification for privacy/retention decisions
## Candidate Capabilities ## Capabilities
- `forms.catalog` - `forms.definitions` resolves an exact, tenant-bound immutable definition.
- `forms.schema` - The API provides bounded catalogue and history reads plus OCC-guarded writes.
- `forms.validation` - The designer provides explicit revision, publication, field-order, type,
- `forms.packageFragments` option, constraint, draft, attachment, signature, policy, and handoff editing.
- `forms.runtime` when runtime is installed - Forms Runtime performs value validation against the resolved definition; the
definition owner does not persist submissions.
## First Implementation Slice ## Recovery And Operations
1. Define manifest metadata, permissions, and capability names. Definition revisions are append-only. Recovery restores the database and then
2. Add form definition/version DTOs. verifies that every runtime submission's `form_id` and `form_revision` resolves
3. Add validation metadata for one reusable form schema. to the same payload. Destructive module retirement requires a verified database
4. Add package fragment import/export format. snapshot and is blocked while definition rows remain. The transactional event
5. Add tests that portal/workflow/reporting can detect form capabilities boundary emits only schema identity, revision, publication state, and field
without importing form internals. count; field content remains in the owning database.
The schema vocabulary covers scalar, choice, structured, attachment, signature,
policy-reference, and handoff constraints. Conditional page layout,
localization authoring, and package-fragment tooling remain product depth that
can deepen this owner without changing the runtime boundary.
+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.14"
description = "Immutable reusable form definitions for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14", "govoplan-access>=0.1.8"]
[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.14"
+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"]
+214
View File
@@ -0,0 +1,214 @@
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.14"
READ_SCOPE = "forms:definition:read"
WRITE_SCOPE = "forms:definition:write"
ADMIN_SCOPE = "forms:definition:admin"
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=("forms_runtime", "portal", "workflow_engine", "cases", "policy"),
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",
),
),
),
),
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=(
"Conditional multi-page layout, localization authoring, and package-fragment tooling 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")
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
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,
)
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
definition_from_mapping,
form_definition_history,
get_form_definition,
list_form_definitions,
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}/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"]
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
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]]
__all__ = [
"FormDefinitionHistoryResponse",
"FormDefinitionListResponse",
"FormDefinitionWriteRequest",
]
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
from datetime import UTC, datetime
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,
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 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."
)
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 @@
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
FormDefinition,
FormFieldDefinition,
InstitutionalReference,
TemporalRevision,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import (
FormDefinitionStoreError,
SqlFormDefinitionProvider,
form_definition_history,
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",
)
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.14",
"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.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+71
View File
@@ -0,0 +1,71 @@
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 FormFieldDefinition = {
key: string;
label: string;
value_type: FormValueType;
required: boolean;
help_text?: string | null;
options: string[];
constraints: Record<string, unknown>;
default_value?: unknown;
};
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[];
metadata: 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
})
});
}
@@ -0,0 +1,330 @@
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
Dialog,
DismissibleAlert,
FormField as Field,
IconButton,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
saveFormDefinition,
type FormDefinition,
type FormFieldDefinition,
type FormValueType
} from "../../api/forms";
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 [draft, setDraft] = useState<FormDefinition>(() => initialDraft(tenantId, definition));
const [changeReason, setChangeReason] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setDraft(initialDraft(tenantId, definition));
setChangeReason("");
setBusy(false);
setError("");
}, [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]);
async function save() {
if (!valid) return;
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);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form definition could not be saved.");
} finally {
setBusy(false);
}
}
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
setDraft((current) => ({
...current,
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field)
}));
}
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={onClose}
closeDisabled={busy}
portal
className="form-definition-dialog"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void save()} disabled={busy || !valid}>
{busy ? "Saving" : "Save revision"}
</Button>
</>
}>
<div className="form-definition-editor">
{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">
<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">
<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">
<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>
</div>
<div className="form-field-editor-heading">
<h3>Fields</h3>
<Button onClick={() => setDraft({ ...draft, fields: [...draft.fields, emptyField(draft.fields.length + 1)] })} 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>
}
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
<IconButton
label={`Remove ${field.label || "field"}`}
icon={<Trash2 size={16} />}
variant="danger"
disabled={busy || draft.fields.length === 1}
onClick={() => setDraft({ ...draft, fields: draft.fields.filter((_, fieldIndex) => fieldIndex !== index) })}
/>
</div>
)}
</div>
<Field label="Change reason">
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
</Field>
</div>
</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 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: [],
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 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());
}
+126
View File
@@ -0,0 +1,126 @@
import { Pencil, Plus, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import {
Button,
DismissibleAlert,
IconButton,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
hasScope,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { listFormDefinitions, type FormDefinition } from "../../api/forms";
import FormDefinitionDialog from "./FormDefinitionDialog";
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 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());
}
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} />
{canWrite && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
<span className="forms-count">{total}</span>
</div>
<PageScrollViewport className="forms-list-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{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)} />
{mayRevise
? <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />
: <span className="forms-row-action-spacer" />}
</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."));
}}
/>
}
</main>
);
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, formsModule } from "./module";
export * from "./api/forms";
+39
View File
@@ -0,0 +1,39 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/forms.css";
const FormsPage = lazy(() => import("./features/forms/FormsPage"));
export const formsModule: PlatformWebModule = {
id: "forms",
label: "Form definitions",
version: "0.1.14",
dependencies: ["access"],
optionalDependencies: ["forms_runtime", "portal", "workflow_engine", "cases", "policy"],
routes: [
{
path: "/forms",
anyOf: ["forms:definition:read"],
order: 36,
surfaceId: "forms.catalogue",
render: (context) => createElement(FormsPage, context)
}
],
navItems: [
{
to: "/forms",
label: "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;
+265
View File
@@ -0,0 +1,265 @@
.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 {
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 36px;
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-action-spacer {
width: 36px;
}
.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-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 {
grid-column: 2 / -1;
}
.form-field-constraints {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 9px;
}
@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 {
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 {
grid-template-columns: 1fr;
}
.form-definition-wide {
grid-column: auto;
}
}