Migrate Forms interface patterns

This commit is contained in:
2026-08-03 13:08:43 +02:00
parent e2033640a1
commit e505536e6f
9 changed files with 429 additions and 31 deletions
+37 -10
View File
@@ -1,8 +1,10 @@
import { Download, Pencil, Plus, RefreshCw, Search, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import {
ActionBlockerHint,
Button,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
IconButton,
FormField,
@@ -10,6 +12,8 @@ import {
PageScrollViewport,
StatusBadge,
hasScope,
useUnsavedChanges,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -21,6 +25,7 @@ import {
type FormPackageFragment
} from "../../api/forms";
import FormDefinitionDialog from "./FormDefinitionDialog";
import { FORMS_DOCUMENTATION, FORMS_FIELD_DOCUMENTATION, FORMS_I18N } from "./interfacePatterns";
export default function FormsPage({ settings, auth }: PlatformRouteContext) {
@@ -36,6 +41,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
const [importReason, setImportReason] = useState("");
const [importAssessment, setImportAssessment] = useState("");
const [importBusy, setImportBusy] = useState(false);
const { requestDiscard } = useUnsavedChanges();
const importInput = useRef<HTMLInputElement>(null);
const canWrite = hasScope(auth, "forms:definition:write");
const canAdmin = hasScope(auth, "forms:definition:admin");
@@ -87,21 +93,40 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
}
}
async function importPackage() {
if (!importing || !importReason.trim()) return;
async function importPackage(): Promise<boolean> {
if (!importing || !importReason.trim()) return false;
setImportBusy(true);
setError("");
try {
await importFormDefinitionPackage(settings, importing, { changeReason: importReason.trim() });
setImporting(null);
await load();
return true;
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form package could not be imported.");
return false;
} finally {
setImportBusy(false);
}
}
useUnsavedDraftGuard({
dirty: Boolean(importing && importReason),
onSave: importPackage,
onDiscard: () => {
setImporting(null);
setImportReason("");
},
title: "i18n:govoplan-forms.unsaved_title",
message: "i18n:govoplan-forms.unsaved_message"
});
function closeImport() {
if (importBusy) return;
if (importing && importReason) requestDiscard(() => setImporting(null));
else setImporting(null);
}
async function downloadPackage(item: FormDefinition) {
setError("");
try {
@@ -135,14 +160,16 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<option value="retired">Retired</option>
</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} />}
<IconButton label="Refresh definitions" icon={<RefreshCw size={16} />} onClick={() => void load()} disabled={loading} disabledReason={loading ? FORMS_I18N.loading : undefined} />
<IconButton label="Import Form package" icon={<Upload size={16} />} onClick={() => importInput.current?.click()} disabled={loading || !canWrite} disabledReason={loading ? FORMS_I18N.loading : !canWrite ? FORMS_I18N.writeReason : undefined} />
<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>}
<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>
<span className="forms-count">{total}</span>
<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />
</div>
<PageScrollViewport className="forms-list-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Form management permission", details: FORMS_I18N.writeReason, requiredAction: FORMS_I18N.permissionAction, actor: FORMS_I18N.permissionActor, target: FORMS_I18N.permissionDestination }} labels={{ requiredAction: FORMS_I18N.requiredAction, actor: FORMS_I18N.actor, target: FORMS_I18N.destination }} documentation={FORMS_DOCUMENTATION} />}
{loading && <LoadingIndicator label="Loading Form definitions" />}
{!loading && !error && items.length === 0 && <div className="forms-empty">No matching definitions.</div>}
{!loading && items.length > 0 &&
@@ -157,7 +184,7 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<StatusBadge status={item.publication_state === "published" ? "active" : "inactive"} label={humanize(item.publication_state)} />
<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)} />}
<IconButton label={`Revise ${item.title}`} icon={<Pencil size={16} />} disabled={!mayRevise} disabledReason={!canWrite ? FORMS_I18N.writeReason : item.publication_state === "retired" || (item.publication_state === "published" && !canAdmin) ? FORMS_I18N.lifecycleReason : undefined} onClick={() => setEditing(item)} />
</span>
</div>
);
@@ -183,16 +210,16 @@ export default function FormsPage({ settings, auth }: PlatformRouteContext) {
<Dialog
open={Boolean(importing)}
title="Import Form package"
onClose={() => setImporting(null)}
onClose={closeImport}
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>
<Button onClick={closeImport} disabled={importBusy} disabledReason={importBusy ? FORMS_I18N.busy : undefined}>Cancel</Button>
<Button variant="primary" onClick={() => void importPackage()} disabled={importBusy || !importReason.trim()} disabledReason={importBusy ? FORMS_I18N.busy : !importReason.trim() ? FORMS_I18N.incomplete : undefined}>{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>
<FormField label="Change reason" documentation={FORMS_FIELD_DOCUMENTATION}><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
</div>
</Dialog>
</main>