feat: implement immutable form definitions
This commit is contained in:
@@ -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
|
||||
```
|
||||
@@ -4,12 +4,31 @@
|
||||
**Repository type:** module (domain).
|
||||
<!-- govoplan-repository-type:end -->
|
||||
|
||||
`govoplan-forms` owns reusable form definitions, validation rules, and form
|
||||
package fragments. Submission runtime behavior is a separate responsibility
|
||||
that should live in `govoplan-forms-runtime` when implemented.
|
||||
`govoplan-forms` owns reusable, immutable form definitions, validation rules,
|
||||
and form package fragments. Submission runtime behavior remains in
|
||||
`govoplan-forms-runtime`.
|
||||
|
||||
This repository is currently a tag-only scaffold. It should gain package
|
||||
metadata and module manifests only after the first backend or WebUI slice is
|
||||
designed.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
+20
-14
@@ -18,7 +18,7 @@ Forms owns:
|
||||
- schema contracts that let portal, workflow, cases, and reporting understand a
|
||||
form without importing form internals
|
||||
|
||||
`govoplan-forms-runtime` owns, when implemented:
|
||||
`govoplan-forms-runtime` owns:
|
||||
|
||||
- public/internal submission sessions, drafts, receipts, submitted values,
|
||||
validation evidence, attachment references, and submission status
|
||||
@@ -62,19 +62,25 @@ Form definitions should carry:
|
||||
- localization keys and fallback text
|
||||
- data classification for privacy/retention decisions
|
||||
|
||||
## Candidate Capabilities
|
||||
## Capabilities
|
||||
|
||||
- `forms.catalog`
|
||||
- `forms.schema`
|
||||
- `forms.validation`
|
||||
- `forms.packageFragments`
|
||||
- `forms.runtime` when runtime is installed
|
||||
- `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.
|
||||
|
||||
## First Implementation Slice
|
||||
## Recovery And Operations
|
||||
|
||||
1. Define manifest metadata, permissions, and capability names.
|
||||
2. Add form definition/version DTOs.
|
||||
3. Add validation metadata for one reusable form schema.
|
||||
4. Add package fragment import/export format.
|
||||
5. Add tests that portal/workflow/reporting can detect form capabilities
|
||||
without importing form internals.
|
||||
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 layout,
|
||||
localization authoring, and package-fragment tooling remain product depth that
|
||||
can deepen this owner without changing the runtime boundary.
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN Forms module."""
|
||||
|
||||
__version__ = "0.1.14"
|
||||
@@ -0,0 +1 @@
|
||||
"""Forms backend package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Forms database models."""
|
||||
@@ -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"]
|
||||
@@ -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."""
|
||||
+77
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, formsModule } from "./module";
|
||||
export * from "./api/forms";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user