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).
|
**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
@@ -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.
|
||||||
|
|||||||
@@ -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