Migrate Voting interface patterns
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user