mirror of
https://github.com/Foundry376/Mailspring.git
synced 2026-02-06 20:28:25 +01:00
* 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>
244 lines
6.1 KiB
Markdown
244 lines
6.1 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// DO THIS
|
|
Actions.queueTask(MyTask.forUpdating({
|
|
model,
|
|
undoData: captureCurrentState(model),
|
|
description: 'My action',
|
|
}));
|
|
```
|
|
|
|
### Don't Forget to Capture State First
|
|
|
|
❌ **Wrong**: Capturing after modification
|
|
|
|
```typescript
|
|
event.ics = newIcs; // Modified first!
|
|
const undoData = { ics: event.ics }; // Too late - this is the new state
|
|
```
|
|
|
|
✅ **Correct**: Capture before modification
|
|
|
|
```typescript
|
|
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
|
|
|
|
## Related Files
|
|
|
|
- `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
|