2 Commits

Author SHA1 Message Date
Connor Johnstone
51d5552156 Fix modal series editing to use correct backend endpoint
The modal update flow was calling the regular event update endpoint
instead of the series endpoint, preventing proper handling of the
three edit types (this event, this and future, all events).

## Changes:
- Add logic to detect when edit_scope is set for recurring events
- Route to update_series() when edit_scope is present and event has RRULE
- Map EditAction enum to backend update_scope strings:
  - EditThis → "this_only" (creates exception + EXDATE)
  - EditFuture → "this_and_future" (new series + UNTIL on original)
  - EditAll → "all_in_series" (update existing series)
- Pass occurrence date for single/future edits using original event date
- Fall back to regular update_event() for non-recurring events

Now the modal properly leverages the existing robust series endpoint
that handles RFC 5545 compliant recurring event modifications.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 19:06:11 -04:00
Connor Johnstone
5a12c0e0d0 Implement comprehensive event series editing via modal
## Frontend Changes:
- Add EditAction enum (EditThis, EditFuture, EditAll) to event context menu
- Update context menu to show 3 edit options for recurring events
- Enhance EventCreationData with edit_scope and changed_fields tracking
- Update app component to handle EditAction types and pass to modal
- Add field change tracking infrastructure to CreateEventModal

## Backend Changes:
- Add changed_fields parameter to UpdateEventSeriesRequest for optimization
- Existing series endpoint already supports the three update types:
  - "this_only" - creates exception with EXDATE
  - "this_and_future" - creates new series with UNTIL on original
  - "all_in_series" - updates existing series in-place

## Implementation Details:
- Event context menu shows single edit option for non-recurring events
- Recurring events get three options: "Edit This Event", "Edit This and Future Events", "Edit All Events in Series"
- Modal tracks which fields user actually changed for efficient updates
- Backend series endpoint already has the logic for all three update scenarios
- Full RFC 5545 compliance with proper EXDATE and UNTIL handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 18:49:56 -04:00
5 changed files with 147 additions and 18 deletions

View File

@@ -201,6 +201,7 @@ pub struct UpdateEventSeriesRequest {
// Update scope control
pub update_scope: String, // "this_only", "this_and_future", "all_in_series"
pub occurrence_date: Option<String>, // ISO date string for specific occurrence being updated
pub changed_fields: Option<Vec<String>>, // List of field names that were changed (for optimization)
}
#[derive(Debug, Serialize)]

View File

