Add conditional localized form definitions
This commit is contained in:
@@ -3,6 +3,11 @@ import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type FormValueType = "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
|
||||
|
||||
export type FormCondition =
|
||||
| { kind: "predicate"; field_key: string; operator: "eq" | "neq" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "is_empty" | "is_not_empty"; value?: unknown }
|
||||
| { kind: "all" | "any"; conditions: FormCondition[] }
|
||||
| { kind: "not"; conditions: [FormCondition] };
|
||||
|
||||
export type FormFieldDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -12,6 +17,35 @@ export type FormFieldDefinition = {
|
||||
options: string[];
|
||||
constraints: Record<string, unknown>;
|
||||
default_value?: unknown;
|
||||
visibility_condition?: FormCondition | null;
|
||||
accessibility?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormSectionDefinition = {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
field_keys: string[];
|
||||
visibility_condition?: FormCondition | null;
|
||||
};
|
||||
|
||||
export type FormPageDefinition = {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
sections: FormSectionDefinition[];
|
||||
visibility_condition?: FormCondition | null;
|
||||
};
|
||||
|
||||
export type FormLocalization = {
|
||||
locale: string;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
field_labels: Record<string, string>;
|
||||
field_help_texts: Record<string, string>;
|
||||
option_labels: Record<string, Record<string, string>>;
|
||||
page_titles: Record<string, string>;
|
||||
section_titles: Record<string, string>;
|
||||
};
|
||||
|
||||
export type FormDefinition = {
|
||||
@@ -40,9 +74,21 @@ export type FormDefinition = {
|
||||
signature_requirement: "none" | "optional" | "required";
|
||||
policy_refs: string[];
|
||||
handoff_kinds: string[];
|
||||
pages?: FormPageDefinition[];
|
||||
fallback_locale?: string | null;
|
||||
localizations?: FormLocalization[];
|
||||
accessibility?: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type FormPackageFragment = {
|
||||
kind: "govoplan.forms.definition";
|
||||
contract_version: "0.1.0";
|
||||
definition: FormDefinition;
|
||||
definition_sha256: string;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function listFormDefinitions(
|
||||
settings: ApiSettings,
|
||||
options: { query?: string; states?: string[]; offset?: number; limit?: number } = {},
|
||||
@@ -69,3 +115,39 @@ export function saveFormDefinition(
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function exportFormDefinitionPackage(
|
||||
settings: ApiSettings,
|
||||
formId: string,
|
||||
revision: string
|
||||
): Promise<FormPackageFragment> {
|
||||
return apiFetch(settings, apiPath(`/api/v1/forms/definitions/${encodeURIComponent(formId)}/package`, { revision }));
|
||||
}
|
||||
|
||||
export function assessFormDefinitionPackage(
|
||||
settings: ApiSettings,
|
||||
fragment: FormPackageFragment
|
||||
): Promise<{ outcome: string; requires_remap: boolean; current_revision?: string | null }> {
|
||||
return apiFetch(settings, "/api/v1/forms/packages/assess", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ fragment })
|
||||
});
|
||||
}
|
||||
|
||||
export function importFormDefinitionPackage(
|
||||
settings: ApiSettings,
|
||||
fragment: FormPackageFragment,
|
||||
options: { targetFormId?: string; targetKey?: string; expectedRevision?: string; changeReason: string }
|
||||
): Promise<FormDefinition> {
|
||||
return apiFetch(settings, "/api/v1/forms/packages/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
fragment,
|
||||
target_form_id: options.targetFormId || null,
|
||||
target_key: options.targetKey || null,
|
||||
expected_revision: options.expectedRevision || null,
|
||||
change_reason: options.changeReason,
|
||||
recorded_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Eye, Languages, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
import {
|
||||
saveFormDefinition,
|
||||
type FormDefinition,
|
||||
type FormCondition,
|
||||
type FormFieldDefinition,
|
||||
type FormLocalization,
|
||||
type FormPageDefinition,
|
||||
type FormValueType
|
||||
} from "../../api/forms";
|
||||
|
||||
@@ -113,7 +116,10 @@ export default function FormDefinitionDialog({
|
||||
function patchField(index: number, patch: Partial<FormFieldDefinition>) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field)
|
||||
fields: current.fields.map((field, fieldIndex) => fieldIndex === index ? { ...field, ...patch } : field),
|
||||
pages: patch.key && patch.key !== current.fields[index].key
|
||||
? remapPageField(current.pages ?? [], current.fields[index].key, patch.key)
|
||||
: current.pages
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -195,11 +201,23 @@ export default function FormDefinitionDialog({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Field label="Accessibility instructions" className="form-definition-wide">
|
||||
<textarea
|
||||
rows={2}
|
||||
value={String(draft.accessibility?.instructions ?? "")}
|
||||
disabled={busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
accessibility: patchOptionalText(draft.accessibility ?? {}, "instructions", event.target.value)
|
||||
})}
|
||||
placeholder="Optional instructions announced before the Form"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="form-field-editor-heading">
|
||||
<h3>Fields</h3>
|
||||
<Button onClick={() => setDraft({ ...draft, fields: [...draft.fields, emptyField(draft.fields.length + 1)] })} disabled={busy}>
|
||||
<Button onClick={() => setDraft(addField(draft))} disabled={busy}>
|
||||
<Plus size={16} aria-hidden="true" />Add field
|
||||
</Button>
|
||||
</div>
|
||||
@@ -222,17 +240,27 @@ export default function FormDefinitionDialog({
|
||||
{isChoice(field.value_type) &&
|
||||
<Field label="Options" className="form-field-options"><input value={field.options.join(", ")} disabled={busy} onChange={(event) => patchField(index, { options: splitValues(event.target.value) })} /></Field>
|
||||
}
|
||||
<ConditionFields
|
||||
condition={field.visibility_condition ?? null}
|
||||
fields={draft.fields}
|
||||
currentKey={field.key}
|
||||
disabled={busy}
|
||||
onChange={(visibility_condition) => patchField(index, { visibility_condition })}
|
||||
/>
|
||||
<ConstraintFields field={field} disabled={busy} onChange={(constraints) => patchField(index, { constraints })} />
|
||||
<IconButton
|
||||
label={`Remove ${field.label || "field"}`}
|
||||
icon={<Trash2 size={16} />}
|
||||
variant="danger"
|
||||
disabled={busy || draft.fields.length === 1}
|
||||
onClick={() => setDraft({ ...draft, fields: draft.fields.filter((_, fieldIndex) => fieldIndex !== index) })}
|
||||
onClick={() => setDraft(removeField(draft, index))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PageEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||
<LocalizationEditor draft={draft} disabled={busy} onChange={setDraft} />
|
||||
<DefinitionPreview definition={draft} previous={definition} />
|
||||
<Field label="Change reason">
|
||||
<input value={changeReason} disabled={busy} maxLength={1000} onChange={(event) => setChangeReason(event.target.value)} placeholder="Why is this revision needed?" />
|
||||
</Field>
|
||||
@@ -262,6 +290,177 @@ function ConstraintFields({ field, disabled, onChange }: { field: FormFieldDefin
|
||||
return null;
|
||||
}
|
||||
|
||||
function ConditionFields({
|
||||
condition,
|
||||
fields,
|
||||
currentKey,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
condition: FormCondition | null;
|
||||
fields: FormFieldDefinition[];
|
||||
currentKey: string;
|
||||
disabled: boolean;
|
||||
onChange: (value: FormCondition | null) => void;
|
||||
}) {
|
||||
const predicate = condition?.kind === "predicate" ? condition : null;
|
||||
const candidates = fields.filter((item) => item.key !== currentKey && item.key.trim());
|
||||
return (
|
||||
<div className="form-field-condition">
|
||||
<Field label="Visible when">
|
||||
<select
|
||||
value={predicate?.field_key ?? ""}
|
||||
disabled={disabled || candidates.length === 0}
|
||||
onChange={(event) => onChange(event.target.value
|
||||
? { kind: "predicate", field_key: event.target.value, operator: "eq", value: true }
|
||||
: null)}>
|
||||
<option value="">Always visible</option>
|
||||
{candidates.map((item) => <option key={item.key} value={item.key}>{item.label || item.key}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{predicate && <>
|
||||
<Field label="Condition">
|
||||
<select
|
||||
value={predicate.operator}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({
|
||||
...predicate,
|
||||
operator: event.target.value as Extract<FormCondition, { kind: "predicate" }>["operator"],
|
||||
...(event.target.value === "is_empty" || event.target.value === "is_not_empty" ? { value: undefined } : {})
|
||||
})}>
|
||||
<option value="eq">Equals</option>
|
||||
<option value="neq">Does not equal</option>
|
||||
<option value="is_empty">Is empty</option>
|
||||
<option value="is_not_empty">Is not empty</option>
|
||||
<option value="contains">Contains</option>
|
||||
</select>
|
||||
</Field>
|
||||
{!(["is_empty", "is_not_empty"] as string[]).includes(predicate.operator) &&
|
||||
<Field label="Value">
|
||||
<input
|
||||
value={conditionInputValue(predicate.value)}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange({ ...predicate, value: parseConditionValue(event.target.value, fields.find((item) => item.key === predicate.field_key)?.value_type) })}
|
||||
/>
|
||||
</Field>
|
||||
}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||
const pages = draft.pages ?? [];
|
||||
function updatePages(next: FormPageDefinition[]) {
|
||||
onChange({ ...draft, pages: next });
|
||||
}
|
||||
return (
|
||||
<section className="form-composition-section">
|
||||
<div className="form-field-editor-heading">
|
||||
<h3>Pages and sections</h3>
|
||||
{pages.length === 0
|
||||
? <Button disabled={disabled} onClick={() => updatePages([defaultPage(draft.fields)])}><Plus size={16} aria-hidden="true" />Enable pages</Button>
|
||||
: <Button disabled={disabled} onClick={() => updatePages([...pages, emptyPage(pages.length + 1)])}><Plus size={16} aria-hidden="true" />Add page</Button>}
|
||||
</div>
|
||||
{pages.length === 0 && <p className="form-section-note">Fields render in their declared order on one page.</p>}
|
||||
{pages.map((page, pageIndex) =>
|
||||
<div className="form-page-editor" key={`${pageIndex}:${page.key}`}>
|
||||
<div className="form-page-editor-heading">
|
||||
<Field label="Page key"><input value={page.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, key: event.target.value }))} /></Field>
|
||||
<Field label="Page title"><input value={page.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, title: event.target.value }))} /></Field>
|
||||
<IconButton label={`Remove page ${page.title || page.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => updatePages(pages.filter((_, index) => index !== pageIndex))} />
|
||||
</div>
|
||||
{page.sections.map((section, sectionIndex) =>
|
||||
<div className="form-section-editor" key={`${sectionIndex}:${section.key}`}>
|
||||
<Field label="Section key"><input value={section.key} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, key: event.target.value }) }))} /></Field>
|
||||
<Field label="Section title"><input value={section.title} disabled={disabled} onChange={(event) => updatePages(replaceAt(pages, pageIndex, { ...page, sections: replaceAt(page.sections, sectionIndex, { ...section, title: event.target.value }) }))} /></Field>
|
||||
<Field label="Fields">
|
||||
<select
|
||||
multiple
|
||||
value={section.field_keys}
|
||||
disabled={disabled}
|
||||
onChange={(event) => updatePages(replaceAt(pages, pageIndex, {
|
||||
...page,
|
||||
sections: replaceAt(page.sections, sectionIndex, {
|
||||
...section,
|
||||
field_keys: Array.from(event.currentTarget.selectedOptions, (option) => option.value)
|
||||
})
|
||||
}))}>
|
||||
{draft.fields.map((field) => <option key={field.key} value={field.key}>{field.label || field.key}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<IconButton label={`Remove section ${section.title || section.key}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled || page.sections.length === 1} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: page.sections.filter((_, index) => index !== sectionIndex) }))} />
|
||||
</div>
|
||||
)}
|
||||
<Button disabled={disabled} onClick={() => updatePages(replaceAt(pages, pageIndex, { ...page, sections: [...page.sections, emptySection(page.sections.length + 1)] }))}><Plus size={15} aria-hidden="true" />Add section</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalizationEditor({ draft, disabled, onChange }: { draft: FormDefinition; disabled: boolean; onChange: (value: FormDefinition) => void }) {
|
||||
const localizations = draft.localizations ?? [];
|
||||
function update(items: FormLocalization[]) {
|
||||
const fallback = items.some((item) => item.locale === draft.fallback_locale)
|
||||
? draft.fallback_locale
|
||||
: items[0]?.locale ?? null;
|
||||
onChange({ ...draft, localizations: items, fallback_locale: fallback });
|
||||
}
|
||||
return (
|
||||
<section className="form-composition-section">
|
||||
<div className="form-field-editor-heading">
|
||||
<h3><Languages size={17} aria-hidden="true" />Localizations</h3>
|
||||
<Button disabled={disabled} onClick={() => update([...localizations, emptyLocalization()])}><Plus size={16} aria-hidden="true" />Add locale</Button>
|
||||
</div>
|
||||
{localizations.length === 0 && <p className="form-section-note">The canonical labels are used for every locale.</p>}
|
||||
{localizations.map((localization, index) =>
|
||||
<div className="form-localization-editor" key={`${index}:${localization.locale}`}>
|
||||
<Field label="Locale"><input value={localization.locale} disabled={disabled} placeholder="de" onChange={(event) => update(replaceAt(localizations, index, { ...localization, locale: event.target.value }))} /></Field>
|
||||
<Field label="Localized title"><input value={localization.title ?? ""} disabled={disabled} onChange={(event) => update(replaceAt(localizations, index, { ...localization, title: event.target.value }))} /></Field>
|
||||
<label className="form-localization-fallback"><input type="radio" checked={draft.fallback_locale === localization.locale} disabled={disabled || !localization.locale} onChange={() => onChange({ ...draft, fallback_locale: localization.locale })} />Fallback</label>
|
||||
<IconButton label={`Remove locale ${localization.locale || index + 1}`} icon={<Trash2 size={16} />} variant="danger" disabled={disabled} onClick={() => update(localizations.filter((_, itemIndex) => itemIndex !== index))} />
|
||||
<div className="form-localization-fields">
|
||||
{draft.fields.map((field) =>
|
||||
<Field key={field.key} label={`${field.label || field.key} label`}>
|
||||
<input
|
||||
value={localization.field_labels[field.key] ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(event) => update(replaceAt(localizations, index, {
|
||||
...localization,
|
||||
field_labels: patchOptionalText(localization.field_labels, field.key, event.target.value)
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DefinitionPreview({ definition, previous }: { definition: FormDefinition; previous: FormDefinition | null }) {
|
||||
const changed = previous ? definitionChanges(previous, definition) : ["New definition"];
|
||||
return (
|
||||
<section className="form-composition-section form-definition-preview">
|
||||
<div className="form-field-editor-heading"><h3><Eye size={17} aria-hidden="true" />Preview and revision changes</h3></div>
|
||||
<div className="form-preview-grid">
|
||||
<div>
|
||||
<strong>{definition.title || "Untitled Form"}</strong>
|
||||
{(definition.pages?.length ? definition.pages : [defaultPage(definition.fields)]).map((page) =>
|
||||
<div key={page.key} className="form-preview-page">
|
||||
<span>{page.title}</span>
|
||||
{page.sections.map((section) => <small key={section.key}>{section.title}: {section.field_keys.join(", ") || "No fields"}</small>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div><strong>Changes</strong><ul>{changed.map((item) => <li key={item}>{item}</li>)}</ul></div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function initialDraft(tenantId: string, definition: FormDefinition | null): FormDefinition {
|
||||
if (definition) return structuredClone(definition);
|
||||
const id = crypto.randomUUID();
|
||||
@@ -280,6 +479,10 @@ function initialDraft(tenantId: string, definition: FormDefinition | null): Form
|
||||
signature_requirement: "none",
|
||||
policy_refs: [],
|
||||
handoff_kinds: [],
|
||||
pages: [],
|
||||
fallback_locale: null,
|
||||
localizations: [],
|
||||
accessibility: {},
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
@@ -299,6 +502,114 @@ function normalizeField(field: FormFieldDefinition): FormFieldDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function addField(definition: FormDefinition): FormDefinition {
|
||||
const field = emptyField(definition.fields.length + 1);
|
||||
const pages = definition.pages ?? [];
|
||||
if (pages.length === 0) return { ...definition, fields: [...definition.fields, field] };
|
||||
const firstPage = pages[0];
|
||||
const firstSection = firstPage.sections[0];
|
||||
return {
|
||||
...definition,
|
||||
fields: [...definition.fields, field],
|
||||
pages: replaceAt(pages, 0, {
|
||||
...firstPage,
|
||||
sections: replaceAt(firstPage.sections, 0, {
|
||||
...firstSection,
|
||||
field_keys: [...firstSection.field_keys, field.key]
|
||||
})
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function removeField(definition: FormDefinition, index: number): FormDefinition {
|
||||
const key = definition.fields[index].key;
|
||||
return {
|
||||
...definition,
|
||||
fields: definition.fields.filter((_, fieldIndex) => fieldIndex !== index),
|
||||
pages: (definition.pages ?? []).map((page) => ({
|
||||
...page,
|
||||
sections: page.sections.map((section) => ({
|
||||
...section,
|
||||
field_keys: section.field_keys.filter((fieldKey) => fieldKey !== key)
|
||||
}))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function remapPageField(pages: FormPageDefinition[], previous: string, next: string): FormPageDefinition[] {
|
||||
return pages.map((page) => ({
|
||||
...page,
|
||||
sections: page.sections.map((section) => ({
|
||||
...section,
|
||||
field_keys: section.field_keys.map((key) => key === previous ? next : key)
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultPage(fields: FormFieldDefinition[]): FormPageDefinition {
|
||||
return {
|
||||
key: "page-1",
|
||||
title: "Form",
|
||||
sections: [{ key: "section-1", title: "Details", field_keys: fields.map((item) => item.key) }]
|
||||
};
|
||||
}
|
||||
|
||||
function emptyPage(index: number): FormPageDefinition {
|
||||
return { key: `page-${index}`, title: `Page ${index}`, sections: [emptySection(1)] };
|
||||
}
|
||||
|
||||
function emptySection(index: number) {
|
||||
return { key: `section-${index}`, title: `Section ${index}`, field_keys: [] as string[] };
|
||||
}
|
||||
|
||||
function emptyLocalization(): FormLocalization {
|
||||
return {
|
||||
locale: "",
|
||||
title: "",
|
||||
description: "",
|
||||
field_labels: {},
|
||||
field_help_texts: {},
|
||||
option_labels: {},
|
||||
page_titles: {},
|
||||
section_titles: {}
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAt<T>(items: T[], index: number, value: T): T[] {
|
||||
return items.map((item, itemIndex) => itemIndex === index ? value : item);
|
||||
}
|
||||
|
||||
function patchOptionalText<T extends Record<string, unknown>>(current: T, key: string, value: string): T {
|
||||
const next = { ...current };
|
||||
if (value.trim()) next[key as keyof T] = value as T[keyof T];
|
||||
else delete next[key as keyof T];
|
||||
return next;
|
||||
}
|
||||
|
||||
function conditionInputValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value === undefined || value === null) return "";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseConditionValue(value: string, type?: FormValueType): unknown {
|
||||
if (type === "boolean") return value.trim().toLowerCase() === "true";
|
||||
if (type === "integer") return Number.parseInt(value, 10);
|
||||
if (type === "number") return Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function definitionChanges(previous: FormDefinition, current: FormDefinition): string[] {
|
||||
const changes: string[] = [];
|
||||
if (previous.title !== current.title) changes.push("Title changed");
|
||||
if (previous.publication_state !== current.publication_state) changes.push(`State: ${previous.publication_state} -> ${current.publication_state}`);
|
||||
if (previous.fields.length !== current.fields.length) changes.push(`Fields: ${previous.fields.length} -> ${current.fields.length}`);
|
||||
if ((previous.pages?.length ?? 0) !== (current.pages?.length ?? 0)) changes.push(`Pages: ${previous.pages?.length ?? 0} -> ${current.pages?.length ?? 0}`);
|
||||
if ((previous.localizations?.length ?? 0) !== (current.localizations?.length ?? 0)) changes.push(`Locales: ${previous.localizations?.length ?? 0} -> ${current.localizations?.length ?? 0}`);
|
||||
if (changes.length === 0 && JSON.stringify(previous) !== JSON.stringify(current)) changes.push("Definition details changed");
|
||||
return changes.length ? changes : ["No unsaved changes"];
|
||||
}
|
||||
|
||||
function splitValues(value: string): string[] {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Pencil, Plus, RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
IconButton,
|
||||
FormField,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import { listFormDefinitions, type FormDefinition } from "../../api/forms";
|
||||
import {
|
||||
assessFormDefinitionPackage,
|
||||
exportFormDefinitionPackage,
|
||||
importFormDefinitionPackage,
|
||||
listFormDefinitions,
|
||||
type FormDefinition,
|
||||
type FormPackageFragment
|
||||
} from "../../api/forms";
|
||||
import FormDefinitionDialog from "./FormDefinitionDialog";
|
||||
|
||||
|
||||
@@ -23,6 +32,11 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||
const [editing, setEditing] = useState<FormDefinition | "new" | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [importing, setImporting] = useState<FormPackageFragment | null>(null);
|
||||
const [importReason, setImportReason] = useState("");
|
||||
const [importAssessment, setImportAssessment] = useState("");
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const canWrite = hasScope(auth, "forms:definition:write");
|
||||
const canAdmin = hasScope(auth, "forms:definition:admin");
|
||||
const tenantId = auth.active_tenant?.id ?? auth.tenant.id;
|
||||
@@ -57,6 +71,52 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||
setSubmittedQuery(query.trim());
|
||||
}
|
||||
|
||||
async function choosePackage(file?: File) {
|
||||
if (!file) return;
|
||||
setError("");
|
||||
try {
|
||||
const fragment = JSON.parse(await file.text()) as FormPackageFragment;
|
||||
const assessment = await assessFormDefinitionPackage(settings, fragment);
|
||||
setImporting(fragment);
|
||||
setImportAssessment(assessment.outcome);
|
||||
setImportReason("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form package could not be read.");
|
||||
} finally {
|
||||
if (importInput.current) importInput.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function importPackage() {
|
||||
if (!importing || !importReason.trim()) return;
|
||||
setImportBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
|
||||
setImporting(null);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
|
||||
} finally {
|
||||
setImportBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPackage(item: FormDefinition) {
|
||||
setError("");
|
||||
try {
|
||||
const fragment = await exportFormDefinitionPackage(settings, item.reference.object_id, item.reference.version);
|
||||
const href = URL.createObjectURL(new Blob([JSON.stringify(fragment, null, 2)], { type: "application/json" }));
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = `${item.key}-${item.reference.version}.govoplan-form.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(href);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form package could not be exported.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="forms-page">
|
||||
<div className="forms-shell">
|
||||
@@ -76,6 +136,8 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||
</select>
|
||||
</label>
|
||||
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} />
|
||||
{canWrite && <IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading} />}
|
||||
<input ref={importInput} className="forms-hidden-input" type="file" accept="application/json,.json" onChange={(event) => void choosePackage(event.target.files?.[0])} />
|
||||
{canWrite && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
|
||||
<span className="forms-count">{total}</span>
|
||||
</div>
|
||||
@@ -93,9 +155,10 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||
<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" />}
|
||||
<span className="forms-row-actions">
|
||||
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(item)} />
|
||||
{mayRevise && <IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} onClick={() => setEditing(item)} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -117,6 +180,21 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<Dialog
|
||||
open={Boolean(importing)}
|
||||
title="Import Form package"
|
||||
onClose={() => setImporting(null)}
|
||||
closeDisabled={importBusy}
|
||||
portal
|
||||
footer={<>
|
||||
<Button onClick={() => setImporting(null)} disabled={importBusy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()}>{importBusy ? "Importing" : "Import as draft"}</Button>
|
||||
</>}>
|
||||
<div className="forms-package-import">
|
||||
<p>Assessment: <strong>{humanize(importAssessment)}</strong>. The source revision is retained as provenance and imported as a new local draft.</p>
|
||||
<FormField label="Change reason"><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
|
||||
</div>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
+124
-7
@@ -61,7 +61,7 @@
|
||||
|
||||
.forms-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto 36px;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(90px, auto) minmax(120px, auto) auto auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 64px;
|
||||
@@ -97,8 +97,25 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.forms-row-action-spacer {
|
||||
width: 36px;
|
||||
.forms-row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.forms-hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.forms-package-import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.forms-package-import p {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.forms-empty {
|
||||
@@ -198,16 +215,109 @@
|
||||
|
||||
.form-field-help,
|
||||
.form-field-options,
|
||||
.form-field-constraints {
|
||||
.form-field-constraints,
|
||||
.form-field-condition {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
.form-field-constraints {
|
||||
.form-field-constraints,
|
||||
.form-field-condition {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.form-composition-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-field-editor-heading h3 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.form-section-note {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.form-page-editor,
|
||||
.form-localization-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-page-editor-heading,
|
||||
.form-section-editor,
|
||||
.form-localization-editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.7fr) minmax(180px, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.form-section-editor {
|
||||
grid-template-columns: minmax(120px, 0.7fr) minmax(160px, 1fr) minmax(220px, 1.5fr) auto;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.form-section-editor select[multiple] {
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
.form-localization-editor {
|
||||
grid-template-columns: minmax(100px, 0.5fr) minmax(200px, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.form-localization-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.form-localization-fields {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.form-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-preview-grid > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-preview-grid ul {
|
||||
margin: 7px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.form-preview-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-preview-page small {
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.forms-toolbar {
|
||||
align-items: stretch;
|
||||
@@ -243,7 +353,8 @@
|
||||
.form-field-required,
|
||||
.form-field-help,
|
||||
.form-field-options,
|
||||
.form-field-constraints {
|
||||
.form-field-constraints,
|
||||
.form-field-condition {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
@@ -255,7 +366,13 @@
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.form-definition-grid,
|
||||
.form-field-constraints {
|
||||
.form-field-constraints,
|
||||
.form-field-condition,
|
||||
.form-page-editor-heading,
|
||||
.form-section-editor,
|
||||
.form-localization-editor,
|
||||
.form-localization-fields,
|
||||
.form-preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user