From 9cd5d9f43dfea6120ca0b4b6a00a9693140b8939 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Thu, 21 Mar 2024 16:24:59 +0000 Subject: [PATCH] Allow updating events --- components/calendar/ViewEventModal.tsx | 3 +- fresh.gen.ts | 4 + islands/calendar/ViewCalendarEvent.tsx | 207 ++++++++++++++++++ lib/data/calendar.ts | 55 ++++- lib/form-utils.tsx | 1 + routes/api/calendar/delete-event.tsx | 3 +- routes/calendar/[calendarEventId].tsx | 120 ++++++++++ .../[calendarId]/[calendarEventId].ics.tsx | 2 +- 8 files changed, 388 insertions(+), 7 deletions(-) create mode 100644 islands/calendar/ViewCalendarEvent.tsx create mode 100644 routes/calendar/[calendarEventId].tsx diff --git a/components/calendar/ViewEventModal.tsx b/components/calendar/ViewEventModal.tsx index 392eb87..311d271 100644 --- a/components/calendar/ViewEventModal.tsx +++ b/components/calendar/ViewEventModal.tsx @@ -94,8 +94,9 @@ export default function ViewEventModal( Delete Edit diff --git a/fresh.gen.ts b/fresh.gen.ts index 2dacd97..648ae8f 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -24,6 +24,7 @@ import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx'; import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; import * as $calendar from './routes/calendar.tsx'; +import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx'; import * as $calendars from './routes/calendars.tsx'; import * as $contacts from './routes/contacts.tsx'; import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx'; @@ -48,6 +49,7 @@ import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx'; import * as $calendar_Calendars from './islands/calendar/Calendars.tsx'; +import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx'; import * as $contacts_Contacts from './islands/contacts/Contacts.tsx'; import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx'; import * as $dashboard_Links from './islands/dashboard/Links.tsx'; @@ -80,6 +82,7 @@ const manifest = { './routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, './routes/calendar.tsx': $calendar, + './routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_, './routes/calendars.tsx': $calendars, './routes/contacts.tsx': $contacts, './routes/contacts/[contactId].tsx': $contacts_contactId_, @@ -106,6 +109,7 @@ const manifest = { './islands/Settings.tsx': $Settings, './islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper, './islands/calendar/Calendars.tsx': $calendar_Calendars, + './islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent, './islands/contacts/Contacts.tsx': $contacts_Contacts, './islands/contacts/ViewContact.tsx': $contacts_ViewContact, './islands/dashboard/Links.tsx': $dashboard_Links, diff --git a/islands/calendar/ViewCalendarEvent.tsx b/islands/calendar/ViewCalendarEvent.tsx new file mode 100644 index 0000000..107a706 --- /dev/null +++ b/islands/calendar/ViewCalendarEvent.tsx @@ -0,0 +1,207 @@ +import { useSignal } from '@preact/signals'; + +import { Calendar, CalendarEvent } from '/lib/types.ts'; +import { capitalizeWord, convertObjectToFormData } from '/lib/utils.ts'; +import { FormField, generateFieldHtml } from '/lib/form-utils.tsx'; +import { + RequestBody as DeleteRequestBody, + ResponseBody as DeleteResponseBody, +} from '/routes/api/calendar/delete-event.tsx'; + +interface ViewCalendarEventProps { + initialCalendarEvent: CalendarEvent; + calendars: Calendar[]; + formData: Record; + error?: string; + notice?: string; +} + +export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[]) { + const fields: FormField[] = [ + { + name: 'title', + label: 'Title', + type: 'text', + placeholder: 'Dentis', + value: calendarEvent.title, + required: true, + }, + { + name: 'calendar_id', + label: 'Calendar', + type: 'select', + value: calendarEvent.calendar_id, + options: calendars.map((calendar) => ({ label: calendar.name, value: calendar.id })), + required: true, + }, + { + name: 'start_date', + label: 'Start date', + type: 'datetime-local', + value: new Date(calendarEvent.start_date).toISOString().substring(0, 16), + required: true, + }, + { + name: 'end_date', + label: 'End date', + type: 'datetime-local', + value: new Date(calendarEvent.end_date).toISOString().substring(0, 16), + required: true, + }, + { + name: 'is_all_day', + label: 'All-day?', + type: 'checkbox', + placeholder: 'YYYYMMDD', + value: 'true', + required: false, + checked: calendarEvent.is_all_day, + }, + { + name: 'status', + label: 'Status', + type: 'select', + value: calendarEvent.status, + options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({ + label: capitalizeWord(status), + value: status, + })), + required: true, + }, + { + name: 'description', + label: 'Description', + type: 'textarea', + placeholder: 'Just a regular check-up.', + value: calendarEvent.extra.description, + required: false, + }, + { + name: 'url', + label: 'URL', + type: 'url', + placeholder: 'https://example.com', + value: calendarEvent.extra.url, + required: false, + }, + { + name: 'location', + label: 'Location', + type: 'text', + placeholder: 'Birmingham, UK', + value: calendarEvent.extra.location, + required: false, + }, + // TODO: More fields, transparency, attendees, recurrence + ]; + + return fields; +} + +export default function viewCalendarEvent( + { initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps, +) { + const isDeleting = useSignal(false); + const calendarEvent = useSignal(initialCalendarEvent); + + const formData = convertObjectToFormData(formDataObject); + + async function onClickDeleteEvent() { + if (confirm('Are you sure you want to delete this event?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { + calendarIds: calendars.map((calendar) => calendar.id), + calendarView: 'day', + calendarStartDate: new Date().toISOString().substring(0, 10), + calendarEventId: calendarEvent.value.id, + calendarId: calendarEvent.value.calendar_id, + }; + const response = await fetch(`/api/calendar/delete-event`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete event!'); + } + + window.location.href = '/calendar'; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+ View calendar +
+ +
+
+ +
+ {error + ? ( +
+

Failed to update!

+

{error}

+
+ ) + : null} + {notice + ? ( +
+

Success!

+

{notice}

+
+ ) + : null} + +
+ {formFields(calendarEvent.peek(), calendars).map((field) => generateFieldHtml(field, formData))} + +
+ +
+
+ + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {!isDeleting.value ? <>  : null} + +
+ + ); +} diff --git a/lib/data/calendar.ts b/lib/data/calendar.ts index d2d20af..bffbb56 100644 --- a/lib/data/calendar.ts +++ b/lib/data/calendar.ts @@ -46,12 +46,11 @@ export async function getCalendarEvents( } } -export async function getCalendarEvent(id: string, calendarId: string, userId: string): Promise { +export async function getCalendarEvent(id: string, userId: string): Promise { const calendarEvents = await db.query( - sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3 LIMIT 1`, + sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`, [ id, - calendarId, userId, ], ); @@ -221,6 +220,56 @@ export async function createCalendarEvent( return newCalendar; } +export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalendarId?: string) { + const revision = crypto.randomUUID(); + + const user = await getUserById(calendarEvent.user_id); + + if (!user) { + throw new Error('User not found'); + } + + const calendar = await getCalendar(calendarEvent.calendar_id, user.id); + + if (!calendar) { + throw new Error('Calendar not found'); + } + + const oldCalendar = oldCalendarId ? await getCalendar(oldCalendarId, user.id) : null; + + await db.query( + sql`UPDATE "bewcloud_calendar_events" SET + "revision" = $3, + "calendar_id" = $4, + "title" = $5, + "start_date" = $6, + "end_date" = $7, + "is_all_day" = $8, + "status" = $9, + "extra" = $10, + "updated_at" = now() + WHERE "id" = $1 AND "revision" = $2`, + [ + calendarEvent.id, + calendarEvent.revision, + revision, + calendarEvent.calendar_id, + calendarEvent.title, + calendarEvent.start_date, + calendarEvent.end_date, + calendarEvent.is_all_day, + calendarEvent.status, + JSON.stringify(calendarEvent.extra), + ], + ); + + await updateCalendarRevision(calendar); + + if (oldCalendar) { + await updateCalendarRevision(oldCalendar); + } +} + export async function deleteCalendarEvent(id: string, calendarId: string, userId: string) { const calendar = await getCalendar(calendarId, userId); diff --git a/lib/form-utils.tsx b/lib/form-utils.tsx index 504f94c..da61512 100644 --- a/lib/form-utils.tsx +++ b/lib/form-utils.tsx @@ -11,6 +11,7 @@ export interface FormField { | 'tel' | 'url' | 'date' + | 'datetime-local' | 'number' | 'range' | 'select' diff --git a/routes/api/calendar/delete-event.tsx b/routes/api/calendar/delete-event.tsx index 8c6afa2..70c6a15 100644 --- a/routes/api/calendar/delete-event.tsx +++ b/routes/api/calendar/delete-event.tsx @@ -42,11 +42,10 @@ export const handler: Handlers = { const calendarEvent = await getCalendarEvent( requestBody.calendarEventId, - requestBody.calendarId, context.state.user.id, ); - if (!calendarEvent) { + if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendar_id) { return new Response('Not Found', { status: 404 }); } diff --git a/routes/calendar/[calendarEventId].tsx b/routes/calendar/[calendarEventId].tsx new file mode 100644 index 0000000..c3ef3de --- /dev/null +++ b/routes/calendar/[calendarEventId].tsx @@ -0,0 +1,120 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts'; +import { convertFormDataToObject } from '/lib/utils.ts'; +import { getCalendarEvent, getCalendars, updateCalendarEvent } from '/lib/data/calendar.ts'; +import { getFormDataField } from '/lib/form-utils.tsx'; +import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx'; + +interface Data { + calendarEvent: CalendarEvent; + calendars: Calendar[]; + error?: string; + notice?: string; + formData: Record; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { calendarEventId } = context.params; + + const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); + + if (!calendarEvent) { + return new Response('Not found', { status: 404 }); + } + + const calendars = await getCalendars(context.state.user.id); + + return await context.render({ calendarEvent, calendars, formData: {} }); + }, + async POST(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { calendarEventId } = context.params; + + const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); + + if (!calendarEvent) { + return new Response('Not found', { status: 404 }); + } + + const calendars = await getCalendars(context.state.user.id); + + const formData = await request.formData(); + + calendarEvent.title = getFormDataField(formData, 'title'); + calendarEvent.start_date = new Date(getFormDataField(formData, 'start_date')); + calendarEvent.end_date = new Date(getFormDataField(formData, 'end_date')); + calendarEvent.is_all_day = getFormDataField(formData, 'is_all_day') === 'true'; + calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status']; + + calendarEvent.extra.description = getFormDataField(formData, 'description') || undefined; + calendarEvent.extra.url = getFormDataField(formData, 'url') || undefined; + calendarEvent.extra.location = getFormDataField(formData, 'location') || undefined; + + const newCalendarId = getFormDataField(formData, 'calendar_id'); + let oldCalendarId: string | undefined; + + if (newCalendarId !== calendarEvent.calendar_id) { + oldCalendarId = calendarEvent.calendar_id; + } + + calendarEvent.calendar_id = newCalendarId; + + // TODO: More fields, transparency, attendees, recurrence + + try { + if (!calendarEvent.title) { + throw new Error(`Title is required.`); + } + + formFields(calendarEvent, calendars).forEach((field) => { + if (field.required) { + const value = formData.get(field.name); + + if (!value) { + throw new Error(`${field.label} is required`); + } + } + }); + + await updateCalendarEvent(calendarEvent, oldCalendarId); + + return await context.render({ + calendarEvent, + calendars, + notice: 'Event updated successfully!', + formData: convertFormDataToObject(formData), + }); + } catch (error) { + console.error(error); + return await context.render({ + calendarEvent, + calendars, + error: error.toString(), + formData: convertFormDataToObject(formData), + }); + } + }, +}; + +export default function ContactsPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx b/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx index 94d8175..a213369 100644 --- a/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx +++ b/routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx @@ -83,7 +83,7 @@ export const handler: Handler = async (request, context } try { - calendarEvent = await getCalendarEvent(calendarEventId, calendarId, context.state.user.id); + calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id); } catch (error) { console.error(error); }