Add CalDAV outbox recovery UI

This commit is contained in:
2026-08-02 15:57:01 +02:00
parent 4e05ab2c3e
commit 77a40ec2f9
14 changed files with 737 additions and 14 deletions
@@ -5,7 +5,7 @@ import {
type ChangeEvent,
type FormEvent,
} from "react";
import { RefreshCw, Trash2 } from "lucide-react";
import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
import {
Button,
ColorPickerField,
@@ -101,6 +101,7 @@ export function CalendarCollectionDialog({
onSave,
onRequestDelete,
onSync,
onOpenOutbox,
onDiscover
@@ -116,7 +117,7 @@ export function CalendarCollectionDialog({
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
@@ -559,6 +560,11 @@ export function CalendarCollectionDialog({
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76
</Button>
{source.source_kind === "caldav" && canManageSources && (
<Button type="button" onClick={() => onOpenOutbox(source)} disabled={saving || syncing}>
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
</Button>
)}
</div>
</div>
}
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
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";
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)}>
<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>
{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}>
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
</Button>
)}
{available.includes("reconcile") && (
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy}>
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
</Button>
)}
{available.includes("discard") && (
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy}>
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
</Button>
)}
</div>
)}
{!available.length && unavailableReason && <p className="calendar-form-note">{unavailableReason}</p>}
</div>
);
}
@@ -57,6 +57,7 @@ import {
type CalendarDeleteDialogState,
} from "./CalendarCollectionDialogs";
import { CalendarEventDialog } from "./CalendarEventDialog";
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
import {
CalendarTimeGrid,
CalendarWeekRows,
@@ -132,6 +133,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
const [syncingSourceId, setSyncingSourceId] = useState("");
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
const [draggingEventId, setDraggingEventId] = useState("");
@@ -1005,8 +1007,21 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
onSave={handleCalendarSave}
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
onSync={handleSyncSource}
onOpenOutbox={(source) => {
if (calendarDialog.kind === "edit") {
setOutboxDialog({ calendar: calendarDialog.calendar, source });
}
}}
onDiscover={handleCalDavDiscovery} />
}
{outboxDialog &&
<CalendarOutboxDialog
settings={settings}
calendar={outboxDialog.calendar}
source={outboxDialog.source}
onClose={() => setOutboxDialog(null)} />
}
{calendarDeleteDialog &&
<CalendarCollectionDeleteDialog