Migrate Voting interface patterns

This commit is contained in:
2026-08-03 13:16:24 +02:00
parent 0a4e06077d
commit 2625990639
9 changed files with 427 additions and 38 deletions
+57 -13
View File
@@ -1,8 +1,11 @@
import { CheckCircle2, Pencil, Plus, RefreshCw, ShieldCheck, XCircle } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActionBlockerHint,
Button,
ConfirmDialog,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FormField,
IconButton,
@@ -10,6 +13,10 @@ import {
PageScrollViewport,
StatusBadge,
hasScope,
i18nMessage,
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
@@ -23,9 +30,11 @@ import {
type VotingEvent
} from "../../api/voting";
import VotingBallotDialog from "./VotingBallotDialog";
import { VOTING_DOCUMENTATION, VOTING_FIELD_DOCUMENTATION, VOTING_I18N } from "./interfacePatterns";
export default function VotingPage({ settings, auth }: PlatformRouteContext) {
const { language, translateText } = usePlatformLanguage();
const [items, setItems] = useState<VotingBallot[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selected, setSelected] = useState<VotingBallot | null>(null);
@@ -37,6 +46,8 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
const [editing, setEditing] = useState<VotingBallot | "new" | null>(null);
const [reasonAction, setReasonAction] = useState<"challenge" | "annul" | null>(null);
const [selections, setSelections] = useState<string[]>([]);
const [pendingTransition, setPendingTransition] = useState<"open" | "close" | "certify" | null>(null);
const [confirmingCast, setConfirmingCast] = useState(false);
const canManage = hasScope(auth, "voting:ballot:manage");
const canCast = hasScope(auth, "voting:ballot:cast");
const canCertify = hasScope(auth, "voting:ballot:certify");
@@ -135,8 +146,9 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
<div className="voting-shell">
<aside className="voting-catalogue">
<div className="voting-toolbar">
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} onClick={() => void loadList()} />
{canManage && <Button variant="primary" onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>}
<IconButton label="Refresh ballots" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? VOTING_I18N.loading : busy ? VOTING_I18N.busy : undefined} onClick={() => void loadList()} />
<Button variant="primary" disabled={!canManage} disabledReason={!canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing("new")}><Plus size={16} aria-hidden="true" />New ballot</Button>
<DocumentationHelpLink reference={VOTING_DOCUMENTATION} />
</div>
<PageScrollViewport className="voting-list-viewport">
{loading && <LoadingIndicator label="Loading ballots" />}
@@ -158,12 +170,12 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
<div><h2>{selected.title}</h2><span>Revision {selected.revision}</span></div>
<div className="voting-actions">
<StatusBadge status={statusTone(selected.state)} label={humanize(selected.state)} />
{canManage && selected.state === "draft" && <IconButton label={`Edit ${selected.title}`} icon={<Pencil size={16} />} disabled={busy} onClick={() => setEditing(selected)} />}
{canManage && selected.state === "draft" && <Button variant="primary" disabled={busy} onClick={() => void run("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
{canManage && selected.state === "open" && <Button variant="primary" disabled={busy} onClick={() => void run("close")}><XCircle size={16} aria-hidden="true" />Close and tally</Button>}
{canCertify && selected.state === "closed" && <Button variant="primary" disabled={busy} onClick={() => void run("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</Button>}
{canCertify && ["closed", "certified"].includes(selected.state) && <Button disabled={busy} onClick={() => setReasonAction("challenge")}>Challenge</Button>}
{canAdmin && selected.state !== "annulled" && <Button variant="danger" disabled={busy} onClick={() => setReasonAction("annul")}>Annul</Button>}
{selected.state === "draft" && <IconButton label={`Edit ${selected.title}`} icon={<Pencil size={16} />} disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setEditing(selected)} />}
{selected.state === "draft" && <Button variant="primary" disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setPendingTransition("open")}><CheckCircle2 size={16} aria-hidden="true" />Open</Button>}
{selected.state === "open" && <Button variant="primary" disabled={busy || !canManage} disabledReason={busy ? VOTING_I18N.busy : !canManage ? VOTING_I18N.manageReason : undefined} onClick={() => setPendingTransition("close")}><XCircle size={16} aria-hidden="true" />Close and tally</Button>}
{selected.state === "closed" && <Button variant="primary" disabled={busy || !canCertify} disabledReason={busy ? VOTING_I18N.busy : !canCertify ? VOTING_I18N.certifyReason : undefined} onClick={() => setPendingTransition("certify")}><ShieldCheck size={16} aria-hidden="true" />Certify</Button>}
{["closed", "certified"].includes(selected.state) && <Button disabled={busy || !canCertify} disabledReason={busy ? VOTING_I18N.busy : !canCertify ? VOTING_I18N.certifyReason : undefined} onClick={() => setReasonAction("challenge")}>Challenge</Button>}
{selected.state !== "annulled" && <Button variant="danger" disabled={busy || !canAdmin} disabledReason={busy ? VOTING_I18N.busy : !canAdmin ? VOTING_I18N.adminReason : undefined} onClick={() => setReasonAction("annul")}>Annul</Button>}
</div>
</div>
<div className="voting-metrics">
@@ -188,8 +200,9 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
<span><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span>
</label>)}
</div>
<Button variant="primary" disabled={busy || selections.length === 0} onClick={() => void cast()}>Submit vote</Button>
<Button variant="primary" disabled={busy || selections.length === 0} disabledReason={busy ? VOTING_I18N.busy : selections.length === 0 ? VOTING_I18N.incomplete : undefined} onClick={() => setConfirmingCast(true)}>Submit vote</Button>
</section>}
{selected.state === "open" && selected.assurance_profile === "recorded" && (!canCast || !eligible) && <ActionBlockerHint reason={{ summary: "Vote unavailable", details: !canCast ? VOTING_I18N.castReason : VOTING_I18N.ineligibleReason, requiredAction: VOTING_I18N.permissionAction, actor: VOTING_I18N.permissionActor, target: VOTING_I18N.permissionDestination }} labels={{ requiredAction: VOTING_I18N.requiredAction, actor: VOTING_I18N.actor, target: VOTING_I18N.destination }} documentation={VOTING_DOCUMENTATION} />}
<section className="voting-section">
<h3>Options</h3>
<div className="voting-option-results">
@@ -217,7 +230,7 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
<section className="voting-section">
<h3>History</h3>
<div className="voting-history">
{history.map((item) => <div key={item.sequence}><span>{item.sequence}</span><strong>{humanize(item.event_type)}</strong><time>{new Date(item.recorded_at).toLocaleString()}</time></div>)}
{history.map((item) => <div key={item.sequence}><span>{item.sequence}</span><strong>{humanize(item.event_type)}</strong><time>{new Date(item.recorded_at).toLocaleString(language)}</time></div>)}
</div>
</section>
</PageScrollViewport>}
@@ -246,13 +259,41 @@ export default function VotingPage({ settings, auth }: PlatformRouteContext) {
await reasonedVotingTransition(settings, selected, reasonAction, reason);
setReasonAction(null);
await reloadSelected(`${humanize(reasonAction)} recorded.`);
return true;
} catch (failure) {
setError(message(failure, "The transition could not be recorded."));
return false;
} finally {
setBusy(false);
}
}}
/>}
<ConfirmDialog
open={Boolean(pendingTransition)}
title="i18n:govoplan-voting.transition_title"
message={pendingTransition ? i18nMessage("i18n:govoplan-voting.transition_message", { action: translateText(humanize(pendingTransition)) }) : ""}
confirmLabel={pendingTransition ? humanize(pendingTransition) : "Confirm"}
busy={busy}
onCancel={() => setPendingTransition(null)}
onConfirm={() => {
if (!pendingTransition) return;
const action = pendingTransition;
setPendingTransition(null);
void run(action);
}}
/>
<ConfirmDialog
open={confirmingCast}
title="i18n:govoplan-voting.cast_title"
message="i18n:govoplan-voting.cast_message"
confirmLabel="Submit vote"
busy={busy}
onCancel={() => setConfirmingCast(false)}
onConfirm={() => {
setConfirmingCast(false);
void cast();
}}
/>
</main>
);
}
@@ -265,10 +306,13 @@ function Hash({ label, value }: { label: string; value?: string | null }) {
return <div className="voting-hash"><span>{label}</span><code>{value || "Not frozen"}</code></div>;
}
function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise<void> }) {
function ReasonDialog({ action, busy, onClose, onConfirm }: { action: "challenge" | "annul"; busy: boolean; onClose: () => void; onConfirm: (reason: string) => Promise<boolean> }) {
const [reason, setReason] = useState("");
return <Dialog open title={`${humanize(action)} ballot`} onClose={onClose} closeDisabled={busy} portal footer={<><Button onClick={onClose} disabled={busy}>Cancel</Button><Button variant={action === "annul" ? "danger" : "primary"} disabled={busy || !reason.trim()} onClick={() => void onConfirm(reason.trim())}>Confirm</Button></>}>
<FormField label="Reason"><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>
const { requestDiscard } = useUnsavedChanges();
useUnsavedDraftGuard({ dirty: Boolean(reason), onSave: async () => Boolean(reason.trim()) && onConfirm(reason.trim()), onDiscard: () => setReason(""), title: "i18n:govoplan-voting.unsaved_title", message: "i18n:govoplan-voting.unsaved_message" });
const requestClose = () => { if (busy) return; if (reason) requestDiscard(onClose); else onClose(); };
return <Dialog open title={`${humanize(action)} ballot`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? VOTING_I18N.busy : undefined}>Cancel</Button><Button variant={action === "annul" ? "danger" : "primary"} disabled={busy || !reason.trim()} disabledReason={busy ? VOTING_I18N.busy : !reason.trim() ? VOTING_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim())}>Confirm</Button></>}>
<FormField label="Reason" documentation={VOTING_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>
</Dialog>;
}