fix: ask before the event and task editors drop typed changes on close or back

This commit is contained in:
Linus Rath
2026-10-31 03:48:23 +01:00
parent a19afe9b48
commit 10e9f81dc1
5 changed files with 186 additions and 14 deletions
+30 -3
View File
@@ -79,6 +79,7 @@ import { SafeAreaModal } from '../SafeAreaModal';
import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction'; import { useKeyboardObstruction } from '../../lib/use-keyboard-obstruction';
import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard'; import { editorKeyboardInset } from '../../lib/calendar-editor-keyboard';
import ToggleSwitch from '../ToggleSwitch'; import ToggleSwitch from '../ToggleSwitch';
import { askBeforeDiscard, editorFormChanged, editorFormSnapshot } from '../../lib/calendar-editor-discard';
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'; type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
@@ -254,12 +255,34 @@ export function EventModal({
const [showEndTime, setShowEndTime] = React.useState(false); const [showEndTime, setShowEndTime] = React.useState(false);
const [saving, setSaving] = 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<string | null>(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. // Reset the form when the editor opens or switches to another event only.
// `calendars` and `currentUserEmails` get new identities when a calendar // `calendars` and `currentUserEmails` get new identities when a calendar
// push or the alias lookup lands, which used to wipe what the user typed. // push or the alias lookup lands, which used to wipe what the user typed.
const formKey = visible ? event?.id ?? 'new' : null; const formKey = visible ? event?.id ?? 'new' : null;
React.useEffect(() => { React.useEffect(() => {
if (!visible) return; if (!visible) return;
setBaseline(null);
if (event) { if (event) {
setTitle(event.title || ''); setTitle(event.title || '');
setDescription(event.description || ''); setDescription(event.description || '');
@@ -306,7 +329,11 @@ export function EventModal({
// The calendars can arrive after a new-event form opened. // The calendars can arrive after a new-event form opened.
React.useEffect(() => { 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]); }, [visible, calendarId, fallbackCalendarId]);
React.useEffect(() => { React.useEffect(() => {
@@ -492,12 +519,12 @@ export function EventModal({
visible={visible} visible={visible}
animationType="slide" animationType="slide"
presentationStyle="fullScreen" presentationStyle="fullScreen"
onRequestClose={onClose} onRequestClose={requestClose}
> >
<SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}> <SafeAreaView style={styles.container} edges={['top', 'bottom']} onLayout={onContainerLayout}>
<View style={styles.header}> <View style={styles.header}>
<Pressable <Pressable
onPress={onClose} onPress={requestClose}
hitSlop={8} hitSlop={8}
style={styles.headerBtn} style={styles.headerBtn}
accessibilityRole="button" accessibilityRole="button"
+32 -11
View File
@@ -34,10 +34,12 @@ import {
emptyTaskEditor, emptyTaskEditor,
priorityToLevel, priorityToLevel,
submitTaskEditor, submitTaskEditor,
taskEditorChanged,
taskEditorFromTask, taskEditorFromTask,
type PriorityLevel, type PriorityLevel,
type TaskEditorState, type TaskEditorState,
} from '../../lib/task-editor'; } from '../../lib/task-editor';
import { askBeforeDiscard } from '../../lib/calendar-editor-discard';
import { isWritableCalendar } from '../../lib/calendar-editability'; import { isWritableCalendar } from '../../lib/calendar-editability';
import { useCalendarLocale } from '../../lib/calendar-locale'; import { useCalendarLocale } from '../../lib/calendar-locale';
import { formatCalendarDate } from '../../lib/date-format'; import { formatCalendarDate } from '../../lib/date-format';
@@ -113,10 +115,13 @@ export function TasksSheet({
// Centred on a wide window, clear of side insets in landscape. // Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets(); const sheetInsets = useSheetInsets();
const overlayOpacity = React.useRef(new Animated.Value(0)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current;
// Close, Back, a tap beside the sheet and a drag down ask before they drop
// a changed task (webmail 13f9f27c4); kept, a dragged sheet comes back.
const closeRef = React.useRef<() => void>(onClose);
const dragHandlers = useSheetDrag({ const dragHandlers = useSheetDrag({
slideY, slideY,
closedY: Dimensions.get('window').height, closedY: Dimensions.get('window').height,
onClose, onClose: React.useCallback(() => closeRef.current(), []),
}); });
const writableCalendars = React.useMemo( const writableCalendars = React.useMemo(
@@ -127,6 +132,12 @@ export function TasksSheet({
const [filter, setFilter] = React.useState<TaskFilter>('all'); const [filter, setFilter] = React.useState<TaskFilter>('all');
const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId)); const [editor, setEditor] = React.useState<TaskEditorState>(() => emptyTaskEditor(defaultCalendarId));
// The task as the editor was opened on it, to tell typed changes.
const [baseline, setBaseline] = React.useState<TaskEditorState>(editor);
const seedEditor = (state: TaskEditorState) => {
setEditor(state);
setBaseline(state);
};
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [showDatePicker, setShowDatePicker] = React.useState(false); const [showDatePicker, setShowDatePicker] = React.useState(false);
const [showTimePicker, setShowTimePicker] = React.useState(false); const [showTimePicker, setShowTimePicker] = React.useState(false);
@@ -136,10 +147,10 @@ export function TasksSheet({
if (!visible) return; if (!visible) return;
const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined; const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined;
if (initial) { if (initial) {
setEditor(taskEditorFromTask(initial, defaultCalendarId)); seedEditor(taskEditorFromTask(initial, defaultCalendarId));
setExpanded(true); setExpanded(true);
} else { } else {
setEditor(emptyTaskEditor(defaultCalendarId)); seedEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(startNew); setExpanded(startNew);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -148,6 +159,7 @@ export function TasksSheet({
React.useEffect(() => { React.useEffect(() => {
if (visible && !editor.calendarId && defaultCalendarId) { if (visible && !editor.calendarId && defaultCalendarId) {
setEditor((e) => ({ ...e, calendarId: defaultCalendarId })); setEditor((e) => ({ ...e, calendarId: defaultCalendarId }));
setBaseline((b) => (b.calendarId ? b : { ...b, calendarId: defaultCalendarId }));
} }
}, [visible, editor.calendarId, defaultCalendarId]); }, [visible, editor.calendarId, defaultCalendarId]);
@@ -189,10 +201,17 @@ export function TasksSheet({
}), [tasks]); }), [tasks]);
const resetEditor = () => { const resetEditor = () => {
setEditor(emptyTaskEditor(defaultCalendarId)); seedEditor(emptyTaskEditor(defaultCalendarId));
setExpanded(false); 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 () => { const handleSave = async () => {
if (!editor.title.trim() || !editor.calendarId || saving) return; if (!editor.title.trim() || !editor.calendarId || saving) return;
setSaving(true); setSaving(true);
@@ -235,9 +254,9 @@ export function TasksSheet({
]; ];
return ( return (
<SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}> <SafeAreaModal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={requestClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}> <Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.overlayPress} onPress={onClose} /> <Pressable style={styles.overlayPress} onPress={requestClose} />
</Animated.View> </Animated.View>
<Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}> <Animated.View style={[styles.sheet, { transform: [{ translateY: slideY }], ...sheetInsets }]}>
@@ -250,7 +269,7 @@ export function TasksSheet({
<ListChecks size={20} color={c.text} /> <ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text> <Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
<Pressable <Pressable
onPress={onClose} onPress={requestClose}
style={styles.closeBtn} style={styles.closeBtn}
hitSlop={10} hitSlop={10}
accessibilityRole="button" accessibilityRole="button"
@@ -359,7 +378,7 @@ export function TasksSheet({
</ScrollView> </ScrollView>
)} )}
{editor.id && ( {editor.id && (
<Pressable onPress={resetEditor} style={styles.cancelEdit}> <Pressable onPress={() => askBeforeDiscard(dirty, t, resetEditor)} style={styles.cancelEdit}>
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text> <Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
</Pressable> </Pressable>
)} )}
@@ -410,9 +429,11 @@ export function TasksSheet({
<Pressable <Pressable
style={styles.taskText} style={styles.taskText}
onPress={() => { onPress={() => {
if (!onUpdate) return; if (!onUpdate || editor.id === task.id) return;
setEditor(taskEditorFromTask(task, defaultCalendarId)); askBeforeDiscard(dirty, t, () => {
setExpanded(true); seedEditor(taskEditorFromTask(task, defaultCalendarId));
setExpanded(true);
});
}} }}
> >
<View style={styles.taskTitleRow}> <View style={styles.taskTitleRow}>
@@ -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);
});
});
+43
View File
@@ -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, unknown>): 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 },
);
}
+10
View File
@@ -7,6 +7,7 @@ import {
getEffectiveTimeZone, getEffectiveTimeZone,
isValidTimeZone, isValidTimeZone,
} from './calendar-timezone'; } from './calendar-timezone';
import { editorFormSnapshot } from './calendar-editor-discard';
export type PriorityLevel = 'none' | 'high' | 'medium' | 'low'; 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 }; 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( export function taskEditorFromTask(
task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>, task: Pick<CalendarEvent, 'id' | 'title' | 'description' | 'due' | 'timeZone' | 'showWithoutTime' | 'priority' | 'calendarIds'>,
fallbackCalendarId: string, fallbackCalendarId: string,