Implement complete event editing functionality with backend update endpoint

Frontend Changes:
- Add edit context menu option to EventContextMenu with pencil icon
- Enhance CreateEventModal to support both create and edit modes
- Add event data conversion methods for pre-populating edit forms
- Implement conditional submit logic (on_create vs on_update callbacks)
- Add update_event method to CalendarService with POST /calendar/events/update

Backend Changes:
- Add UpdateEventRequest and UpdateEventResponse models
- Implement update_event handler with event search by UID across calendars
- Add POST /api/calendar/events/update route
- Full validation and parsing of all event properties for updates
- Integrate with existing CalDAV client update_event functionality

Users can now right-click events, select "Edit Event", modify properties in the modal, and successfully update existing events instead of creating duplicates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-29 09:41:16 -04:00
parent 1b57adab98
commit 2a2666e75f
7 changed files with 570 additions and 14 deletions

View File

@@ -480,6 +480,16 @@ pub fn App() -> Html {
let event_context_menu_open = event_context_menu_open.clone();
move |_| event_context_menu_open.set(false)
})}
on_edit={Callback::from({
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
event_context_menu_open.set(false);
create_event_modal_open.set(true);
}
})}
on_delete={Callback::from({
let auth_token = auth_token.clone();
let event_context_menu_event = event_context_menu_event.clone();
@@ -575,11 +585,116 @@ pub fn App() -> Html {
<CreateEventModal
is_open={*create_event_modal_open}
selected_date={(*selected_date_for_event).clone()}
event_to_edit={(*event_context_menu_event).clone()}
on_close={Callback::from({
let create_event_modal_open = create_event_modal_open.clone();
move |_| create_event_modal_open.set(false)
let event_context_menu_event = event_context_menu_event.clone();
move |_| {
create_event_modal_open.set(false);
// Clear the event being edited
event_context_menu_event.set(None);
}
})}
on_create={on_event_create}
on_update={Callback::from({
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();
move |(original_event, updated_data): (CalendarEvent, EventCreationData)| {
web_sys::console::log_1(&format!("Updating event: {:?}", updated_data).into());
create_event_modal_open.set(false);
event_context_menu_event.set(None);
if let Some(token) = (*auth_token).clone() {
wasm_bindgen_futures::spawn_local(async move {
let calendar_service = CalendarService::new();
// Get CalDAV password from storage
let password = if let Ok(credentials_str) = LocalStorage::get::<String>("caldav_credentials") {
if let Ok(credentials) = serde_json::from_str::<serde_json::Value>(&credentials_str) {
credentials["password"].as_str().unwrap_or("").to_string()
} else {
String::new()
}
} else {
String::new()
};
// Format date and time strings
let start_date = updated_data.start_date.format("%Y-%m-%d").to_string();
let start_time = updated_data.start_time.format("%H:%M").to_string();
let end_date = updated_data.end_date.format("%Y-%m-%d").to_string();
let end_time = updated_data.end_time.format("%H:%M").to_string();
// Convert enums to strings for backend
let status_str = match updated_data.status {
EventStatus::Tentative => "tentative",
EventStatus::Cancelled => "cancelled",
_ => "confirmed",
}.to_string();
let class_str = match updated_data.class {
EventClass::Private => "private",
EventClass::Confidential => "confidential",
_ => "public",
}.to_string();
let reminder_str = match updated_data.reminder {
ReminderType::Minutes15 => "15min",
ReminderType::Minutes30 => "30min",
ReminderType::Hour1 => "1hour",
ReminderType::Hours2 => "2hours",
ReminderType::Day1 => "1day",
ReminderType::Days2 => "2days",
ReminderType::Week1 => "1week",
_ => "none",
}.to_string();
let recurrence_str = match updated_data.recurrence {
RecurrenceType::Daily => "daily",
RecurrenceType::Weekly => "weekly",
RecurrenceType::Monthly => "monthly",
RecurrenceType::Yearly => "yearly",
_ => "none",
}.to_string();
match calendar_service.update_event(
&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.recurrence_days,
updated_data.selected_calendar
).await {
Ok(_) => {
web_sys::console::log_1(&"Event updated successfully".into());
// Trigger a page reload to refresh events from all calendars
web_sys::window().unwrap().location().reload().unwrap();
}
Err(err) => {
web_sys::console::error_1(&format!("Failed to update event: {}", err).into());
web_sys::window().unwrap().alert_with_message(&format!("Failed to update event: {}", err)).unwrap();
}
}
});
}
}
})}
available_calendars={user_info.as_ref().map(|ui| ui.calendars.clone()).unwrap_or_default()}
/>
</div>

View File

@@ -1,14 +1,16 @@
use yew::prelude::*;
use web_sys::{HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement};
use chrono::{NaiveDate, NaiveTime};
use crate::services::calendar_service::CalendarInfo;
use crate::services::calendar_service::{CalendarInfo, CalendarEvent};
#[derive(Properties, PartialEq)]
pub struct CreateEventModalProps {
pub is_open: bool,
pub selected_date: Option<NaiveDate>,
pub event_to_edit: Option<CalendarEvent>,
pub on_close: Callback<()>,
pub on_create: Callback<EventCreationData>,
pub on_update: Callback<(CalendarEvent, EventCreationData)>, // (original_event, updated_data)
pub available_calendars: Vec<CalendarInfo>,
}
@@ -25,6 +27,16 @@ impl Default for EventStatus {
}
}
impl EventStatus {
pub fn from_service_status(status: &crate::services::calendar_service::EventStatus) -> Self {
match status {
crate::services::calendar_service::EventStatus::Tentative => EventStatus::Tentative,
crate::services::calendar_service::EventStatus::Confirmed => EventStatus::Confirmed,
crate::services::calendar_service::EventStatus::Cancelled => EventStatus::Cancelled,
}
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum EventClass {
Public,
@@ -38,6 +50,16 @@ impl Default for EventClass {
}
}
impl EventClass {
pub fn from_service_class(class: &crate::services::calendar_service::EventClass) -> Self {
match class {
crate::services::calendar_service::EventClass::Public => EventClass::Public,
crate::services::calendar_service::EventClass::Private => EventClass::Private,
crate::services::calendar_service::EventClass::Confidential => EventClass::Confidential,
}
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum ReminderType {
None,
@@ -71,6 +93,18 @@ impl Default for RecurrenceType {
}
}
impl RecurrenceType {
pub fn from_rrule(rrule: Option<&str>) -> Self {
match rrule {
Some(rule) if rule.contains("FREQ=DAILY") => RecurrenceType::Daily,
Some(rule) if rule.contains("FREQ=WEEKLY") => RecurrenceType::Weekly,
Some(rule) if rule.contains("FREQ=MONTHLY") => RecurrenceType::Monthly,
Some(rule) if rule.contains("FREQ=YEARLY") => RecurrenceType::Yearly,
_ => RecurrenceType::None,
}
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct EventCreationData {
pub title: String,
@@ -122,25 +156,56 @@ impl Default for EventCreationData {
}
}
impl EventCreationData {
pub fn from_calendar_event(event: &CalendarEvent) -> Self {
// Convert CalendarEvent to EventCreationData for editing
Self {
title: event.summary.clone().unwrap_or_default(),
description: event.description.clone().unwrap_or_default(),
start_date: event.start.date_naive(),
start_time: event.start.time(),
end_date: event.end.as_ref().map(|e| e.date_naive()).unwrap_or(event.start.date_naive()),
end_time: event.end.as_ref().map(|e| e.time()).unwrap_or(event.start.time()),
location: event.location.clone().unwrap_or_default(),
all_day: event.all_day,
status: EventStatus::from_service_status(&event.status),
class: EventClass::from_service_class(&event.class),
priority: event.priority,
organizer: event.organizer.clone().unwrap_or_default(),
attendees: event.attendees.join(", "),
categories: event.categories.join(", "),
reminder: ReminderType::default(), // TODO: Convert from event reminders
recurrence: RecurrenceType::from_rrule(event.recurrence_rule.as_deref()),
recurrence_days: vec![false; 7], // TODO: Parse from RRULE
selected_calendar: event.calendar_path.clone(),
}
}
}
#[function_component(CreateEventModal)]
pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
let event_data = use_state(|| EventCreationData::default());
// Initialize with selected date if provided
use_effect_with((props.selected_date, props.is_open, props.available_calendars.clone()), {
// 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()), {
let event_data = event_data.clone();
move |(selected_date, is_open, available_calendars)| {
move |(selected_date, event_to_edit, is_open, available_calendars)| {
if *is_open {
let mut data = if let Some(date) = selected_date {
let mut data = (*event_data).clone();
let mut data = if let Some(event) = event_to_edit {
// Pre-populate with event data for editing
EventCreationData::from_calendar_event(event)
} else if let Some(date) = selected_date {
// Initialize with selected date for new event
let mut data = EventCreationData::default();
data.start_date = *date;
data.end_date = *date;
data
} else {
// Default initialization
EventCreationData::default()
};
// Set default calendar to the first available one
// Set default calendar to the first available one if none selected
if data.selected_calendar.is_none() && !available_calendars.is_empty() {
data.selected_calendar = Some(available_calendars[0].path.clone());
}
@@ -401,11 +466,19 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
})
};
let on_create_click = {
let on_submit_click = {
let event_data = event_data.clone();
let on_create = props.on_create.clone();
let on_update = props.on_update.clone();
let event_to_edit = props.event_to_edit.clone();
Callback::from(move |_: MouseEvent| {
on_create.emit((*event_data).clone());
if let Some(original_event) = &event_to_edit {
// We're editing - call on_update with original event and new data
on_update.emit((original_event.clone(), (*event_data).clone()));
} else {
// We're creating - call on_create with new data
on_create.emit((*event_data).clone());
}
})
};
@@ -422,7 +495,7 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
<div class="modal-backdrop" onclick={on_backdrop_click}>
<div class="modal-content create-event-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
<div class="modal-header">
<h3>{"Create New Event"}</h3>
<h3>{if props.event_to_edit.is_some() { "Edit Event" } else { "Create New Event" }}</h3>
<button type="button" class="modal-close" onclick={Callback::from({
let on_close = props.on_close.clone();
move |_: MouseEvent| on_close.emit(())
@@ -707,10 +780,10 @@ pub fn create_event_modal(props: &CreateEventModalProps) -> Html {
<button
type="button"
class="btn btn-primary"
onclick={on_create_click}
onclick={on_submit_click}
disabled={data.title.trim().is_empty()}
>
{"Create Event"}
{if props.event_to_edit.is_some() { "Update Event" } else { "Create Event" }}
</button>
</div>
</div>

View File

@@ -15,6 +15,7 @@ pub struct EventContextMenuProps {
pub x: i32,
pub y: i32,
pub event: Option<CalendarEvent>,
pub on_edit: Callback<()>,
pub on_delete: Callback<DeleteAction>,
pub on_close: Callback<()>,
}
@@ -37,6 +38,15 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
.map(|event| event.recurrence_rule.is_some())
.unwrap_or(false);
let on_edit_click = {
let on_edit = props.on_edit.clone();
let on_close = props.on_close.clone();
Callback::from(move |_: MouseEvent| {
on_edit.emit(());
on_close.emit(());
})
};
let create_delete_callback = |action: DeleteAction| {
let on_delete = props.on_delete.clone();
let on_close = props.on_close.clone();
@@ -52,6 +62,10 @@ pub fn event_context_menu(props: &EventContextMenuProps) -> Html {
class="context-menu"
style={style}
>
<div class="context-menu-item" onclick={on_edit_click}>
<span class="context-menu-icon">{"✏️"}</span>
{"Edit Event"}
</div>
{
if is_recurring {
html! {

View File

@@ -755,6 +755,97 @@ impl CalendarService {
}
}
pub async fn update_event(
&self,
token: &str,
password: &str,
event_uid: String,
title: String,
description: String,
start_date: String,
start_time: String,
end_date: String,
end_time: String,
location: String,
all_day: bool,
status: String,
class: String,
priority: Option<u8>,
organizer: String,
attendees: String,
categories: String,
reminder: String,
recurrence: String,
recurrence_days: Vec<bool>,
calendar_path: Option<String>
) -> Result<(), String> {
let window = web_sys::window().ok_or("No global window exists")?;
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
let body = serde_json::json!({
"uid": event_uid,
"title": title,
"description": description,
"start_date": start_date,
"start_time": start_time,
"end_date": end_date,
"end_time": end_time,
"location": location,
"all_day": all_day,
"status": status,
"class": class,
"priority": priority,
"organizer": organizer,
"attendees": attendees,
"categories": categories,
"reminder": reminder,
"recurrence": recurrence,
"recurrence_days": recurrence_days,
"calendar_path": calendar_path
});
let body_string = serde_json::to_string(&body)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
let url = format!("{}/calendar/events/update", self.base_url);
opts.set_body(&body_string.into());
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Request creation failed: {:?}", e))?;
request.headers().set("Authorization", &format!("Bearer {}", token))
.map_err(|e| format!("Authorization header setting failed: {:?}", e))?;
request.headers().set("X-CalDAV-Password", password)
.map_err(|e| format!("Password header setting failed: {:?}", e))?;
request.headers().set("Content-Type", "application/json")
.map_err(|e| format!("Content-Type header setting failed: {:?}", e))?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Network request failed: {:?}", e))?;
let resp: Response = resp_value.dyn_into()
.map_err(|e| format!("Response cast failed: {:?}", e))?;
let text = JsFuture::from(resp.text()
.map_err(|e| format!("Text extraction failed: {:?}", e))?)
.await
.map_err(|e| format!("Text promise failed: {:?}", e))?;
let text_string = text.as_string()
.ok_or("Response text is not a string")?;
if resp.ok() {
Ok(())
} else {
Err(format!("Request failed with status {}: {}", resp.status(), text_string))
}
}
/// Delete a calendar from the CalDAV server
pub async fn delete_calendar(
&self,