Implement destructive CalDAV move saga
This commit is contained in:
@@ -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() : "";
|
||||
|
||||
Reference in New Issue
Block a user