Implement complete recurring event drag modification system
- Add recurring edit modal with three modification options: • "Only this event" - Creates exception for single occurrence • "This and future events" - Splits series from occurrence forward • "All occurrences in this series" - Updates entire series time - Enhance backend update API to support series modifications: • Add update_action parameter for recurring event operations • Implement time-only updates that preserve original start dates • Convert timestamped occurrence UIDs to base UIDs for series updates • Preserve recurrence rules during series modifications - Fix recurring event drag operations: • Show modal for recurring events instead of direct updates • Handle EXDATE creation for single occurrence modifications • Support series splitting with UNTIL clause modifications • Maintain proper UID management for different modification types - Clean up debug logging and restore page refresh for data consistency 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -353,8 +353,12 @@ pub fn App() -> Html {
|
||||
new_start.format("%Y-%m-%d %H:%M"),
|
||||
new_end.format("%Y-%m-%d %H:%M")).into());
|
||||
|
||||
// Use the original UID for all updates
|
||||
let backend_uid = original_event.uid.clone();
|
||||
|
||||
if let Some(token) = (*auth_token).clone() {
|
||||
let original_event = original_event.clone();
|
||||
let backend_uid = backend_uid.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let calendar_service = CalendarService::new();
|
||||
|
||||
@@ -406,7 +410,7 @@ pub fn App() -> Html {
|
||||
match calendar_service.update_event(
|
||||
&token,
|
||||
&password,
|
||||
original_event.uid,
|
||||
backend_uid,
|
||||
original_event.summary.unwrap_or_default(),
|
||||
original_event.description.unwrap_or_default(),
|
||||
start_date,
|
||||
|
||||
@@ -250,6 +250,7 @@ pub fn Calendar(props: &CalendarProps) -> Html {
|
||||
on_event_context_menu={props.on_event_context_menu.clone()}
|
||||
on_calendar_context_menu={props.on_calendar_context_menu.clone()}
|
||||
on_create_event={Some(on_create_event)}
|
||||
on_create_event_request={props.on_create_event_request.clone()}
|
||||
on_event_update={Some(on_event_update)}
|
||||
context_menus_open={props.context_menus_open}
|
||||
time_increment={*time_increment}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod create_event_modal;
|
||||
pub mod sidebar;
|
||||
pub mod calendar_list_item;
|
||||
pub mod route_handler;
|
||||
pub mod recurring_edit_modal;
|
||||
|
||||
pub use login::Login;
|
||||
pub use calendar::Calendar;
|
||||
@@ -26,4 +27,5 @@ pub use calendar_context_menu::CalendarContextMenu;
|
||||
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
pub use sidebar::{Sidebar, ViewMode};
|
||||
pub use calendar_list_item::CalendarListItem;
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use route_handler::RouteHandler;
|
||||
pub use recurring_edit_modal::{RecurringEditModal, RecurringEditAction};
|
||||
93
src/components/recurring_edit_modal.rs
Normal file
93
src/components/recurring_edit_modal.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use yew::prelude::*;
|
||||
use chrono::NaiveDateTime;
|
||||
use crate::services::calendar_service::CalendarEvent;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum RecurringEditAction {
|
||||
ThisEvent,
|
||||
FutureEvents,
|
||||
AllEvents,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct RecurringEditModalProps {
|
||||
pub show: bool,
|
||||
pub event: CalendarEvent,
|
||||
pub new_start: NaiveDateTime,
|
||||
pub new_end: NaiveDateTime,
|
||||
pub on_choice: Callback<RecurringEditAction>,
|
||||
pub on_cancel: Callback<()>,
|
||||
}
|
||||
|
||||
#[function_component(RecurringEditModal)]
|
||||
pub fn recurring_edit_modal(props: &RecurringEditModalProps) -> Html {
|
||||
if !props.show {
|
||||
return html! {};
|
||||
}
|
||||
|
||||
let event_title = props.event.summary.as_ref().map(|s| s.as_str()).unwrap_or("Untitled Event");
|
||||
|
||||
let on_this_event = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::ThisEvent);
|
||||
})
|
||||
};
|
||||
|
||||
let on_future_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::FutureEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_all_events = {
|
||||
let on_choice = props.on_choice.clone();
|
||||
Callback::from(move |_| {
|
||||
on_choice.emit(RecurringEditAction::AllEvents);
|
||||
})
|
||||
};
|
||||
|
||||
let on_cancel = {
|
||||
let on_cancel = props.on_cancel.clone();
|
||||
Callback::from(move |_| {
|
||||
on_cancel.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal-content recurring-edit-modal">
|
||||
<div class="modal-header">
|
||||
<h3>{"Edit Recurring Event"}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{format!("You're modifying \"{}\" which is part of a recurring series.", event_title)}</p>
|
||||
<p>{"How would you like to apply this change?"}</p>
|
||||
|
||||
<div class="recurring-edit-options">
|
||||
<button class="btn btn-primary recurring-option" onclick={on_this_event}>
|
||||
<div class="option-title">{"This event only"}</div>
|
||||
<div class="option-description">{"Change only this occurrence"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_future_events}>
|
||||
<div class="option-title">{"This and future events"}</div>
|
||||
<div class="option-description">{"Change this occurrence and all future occurrences"}</div>
|
||||
</button>
|
||||
|
||||
<button class="btn btn-primary recurring-option" onclick={on_all_events}>
|
||||
<div class="option-title">{"All events in series"}</div>
|
||||
<div class="option-description">{"Change all occurrences in the series"}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick={on_cancel}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateT
|
||||
use std::collections::HashMap;
|
||||
use web_sys::MouseEvent;
|
||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||
use crate::components::{RecurringEditModal, RecurringEditAction, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct WeekViewProps {
|
||||
@@ -21,6 +22,8 @@ pub struct WeekViewProps {
|
||||
#[prop_or_default]
|
||||
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
|
||||
#[prop_or_default]
|
||||
pub on_create_event_request: Option<Callback<EventCreationData>>,
|
||||
#[prop_or_default]
|
||||
pub on_event_update: Option<Callback<(CalendarEvent, NaiveDateTime, NaiveDateTime)>>,
|
||||
#[prop_or_default]
|
||||
pub context_menus_open: bool,
|
||||
@@ -56,6 +59,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Drag state for event creation
|
||||
let drag_state = use_state(|| None::<DragState>);
|
||||
|
||||
// State for recurring event edit modal
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct PendingRecurringEdit {
|
||||
event: CalendarEvent,
|
||||
new_start: NaiveDateTime,
|
||||
new_end: NaiveDateTime,
|
||||
}
|
||||
|
||||
let pending_recurring_edit = use_state(|| None::<PendingRecurringEdit>);
|
||||
|
||||
// Helper function to get calendar color for an event
|
||||
let get_event_color = |event: &CalendarEvent| -> String {
|
||||
@@ -85,6 +98,111 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
|
||||
// Add the final midnight boundary to show where the day ends
|
||||
time_labels.push("12 AM".to_string());
|
||||
|
||||
|
||||
// Handlers for recurring event modification modal
|
||||
let on_recurring_choice = {
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
let on_event_update = props.on_event_update.clone();
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_create_event_request = props.on_create_event_request.clone();
|
||||
Callback::from(move |action: RecurringEditAction| {
|
||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||
match action {
|
||||
RecurringEditAction::ThisEvent => {
|
||||
// Create exception for this occurrence only
|
||||
|
||||
// 1. First, add EXDATE to the original series to exclude this occurrence
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
let mut updated_series = edit.event.clone();
|
||||
updated_series.exception_dates.push(edit.event.start);
|
||||
|
||||
// Keep the original series times unchanged - we're only adding EXDATE
|
||||
let original_start = edit.event.start.with_timezone(&chrono::Local).naive_local();
|
||||
let original_end = edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local();
|
||||
|
||||
web_sys::console::log_1(&format!("📅 Adding EXDATE {} to series '{}'",
|
||||
edit.event.start.format("%Y-%m-%d %H:%M:%S UTC"),
|
||||
edit.event.summary.as_deref().unwrap_or("Untitled")
|
||||
).into());
|
||||
|
||||
// Update the original series with the exception (times unchanged)
|
||||
update_callback.emit((updated_series, original_start, original_end));
|
||||
}
|
||||
|
||||
// 2. Then create the new single event using the update callback (to avoid refresh)
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
let mut new_event = edit.event.clone();
|
||||
new_event.uid = format!("{}-exception-{}", edit.event.uid, edit.event.start.timestamp());
|
||||
new_event.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc);
|
||||
new_event.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc));
|
||||
new_event.recurrence_rule = None; // This is now a single event
|
||||
new_event.exception_dates.clear(); // No exception dates for single event
|
||||
|
||||
// Use update callback to create the new event (should work without refresh)
|
||||
update_callback.emit((new_event, edit.new_start, edit.new_end));
|
||||
}
|
||||
},
|
||||
RecurringEditAction::FutureEvents => {
|
||||
// Split series and modify future events
|
||||
web_sys::console::log_1(&format!("🔄 Splitting series and modifying future: {}",
|
||||
edit.event.summary.as_deref().unwrap_or("Untitled")).into());
|
||||
// 1. Update original series to end before this occurrence
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
let mut original_series = edit.event.clone();
|
||||
|
||||
// Add UNTIL clause to end the series before this occurrence
|
||||
let until_date = edit.event.start - chrono::Duration::days(1);
|
||||
|
||||
// Parse existing RRULE and add UNTIL
|
||||
if let Some(rrule) = &original_series.recurrence_rule {
|
||||
let updated_rrule = if rrule.contains("UNTIL=") {
|
||||
// Replace existing UNTIL
|
||||
let re = regex::Regex::new(r"UNTIL=[^;]*").unwrap();
|
||||
re.replace(rrule, &format!("UNTIL={}", until_date.format("%Y%m%dT%H%M%SZ"))).to_string()
|
||||
} else {
|
||||
// Add UNTIL to existing rule
|
||||
format!("{};UNTIL={}", rrule, until_date.format("%Y%m%dT%H%M%SZ"))
|
||||
};
|
||||
original_series.recurrence_rule = Some(updated_rrule);
|
||||
}
|
||||
|
||||
// Update original series
|
||||
update_callback.emit((original_series, edit.event.start.with_timezone(&chrono::Local).naive_local(), edit.event.end.unwrap_or(edit.event.start).with_timezone(&chrono::Local).naive_local()));
|
||||
}
|
||||
|
||||
// 2. Create new series starting from this occurrence with modified times
|
||||
if let Some(update_callback) = &on_event_update {
|
||||
let mut new_series = edit.event.clone();
|
||||
new_series.uid = format!("{}-future-{}", edit.event.uid, edit.event.start.timestamp());
|
||||
new_series.start = chrono::DateTime::from_naive_utc_and_offset(edit.new_start, chrono::Utc);
|
||||
new_series.end = Some(chrono::DateTime::from_naive_utc_and_offset(edit.new_end, chrono::Utc));
|
||||
new_series.exception_dates.clear(); // New series has no exceptions
|
||||
|
||||
// Use update callback to create the new series (should work without refresh)
|
||||
update_callback.emit((new_series, edit.new_start, edit.new_end));
|
||||
}
|
||||
},
|
||||
RecurringEditAction::AllEvents => {
|
||||
// Modify the entire series
|
||||
let series_event = edit.event.clone();
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((series_event, edit.new_start, edit.new_end));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
pending_recurring_edit.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
let on_recurring_cancel = {
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
Callback::from(move |_| {
|
||||
pending_recurring_edit.set(None);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="week-view-container">
|
||||
@@ -204,6 +322,7 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let drag_state = drag_state_clone.clone();
|
||||
let on_create_event = props.on_create_event.clone();
|
||||
let on_event_update = props.on_event_update.clone();
|
||||
let pending_recurring_edit = pending_recurring_edit.clone();
|
||||
let time_increment = props.time_increment;
|
||||
Callback::from(move |_e: MouseEvent| {
|
||||
if let Some(current_drag) = (*drag_state).clone() {
|
||||
@@ -252,8 +371,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
|
||||
let new_end_datetime = new_start_datetime + original_duration;
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
}
|
||||
}
|
||||
},
|
||||
DragType::ResizeEventStart(event) => {
|
||||
@@ -277,8 +407,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
original_end
|
||||
};
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
}
|
||||
}
|
||||
},
|
||||
DragType::ResizeEventEnd(event) => {
|
||||
@@ -297,8 +438,19 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
original_start
|
||||
};
|
||||
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
// Check if this is a recurring event
|
||||
if event.recurrence_rule.is_some() {
|
||||
// Show modal for recurring event modification
|
||||
pending_recurring_edit.set(Some(PendingRecurringEdit {
|
||||
event: event.clone(),
|
||||
new_start: new_start_datetime,
|
||||
new_end: new_end_datetime,
|
||||
}));
|
||||
} else {
|
||||
// Regular event - proceed with update
|
||||
if let Some(callback) = &on_event_update {
|
||||
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,6 +847,20 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Recurring event modification modal
|
||||
if let Some(edit) = (*pending_recurring_edit).clone() {
|
||||
<RecurringEditModal
|
||||
show={true}
|
||||
event={edit.event}
|
||||
new_start={edit.new_start}
|
||||
new_end={edit.new_end}
|
||||
on_choice={on_recurring_choice}
|
||||
on_cancel={on_recurring_cancel}
|
||||
/>
|
||||
} else {
|
||||
<></>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,7 +804,9 @@ impl CalendarService {
|
||||
"reminder": reminder,
|
||||
"recurrence": recurrence,
|
||||
"recurrence_days": recurrence_days,
|
||||
"calendar_path": calendar_path
|
||||
"calendar_path": calendar_path,
|
||||
"update_action": "update_series",
|
||||
"occurrence_date": null
|
||||
});
|
||||
|
||||
let body_string = serde_json::to_string(&body)
|
||||
|
||||
Reference in New Issue
Block a user