Implement destructive CalDAV move saga
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { RefreshCw, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
type ApiSettings,
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
cancelCalendarMigration,
|
||||
getCalendarMigration,
|
||||
type CalendarMigrationBatch,
|
||||
} from "../../api/calendar";
|
||||
import { dateTimeLabel, errorText } from "./calendarViewModel";
|
||||
|
||||
export function CalendarMigrationDialog({
|
||||
settings,
|
||||
batchId,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
batchId: string;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [batch, setBatch] = useState<CalendarMigrationBatch | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [cancellationEvidence, setCancellationEvidence] = useState("");
|
||||
const onChangedRef = useRef(onChanged);
|
||||
onChangedRef.current = onChanged;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await getCalendarMigration(settings, batchId);
|
||||
setBatch(next);
|
||||
setError("");
|
||||
if (next.status === "completed" || next.status === "cancelled") {
|
||||
onChangedRef.current();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [batchId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!batch || !["active", "blocked", "cancel_requested"].includes(batch.status)) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = window.setInterval(() => void load(), 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [batch, load]);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!batch?.total_resources) return 0;
|
||||
const completedSteps = batch.copied_resources + batch.deleted_source_resources;
|
||||
return Math.round((completedSteps / (batch.total_resources * 2)) * 100);
|
||||
}, [batch]);
|
||||
|
||||
async function cancelMigration() {
|
||||
if (!batch?.can_cancel || cancellationEvidence.trim().length < 10) return;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await cancelCalendarMigration(
|
||||
settings,
|
||||
batch.id,
|
||||
cancellationEvidence.trim(),
|
||||
);
|
||||
setBatch(next);
|
||||
setCancellationEvidence("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Remote calendar move"
|
||||
className="calendar-migration-dialog"
|
||||
closeDisabled={working}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" onClick={() => void load()} disabled={loading || working}>
|
||||
<RefreshCw size={16} /> Refresh
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} disabled={working}>Close</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{loading && !batch ? (
|
||||
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
|
||||
) : (
|
||||
<div className="calendar-migration-body">
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{batch && (
|
||||
<>
|
||||
<div className="calendar-migration-heading">
|
||||
<div>
|
||||
<strong>{phaseLabel(batch.phase)}</strong>
|
||||
<span>Updated {dateTimeLabel(new Date(batch.updated_at))}</span>
|
||||
</div>
|
||||
<StatusBadge status={batch.status} label={statusLabel(batch.status)} />
|
||||
</div>
|
||||
<progress value={progress} max={100} aria-label="Remote move progress" />
|
||||
<dl className="calendar-migration-summary">
|
||||
<div><dt>Events</dt><dd>{batch.total_events}</dd></div>
|
||||
<div><dt>Copied</dt><dd>{batch.copied_resources} / {batch.total_resources}</dd></div>
|
||||
<div><dt>Source deleted</dt><dd>{batch.deleted_source_resources} / {batch.total_resources}</dd></div>
|
||||
<div><dt>Conflicts</dt><dd>{batch.conflict_count}</dd></div>
|
||||
</dl>
|
||||
{typeof batch.authorization_evidence.note === "string" && (
|
||||
<p className="calendar-form-note">
|
||||
Authorization evidence: {batch.authorization_evidence.note}
|
||||
</p>
|
||||
)}
|
||||
{batch.last_error && <p className="calendar-migration-error">{batch.last_error}</p>}
|
||||
<ol className="calendar-migration-resources">
|
||||
{batch.resources.map((resource) => (
|
||||
<li key={resource.id}>
|
||||
<div>
|
||||
<StatusBadge status={resource.status} label={statusLabel(resource.status)} />
|
||||
<code title={resource.source_href}>{resource.source_href}</code>
|
||||
</div>
|
||||
<span>
|
||||
Copy: {statusLabel(resource.destination_operation_status || "pending")}; source: {statusLabel(resource.source_delete_operation_status || "pending")}
|
||||
</span>
|
||||
{resource.last_error && <p>{resource.last_error}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{batch.can_cancel && (
|
||||
<section className="calendar-migration-cancel">
|
||||
<label>
|
||||
<span>Cancellation evidence</span>
|
||||
<textarea
|
||||
value={cancellationEvidence}
|
||||
onChange={(event) => setCancellationEvidence(event.target.value)}
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="Record why the source must be retained."
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => void cancelMigration()}
|
||||
disabled={working || cancellationEvidence.trim().length < 10}
|
||||
>
|
||||
<XCircle size={16} /> Cancel remote move
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseLabel(value: string): string {
|
||||
return value.replace(/_/g, " ").replace(/^./, (letter: string) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function statusLabel(value: string): string {
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
Reference in New Issue
Block a user