Files
mailspring-mirror/docs/undo-redo-task-pattern.md
Ben Gotow 6760c0fd1e Implement Calendar event syncback for changes (#2574)
* Add implementation plan for calendar ICS helpers and recurring event dialog

This plan addresses two issues with calendar event drag persistence:

1. ICS data inconsistency - currently only cached fields are updated,
   not the underlying ICS data. Plan includes a new ics-event-helpers.ts
   module with functions to create, update, and modify ICS data.

2. Missing recurring event handling - no dialog asking whether to modify
   this occurrence or all occurrences. Plan includes a confirmation
   dialog component and integration with the drag persistence flow.

* Implement ICS helpers and recurring event dialog for calendar events

This change ensures calendar event modifications properly update the
underlying ICS data, not just the cached recurrenceStart/recurrenceEnd fields.

New files:
- ics-event-helpers.ts: Centralized helpers for ICS manipulation
  - createICSString: Create new ICS events from scratch
  - updateEventTimes: Update DTSTART/DTEND in existing ICS
  - createRecurrenceException: Create EXDATE + exception event
  - updateRecurringEventTimes: Shift entire recurring series
  - isRecurringEvent/getRecurrenceInfo: Query recurrence status

- recurring-event-dialog.ts: Dialog asking "this occurrence only"
  vs "all occurrences" when modifying recurring events

Modified files:
- mailspring-calendar.tsx: Updated _persistDragChange to use ICS helpers
  and show recurring event dialog. Added separate methods for simple
  event changes, single occurrence exceptions, and all-occurrences updates.
  Undo/redo now properly restores ICS data.

- calendar-event-popover.tsx: Updated saveEdits to use ICS helpers
  and handle recurring events with the same dialog flow.

- mailspring-exports.js/d.ts: Export ICSEventHelpers module

Fixes the issue where dragging events appeared to move them but the
underlying ICS data was not updated, causing sync inconsistencies.

* Add remaining work plan for calendar ICS implementation

Documents the prioritized list of remaining issues identified during
code review, including:
- High: recurring event deletion, quick event ICS generation, undo fix
- Medium: deprecated lifecycle, race conditions, timezone handling
- Low: validation, null checks, localization

Includes implementation details and recommended order of work.

* Add ICS generation, recurring deletion dialog, and fix deprecated lifecycle

Three improvements to calendar event handling:

1. Quick Event Creation (quick-event-popover.tsx):
   - Now generates proper ICS data using ICSEventHelpers.createICSString()
   - Sets ics and icsuid fields on new Event models
   - Exported generateUID() from ics-event-helpers.ts

2. Recurring Event Deletion (mailspring-calendar.tsx):
   - Added recurring event dialog to _onDeleteSelectedEvents
   - "This occurrence only" adds EXDATE to master event
   - "All occurrences" deletes the entire series
   - Added addExclusionDate() helper to ics-event-helpers.ts
   - Split deletion logic into _deleteEvent, _deleteOccurrence, _deleteEntireEvent

3. React Lifecycle Fix (mailspring-calendar.tsx):
   - Changed deprecated componentWillMount to componentDidMount
   - Improves React 18 compatibility

* Improve timezone handling and add validation in ICS helpers

Timezone improvements:
- createICALTime now properly preserves timezone from original events
- Uses convertToZone for correct timezone conversion instead of just
  attaching zone after creation
- formatDateOnly now uses local date (not UTC) for all-day events to
  prevent off-by-one day errors
- createICSString now accepts optional timezone parameter
- createRecurrenceException preserves master event's timezone

Validation improvements:
- Added validateTimestamps helper to check for valid positive numbers
- Validates that end time is not before start time
- Added null checks for VEVENT component in all functions that need it
- Functions now throw descriptive errors for malformed ICS

Also renamed formatDateTime to formatDateTimeUTC for clarity.

* Replace any types with proper TypeScript interfaces and extract recurring event utility

- Add UndoBlock interface to UndoRedoStore with public queueUndoBlock method
- Export UndoBlock from mailspring-exports for external use
- Replace `any[]` attendees type with EventAttendee interface
- Fix HTMLMetaElement cast in extractNotesFromDescription
- Add proper types to EventAttendeesInput props and methods
- Create recurring-event-actions.ts shared utility with:
  - modifySimpleEvent: Update non-recurring events
  - createOccurrenceException: Create exception for single occurrence
  - modifyAllOccurrences: Shift entire recurring series
  - modifyEventWithRecurringSupport: Main entry point with dialog handling
- Refactor mailspring-calendar.tsx to use shared utility (removes ~90 lines)
- Refactor calendar-event-popover.tsx to use shared utility (removes ~70 lines)
- Remove duplicate recurring event handling code from both components

* Use task-based undo for calendar events instead of custom UndoBlock

Following the established pattern from SyncbackMetadataTask, this change
enhances SyncbackEventTask to support undo by storing original event data:

- Add undoData and taskDescription attributes to SyncbackEventTask
- Add canBeUndone getter that returns true when undoData is provided
- Implement createUndoTask() that swaps current/original state (like redo)
- Update forUpdating() to accept undoData and description parameters

Update recurring-event-actions.ts to use task-based undo:
- Capture event snapshot before modifications
- Pass undoData to SyncbackEventTask.forUpdating() calls
- Pass description for undo toast display

Remove custom UndoBlock from UndoRedoStore:
- Remove exported UndoBlock interface
- Remove queueUndoBlock method
- Keep internal UndoBlock type for task-based undo

Update mailspring-calendar.tsx:
- Remove manual _registerUndoAction method
- Update keyboard handler to pass undoData to task
- Update drag handler to pass description to shared utility
- Undo is now automatically handled by UndoRedoStore._onQueue

This aligns with how other undoable tasks work (ChangeStarredTask,
SyncbackMetadataTask) and removes the need for custom undo handling.

* Add undo-redo task pattern documentation for future implementers

- Create docs/undo-redo-task-pattern.md with comprehensive guide
- Document toggle pattern (ChangeStarredTask) and snapshot pattern
- Include implementation steps, anti-patterns, and related files
- Update CLAUDE.md to reference the new documentation

* Fix recurring event handling and undo/redo issues in calendar

- Keyboard events (arrow keys) now show recurring event dialog like drag does
- Single occurrence deletion now supports undo via undoData
- Add newData snapshot to SyncbackEventTask for reliable redo
- Override createIdenticalTask to use captured snapshots

Fixes three PR review findings:
1. Arrow key changes bypassed recurring event dialog
2. _deleteOccurrence didn't pass undoData (not undoable)
3. Redo now uses captured snapshots instead of potentially stale event refs

* Improve TypeScript typing and add user error feedback

- Add type annotation to constructor props parameter
- Show error dialogs when calendar operations fail (delete, keyboard move, drag)
- Add documentation about Event.clone() deep clone requirement for undo/redo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 10:28:02 -06:00

6.1 KiB

Undo/Redo Task Pattern in Mailspring

This document explains how to implement undoable tasks in Mailspring following the established patterns.

Overview

Mailspring uses a task-based architecture where operations are represented as Task objects that get queued and executed by the sync engine. The UndoRedoStore automatically tracks tasks that can be undone and provides undo/redo functionality.

How It Works

Automatic Registration

When a task is queued via Actions.queueTask(), the UndoRedoStore._onQueue() listener checks if the task has canBeUndone = true. If so, it automatically registers the task for undo:

// In UndoRedoStore
_onQueue = (taskOrTasks: Task | Task[]) => {
  const tasks = taskOrTasks instanceof Array ? taskOrTasks : [taskOrTasks];

  if (tasks.every(t => t.canBeUndone)) {
    const block = {
      tasks: tasks,
      description: tasks.map(t => t.description()).join(', '),
      undo: () => {
        Actions.queueTasks(tasks.map(t => t.createUndoTask()));
      },
      redo: () => {
        Actions.queueTasks(tasks.map(t => t.createIdenticalTask()));
      },
    };
    this._onQueueBlock(block);
  }
};

Task Requirements

For a task to be undoable, it must:

  1. Have canBeUndone return true
  2. Implement createUndoTask() that returns a task to reverse the operation
  3. Implement description() for the undo toast message

Patterns

Pattern 1: Simple State Toggle (ChangeStarredTask)

For operations that toggle a boolean state:

export class ChangeStarredTask extends ChangeMailTask {
  starred: boolean;

  // Inherited from ChangeMailTask: canBeUndone defaults to true

  createUndoTask() {
    const task = super.createUndoTask();
    task.starred = !this.starred;  // Simply flip the flag
    return task;
  }
}

Pattern 2: State Snapshot (SyncbackMetadataTask, SyncbackEventTask)

For operations where the "inverse" isn't a simple toggle, store the original state:

interface EventSnapshot {
  ics: string;
  recurrenceStart: number;
  recurrenceEnd: number;
}

export class SyncbackEventTask extends Task {
  event: Event;
  undoData?: EventSnapshot;  // Original state before modification
  taskDescription?: string;

  static forUpdating({ event, undoData, description }) {
    return new SyncbackEventTask({
      event,
      calendarId: event.calendarId,
      accountId: event.accountId,
      undoData,
      taskDescription: description,
    });
  }

  get canBeUndone(): boolean {
    return !!this.undoData;  // Only undoable if we have original state
  }

  description(): string | null {
    return this.taskDescription || null;
  }

  createUndoTask(): SyncbackEventTask {
    // Restore the original state
    const restoredEvent = this.event.clone();
    restoredEvent.ics = this.undoData.ics;
    restoredEvent.recurrenceStart = this.undoData.recurrenceStart;
    restoredEvent.recurrenceEnd = this.undoData.recurrenceEnd;

    // The undo task stores current state so redo works
    const undoTaskUndoData: EventSnapshot = {
      ics: this.event.ics,
      recurrenceStart: this.event.recurrenceStart,
      recurrenceEnd: this.event.recurrenceEnd,
    };

    return new SyncbackEventTask({
      event: restoredEvent,
      calendarId: this.calendarId,
      accountId: this.accountId,
      undoData: undoTaskUndoData,
      taskDescription: localized('Undo %@', this.taskDescription),
    });
  }
}

Implementation Steps

Step 1: Add Undo Data Attributes

Add attributes to store the original state:

static attributes = {
  ...Task.attributes,

  undoData: Attributes.Obj({
    modelKey: 'undoData',
  }),
  taskDescription: Attributes.String({
    modelKey: 'taskDescription',
  }),
};

Step 2: Capture State Before Modification

Before modifying the model, capture its current state:

function modifyEvent(event: Event, newData: EventData) {
  // Capture BEFORE modifying
  const undoData = {
    ics: event.ics,
    recurrenceStart: event.recurrenceStart,
    recurrenceEnd: event.recurrenceEnd,
  };

  // Now modify
  event.ics = newData.ics;
  event.recurrenceStart = newData.start;
  event.recurrenceEnd = newData.end;

  // Queue with undo support
  Actions.queueTask(SyncbackEventTask.forUpdating({
    event,
    undoData,
    description: localized('Edit event'),
  }));
}

Step 3: Implement canBeUndone and createUndoTask

get canBeUndone(): boolean {
  return !!this.undoData;
}

createUndoTask(): YourTask {
  if (!this.undoData) {
    throw new Error('Cannot create undo task without undoData');
  }

  // Create task that restores original state
  // Store current state in the undo task's undoData for redo
}

Anti-Patterns to Avoid

Don't Use Custom UndoBlock

Wrong: Manually registering undo callbacks

// DON'T DO THIS
const undoBlock = {
  description: 'My action',
  undo: async () => { /* custom undo logic */ },
  redo: async () => { /* custom redo logic */ },
};
UndoRedoStore.queueUndoBlock(undoBlock);

Correct: Use task-based undo

// DO THIS
Actions.queueTask(MyTask.forUpdating({
  model,
  undoData: captureCurrentState(model),
  description: 'My action',
}));

Don't Forget to Capture State First

Wrong: Capturing after modification

event.ics = newIcs;  // Modified first!
const undoData = { ics: event.ics };  // Too late - this is the new state

Correct: Capture before modification

const undoData = { ics: event.ics };  // Capture original
event.ics = newIcs;  // Now modify

Testing Undo

  1. Perform the action
  2. Press Cmd/Ctrl+Z to undo
  3. Verify the model is restored to original state
  4. Press Cmd/Ctrl+Shift+Z to redo
  5. Verify the model has the modified state again
  • app/src/flux/stores/undo-redo-store.ts - UndoRedoStore implementation
  • app/src/flux/tasks/task.ts - Base Task class
  • app/src/flux/tasks/change-mail-task.ts - Base class for mail changes
  • app/src/flux/tasks/syncback-metadata-task.ts - Example of snapshot pattern
  • app/src/flux/tasks/syncback-event-task.ts - Calendar event undo implementation