feat: implement immutable form definitions
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user