Implement destructive CalDAV move saga
This commit is contained in:
@@ -220,6 +220,49 @@ export type CalendarOutboxOperationListResponse = {
|
||||
operations: CalendarOutboxOperation[];
|
||||
};
|
||||
|
||||
export type CalendarMigrationResource = {
|
||||
id: string;
|
||||
source_href: string;
|
||||
destination_href: string;
|
||||
event_ids: string[];
|
||||
status: string;
|
||||
destination_operation_id?: string | null;
|
||||
destination_operation_status?: string | null;
|
||||
source_delete_operation_id?: string | null;
|
||||
source_delete_operation_status?: string | null;
|
||||
last_error?: string | null;
|
||||
};
|
||||
|
||||
export type CalendarMigrationBatch = {
|
||||
id: string;
|
||||
migration_kind: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
source_calendar_id: string;
|
||||
target_calendar_id: string;
|
||||
source_sync_source_id: string;
|
||||
target_sync_source_id: string;
|
||||
total_resources: number;
|
||||
copied_resources: number;
|
||||
deleted_source_resources: number;
|
||||
conflict_count: number;
|
||||
total_events: number;
|
||||
last_error?: string | null;
|
||||
can_cancel: boolean;
|
||||
authorization_evidence: Record<string, unknown>;
|
||||
cancellation_evidence?: Record<string, unknown> | null;
|
||||
created_by_user_id?: string | null;
|
||||
created_by_api_key_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
completed_at?: string | null;
|
||||
resources: CalendarMigrationResource[];
|
||||
};
|
||||
|
||||
export type CalendarMigrationBatchListResponse = {
|
||||
migrations: CalendarMigrationBatch[];
|
||||
};
|
||||
|
||||
export type CalendarCollectionCreatePayload = {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
@@ -241,6 +284,8 @@ export type CalendarCollectionDeletePayload = {
|
||||
target_calendar_id?: string | null;
|
||||
make_target_default?: boolean;
|
||||
external_action?: CalendarBulkMoveExternalAction | null;
|
||||
destructive_confirmation?: string | null;
|
||||
evidence_note?: string | null;
|
||||
};
|
||||
|
||||
export type CalendarEventCreatePayload = {
|
||||
@@ -396,6 +441,35 @@ export function recoverCalendarOutboxOperation(
|
||||
});
|
||||
}
|
||||
|
||||
export function listCalendarMigrations(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; limit?: number } = {},
|
||||
): Promise<CalendarMigrationBatchListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.calendar_id) search.set("calendar_id", params.calendar_id);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
const suffix = search.toString() ? `?${search.toString()}` : "";
|
||||
return apiFetch<CalendarMigrationBatchListResponse>(settings, `/api/v1/calendar/migrations${suffix}`);
|
||||
}
|
||||
|
||||
export function getCalendarMigration(
|
||||
settings: ApiSettings,
|
||||
batchId: string,
|
||||
): Promise<CalendarMigrationBatch> {
|
||||
return apiFetch<CalendarMigrationBatch>(settings, `/api/v1/calendar/migrations/${batchId}`);
|
||||
}
|
||||
|
||||
export function cancelCalendarMigration(
|
||||
settings: ApiSettings,
|
||||
batchId: string,
|
||||
evidenceNote: string,
|
||||
): Promise<CalendarMigrationBatch> {
|
||||
return apiFetch<CalendarMigrationBatch>(settings, `/api/v1/calendar/migrations/${batchId}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ evidence_note: evidenceNote }),
|
||||
});
|
||||
}
|
||||
|
||||
export function listCalendarEvents(
|
||||
settings: ApiSettings,
|
||||
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
ColorPickerField,
|
||||
@@ -102,6 +102,7 @@ export function CalendarCollectionDialog({
|
||||
onRequestDelete,
|
||||
onSync,
|
||||
onOpenOutbox,
|
||||
onOpenMigration,
|
||||
onDiscover
|
||||
|
||||
|
||||
@@ -117,7 +118,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>;onOpenOutbox: (source: CalendarSyncSource) => 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;onOpenMigration: (batchId: string) => 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");
|
||||
@@ -146,6 +147,8 @@ export function CalendarCollectionDialog({
|
||||
const [discoveryError, setDiscoveryError] = useState("");
|
||||
const formId = "calendar-collection-form";
|
||||
const isExistingSyncSource = isEdit && Boolean(source);
|
||||
const migrationBatchId = calendar ? calendarMigrationBatchId(calendar) : "";
|
||||
const migrationLocked = Boolean(migrationBatchId);
|
||||
const canEditSource = canManageSources;
|
||||
const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource;
|
||||
const effectiveCollectionUrl = (collectionUrl || davUrl).trim();
|
||||
@@ -160,6 +163,7 @@ export function CalendarCollectionDialog({
|
||||
|
||||
const saveDisabled =
|
||||
saving ||
|
||||
migrationLocked ||
|
||||
!canWrite ||
|
||||
!name.trim() ||
|
||||
sourceDetailsInvalid;
|
||||
@@ -344,7 +348,7 @@ export function CalendarCollectionDialog({
|
||||
<>
|
||||
<div>
|
||||
{calendar && canDelete &&
|
||||
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving}>
|
||||
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving || migrationLocked}>
|
||||
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
|
||||
</Button>
|
||||
}
|
||||
@@ -357,6 +361,11 @@ export function CalendarCollectionDialog({
|
||||
}>
|
||||
|
||||
<form id={formId} className="calendar-dialog-form" onSubmit={submit}>
|
||||
{migrationLocked &&
|
||||
<p className="calendar-form-note">
|
||||
Calendar and event changes are locked while the destructive remote move is being reconciled.
|
||||
</p>
|
||||
}
|
||||
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>}
|
||||
{!isEdit &&
|
||||
<SegmentedControl
|
||||
@@ -553,11 +562,11 @@ export function CalendarCollectionDialog({
|
||||
type="button"
|
||||
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
|
||||
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
|
||||
disabled={saving || syncing || !canSyncSources}>
|
||||
disabled={saving || syncing || migrationLocked || !canSyncSources}>
|
||||
|
||||
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
|
||||
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || migrationLocked || !canSyncSources}>
|
||||
i18n:govoplan-calendar.full_sync.21b89c76
|
||||
</Button>
|
||||
{source.source_kind === "caldav" && canManageSources && (
|
||||
@@ -565,6 +574,11 @@ export function CalendarCollectionDialog({
|
||||
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
|
||||
</Button>
|
||||
)}
|
||||
{migrationBatchId && canManageSources && (
|
||||
<Button type="button" onClick={() => onOpenMigration(migrationBatchId)} disabled={saving}>
|
||||
<ArrowRightLeft size={16} /> Remote move
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -602,18 +616,28 @@ export function CalendarCollectionDeleteDialog({
|
||||
const [eventAction, setEventAction] = useState<CalendarDeleteEventAction>("delete");
|
||||
const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId);
|
||||
const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId));
|
||||
const [remoteMoveConfirmation, setRemoteMoveConfirmation] = useState("");
|
||||
const [remoteMoveEvidence, setRemoteMoveEvidence] = useState("");
|
||||
const effectiveEventAction: CalendarDeleteEventAction = canMoveEvents ? eventAction : "delete";
|
||||
const confirmDisabled = saving || loadingEventCount || canMoveEvents && effectiveEventAction === "move" && !targetCalendarId;
|
||||
const actionLabel = calendarDeleteActionLabel(calendar);
|
||||
const targetSource = syncSourceByCalendarId.get(targetCalendarId) ?? null;
|
||||
const externalAction = calendarBulkMoveExternalAction(source, targetSource);
|
||||
const isRemoteMove = effectiveEventAction === "move" && externalAction === "remote_move";
|
||||
const confirmDisabled = saving || loadingEventCount ||
|
||||
(canMoveEvents && effectiveEventAction === "move" && !targetCalendarId) ||
|
||||
(isRemoteMove && (
|
||||
remoteMoveConfirmation !== "MOVE REMOTE EVENTS" ||
|
||||
remoteMoveEvidence.trim().length < 10
|
||||
));
|
||||
|
||||
function confirm() {
|
||||
void onDelete(calendar, {
|
||||
event_action: effectiveEventAction,
|
||||
target_calendar_id: effectiveEventAction === "move" ? targetCalendarId : null,
|
||||
make_target_default: effectiveEventAction === "move" && calendar.is_default && makeTargetDefault,
|
||||
external_action: effectiveEventAction === "move" ? externalAction : null
|
||||
external_action: effectiveEventAction === "move" ? externalAction : null,
|
||||
destructive_confirmation: isRemoteMove ? remoteMoveConfirmation : null,
|
||||
evidence_note: isRemoteMove ? remoteMoveEvidence.trim() : null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -679,6 +703,32 @@ export function CalendarCollectionDeleteDialog({
|
||||
<ToggleSwitch label="i18n:govoplan-calendar.make_target_calendar_the_default.10a3977b" checked={makeTargetDefault} onChange={setMakeTargetDefault} />
|
||||
}
|
||||
<p className="calendar-form-note">{calendarBulkMoveConsequence(externalAction)}</p>
|
||||
{isRemoteMove &&
|
||||
<div className="calendar-remote-move-confirmation">
|
||||
<p className="calendar-delete-warning">
|
||||
This operation copies every CalDAV resource before conditionally deleting the source resources. Calendar and event edits remain locked until the batch completes or is reconciled.
|
||||
</p>
|
||||
<label>
|
||||
<span>Type <code>MOVE REMOTE EVENTS</code> to authorize remote deletion</span>
|
||||
<input
|
||||
value={remoteMoveConfirmation}
|
||||
onChange={(item) => setRemoteMoveConfirmation(item.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Authorization evidence</span>
|
||||
<textarea
|
||||
value={remoteMoveEvidence}
|
||||
onChange={(item) => setRemoteMoveEvidence(item.target.value)}
|
||||
minLength={10}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="Record the approved change, ticket, or maintenance window."
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</fieldset>
|
||||
@@ -828,7 +878,13 @@ function calendarMoveTargetIsSupported(
|
||||
source: CalendarSyncSource | null,
|
||||
targetSource: CalendarSyncSource | null)
|
||||
: boolean {
|
||||
if (source) return targetSource === null;
|
||||
if (source) {
|
||||
return targetSource === null || (
|
||||
calendarSyncSourceAcceptsCopies(source) &&
|
||||
targetSource !== null &&
|
||||
calendarSyncSourceAcceptsCopies(targetSource)
|
||||
);
|
||||
}
|
||||
return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource);
|
||||
}
|
||||
|
||||
@@ -842,18 +898,26 @@ targetSource: CalendarSyncSource | null)
|
||||
: CalendarBulkMoveExternalAction | undefined {
|
||||
if (source && !targetSource) return "detach_keep_remote";
|
||||
if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote";
|
||||
if (
|
||||
source &&
|
||||
targetSource &&
|
||||
calendarSyncSourceAcceptsCopies(source) &&
|
||||
calendarSyncSourceAcceptsCopies(targetSource)
|
||||
) return "remote_move";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function calendarBulkMoveOptionLabel(action: CalendarBulkMoveExternalAction | undefined): string {
|
||||
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.detach_local_events_and_keep_remote_events.c58ad4e7";
|
||||
if (action === "copy_to_remote") return "i18n:govoplan-calendar.move_and_copy_events_to_the_external_calendar.23c3a004";
|
||||
if (action === "remote_move") return "Move events between synchronized calendars";
|
||||
return "i18n:govoplan-calendar.move_events_to_another_calendar.830c7b09";
|
||||
}
|
||||
|
||||
function calendarBulkMoveConsequence(action: CalendarBulkMoveExternalAction | undefined): string {
|
||||
if (action === "detach_keep_remote") return "i18n:govoplan-calendar.govoplan_moves_the_local_event_copies_to_the_targe.4863e46b";
|
||||
if (action === "copy_to_remote") return "i18n:govoplan-calendar.govoplan_moves_the_events_locally_and_queues_durab.da075b16";
|
||||
if (action === "remote_move") return "GovOPlaN first copies all CalDAV resources, then deletes source resources with their recorded ETags. Conflicts stop the batch for reconciliation.";
|
||||
return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937";
|
||||
}
|
||||
|
||||
@@ -895,6 +959,13 @@ function calendarIsExternal(calendar: CalendarCollection): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function calendarMigrationBatchId(calendar: CalendarCollection): string {
|
||||
const remoteMove = calendar.metadata?.remote_move;
|
||||
if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return "";
|
||||
const batchId = (remoteMove as Record<string, unknown>).batch_id;
|
||||
return typeof batchId === "string" ? batchId : "";
|
||||
}
|
||||
|
||||
function metadataText(metadata: Record<string, unknown>, key: string): string {
|
||||
const value = metadata[key];
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
|
||||
@@ -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, " ");
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
} from "./CalendarCollectionDialogs";
|
||||
import { CalendarEventDialog } from "./CalendarEventDialog";
|
||||
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
|
||||
import { CalendarMigrationDialog } from "./CalendarMigrationDialog";
|
||||
import {
|
||||
CalendarTimeGrid,
|
||||
CalendarWeekRows,
|
||||
@@ -134,6 +135,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
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 [migrationBatchId, setMigrationBatchId] = useState("");
|
||||
const [syncingSourceId, setSyncingSourceId] = useState("");
|
||||
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
|
||||
const [draggingEventId, setDraggingEventId] = useState("");
|
||||
@@ -238,7 +240,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [days, mode]);
|
||||
|
||||
async function loadCalendars() {
|
||||
async function loadCalendars(): Promise<CalendarCollection[]> {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -254,8 +256,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
return next.length > 0 ? next : ids;
|
||||
});
|
||||
setEventCalendarId((current) => current && ids.includes(current) ? current : ids[0] ?? "");
|
||||
return response.calendars;
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -387,11 +391,19 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
await deleteCalendar(settings, calendar.id, payload);
|
||||
setCalendarDialog(null);
|
||||
setCalendarDeleteDialog(null);
|
||||
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
|
||||
setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id));
|
||||
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
|
||||
setEventCalendarId((current) => current === calendar.id ? "" : current);
|
||||
await loadCalendars();
|
||||
const remoteMove = payload.external_action === "remote_move";
|
||||
if (!remoteMove) {
|
||||
setCalendars((current) => current.filter((item) => item.id !== calendar.id));
|
||||
setSyncSources((current) => current.filter((item) => item.calendar_id !== calendar.id));
|
||||
setVisibleCalendarIds((current) => current.filter((id) => id !== calendar.id));
|
||||
setEventCalendarId((current) => current === calendar.id ? "" : current);
|
||||
}
|
||||
const refreshedCalendars = await loadCalendars();
|
||||
if (remoteMove) {
|
||||
const refreshedSource = refreshedCalendars.find((item) => item.id === calendar.id);
|
||||
const batchId = refreshedSource ? calendarRemoteMoveBatchId(refreshedSource) : "";
|
||||
if (batchId) setMigrationBatchId(batchId);
|
||||
}
|
||||
if (calendars.length > 1 || payload.event_action === "move") {
|
||||
await loadEvents();
|
||||
} else {
|
||||
@@ -1012,6 +1024,10 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
setOutboxDialog({ calendar: calendarDialog.calendar, source });
|
||||
}
|
||||
}}
|
||||
onOpenMigration={(batchId) => {
|
||||
setCalendarDialog(null);
|
||||
setMigrationBatchId(batchId);
|
||||
}}
|
||||
onDiscover={handleCalDavDiscovery} />
|
||||
|
||||
}
|
||||
@@ -1022,6 +1038,17 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
|
||||
source={outboxDialog.source}
|
||||
onClose={() => setOutboxDialog(null)} />
|
||||
|
||||
}
|
||||
{migrationBatchId &&
|
||||
<CalendarMigrationDialog
|
||||
settings={settings}
|
||||
batchId={migrationBatchId}
|
||||
onClose={() => setMigrationBatchId("")}
|
||||
onChanged={() => {
|
||||
void loadCalendars();
|
||||
void loadEvents();
|
||||
}} />
|
||||
|
||||
}
|
||||
{calendarDeleteDialog &&
|
||||
<CalendarCollectionDeleteDialog
|
||||
@@ -1042,6 +1069,13 @@ function compareCalendars(left: CalendarCollection, right: CalendarCollection):
|
||||
return left.name.localeCompare(right.name);
|
||||
}
|
||||
|
||||
function calendarRemoteMoveBatchId(calendar: CalendarCollection): string {
|
||||
const remoteMove = calendar.metadata?.remote_move;
|
||||
if (!remoteMove || typeof remoteMove !== "object" || Array.isArray(remoteMove)) return "";
|
||||
const batchId = (remoteMove as Record<string, unknown>).batch_id;
|
||||
return typeof batchId === "string" ? batchId : "";
|
||||
}
|
||||
|
||||
function calendarViewPreferences(
|
||||
response: CalendarViewPreferencesResponse
|
||||
): CalendarViewPreferences {
|
||||
|
||||
@@ -1185,6 +1185,141 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-remote-move-confirmation {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--color-danger, #b42318);
|
||||
background: var(--color-danger-subtle, rgba(180, 35, 24, 0.08));
|
||||
}
|
||||
|
||||
.calendar-remote-move-confirmation label,
|
||||
.calendar-migration-cancel label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-remote-move-confirmation input,
|
||||
.calendar-remote-move-confirmation textarea,
|
||||
.calendar-migration-cancel textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-migration-dialog {
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.calendar-migration-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.calendar-migration-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-migration-heading > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.calendar-migration-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.calendar-migration-body progress {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.calendar-migration-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar-migration-summary div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
border: var(--border-line);
|
||||
}
|
||||
|
||||
.calendar-migration-summary dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.calendar-migration-summary dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.calendar-migration-resources {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 280px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar-migration-resources li {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 9px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.calendar-migration-resources li > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.calendar-migration-resources code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-migration-resources span,
|
||||
.calendar-migration-resources p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.calendar-migration-error {
|
||||
margin: 0;
|
||||
color: var(--color-danger, #b42318);
|
||||
}
|
||||
|
||||
.calendar-migration-cancel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: var(--border-line);
|
||||
}
|
||||
|
||||
.calendar-migration-cancel .button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.calendar-migration-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-outbox-item-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user