Add conditional localized form definitions

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 343a208894
commit e2033640a1
11 changed files with 1346 additions and 33 deletions
+84 -6
View File
@@ -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>
);
}