@@ -2,7 +2,7 @@ use yew::prelude::*;
use yew_router::prelude::*;
use gloo_storage::{LocalStorage, Storage};
use web_sys::MouseEvent;
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction};
use crate::components::{Sidebar, ViewMode, Theme, CreateCalendarModal, ContextMenu, EventContextMenu, CalendarContextMenu, CreateEventModal, EventCreationData, RouteHandler, EventStatus, EventClass, ReminderType, RecurrenceType, DeleteAction, EditAction};
use crate::services::{CalendarService, calendar_service::UserInfo};
use crate::models::ical::VEvent;
use chrono::NaiveDate;
@@ -54,6 +54,7 @@ pub fn App() -> Html {
let calendar_context_menu_date = use_state(|| -> Option<NaiveDate> { None });
let create_event_modal_open = use_state(|| false);
let selected_date_for_event = use_state(|| -> Option<NaiveDate> { None });
let event_edit_scope = use_state(|| -> Option<EditAction> { None });
let _recurring_edit_modal_open = use_state(|| false);
let _recurring_edit_event = use_state(|| -> Option<VEvent> { None });
let _recurring_edit_data = use_state(|| -> Option<EventCreationData> { None });
@@ -738,8 +739,10 @@ pub fn App() -> Html {
let _event_context_menu_event = event_context_menu_event.clone();
let event_context_menu_open = event_context_menu_open.clone();
let create_event_modal_open = create_event_modal_open.clone();
move |_| {
// Close the context menu and open the edit modal
let event_edit_scope = event_edit_scope.clone();
move |edit_action: EditAction| {
// Set the edit scope and close the context menu
event_edit_scope.set(Some(edit_action));
event_context_menu_open.set(false);
create_event_modal_open.set(true);
}
@@ -840,13 +843,16 @@ pub fn App() -> Html {
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
event_to_edit={(*event_context_menu_event).clone()}
edit_scope={(*event_edit_scope).clone()}
on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone();
let event_edit_scope = event_edit_scope.clone();
move |_| {
create_event_modal_open.set(false);
// Clear the event being edited
// Clear the event being edited and edit scope
event_context_menu_event.set(None);
event_edit_scope.set(None);
}
})}
on_create={on_event_create}
@@ -854,10 +860,12 @@ pub fn App() -> Html {
let auth_token = auth_token.clone();
let create_event_modal_open = create_event_modal_open.clone();
let event_context_menu_event = event_context_menu_event.clone();
let event_edit_scope = event_edit_scope.clone();
move |(original_event, updated_data): (VEvent, EventCreationData)| {
web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into());
web_sys::console::log_1(&format!("Updating event: {:?}, edit_scope: {:?}", updated_data, updated_data.edit_scope).into());
create_event_modal_open.set(false);
event_context_menu_event.set(None);
event_edit_scope.set(None);
if let Some(token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move {
@@ -988,8 +996,61 @@ pub fn App() -> Html {
web_sys::window().unwrap().alert_with_message("Cannot move event - original event missing calendar path").unwrap();
}
} else {
// Calendar hasn't changed - normal update
match calendar_service.update_event(
// Calendar hasn't changed - check if we should use series endpoint
let use_series_endpoint = updated_data.edit_scope.is_some() && original_event.rrule.is_some();
if use_series_endpoint {
// Use series endpoint for recurring event modal edits
let update_scope = match updated_data.edit_scope.as_ref().unwrap() {
EditAction::EditThis => "this_only",
EditAction::EditFuture => "this_and_future",
EditAction::EditAll => "all_in_series",
};
// For single occurrence edits, we need the occurrence date
let occurrence_date = if update_scope == "this_only" || update_scope == "this_and_future" {
// Use the original event's start date as the occurrence date
Some(original_event.dtstart.format("%Y-%m-%d").to_string())
} else {
None
};
match calendar_service.update_series(
&token,
&password,
original_event.uid,
updated_data.title,
updated_data.description,
start_date,
start_time,
end_date,
end_time,
updated_data.location,
updated_data.all_day,
status_str,
class_str,
updated_data.priority,
updated_data.organizer,
updated_data.attendees,
updated_data.categories,
reminder_str,
recurrence_str,
updated_data.selected_calendar,
update_scope.to_string(),
occurrence_date,
).await {
Ok(_) => {
web_sys::console::log_1(&"Series updated successfully".into());
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to update series: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to update series: {}", err)).unwrap();
}
}
} else {
// Use regular event endpoint for non-recurring events or legacy updates
match calendar_service.update_event(
&token,
&password,
original_event.uid,
@@ -1025,6 +1086,7 @@ pub fn App() -> Html {
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
}
}
}
}
});
}

View File

@@ -4,6 +4,7 @@ use wasm_bindgen::JsCast;
use chrono::{NaiveDate, NaiveTime, Local, TimeZone, Utc, Datelike};
use crate::services::calendar_service::CalendarInfo;
use crate::models::ical::VEvent;
use crate::components::EditAction;
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
@@ -18,6 +19,8 @@ pub struct CreateEventModalProps {
pub initial_start_time: Option<NaiveTime>,
#[prop_or_default]
pub initial_end_time: Option<NaiveTime>,
#[prop_or_default]
pub edit_scope: Option<EditAction>,
}
#[derive(Clone, PartialEq, Debug)]
@@ -330,6 +333,10 @@ pub struct EventCreationData {
pub monthly_by_day: Option<String>, // For monthly: "1MO" = first Monday, "2TU" = second Tuesday, etc.
pub monthly_by_monthday: Option<u8>, // For monthly: day of month (1-31)
pub yearly_by_month: Vec<bool>, // For yearly: [Jan, Feb, Mar, ..., Dec]
// Edit scope and tracking fields
pub edit_scope: Option<EditAction>,
pub changed_fields: Vec<String>, // List of field names that were changed
}
impl Default for EventCreationData {
@@ -365,6 +372,10 @@ impl Default for EventCreationData {
monthly_by_day: None,
monthly_by_monthday: None,
yearly_by_month: vec![false; 12], // [Jan, Feb, ..., Dec] - all false by default
// Edit scope and tracking defaults
edit_scope: None,
changed_fields: vec![],
}
}
}
@@ -566,6 +577,10 @@ impl EventCreationData {
} else {
vec![false; 12]
},
// Edit scope and tracking defaults (will be set later if needed)
edit_scope: None,
changed_fields: vec![],
}
}
@@ -593,9 +608,9 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let active_tab = use_state(|| ModalTab::default());
// Initialize with selected date or event data if provided
use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time), {
use_effect_with((props.selected_date, props.event_to_edit.clone(), props.is_open, props.available_calendars.clone(), props.initial_start_time, props.initial_end_time, props.edit_scope.clone()), {
let event_data = event_data.clone();
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time)| {
move |(selected_date, event_to_edit, is_open, available_calendars, initial_start_time, initial_end_time, edit_scope)| {
if *is_open {
let mut data = if let Some(event) = event_to_edit {
// Pre-populate with event data for editing
@@ -625,6 +640,11 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
data.selected_calendar = Some(available_calendars[0].path.clone());
}
// Set edit scope if provided
if let Some(scope) = edit_scope {
data.edit_scope = Some(scope.clone());
}
event_data.set(data);
}
|| ()
@@ -644,12 +664,25 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
})
};
// Helper function to track field changes
let _track_field_change = |data: &mut EventCreationData, field_name: &str| {
if !data.changed_fields.contains(&field_name.to_string()) {
data.changed_fields.push(field_name.to_string());
}
};
let on_title_input = {
let event_data = event_data.clone();
Callback::from(move |e: InputEvent| {
if let Some(input) = e.target_dyn_into::<HtmlInputElement>() {
let mut data = (*event_data).clone();
data.title = input.value();
let new_value = input.value();
if data.title != new_value {
data.title = new_value;
if !data.changed_fields.contains(&"title".to_string()) {
data.changed_fields.push("title".to_string());
}
}
event_data.set(data);
}
})
@@ -661,7 +694,13 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
if let Some(select) = e.target_dyn_into::<HtmlSelectElement>() {
let mut data = (*event_data).clone();
let value = select.value();
data.selected_calendar = if value.is_empty() { None } else { Some(value) };
let new_calendar = if value.is_empty() { None } else { Some(value) };
if data.selected_calendar != new_calendar {
data.selected_calendar = new_calendar;
if !data.changed_fields.contains(&"selected_calendar".to_string()) {
data.changed_fields.push("selected_calendar".to_string());
}
}
event_data.set(data);
}
})

