254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { Download, Pencil, Plus, Search, Upload } from "lucide-react";
|
|
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
|
import { useSearchParams } from "react-router";
|
|
import { ActionBlockerHint,
|
|
Button,
|
|
Dialog,
|
|
DocumentationHelpLink,
|
|
DismissibleAlert,
|
|
IconButton,
|
|
FormField,
|
|
FilterBar,
|
|
LoadingIndicator,
|
|
PageScrollViewport,
|
|
StatePanel,
|
|
StatusBadge,
|
|
hasScope,
|
|
useUnsavedChanges,
|
|
useUnsavedDraftGuard,
|
|
WorkspaceActionBar,
|
|
WorkspaceFrame,
|
|
type PlatformRouteContext
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
assessFormDefinitionPackage,
|
|
exportFormDefinitionPackage,
|
|
importFormDefinitionPackage,
|
|
listFormDefinitions,
|
|
type FormDefinition,
|
|
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) {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
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 [importing, setImporting] = useState<FormPackageFragment | null>(null);
|
|
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");
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const formId = searchParams.get("formId");
|
|
if (!formId || editing || loading) return;
|
|
const requested = items.find((item) => item.reference.object_id === formId);
|
|
if (!requested) return;
|
|
setEditing(requested);
|
|
const next = new URLSearchParams(searchParams);
|
|
next.delete("formId");
|
|
setSearchParams(next, { replace: true });
|
|
}, [editing, items, loading, searchParams, setSearchParams]);
|
|
|
|
function search(event: FormEvent) {
|
|
event.preventDefault();
|
|
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(): 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 {
|
|
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">
|
|
<WorkspaceFrame className="forms-shell" label="Form definitions" interfaceId="forms.catalogue" helpContextId="forms.page.catalogue" helpModuleId="forms">
|
|
<WorkspaceActionBar
|
|
scope="workspace"
|
|
variant="collection"
|
|
refreshable
|
|
reloadAction={{ onReload: () => void load(), loading, label: "Refresh definitions" }}
|
|
className="forms-toolbar"
|
|
contextActions={<>
|
|
<FilterBar as="form" surface="control" wrap="never" width="default" 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>
|
|
</FilterBar>
|
|
<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="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])} />
|
|
<span className="forms-count">{total}</span>
|
|
</>}
|
|
createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? FORMS_I18N.writeReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New definition</Button>}
|
|
helpAction={<DocumentationHelpLink reference={FORMS_DOCUMENTATION} />}
|
|
/>
|
|
<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 && <StatePanel size="compact" description="No matching definitions." />}
|
|
{!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)} />
|
|
<span className="forms-row-actions">
|
|
<IconButton label={`Export ${item.title}`} icon={<Download size={16} />} onClick={() => void downloadPackage(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>
|
|
);
|
|
})}
|
|
</div>
|
|
}
|
|
</PageScrollViewport>
|
|
</WorkspaceFrame>
|
|
{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."));
|
|
}}
|
|
/>
|
|
}
|
|
<Dialog
|
|
open={Boolean(importing)}
|
|
title="Import Form package"
|
|
onClose={closeImport}
|
|
closeDisabled={importBusy}
|
|
portal
|
|
footer={<>
|
|
<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" documentation={FORMS_FIELD_DOCUMENTATION}><input value={importReason} maxLength={1000} disabled={importBusy} onChange={(event) => setImportReason(event.target.value)} /></FormField>
|
|
</div>
|
|
</Dialog>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function humanize(value: string): string {
|
|
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
}
|