From 56b387a7849a5ce3e0eeb7cd53da4f624559043f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 29 Jul 2026 19:53:11 +0200 Subject: [PATCH] refactor: split calendar page components --- webui/package.json | 3 +- .../calendar/CalendarCollectionDialogs.tsx | 827 ++++++ .../features/calendar/CalendarEventDialog.tsx | 562 ++++ webui/src/features/calendar/CalendarPage.tsx | 2312 +---------------- webui/src/features/calendar/CalendarViews.tsx | 666 +++++ .../features/calendar/calendarViewModel.ts | 889 +++++++ webui/tests/calendar-page-structure.test.mjs | 78 + webui/tests/calendar-view-model.test.ts | 96 + webui/tsconfig.calendar-page-tests.json | 38 + 9 files changed, 3207 insertions(+), 2264 deletions(-) create mode 100644 webui/src/features/calendar/CalendarCollectionDialogs.tsx create mode 100644 webui/src/features/calendar/CalendarEventDialog.tsx create mode 100644 webui/src/features/calendar/CalendarViews.tsx create mode 100644 webui/src/features/calendar/calendarViewModel.ts create mode 100644 webui/tests/calendar-page-structure.test.mjs create mode 100644 webui/tests/calendar-view-model.test.ts create mode 100644 webui/tsconfig.calendar-page-tests.json diff --git a/webui/package.json b/webui/package.json index eeb3102..98a5ab1 100644 --- a/webui/package.json +++ b/webui/package.json @@ -14,7 +14,8 @@ "./styles/calendar.css": "./src/styles/calendar.css" }, "scripts": { - "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs" + "test:calendar-picker": "rm -rf .calendar-picker-test-build && mkdir -p .calendar-picker-test-build && printf '{\"type\":\"commonjs\"}\\n' > .calendar-picker-test-build/package.json && tsc -p tsconfig.calendar-picker-tests.json && node .calendar-picker-test-build/tests/calendar-picker.test.js && node tests/calendar-picker-structure.test.mjs", + "test:calendar-page": "tsc -p tsconfig.calendar-page-tests.json && node --experimental-strip-types tests/calendar-view-model.test.ts && node tests/calendar-page-structure.test.mjs" }, "peerDependencies": { "@govoplan/core-webui": "^0.1.9", diff --git a/webui/src/features/calendar/CalendarCollectionDialogs.tsx b/webui/src/features/calendar/CalendarCollectionDialogs.tsx new file mode 100644 index 0000000..bebeb46 --- /dev/null +++ b/webui/src/features/calendar/CalendarCollectionDialogs.tsx @@ -0,0 +1,827 @@ +import { + useEffect, + useMemo, + useState, + type ChangeEvent, + type FormEvent, +} from "react"; +import { RefreshCw, Trash2 } from "lucide-react"; +import { + Button, + ColorPickerField, + Dialog, + PasswordField, + SegmentedControl, + ToggleSwitch, + i18nMessage, + useUnsavedDraftGuard, + type ApiSettings, +} from "@govoplan/core-webui"; +import { + listCalendarCredentials, + type CalendarBulkMoveExternalAction, + type CalendarCalDavAuthType, + type CalendarCalDavConflictPolicy, + type CalendarCalDavDiscoveryCandidate, + type CalendarCalDavDiscoveryPayload, + type CalendarCalDavSyncDirection, + type CalendarCollection, + type CalendarCollectionDeletePayload, + type CalendarCredentialEnvelope, + type CalendarDeleteEventAction, + type CalendarSyncSource, + type CalendarSyncSourceCreatePayload, + type CalendarSyncSourceKind, + type CalendarSyncSourceUpdatePayload, +} from "../../api/calendar"; +import { + DEFAULT_CALENDAR_COLOR, + calendarDraftKey, + dateTimeLabel, + errorText, + normalizeHexColor, +} from "./calendarViewModel"; + +export type CalendarSourceMode = "local" | CalendarSyncSourceKind; +type CalendarSourceSwitchMode = + | "local" + | "caldav" + | "ics" + | "graph" + | "ews"; +export type CalendarCollectionDialogState = + | { kind: "create" } + | { + kind: "edit"; + calendar: CalendarCollection; + eventCount: number | null; + loadingEventCount: boolean; + }; +export type CalendarDeleteDialogState = { + calendar: CalendarCollection; + eventCount: number | null; + loadingEventCount: boolean; +}; +export type CalendarCalDavFormPayload = { + collection_url: string; + dav_url: string; + display_name: string; + auth_type: CalendarCalDavAuthType; + username: string; + credential_envelope_id: string; + password: string; + bearer_token: string; + sync_enabled: boolean; + sync_interval_seconds: number; + sync_direction: CalendarCalDavSyncDirection; + conflict_policy: CalendarCalDavConflictPolicy; +}; +export type CalendarCollectionFormPayload = { + sourceMode: CalendarSourceMode; + name: string; + color: string; + caldav: CalendarCalDavFormPayload; +}; + +export function CalendarCollectionDialog({ + state, + settings, + source, + saving, + syncingSourceId, + canWrite, + canDelete, + canManageSources, + canSyncSources, + onCancel, + onSave, + onRequestDelete, + onSync, + onDiscover + + + + + + + + + + + + + + +}: {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;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { + const calendar = state.kind === "edit" ? state.calendar : null; + const isEdit = Boolean(calendar); + const [sourceMode, setSourceMode] = useState(source ? source.source_kind : "local"); + const [name, setName] = useState(calendar?.name ?? ""); + const [color, setColor] = useState(normalizeHexColor(calendar?.color) || DEFAULT_CALENDAR_COLOR); + const [davUrl, setDavUrl] = useState(source?.collection_url ?? ""); + const [collectionUrl, setCollectionUrl] = useState(source?.collection_url ?? ""); + const [displayName, setDisplayName] = useState(source?.display_name ?? ""); + const [authType, setAuthType] = useState(source?.auth_type ?? "basic"); + const [username, setUsername] = useState(source?.username ?? ""); + const [credentialEnvelopeId, setCredentialEnvelopeId] = useState(source?.credential_envelope_id ?? ""); + const [availableCredentials, setAvailableCredentials] = useState([]); + const [credentialsError, setCredentialsError] = useState(""); + const [password, setPassword] = useState(""); + const [bearerToken, setBearerToken] = useState(""); + const [syncEnabled, setSyncEnabled] = useState(source?.sync_enabled ?? true); + const [syncIntervalMinutes, setSyncIntervalMinutes] = useState(Math.max(1, Math.round((source?.sync_interval_seconds ?? 900) / 60))); + const [syncDirection, setSyncDirection] = useState(source?.sync_direction ?? "two_way"); + const [conflictPolicy, setConflictPolicy] = useState(source?.conflict_policy ?? "etag"); + const [discoveredCalendars, setDiscoveredCalendars] = useState([]); + const [selectedDiscoveredUrl, setSelectedDiscoveredUrl] = useState(source?.collection_url ?? ""); + const [discovering, setDiscovering] = useState(false); + const [discoveryError, setDiscoveryError] = useState(""); + const formId = "calendar-collection-form"; + const isExistingSyncSource = isEdit && Boolean(source); + const canEditSource = canManageSources; + const canEditMutableSourceSettings = canEditSource && !isExistingSyncSource; + const effectiveCollectionUrl = (collectionUrl || davUrl).trim(); + const effectiveAuthType = sourceMode === "graph" ? "bearer" : authType; + const needsSourceSecret = sourceMode !== "local" && ( + effectiveAuthType === "basic" && !source?.has_credential && !credentialEnvelopeId && !password.trim() || + effectiveAuthType === "bearer" && !source?.has_credential && !credentialEnvelopeId && !bearerToken.trim()); + + const sourceDetailsInvalid = sourceMode !== "local" && ( + !isExistingSyncSource && !canEditSource || + canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); + + const saveDisabled = + saving || + !canWrite || + !name.trim() || + sourceDetailsInvalid; + + const syncing = source ? syncingSourceId === source.id : false; + + const collectionDraft = { + sourceMode, + name, + color, + davUrl, + collectionUrl, + displayName, + authType, + username, + credentialEnvelopeId, + password, + bearerToken, + syncEnabled, + syncIntervalMinutes, + syncDirection, + conflictPolicy + }; + const initialCollectionDraftKey = useMemo(() => calendarDraftKey(collectionDraft), []); + const collectionDirty = calendarDraftKey(collectionDraft) !== initialCollectionDraftKey; + + useUnsavedDraftGuard({ + dirty: collectionDirty, + onSave: saveCurrent, + onDiscard: onCancel + }); + + useEffect(() => { + if (sourceMode === "local" || !canManageSources) { + setAvailableCredentials([]); + setCredentialsError(""); + return; + } + let active = true; + listCalendarCredentials(settings, source?.id) + .then((response) => { + if (active) setAvailableCredentials(response.credentials); + }) + .catch((err) => { + if (active) { + setAvailableCredentials([]); + setCredentialsError(errorText(err)); + } + }); + return () => { + active = false; + }; + }, [canManageSources, settings.accessToken, settings.apiBaseUrl, settings.apiKey, source?.id, sourceMode]); + + function currentPayload(): CalendarCollectionFormPayload { + return { + sourceMode, + name, + color, + caldav: { + collection_url: effectiveCollectionUrl, + dav_url: davUrl, + display_name: displayName, + auth_type: effectiveAuthType, + username, + credential_envelope_id: credentialEnvelopeId, + password, + bearer_token: bearerToken, + sync_enabled: syncEnabled, + sync_interval_seconds: syncIntervalMinutes * 60, + sync_direction: syncDirection, + conflict_policy: conflictPolicy + } + }; + } + + async function saveCurrent(): Promise { + if (saveDisabled) return false; + return onSave(currentPayload()); + } + + function submit(formEvent: FormEvent) { + formEvent.preventDefault(); + void saveCurrent(); + } + + function selectSourceMode(next: CalendarSourceMode) { + setSourceMode(next); + setDiscoveryError(""); + setDiscoveredCalendars([]); + if (next === "graph") { + setAuthType("bearer"); + setSyncDirection("inbound"); + if (!davUrl.trim()) handleDavUrlChange("me/calendar"); + return; + } + if (next === "ews") { + setAuthType("basic"); + setSyncDirection("inbound"); + if (!davUrl.trim()) handleDavUrlChange("https://exchange.example.org/EWS/Exchange.asmx"); + return; + } + if (next === "ics" || next === "webcal") { + setAuthType("none"); + setSyncDirection("inbound"); + return; + } + if (next === "caldav") { + setSyncDirection("two_way"); + } + } + + function handleDavUrlChange(next: string) { + setDavUrl(next); + setCollectionUrl(next); + setSelectedDiscoveredUrl(""); + setDiscoveredCalendars([]); + setDiscoveryError(""); + } + + function applyDiscoveredCalendar(candidate: CalendarCalDavDiscoveryCandidate) { + setSelectedDiscoveredUrl(candidate.collection_url); + setCollectionUrl(candidate.collection_url); + if (candidate.display_name) { + if (!isExistingSyncSource) setDisplayName(candidate.display_name); + if (!isEdit && !name.trim()) setName(candidate.display_name); + } + const discoveredColor = normalizeHexColor(candidate.color); + if (!isEdit && discoveredColor) setColor(discoveredColor); + } + + function handleDiscoveredCalendarChange(event: ChangeEvent) { + const candidate = discoveredCalendars.find((item) => item.collection_url === event.target.value); + if (candidate) applyDiscoveredCalendar(candidate); + } + + async function handleDiscover() { + const url = davUrl.trim(); + if (!url) return; + setDiscovering(true); + setDiscoveryError(""); + try { + const payload: CalendarCalDavDiscoveryPayload = { + url, + source_id: source?.id ?? null, + auth_type: authType, + username: authType === "basic" ? username.trim() || null : null + }; + if (credentialEnvelopeId) payload.credential_ref = credentialEnvelopeRef(credentialEnvelopeId); + if (!credentialEnvelopeId && authType === "basic" && password.trim()) payload.password = password; + if (!credentialEnvelopeId && authType === "bearer" && bearerToken.trim()) payload.bearer_token = bearerToken; + const response = await onDiscover(payload); + setDiscoveredCalendars(response.calendars); + if (response.calendars.length > 0) { + applyDiscoveredCalendar(response.calendars[0]); + } else { + setDiscoveryError("i18n:govoplan-calendar.no_calendar_collections_found.6453624a"); + } + } catch (err) { + setDiscoveryError(errorText(err)); + } finally { + setDiscovering(false); + } + } + + return ( + +
+ {calendar && canDelete && + + } +
+
+ + +
+ + }> + +
+ {sourceMode !== "local" && !canManageSources &&

i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa

} + {!isEdit && + + } +
+ + +
+ {sourceMode === "local" ? +
+

i18n:govoplan-calendar.local_calendar.ed3f72f8

+

i18n:govoplan-calendar.events_are_stored_in_govoplan_and_are_not_synced.3660e504

+
: + +
+
+

{calendarSourcePaneTitle(sourceMode)}

+ {source && + + {syncing ? "i18n:govoplan-calendar.syncing.4ae6fa22" : source.last_status || "i18n:govoplan-calendar.not_synced.4c205136"} + + } +
+
+ + + {effectiveAuthType !== "none" && + + } + {credentialsError &&

{credentialsError}

} + {effectiveAuthType === "basic" && + <> + + {!credentialEnvelopeId && + + } + + } + {effectiveAuthType === "bearer" && !credentialEnvelopeId && + + } +
+ {sourceMode === "caldav" && + + } + {effectiveCollectionUrl && {effectiveCollectionUrl}} +
+
+ {discoveryError &&

{discoveryError}

} + {sourceMode === "caldav" && discoveredCalendars.length > 0 && + + } +
+ i18n:govoplan-calendar.advanced.4d064726 +
+ +
+
+ + + + +
+
+ {source && +
+
+
i18n:govoplan-calendar.last_attempt.82aee111
{source.last_attempt_at ? dateTimeLabel(new Date(source.last_attempt_at)) : "i18n:govoplan-calendar.never.80c3052d"}
+
i18n:govoplan-calendar.last_sync.ef0ef267
{source.last_synced_at ? dateTimeLabel(new Date(source.last_synced_at)) : "i18n:govoplan-calendar.never.80c3052d"}
+
i18n:govoplan-calendar.next_sync.88c7af72
{source.next_sync_at && source.sync_enabled ? dateTimeLabel(new Date(source.next_sync_at)) : "i18n:govoplan-calendar.not_scheduled.9c367369"}
+
i18n:govoplan-calendar.credential.8bede3ea
{source.has_credential ? "i18n:govoplan-calendar.configured.668c5fff" : "i18n:govoplan-calendar.not_configured.811931bb"}
+
+ {source.last_error &&

{source.last_error}

} +
+ + +
+
+ } + {needsSourceSecret &&

i18n:govoplan-calendar.enter_a_password_or_token_for_this_source.74c09a54

} +
+ } + +
); + +} + +export function CalendarCollectionDeleteDialog({ + state, + calendars, + syncSources, + saving, + onCancel, + onDelete + + + + + + +}: {state: CalendarDeleteDialogState;calendars: CalendarCollection[];syncSources: CalendarSyncSource[];saving: boolean;onCancel: () => void;onDelete: (calendar: CalendarCollection, payload: CalendarCollectionDeletePayload) => Promise;}) { + const { calendar, eventCount, loadingEventCount } = state; + const syncSourceByCalendarId = new Map(syncSources.map((source) => [source.calendar_id, source])); + const source = syncSourceByCalendarId.get(calendar.id) ?? null; + const moveTargets = calendars.filter((item) => + item.id !== calendar.id && calendarMoveTargetIsSupported(source, syncSourceByCalendarId.get(item.id) ?? null) + ); + const firstMoveTargetId = moveTargets[0]?.id || ""; + const hasEvents = (eventCount ?? 0) > 0; + const canMoveEvents = hasEvents && moveTargets.length > 0; + const [eventAction, setEventAction] = useState("delete"); + const [targetCalendarId, setTargetCalendarId] = useState(firstMoveTargetId); + const [makeTargetDefault, setMakeTargetDefault] = useState(calendar.is_default && Boolean(firstMoveTargetId)); + 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); + + 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 + }); + } + + return ( + + + + + }> + +
+