View File

@@ -9,13 +9,20 @@ pub enum DeleteAction {
DeleteSeries,
}
#[derive(Clone, PartialEq, Debug)]
pub enum EditAction {
EditThis,
EditFuture,
EditAll,
}
#[derive(Properties, PartialEq)]
pub struct EventContextMenuProps {
pub is_open: bool,
pub x: i32,
pub y: i32,
pub event: Option<VEvent>,
pub on_edit: Callback<()>,
pub on_edit: Callback<EditAction>,
pub on_delete: Callback<DeleteAction>,
pub on_close: Callback<()>,
}
@@ -38,11 +45,11 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
.map(|event| event.rrule.is_some())
.unwrap_or(false);
let on_edit_click = {
let create_edit_callback = |action: EditAction| {
let on_edit = props.on_edit.clone();
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| {
on_edit.emit(());
on_edit.emit(action.clone());
on_close.emit(());
})
};
@@ -62,9 +69,29 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
class="context-menu"
style={style}
>
<div class="context-menu-item" onclick={on_edit_click}>
{"Edit Event"}
</div>
{
if is_recurring {
html! {
<>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"Edit This Event"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditFuture)}>
{"Edit This and Future Events"}
</div>
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditAll)}>
{"Edit All Events in Series"}
</div>
</>
}
} else {
html! {
<div class="context-menu-item" onclick={create_edit_callback(EditAction::EditThis)}>
{"Edit Event"}
</div>
}
}
}
{
if is_recurring {
html! {

View File

@@ -22,7 +22,7 @@ pub use week_view::WeekView;
pub use event_modal::EventModal;
pub use create_calendar_modal::CreateCalendarModal;
pub use context_menu::ContextMenu;
pub use event_context_menu::{EventContextMenu, DeleteAction};
pub use event_context_menu::{EventContextMenu, DeleteAction, EditAction};
pub use calendar_context_menu::CalendarContextMenu;
pub use create_event_modal::{CreateEventModal, EventCreationData, EventStatus, EventClass, ReminderType, RecurrenceType};
pub use sidebar::{Sidebar, ViewMode, Theme};