From 10e9f81dc12c71343c87f848e87e8846bd637087 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:48:23 +0100 Subject: [PATCH] fix: ask before the event and task editors drop typed changes on close or back --- src/components/calendar/EventModal.tsx | 33 ++++++++- src/components/calendar/TasksSheet.tsx | 43 ++++++++--- .../__tests__/calendar-editor-discard.test.ts | 71 +++++++++++++++++++ src/lib/calendar-editor-discard.ts | 43 +++++++++++ src/lib/task-editor.ts | 10 +++ 5 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 src/lib/__tests__/calendar-editor-discard.test.ts create mode 100644 src/lib/calendar-editor-discard.ts diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index f3041203..f3d58ad5 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -79,6 +79,7 @@ import { SafeAreaModal } from '../SafeAreaModal'; import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import ToggleSwitch from '../ToggleSwitch'; +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; @@ -254,12 +255,34 @@ export function EventModal({ const [showEndTime, setShowEndTime] = React.useState(false); const [saving, setSaving] = React.useState(false); + // What the form holds against what it opened with: Close and Back ask + // before they drop changes (webmail 13f9f27c4). + const formSnapshot = React.useMemo(() => editorFormSnapshot({ + title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl, + }), [title, description, allDay, start, end, calendarId, attendees, recurrence, customRule, + reminders, useDefaults, preserved, sendInvitations, location, videoUrl]); + const [baseline, setBaseline] = React.useState(null); + // Before the reset below: the reset clears the baseline after this took + // the form of the render before it, and the next render takes the + // seeded form. + React.useEffect(() => { + if (!visible) { + if (baseline !== null) setBaseline(null); + return; + } + if (baseline === null) setBaseline(formSnapshot); + }, [visible, baseline, formSnapshot]); + const dirty = visible && editorFormChanged(baseline, formSnapshot); + const requestClose = () => askBeforeDiscard(dirty, t, onClose); + // Reset the form when the editor opens or switches to another event only. // `calendars` and `currentUserEmails` get new identities when a calendar // push or the alias lookup lands, which used to wipe what the user typed. const formKey = visible ? event?.id ?? 'new' : null; React.useEffect(() => { if (!visible) return; + setBaseline(null); if (event) { setTitle(event.title || ''); setDescription(event.description || ''); @@ -306,7 +329,11 @@ export function EventModal({ // The calendars can arrive after a new-event form opened. React.useEffect(() => { - if (visible && !calendarId && fallbackCalendarId) setCalendarId(fallbackCalendarId); + if (visible && !calendarId && fallbackCalendarId) { + setCalendarId(fallbackCalendarId); + // Not a change of the user's. + setBaseline(null); + } }, [visible, calendarId, fallbackCalendarId]); React.useEffect(() => { @@ -492,12 +519,12 @@ export function EventModal({ visible={visible} animationType="slide" presentationStyle="fullScreen" - onRequestClose={onClose} + onRequestClose={requestClose} > void>(onClose); const dragHandlers = useSheetDrag({ slideY, closedY: Dimensions.get('window').height, - onClose, + onClose: React.useCallback(() => closeRef.current(), []), }); const writableCalendars = React.useMemo( @@ -127,6 +132,12 @@ export function TasksSheet({ const [filter, setFilter] = React.useState('all'); const [editor, setEditor] = React.useState(() => emptyTaskEditor(defaultCalendarId)); + // The task as the editor was opened on it, to tell typed changes. + const [baseline, setBaseline] = React.useState(editor); + const seedEditor = (state: TaskEditorState) => { + setEditor(state); + setBaseline(state); + }; const [expanded, setExpanded] = React.useState(false); const [showDatePicker, setShowDatePicker] = React.useState(false); const [showTimePicker, setShowTimePicker] = React.useState(false); @@ -136,10 +147,10 @@ export function TasksSheet({ if (!visible) return; const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined; if (initial) { - setEditor(taskEditorFromTask(initial, defaultCalendarId)); + seedEditor(taskEditorFromTask(initial, defaultCalendarId)); setExpanded(true); } else { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(startNew); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -148,6 +159,7 @@ export function TasksSheet({ React.useEffect(() => { if (visible && !editor.calendarId && defaultCalendarId) { setEditor((e) => ({ ...e, calendarId: defaultCalendarId })); + setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId })); } }, [visible, editor.calendarId, defaultCalendarId]); @@ -189,10 +201,17 @@ export function TasksSheet({ }), [tasks]); const resetEditor = () => { - setEditor(emptyTaskEditor(defaultCalendarId)); + seedEditor(emptyTaskEditor(defaultCalendarId)); setExpanded(false); }; + const dirty = visible && taskEditorChanged(baseline, editor); + const springBack = () => { + Animated.spring(slideY, { toValue: 0, useNativeDriver: true, bounciness: 0 }).start(); + }; + const requestClose = () => askBeforeDiscard(dirty, t, onClose, springBack); + closeRef.current = requestClose; + const handleSave = async () => { if (!editor.title.trim() || !editor.calendarId || saving) return; setSaving(true); @@ -235,9 +254,9 @@ export function TasksSheet({ ]; return ( - + - + @@ -250,7 +269,7 @@ export function TasksSheet({ {t('calendar.tasks.label', 'Tasks')} )} {editor.id && ( - + askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}> {t('calendar.form.cancel', 'Cancel')} )} @@ -410,9 +429,11 @@ export function TasksSheet({ { - if (!onUpdate) return; - setEditor(taskEditorFromTask(task, defaultCalendarId)); - setExpanded(true); + if (!onUpdate || editor.id === task.id) return; + askBeforeDiscard(dirty, t, () => { + seedEditor(taskEditorFromTask(task, defaultCalendarId)); + setExpanded(true); + }); }} > diff --git a/src/lib/__tests__/calendar-editor-discard.test.ts b/src/lib/__tests__/calendar-editor-discard.test.ts new file mode 100644 index 00000000..8c2e5292 --- /dev/null +++ b/src/lib/__tests__/calendar-editor-discard.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const alert = vi.fn(); +vi.mock('react-native', () => ({ Alert: { alert: (...args: unknown[]) => alert(...args) } })); + +import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../calendar-editor-discard'; +import { emptyTaskEditor, taskEditorChanged, taskEditorFromTask } from '../task-editor'; + +const t = (_key: string, fallback?: string) => fallback ?? ''; + +type Button = { text: string; style?: string; onPress?: () => void }; +const buttons = (): Button[] => alert.mock.calls.at(-1)![2] as Button[]; + +beforeEach(() => alert.mockReset()); + +describe('askBeforeDiscard', () => { + it('closes at once when nothing was changed', () => { + const close = vi.fn(); + askBeforeDiscard(false, t, close); + expect(close).toHaveBeenCalledTimes(1); + expect(alert).not.toHaveBeenCalled(); + }); + + it('asks before it drops changes, and closes only on Discard', () => { + const close = vi.fn(); + const keep = vi.fn(); + askBeforeDiscard(true, t, close, keep); + expect(close).not.toHaveBeenCalled(); + expect(alert.mock.calls[0][0]).toBe('Discard changes?'); + const [keepButton, discardButton] = buttons(); + expect(keepButton.text).toBe('Keep editing'); + keepButton.onPress?.(); + expect(keep).toHaveBeenCalledTimes(1); + expect(close).not.toHaveBeenCalled(); + expect(discardButton.style).toBe('destructive'); + discardButton.onPress?.(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); + +describe('editor form changes', () => { + const form = (title: string, start = new Date('2026-11-02T10:00:00Z')) => + editorFormSnapshot({ title, start, attendees: [{ email: 'a@example.com' }] }); + + it('counts a typed title or a moved start as a change, the same form as none', () => { + const baseline = form('Lunch'); + expect(editorFormChanged(baseline, form('Lunch'))).toBe(false); + expect(editorFormChanged(baseline, form('Lunch with Ana'))).toBe(true); + expect(editorFormChanged(baseline, form('Lunch', new Date('2026-11-02T11:00:00Z')))).toBe(true); + }); + + it('counts nothing before the form was seeded', () => { + expect(editorFormChanged(null, form('Lunch'))).toBe(false); + }); +}); + +describe('taskEditorChanged', () => { + it('tells a typed title, note, due date or priority from the task as opened', () => { + const opened = taskEditorFromTask({ id: 't1', title: 'Pay rent', calendarIds: { c1: true } } as never, 'c1'); + expect(taskEditorChanged(opened, { ...opened })).toBe(false); + expect(taskEditorChanged(opened, { ...opened, title: 'Pay rent today' })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, description: 'IBAN' })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, due: new Date('2026-11-03T00:00:00Z') })).toBe(true); + expect(taskEditorChanged(opened, { ...opened, priority: 'high' })).toBe(true); + }); + + it('compares due dates by their instant', () => { + const a = { ...emptyTaskEditor('c1'), due: new Date('2026-11-03T09:00:00Z') }; + expect(taskEditorChanged(a, { ...a, due: new Date('2026-11-03T09:00:00Z') })).toBe(false); + }); +}); diff --git a/src/lib/calendar-editor-discard.ts b/src/lib/calendar-editor-discard.ts new file mode 100644 index 00000000..f824a81b --- /dev/null +++ b/src/lib/calendar-editor-discard.ts @@ -0,0 +1,43 @@ +import { Alert } from 'react-native'; + +type Translate = (key: string, fallback?: string) => string; + +/** + * The editor's form as one comparable string. Dates compare by their + * instant (JSON renders them as ISO strings); the fields are compared in + * the order given, which the editor keeps fixed. + */ +export function editorFormSnapshot(fields: Record): string { + return JSON.stringify(fields); +} + +/** Whether the form holds changes against `baseline`, the form it opened with (null: not seeded yet). */ +export function editorFormChanged(baseline: string | null, current: string): boolean { + return baseline !== null && baseline !== current; +} + +/** + * Run `discard` at once when nothing would be lost, else after the user + * confirms "Discard"; "Keep editing" runs `keep` (webmail 13f9f27c4: the + * event and task editors ask before X, Cancel or Escape drop their changes). + */ +export function askBeforeDiscard( + dirty: boolean, + t: Translate, + discard: () => void, + keep?: () => void, +): void { + if (!dirty) { + discard(); + return; + } + Alert.alert( + t('calendar.discard.title', 'Discard changes?'), + t('calendar.discard.message', "Your changes haven't been saved."), + [ + { text: t('calendar.discard.keep', 'Keep editing'), style: 'cancel', onPress: keep }, + { text: t('calendar.discard.confirm', 'Discard'), style: 'destructive', onPress: discard }, + ], + { cancelable: true, onDismiss: keep }, + ); +} diff --git a/src/lib/task-editor.ts b/src/lib/task-editor.ts index 8933c8a0..6171a76d 100644 --- a/src/lib/task-editor.ts +++ b/src/lib/task-editor.ts @@ -7,6 +7,7 @@ import { getEffectiveTimeZone, isValidTimeZone, } from './calendar-timezone'; +import { editorFormSnapshot } from './calendar-editor-discard'; export type PriorityLevel = 'none' | 'high' | 'medium' | 'low'; @@ -44,6 +45,15 @@ export function emptyTaskEditor(calendarId: string): TaskEditorState { return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId }; } +/** Whether the editor holds changes against the task it was opened on (or the empty form). */ +export function taskEditorChanged(baseline: TaskEditorState, current: TaskEditorState): boolean { + const key = (e: TaskEditorState) => editorFormSnapshot({ + id: e.id, title: e.title, description: e.description, due: e.due?.getTime() ?? null, + withTime: e.withTime, priority: e.priority, calendarId: e.calendarId, + }); + return key(baseline) !== key(current); +} + export function taskEditorFromTask( task: Pick, fallbackCalendarId: string,