+ {loadingEventCount ? + "i18n:govoplan-calendar.loading_event_count.716ad3c2" : + calendarDeleteWarning(calendar, eventCount ?? 0, effectiveEventAction, targetCalendarId, moveTargets)} +

+ {!loadingEventCount && eventCount === null &&

i18n:govoplan-calendar.event_count_could_not_be_loaded_the_backend_will.163ff055

} + {canMoveEvents && +
+ {eventCount} event{eventCount === 1 ? "" : "s"} + + + {eventAction === "move" && + <> + + {calendar.is_default && + + } +

{calendarBulkMoveConsequence(externalAction)}

+ + } +
+ } + {!loadingEventCount && hasEvents && moveTargets.length === 0 && +

{source ? + "i18n:govoplan-calendar.no_local_target_calendar_is_available_add_a_local_.fad3cb65" : + "i18n:govoplan-calendar.no_compatible_target_calendar_is_available_add_a_l.1cf6e47b"}

+ } +
+
); + +} + + +export function syncSourceCreatePayload(sourceMode: CalendarSourceMode, payload: CalendarCalDavFormPayload): Omit { + const sourceKind = syncSourceKindForMode(sourceMode, payload.collection_url); + const result: Omit = { + source_kind: sourceKind, + collection_url: payload.collection_url.trim(), + display_name: payload.display_name.trim() || null, + auth_type: sourceKind === "graph" ? "bearer" : payload.auth_type, + username: sourceKind !== "graph" && payload.auth_type === "basic" ? payload.username.trim() || null : null, + sync_enabled: payload.sync_enabled, + sync_interval_seconds: Math.max(60, payload.sync_interval_seconds), + sync_direction: sourceKind === "caldav" ? payload.sync_direction : "inbound", + conflict_policy: payload.conflict_policy, + metadata: {} + }; + if (payload.credential_envelope_id) { + result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id); + } else if (result.auth_type === "basic" && payload.password.trim()) { + result.password = payload.password; + } else if (result.auth_type === "bearer" && payload.bearer_token.trim()) { + result.bearer_token = payload.bearer_token; + } + return result; +} + +export function syncSourceConnectionUpdatePayload(payload: CalendarCalDavFormPayload): CalendarSyncSourceUpdatePayload { + const result: CalendarSyncSourceUpdatePayload = { + collection_url: payload.collection_url.trim(), + auth_type: payload.auth_type, + username: payload.auth_type === "basic" ? payload.username.trim() || null : null + }; + if (payload.credential_envelope_id) { + result.credential_ref = credentialEnvelopeRef(payload.credential_envelope_id); + } else if (payload.auth_type === "basic" && payload.password.trim()) { + result.password = payload.password; + } else if (payload.auth_type === "bearer" && payload.bearer_token.trim()) { + result.bearer_token = payload.bearer_token; + } + return result; +} + +function credentialEnvelopeRef(credentialId: string): string { + return `credential-envelope:${credentialId}`; +} + +function syncSourceKindForMode(sourceMode: CalendarSourceMode, collectionUrl: string): CalendarSyncSourceKind { + if (sourceMode === "ics" && collectionUrl.trim().toLowerCase().startsWith("webcal://")) return "webcal"; + return sourceMode === "local" ? "caldav" : sourceMode; +} + +function sourceSwitchMode(sourceMode: CalendarSourceMode): CalendarSourceSwitchMode { + return sourceMode === "webcal" ? "ics" : sourceMode; +} + +function syncTransientPayload(authType: CalendarCalDavAuthType, password: string, bearerToken: string) { + if (authType === "basic" && password.trim()) return { password }; + if (authType === "bearer" && bearerToken.trim()) return { bearer_token: bearerToken }; + return {}; +} + +function calendarSourcePaneTitle(sourceMode: CalendarSourceMode): string { + if (sourceMode === "caldav") return "i18n:govoplan-calendar.caldav_source.459ed16a"; + if (sourceMode === "ics" || sourceMode === "webcal") return "i18n:govoplan-calendar.ics_webcal_subscription.03bc0d63"; + if (sourceMode === "graph") return "i18n:govoplan-calendar.microsoft_graph_source.ec4f1383"; + if (sourceMode === "ews") return "i18n:govoplan-calendar.exchange_web_services_source.53caabf3"; + return "i18n:govoplan-calendar.local_calendar.ed3f72f8"; +} + +function calendarSourceUrlLabel(sourceMode: CalendarSourceMode): string { + if (sourceMode === "caldav") return "i18n:govoplan-calendar.dav_url.4205e180"; + if (sourceMode === "graph") return "i18n:govoplan-calendar.graph_calendar_url.e59607f3"; + if (sourceMode === "ews") return "i18n:govoplan-calendar.ews_endpoint.a3273983"; + return "i18n:govoplan-calendar.subscription_url.b8b6a1a0"; +} + +function calendarSourceUrlPlaceholder(sourceMode: CalendarSourceMode): string { + if (sourceMode === "caldav") return "https://cloud.example.org/remote.php/dav"; + if (sourceMode === "graph") return "me/calendar or https://graph.microsoft.com/v1.0/me/calendar/events/delta"; + if (sourceMode === "ews") return "https://exchange.example.org/EWS/Exchange.asmx"; + return "webcal://example.org/calendar.ics or https://example.org/calendar.ics"; +} + +function calendarDeleteActionLabel(calendar: CalendarCollection): string { + return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove.e963907d" : "i18n:govoplan-calendar.delete.f6fdbe48"; +} + +function calendarEventDeleteOptionLabel(calendar: CalendarCollection): string { + return calendarIsExternal(calendar) ? "i18n:govoplan-calendar.remove_local_events_with_this_calendar.fb8831e1" : "i18n:govoplan-calendar.delete_events_with_this_calendar.cc0e1287"; +} + +function calendarMoveTargetIsSupported( +source: CalendarSyncSource | null, +targetSource: CalendarSyncSource | null) +: boolean { + if (source) return targetSource === null; + return targetSource === null || calendarSyncSourceAcceptsCopies(targetSource); +} + +function calendarSyncSourceAcceptsCopies(source: CalendarSyncSource): boolean { + return source.source_kind === "caldav" && source.sync_enabled && source.sync_direction === "two_way"; +} + +function calendarBulkMoveExternalAction( +source: CalendarSyncSource | null, +targetSource: CalendarSyncSource | null) +: CalendarBulkMoveExternalAction | undefined { + if (source && !targetSource) return "detach_keep_remote"; + if (!source && targetSource && calendarSyncSourceAcceptsCopies(targetSource)) return "copy_to_remote"; + 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"; + 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"; + return "i18n:govoplan-calendar.events_remain_in_govoplan_no_external_calendar_is_.62cb5937"; +} + +function calendarDeleteWarning( +calendar: CalendarCollection, +eventCount: number, +eventAction: CalendarDeleteEventAction, +targetCalendarId: string, +moveTargets: CalendarCollection[]) +: string { + const action = calendarIsExternal(calendar) ? "i18n:govoplan-calendar.removing.b7d2c38b" : "i18n:govoplan-calendar.deleting.2cda36c9"; + const eventWord = eventCount === 1 ? "i18n:govoplan-calendar.event" : "i18n:govoplan-calendar.events"; + if (eventAction === "move") { + const target = moveTargets.find((item) => item.id === targetCalendarId); + return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_move_value_value_to_val.6c87e1c1", { value0: action, value1: eventCount, value2: eventWord, value3: target?.name || "i18n:govoplan-calendar.the_selected_calendar.67caa12f" }); + } + if (calendarIsExternal(calendar)) { + return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_remove_value_local_valu.8f21a59e", { value0: action, value1: eventCount, value2: eventWord }); + } + return i18nMessage("i18n:govoplan-calendar.value_this_calendar_will_delete_value_value.7869d1f1", { value0: action, value1: eventCount, value2: eventWord }); +} + +function calendarIsExternal(calendar: CalendarCollection): boolean { + const metadata = calendar.metadata; + if (!metadata || typeof metadata !== "object") return false; + const sourceKind = metadataText(metadata, "source_kind") || metadataText(metadata, "sourceKind") || metadataText(metadata, "connector_kind") || metadataText(metadata, "connectorKind"); + if (sourceKind && sourceKind !== "local") return true; + const connectionKind = metadataText(metadata, "connection_kind") || metadataText(metadata, "connectionKind"); + if (connectionKind && connectionKind !== "local") return true; + return Boolean( + metadataFlag(metadata, "external") || + metadataFlag(metadata, "remote") || + metadataFlag(metadata, "sync") || + metadataFlag(metadata, "caldav") || + metadataText(metadata, "connector_id") || + metadataText(metadata, "connectorId") || + metadataText(metadata, "sync_href") || + metadataText(metadata, "syncHref") + ); +} + +function metadataText(metadata: Record, key: string): string { + const value = metadata[key]; + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function metadataFlag(metadata: Record, key: string): boolean { + return metadata[key] === true; +} diff --git a/webui/src/features/calendar/CalendarEventDialog.tsx b/webui/src/features/calendar/CalendarEventDialog.tsx new file mode 100644 index 0000000..c61e904 --- /dev/null +++ b/webui/src/features/calendar/CalendarEventDialog.tsx @@ -0,0 +1,562 @@ +import { + useMemo, + useState, + type FormEvent, +} from "react"; +import { Trash2 } from "lucide-react"; +import { + Button, + DateField, + Dialog, + TimeField, + ToggleSwitch, + useUnsavedDraftGuard, +} from "@govoplan/core-webui"; +import type { + CalendarCollection, + CalendarEvent, + CalendarEventCreatePayload, +} from "../../api/calendar"; +import { + addDays, + addHours, + addMinutesToTime, + calendarDraftKey, + errorText, + localDateTime, + startOfDay, + toInputDate, + toInputTime, +} from "./calendarViewModel"; + +type CalendarEventEndMode = "end" | "duration"; +type CalendarEventAdvancedPayload = Pick< + CalendarEventCreatePayload, + | "organizer" + | "attendees" + | "categories" + | "rrule" + | "rdate" + | "exdate" + | "reminders" + | "attachments" + | "related_to" + | "icalendar" + | "metadata" +>; + +export function CalendarEventDialog({ + calendars, + defaultCalendarId, + event, + focusDate, + saving, + canWrite, + canDelete, + onCancel, + onSave, + onDelete + + + + + + + + + + + +}: {calendars: CalendarCollection[];defaultCalendarId: string;event: CalendarEvent | null;focusDate: Date;saving: boolean;canWrite: boolean;canDelete: boolean;onCancel: () => void;onSave: (payload: CalendarEventCreatePayload, event: CalendarEvent | null) => Promise;onDelete: (event: CalendarEvent) => Promise;}) { + const initialStart = event ? new Date(event.start_at) : focusDate; + const initialEnd = event?.end_at ? new Date(event.end_at) : addHours(initialStart, 1); + const initialAllDayEnd = event?.all_day && event.end_at ? addDays(startOfDay(initialEnd), -1) : initialEnd; + const initialDurationSeconds = event?.duration_seconds ?? Math.max(3600, Math.round((initialEnd.getTime() - initialStart.getTime()) / 1000)); + const [summary, setSummary] = useState(event?.summary ?? ""); + const [calendarId, setCalendarId] = useState(event?.calendar_id ?? defaultCalendarId); + const [description, setDescription] = useState(event?.description ?? ""); + const [location, setLocation] = useState(event?.location ?? ""); + const [startDate, setStartDate] = useState(toInputDate(initialStart)); + const [endDate, setEndDate] = useState(toInputDate(initialAllDayEnd < initialStart ? initialStart : initialAllDayEnd)); + const [startTime, setStartTime] = useState(toInputTime(initialStart)); + const [endTime, setEndTime] = useState(toInputTime(initialEnd)); + const [allDay, setAllDay] = useState(event?.all_day ?? false); + const [endMode, setEndMode] = useState(event && eventUsesDuration(event) ? "duration" : "end"); + const [durationSeconds, setDurationSeconds] = useState(String(initialDurationSeconds)); + const [uid, setUid] = useState(event?.uid ?? ""); + const [recurrenceId, setRecurrenceId] = useState(event?.recurrence_id ?? ""); + const [sequence, setSequence] = useState(String(event?.sequence ?? 0)); + const [status, setStatus] = useState(event?.status ?? "CONFIRMED"); + const [transparency, setTransparency] = useState(event?.transparency ?? "OPAQUE"); + const [classification, setClassification] = useState(event?.classification ?? "PUBLIC"); + const [timezone, setTimezone] = useState(event?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"); + const [categoriesText, setCategoriesText] = useState((event?.categories ?? []).join(", ")); + const [organizerJson, setOrganizerJson] = useState(jsonTextareaValue(event?.organizer ?? null)); + const [attendeesJson, setAttendeesJson] = useState(jsonTextareaValue(event?.attendees ?? [])); + const [rruleText, setRruleText] = useState(event?.rrule ? serializeRRuleText(event.rrule) : ""); + const [rdateJson, setRdateJson] = useState(jsonTextareaValue(event?.rdate ?? [])); + const [exdateJson, setExdateJson] = useState(jsonTextareaValue(event?.exdate ?? [])); + const [remindersJson, setRemindersJson] = useState(jsonTextareaValue(event?.reminders ?? [])); + const [attachmentsJson, setAttachmentsJson] = useState(jsonTextareaValue(event?.attachments ?? [])); + const [relatedToJson, setRelatedToJson] = useState(jsonTextareaValue(event?.related_to ?? [])); + const [sourceKind, setSourceKind] = useState(event?.source_kind ?? "local"); + const [sourceHref, setSourceHref] = useState(event?.source_href ?? ""); + const [etag, setEtag] = useState(event?.etag ?? ""); + const [icalendarJson, setICalendarJson] = useState(jsonTextareaValue(event?.icalendar ?? {})); + const [metadataJson, setMetadataJson] = useState(jsonTextareaValue(event?.metadata ?? {})); + const [formError, setFormError] = useState(""); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const formId = "calendar-event-form"; + const eventDraft = { + summary, + calendarId, + description, + location, + startDate, + endDate, + startTime, + endTime, + allDay, + endMode, + durationSeconds, + uid, + recurrenceId, + sequence, + status, + transparency, + classification, + timezone, + categoriesText, + organizerJson, + attendeesJson, + rruleText, + rdateJson, + exdateJson, + remindersJson, + attachmentsJson, + relatedToJson, + sourceKind, + sourceHref, + etag, + icalendarJson, + metadataJson + }; + const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []); + const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey; + + useUnsavedDraftGuard({ + dirty: eventDirty, + onSave: saveCurrent, + onDiscard: onCancel + }); + + function handleAllDayChange(next: boolean) { + setAllDay(next); + if (next) { + setStartTime("00:00"); + setEndTime("00:00"); + } else if (startTime === "00:00" && endTime === "00:00") { + setStartTime("09:00"); + setEndTime("10:00"); + } + } + + function handleStartDateChange(next: string) { + setStartDate(next); + if (endDate < next) setEndDate(next); + } + + function handleStartTimeChange(next: string) { + setStartTime(next); + if (startDate === endDate && endTime <= next) setEndTime(addMinutesToTime(next, 60)); + } + + async function saveCurrent(): Promise { + setFormError(""); + const startAt = allDay ? localDateTime(startDate, "00:00") : localDateTime(startDate, startTime); + const parsedDurationSeconds = Math.max(0, Number(durationSeconds) || 0); + const usesDuration = !allDay && endMode === "duration"; + const endAt = allDay ? + addDays(localDateTime(endDate, "00:00"), 1) : + usesDuration ? + new Date(startAt.getTime() + parsedDurationSeconds * 1000) : + localDateTime(endDate, endTime); + if (usesDuration && parsedDurationSeconds <= 0) { + setFormError("i18n:govoplan-calendar.duration_must_be_greater_than_zero.519292f7"); + return false; + } + if (endAt <= startAt) { + setFormError(allDay ? "i18n:govoplan-calendar.end_date_must_be_on_or_after_start_date.a2518865" : "i18n:govoplan-calendar.end_date_and_time_must_be_after_start_date_and_t.daf31831"); + return false; + } + let advanced: CalendarEventAdvancedPayload; + try { + advanced = { + organizer: parseJsonObjectOrNull(organizerJson, "i18n:govoplan-calendar.organizer.debd1720"), + attendees: parseJsonArray(attendeesJson, "i18n:govoplan-calendar.attendees.a45a0962"), + categories: commaSeparatedValues(categoriesText), + rrule: parseRRuleInput(rruleText), + rdate: parseJsonArray(rdateJson, "RDATE"), + exdate: parseJsonArray(exdateJson, "EXDATE"), + reminders: parseJsonArray(remindersJson, "i18n:govoplan-calendar.reminders.ae8c3939"), + attachments: parseJsonArray(attachmentsJson, "i18n:govoplan-calendar.attachments.6771ade6"), + related_to: parseJsonArray(relatedToJson, "i18n:govoplan-calendar.related_to.0e7989ff"), + icalendar: withDurationMode(parseJsonObject(icalendarJson, "i18n:govoplan-calendar.icalendar.f388476d"), usesDuration), + metadata: parseJsonObject(metadataJson, "i18n:govoplan-calendar.metadata.251edc0e") + }; + } catch (err) { + setFormError(errorText(err)); + return false; + } + const payload: CalendarEventCreatePayload = { + calendar_id: calendarId, + summary, + description: description || null, + location: location || null, + sequence: Math.max(0, Number(sequence) || 0), + status, + transparency, + classification, + start_at: startAt.toISOString(), + end_at: endAt.toISOString(), + duration_seconds: usesDuration ? parsedDurationSeconds : null, + all_day: allDay, + timezone: timezone.trim() || null, + source_kind: sourceKind.trim() || "local", + source_href: sourceHref.trim() || null, + etag: etag.trim() || null, + ...advanced + }; + if (!event) { + if (uid.trim()) payload.uid = uid.trim(); + if (recurrenceId.trim()) payload.recurrence_id = recurrenceId.trim(); + } + return onSave(payload, event); + } + + function submit(formEvent: FormEvent) { + formEvent.preventDefault(); + void saveCurrent(); + } + + function requestDelete() { + if (!event) return; + if (!confirmingDelete) { + setConfirmingDelete(true); + return; + } + void onDelete(event); + } + + return ( + +
+ {event && canDelete && + + } +
+
+ + +
+ + }> + +
+ {formError &&

{formError}

} + + +