227 lines
9.0 KiB
TypeScript
227 lines
9.0 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
|
|
import {
|
|
ActionBlockerHint,
|
|
Button,
|
|
ConfirmDialog,
|
|
Dialog,
|
|
DismissibleAlert,
|
|
DocumentationHelpLink,
|
|
SegmentedControl,
|
|
StatusBadge,
|
|
type ApiSettings,
|
|
} from "@govoplan/core-webui";
|
|
import {
|
|
listCalendarOutbox,
|
|
recoverCalendarOutboxOperation,
|
|
type CalendarCollection,
|
|
type CalendarOutboxOperation,
|
|
type CalendarSyncSource,
|
|
} from "../../api/calendar";
|
|
import { dateTimeLabel, errorText } from "./calendarViewModel";
|
|
import {
|
|
CALENDAR_I18N,
|
|
CALENDAR_RECOVERY_DOCUMENTATION,
|
|
} from "./interfacePatterns";
|
|
|
|
type OutboxFilter = "unresolved" | "all";
|
|
type RecoveryAction = "retry" | "reconcile" | "discard";
|
|
|
|
const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]);
|
|
|
|
export function CalendarOutboxDialog({
|
|
settings,
|
|
calendar,
|
|
source,
|
|
onClose,
|
|
}: {
|
|
settings: ApiSettings;
|
|
calendar: CalendarCollection;
|
|
source: CalendarSyncSource;
|
|
onClose: () => void;
|
|
}) {
|
|
const [operations, setOperations] = useState<CalendarOutboxOperation[]>([]);
|
|
const [filter, setFilter] = useState<OutboxFilter>("unresolved");
|
|
const [loading, setLoading] = useState(true);
|
|
const [busyOperationId, setBusyOperationId] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [discardOperation, setDiscardOperation] = useState<CalendarOutboxOperation | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const response = await listCalendarOutbox(settings, {
|
|
source_id: source.id,
|
|
limit: 100,
|
|
});
|
|
setOperations(response.operations);
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [settings, source.id]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const visibleOperations = useMemo(
|
|
() => filter === "all"
|
|
? operations
|
|
: operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)),
|
|
[filter, operations],
|
|
);
|
|
const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length;
|
|
const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length;
|
|
|
|
async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) {
|
|
setBusyOperationId(operation.id);
|
|
setError("");
|
|
try {
|
|
await recoverCalendarOutboxOperation(settings, operation.id, action);
|
|
setDiscardOperation(null);
|
|
await load();
|
|
} catch (err) {
|
|
setError(errorText(err));
|
|
} finally {
|
|
setBusyOperationId("");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog
|
|
open
|
|
title="i18n:govoplan-calendar.outbound_changes.7038a839"
|
|
className="calendar-outbox-dialog"
|
|
closeDisabled={Boolean(busyOperationId)}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)} disabledReason={loading ? CALENDAR_I18N.loading : busyOperationId ? CALENDAR_I18N.saving : undefined}>
|
|
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
|
|
</Button>
|
|
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
|
|
i18n:govoplan-core.close.bbfa773e
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="calendar-outbox-body">
|
|
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
|
|
<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />
|
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
|
<div className="calendar-outbox-toolbar">
|
|
<SegmentedControl<OutboxFilter>
|
|
value={filter}
|
|
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
|
|
onChange={setFilter}
|
|
options={[
|
|
{ id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" },
|
|
{ id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" },
|
|
]}
|
|
/>
|
|
<dl className="calendar-outbox-summary">
|
|
<div><dt>i18n:govoplan-calendar.unresolved.11c41de4</dt><dd>{unresolvedCount}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.conflicts_dead.31252c3a</dt><dd>{conflictCount}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.shown.498e85a1</dt><dd>{visibleOperations.length}</dd></div>
|
|
</dl>
|
|
</div>
|
|
{loading && !operations.length
|
|
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
|
|
: visibleOperations.length
|
|
? (
|
|
<ol className="calendar-outbox-list">
|
|
{visibleOperations.map((operation) => (
|
|
<li key={operation.id} className="calendar-outbox-item">
|
|
<div className="calendar-outbox-item-heading">
|
|
<div>
|
|
<StatusBadge status={operation.status} />
|
|
<strong>{operation.operation_kind.toUpperCase()}</strong>
|
|
</div>
|
|
<time dateTime={operation.updated_at}>{dateTimeLabel(new Date(operation.updated_at))}</time>
|
|
</div>
|
|
<code title={operation.resource_href}>{operation.resource_href}</code>
|
|
<dl className="calendar-outbox-item-facts">
|
|
<div><dt>i18n:govoplan-calendar.attempts.448fa12e</dt><dd>{operation.attempt_count}/{operation.max_attempts}</dd></div>
|
|
<div><dt>i18n:govoplan-calendar.available.17bc146b</dt><dd>{dateTimeLabel(new Date(operation.available_at))}</dd></div>
|
|
</dl>
|
|
{operation.last_error && <p className="calendar-outbox-error">{operation.last_error}</p>}
|
|
<RecoveryActions
|
|
operation={operation}
|
|
busy={busyOperationId === operation.id}
|
|
onRecover={(action) => action === "discard"
|
|
? setDiscardOperation(operation)
|
|
: void recover(operation, action)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)
|
|
: <p className="calendar-form-note">i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64</p>}
|
|
{operations.length === 100 && (
|
|
<p className="calendar-form-note">i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365</p>
|
|
)}
|
|
</div>
|
|
</Dialog>
|
|
<ConfirmDialog
|
|
open={Boolean(discardOperation)}
|
|
title="i18n:govoplan-calendar.discard_local_desired_state.952f3be1"
|
|
message="i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d"
|
|
confirmLabel="i18n:govoplan-calendar.discard_change.85474ec4"
|
|
tone="danger"
|
|
busy={Boolean(busyOperationId)}
|
|
onCancel={() => setDiscardOperation(null)}
|
|
onConfirm={() => discardOperation && void recover(discardOperation, "discard")}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function RecoveryActions({
|
|
operation,
|
|
busy,
|
|
onRecover,
|
|
}: {
|
|
operation: CalendarOutboxOperation;
|
|
busy: boolean;
|
|
onRecover: (action: RecoveryAction) => void;
|
|
}) {
|
|
const available = (["retry", "reconcile", "discard"] as const).filter(
|
|
(action) => operation.actions[action]?.allowed,
|
|
);
|
|
const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason;
|
|
return (
|
|
<div className="calendar-outbox-recovery">
|
|
{available.length > 0 && (
|
|
<div className="calendar-outbox-actions">
|
|
{available.includes("retry") && (
|
|
<Button type="button" onClick={() => onRecover("retry")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
|
|
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
|
|
</Button>
|
|
)}
|
|
{available.includes("reconcile") && (
|
|
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
|
|
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
|
|
</Button>
|
|
)}
|
|
{available.includes("discard") && (
|
|
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
|
|
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!available.length && unavailableReason && (
|
|
<ActionBlockerHint
|
|
tone="info"
|
|
reason={{ summary: unavailableReason }}
|
|
documentation={CALENDAR_RECOVERY_DOCUMENTATION}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|