diff --git a/client/src/components/composite/Admin/AdminEventView/AdminAllEvents/AdminAllEvents.tsx b/client/src/components/composite/Admin/AdminEventView/AdminAllEvents/AdminAllEvents.tsx index 124993ff..0df05b62 100644 --- a/client/src/components/composite/Admin/AdminEventView/AdminAllEvents/AdminAllEvents.tsx +++ b/client/src/components/composite/Admin/AdminEventView/AdminAllEvents/AdminAllEvents.tsx @@ -1,6 +1,4 @@ -import EventsCardPreview, { - IEventsCardPreview -} from "@/components/generic/Event/EventPreview/EventPreview" +import EventsCardPreview from "@/components/generic/Event/EventPreview/EventPreview" import { DateUtils } from "@/components/utils/DateUtils" import { Event } from "@/models/Events" import { useCallback, useMemo, useState } from "react" @@ -127,7 +125,7 @@ const AdminAllEvents = ({ /** * Detailed view of the event */ - const previewCurrentEvents: IEventsCardPreview[] = + const previewCurrentEvents = eventList.upcomingAndCurrentEvents?.map((event) => { return EventRenderingUtils.previewTransformer( event, @@ -137,7 +135,7 @@ const AdminAllEvents = ({ ) }) || [] - const previewPastEvents: IEventsCardPreview[] = + const previewPastEvents = eventList.pastEvents?.map((event) => { return EventRenderingUtils.previewTransformer( event, @@ -164,11 +162,11 @@ const AdminAllEvents = ({ )} {previewCurrentEvents.map((event) => ( - + ))} {previewPastEvents.map((event) => ( - + ))} )} diff --git a/client/src/components/composite/Admin/AdminEventView/AdminEventForm/AdminEventForm.tsx b/client/src/components/composite/Admin/AdminEventView/AdminEventForm/AdminEventForm.tsx index 09338135..b49ef848 100644 --- a/client/src/components/composite/Admin/AdminEventView/AdminEventForm/AdminEventForm.tsx +++ b/client/src/components/composite/Admin/AdminEventView/AdminEventForm/AdminEventForm.tsx @@ -26,8 +26,18 @@ interface IAdminEventForm { /** * To be called after user submits the new data for the event + * + * (the big call to action button) */ handlePostEvent: (data: CreateEventBody) => void + + /** + * Handler which is passed in if {@link isEditMode} is `true`, + * + * if the user confirms they want the event deleted this handler will be called + */ + handleDeleteEvent?: () => void + /** * If the panel should suggest that the event is being edited, instead of created * @@ -42,6 +52,18 @@ interface IAdminEventForm { defaultData?: CreateEventBody["data"] } +const USER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone + +const TimezoneIndicator = () => ( + <> + {USER_TIMEZONE && ( +
+ The timezone is: {USER_TIMEZONE} +
+ )} + +) + export const AdminEventFormKeys = { TITLE: "title", DESCRIPTION: "description", @@ -61,7 +83,8 @@ const AdminEventForm = ({ handlePostEvent, generateImageLink, isEditMode = false, - defaultData + defaultData, + handleDeleteEvent }: IAdminEventForm) => { const [isSubmitting, setIsSubmitting] = useState(false) @@ -102,7 +125,11 @@ const AdminEventForm = ({ Number.parseInt(data.get(AdminEventFormKeys.MAX_OCCUPANCY) as string) || undefined, physical_end_date: physical_end_date - ? Timestamp.fromDate(new Date(physical_end_date as string)) + ? Timestamp.fromDate( + new Date( + (physical_end_date as string).replace(/-/g, "/").replace("T", " ") + ) + ) : undefined } @@ -128,6 +155,20 @@ const AdminEventForm = ({ return (

{formTitle}

+ {isEditMode && ( + + )}
- +

Event Dates

+ { return undefined }, + eventPreviousData: { + id: "1", + title: "CACK", + location: "STRAIGHT ZHAO", + physical_start_date: earlierStartDate, + physical_end_date: earlierStartDate, + sign_up_start_date: earlierStartDate, + sign_up_end_date: earlierStartDate, + google_forms_link: "https://google.com", + description: "default data" + }, rawEvents: [ { id: "1", diff --git a/client/src/components/composite/Admin/AdminEventView/AdminEventView.tsx b/client/src/components/composite/Admin/AdminEventView/AdminEventView.tsx index 4bbcf948..b4f0bc05 100644 --- a/client/src/components/composite/Admin/AdminEventView/AdminEventView.tsx +++ b/client/src/components/composite/Admin/AdminEventView/AdminEventView.tsx @@ -1,8 +1,9 @@ import Button from "@/components/generic/FigmaButtons/FigmaButton" -import { CreateEventBody, Event } from "@/models/Events" +import { CreateEventBody, EditEventBody, Event } from "@/models/Events" import { useState } from "react" import AdminEventForm from "./AdminEventForm/AdminEventForm" import AdminAllEvents from "./AdminAllEvents/AdminAllEvents" +import Loader from "@/components/generic/SuspenseComponent/Loader" type EventViewModes = "view-all-events" | "creating-new-event" | "editing-event" @@ -44,6 +45,22 @@ interface IAdminEventView { * Function to fetch more events. */ fetchMoreEvents?: () => void + + /** + * If passed in, will open the edit panel for the event with given data + */ + eventPreviousData?: Event + + /** + * Will be called when the admin is _editing_ a selected event + */ + handleEditEvent?: (eventId: string, newData: EditEventBody) => void + + /** + * Obtains the latest data for an event to edit, if `undefined` is passed + * in then it means that no event should be edited + */ + fetchEventToEdit?: (eventId?: string) => void } const AdminEventViewContent = ({ @@ -54,16 +71,28 @@ const AdminEventViewContent = ({ rawEvents, hasMoreEvents, isLoading, - fetchMoreEvents - // TODO: extend with the event id to allow showing an edit view + fetchMoreEvents, + handleEditEvent, + eventPreviousData, + fetchEventToEdit }: { mode: EventViewModes setMode: (mode: EventViewModes) => void } & IAdminEventView) => { + /** + * Used to make the `PATCH` request for the event (need to specify path with `id`) + */ + const [editedEventId, setEditedEventId] = useState() + switch (mode) { case "view-all-events": return ( { + setEditedEventId(id) + fetchEventToEdit?.(id) + setMode("editing-event") + }} rawEvents={rawEvents} hasMoreEvents={hasMoreEvents} isLoading={isLoading} @@ -83,7 +112,28 @@ const AdminEventViewContent = ({ /> ) case "editing-event": - return null + if (!editedEventId) { + setMode("view-all-events") + return + } + + if (!eventPreviousData) { + return + } + + return ( + { + return await generateImageLink(image) + }} + defaultData={eventPreviousData} + handlePostEvent={async (data) => { + await handleEditEvent?.(editedEventId, data.data) + setMode("view-all-events") + }} + isEditMode + /> + ) } } @@ -95,6 +145,7 @@ const buttonMessage = (mode: EventViewModes) => { case "view-all-events": return "Create Event" case "creating-new-event": + return "Back to Events" case "editing-event": return "Back to Events" } @@ -110,7 +161,10 @@ const AdminEventView = ({ rawEvents = [], hasMoreEvents, isLoading, - fetchMoreEvents + fetchMoreEvents, + eventPreviousData, + handleEditEvent, + fetchEventToEdit }: IAdminEventView) => { const [mode, setMode] = useState("view-all-events") @@ -127,7 +181,10 @@ const AdminEventView = ({ setMode("creating-new-event") break case "creating-new-event": + setMode("view-all-events") + break case "editing-event": + fetchEventToEdit?.() setMode("view-all-events") } }} @@ -136,14 +193,18 @@ const AdminEventView = ({
+ {/** TODO: pass in delete handler */} diff --git a/client/src/components/composite/Admin/AdminEventView/WrappedAdminEventView.tsx b/client/src/components/composite/Admin/AdminEventView/WrappedAdminEventView.tsx index 42997a96..0ac3409d 100644 --- a/client/src/components/composite/Admin/AdminEventView/WrappedAdminEventView.tsx +++ b/client/src/components/composite/Admin/AdminEventView/WrappedAdminEventView.tsx @@ -1,10 +1,16 @@ "use client" -import { useCreateEventMutation } from "@/services/Admin/AdminMutations" +import { + useCreateEventMutation, + useEditEventMutation +} from "@/services/Admin/AdminMutations" import AdminEventView from "./AdminEventView" import StorageService from "@/services/Storage/StorageService" import { useLatestEventsQuery } from "@/services/Event/EventQueries" -import { useMemo } from "react" +import { useMemo, useState } from "react" +import { useGetEventQuery } from "@/services/Admin/AdminQueries" +import { Event } from "@/models/Events" +import Loader from "@/components/generic/SuspenseComponent/Loader" const WrappedAdminEventView = () => { const { mutateAsync: handleEventCreation } = useCreateEventMutation() @@ -16,6 +22,15 @@ const WrappedAdminEventView = () => { fetchNextPage, isFetchingNextPage } = useLatestEventsQuery() + + const { mutateAsync: editEvent } = useEditEventMutation() + + const [eventPreviousData, setEventPreviousData] = useState< + Event | undefined + >() + + const { mutateAsync: fetchEventToBeEdited } = useGetEventQuery() + const rawEvents = useMemo(() => { const flattenedEvents = data?.pages.flatMap((page) => { return page.data || [] @@ -23,6 +38,10 @@ const WrappedAdminEventView = () => { return flattenedEvents }, [data]) + if (!fetchEventToBeEdited) { + return + } + return ( <> { generateImageLink={async (image) => await StorageService.uploadEventImage(image) } + fetchEventToEdit={async (id) => { + if (id) { + setEventPreviousData(await fetchEventToBeEdited(id)) + } else { + /** + * If we go back to the main screen we + * don't have an event id thats being edited, + * so we have to consider the data stale + */ + setEventPreviousData(undefined) + } + }} + handleEditEvent={async (eventId, newData) => { + await editEvent({ eventId, newData }) + }} + eventPreviousData={eventPreviousData} rawEvents={rawEvents || []} hasMoreEvents={hasNextPage} isLoading={isPending} diff --git a/client/src/components/composite/EventsView/EventsView.tsx b/client/src/components/composite/EventsView/EventsView.tsx index dc8c3ddc..e8755ae6 100644 --- a/client/src/components/composite/EventsView/EventsView.tsx +++ b/client/src/components/composite/EventsView/EventsView.tsx @@ -1,6 +1,4 @@ -import EventsCardPreview, { - IEventsCardPreview -} from "@/components/generic/Event/EventPreview/EventPreview" +import EventsCardPreview from "@/components/generic/Event/EventPreview/EventPreview" import EventDetailed from "@/components/generic/Event/EventDetailed/EventDetailed" import { DateUtils } from "@/components/utils/DateUtils" import { Event } from "@/models/Events" @@ -195,7 +193,7 @@ const EventsPage = ({ ) }, [selectedEventObject, eventSelectionHandler]) - const previewCurrentEvents: IEventsCardPreview[] = + const previewCurrentEvents = eventList.upcomingAndCurrentEvents?.map((event) => { return EventRenderingUtils.previewTransformer( event, @@ -204,7 +202,7 @@ const EventsPage = ({ ) }) || [] - const previewPastEvents: IEventsCardPreview[] = + const previewPastEvents = eventList.pastEvents?.map((event) => { return EventRenderingUtils.previewTransformer( event, @@ -235,11 +233,11 @@ const EventsPage = ({ )} {previewCurrentEvents.map((event) => ( - + ))} {previewPastEvents.map((event) => ( - + ))} )} diff --git a/client/src/components/generic/Event/EventUtils.test.ts b/client/src/components/generic/Event/EventUtils.test.ts index 5b3730e0..b7de6f4f 100644 --- a/client/src/components/generic/Event/EventUtils.test.ts +++ b/client/src/components/generic/Event/EventUtils.test.ts @@ -1,33 +1,15 @@ import { EventRenderingUtils } from "./EventUtils" describe("dateTimeLocalPlaceHolder", () => { - it("should return a formatted string in ISO 8601 format without milliseconds", () => { - const date = new Date("2024-11-01T23:34:15.123Z") + it("should format the date correctly in ISO 8601 format without milliseconds", () => { + const date = new Date(2024, 10, 3, 14, 30) // November 3, 2024, 14:30 const result = EventRenderingUtils.dateTimeLocalPlaceHolder(date) - expect(result).toBe("2024-11-01T23:34:15") + expect(result).toBe("2024-11-03T14:30") }) - it("should handle dates without milliseconds correctly", () => { - const date = new Date("2024-11-01T23:34:15Z") + it("should pad single digit month, day, hours, and minutes with leading zeros", () => { + const date = new Date(2024, 0, 5, 9, 7) // January 5, 2024, 09:07 const result = EventRenderingUtils.dateTimeLocalPlaceHolder(date) - expect(result).toBe("2024-11-01T23:34:15") - }) - - it("should handle different time zones correctly", () => { - const date = new Date("2024-11-01T23:34:15.123+09:00") - const result = EventRenderingUtils.dateTimeLocalPlaceHolder(date) - expect(result).toBe("2024-11-01T14:34:15") - }) - - it("should handle leap years correctly", () => { - const date = new Date("2024-02-29T23:34:15.123Z") - const result = EventRenderingUtils.dateTimeLocalPlaceHolder(date) - expect(result).toBe("2024-02-29T23:34:15") - }) - - it("should handle dates before 1970 correctly", () => { - const date = new Date("1969-12-31T23:34:15.123Z") - const result = EventRenderingUtils.dateTimeLocalPlaceHolder(date) - expect(result).toBe("1969-12-31T23:34:15") + expect(result).toBe("2024-01-05T09:07") }) }) diff --git a/client/src/components/generic/Event/EventUtils.ts b/client/src/components/generic/Event/EventUtils.ts index 05ff23a9..46e791f0 100644 --- a/client/src/components/generic/Event/EventUtils.ts +++ b/client/src/components/generic/Event/EventUtils.ts @@ -13,6 +13,15 @@ export const IMAGE_PLACEHOLDER_SRC = * Static methods to format strings related to events */ export const EventMessages = { + /** + * Message to be displayed for deleting an event + * + * @param title the title of the event + * @returns a formatted, user-readable string asking for confirmation + */ + adminDeleteEventConfirmation: (title: string) => { + return `Are you sure you want to delete the event ${title}? This can NOT be undone!` + }, /** * Message to be displayed for confirming event creation or editing * @@ -85,6 +94,13 @@ export const EventDateComparisons = { } } as const +/** + * Utility type to allow for a key to to be associated with a preview + * + * (generally using the firebase `uid`) + */ +export type EventCardPreviewWithKey = IEventsCardPreview & { key: string } + export const EventRenderingUtils = { /** * Generates a placeholder string for a local date and time input field @@ -93,9 +109,13 @@ export const EventRenderingUtils = { * @returns a formatted string in ISO 8601 format without milliseconds */ dateTimeLocalPlaceHolder: (date: Date) => { - const isoString = date.toISOString() - const placeholderString = isoString.substring(0, isoString.lastIndexOf(".")) - return placeholderString + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hours = String(date.getHours()).padStart(2, "0") + const minutes = String(date.getMinutes()).padStart(2, "0") + + return `${year}-${month}-${day}T${hours}:${minutes}` }, /** * Utility function to convert a raw {@link Event} into {@link IEventsCardPreview} @@ -109,7 +129,7 @@ export const EventRenderingUtils = { eventSetter: (id?: string) => void, buttonText?: string, variant?: EventCardPreviewVariant - ): IEventsCardPreview => { + ): EventCardPreviewWithKey => { let eventStartDate if (event.physical_start_date) { @@ -131,6 +151,7 @@ export const EventRenderingUtils = { ) return { + key: event.id || event.title, date: eventStartDate ? EventMessages.eventDateRange(eventStartDate, eventEndDate) : "", diff --git a/client/src/models/Events.ts b/client/src/models/Events.ts index 9e05a70b..508f2dfb 100644 --- a/client/src/models/Events.ts +++ b/client/src/models/Events.ts @@ -2,4 +2,6 @@ import { components } from "./__generated__/schema" export type CreateEventBody = components["schemas"]["CreateEventBody"] +export type EditEventBody = components["schemas"]["Partial_Event_"] + export type Event = components["schemas"]["Event"] & { id?: string } // Assume all responses will return an Id diff --git a/client/src/services/Admin/AdminMutations.ts b/client/src/services/Admin/AdminMutations.ts index ff402b5f..6905ac19 100644 --- a/client/src/services/Admin/AdminMutations.ts +++ b/client/src/services/Admin/AdminMutations.ts @@ -10,6 +10,7 @@ import { } from "./AdminQueries" import { CombinedUserData } from "@/models/User" import { replaceUserInPage } from "./AdminUtils" +import { ALL_EVENTS_QUERY_KEY } from "../Event/EventQueries" export function usePromoteUserMutation() { return useMutation({ @@ -175,7 +176,24 @@ export function useCreateEventMutation() { return useMutation({ mutationKey: ["create-booking"], retry: false, - mutationFn: AdminService.createEvent - // TODO: invalidate all events query (we need to refetch the events) + mutationFn: AdminService.createEvent, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ALL_EVENTS_QUERY_KEY] + }) + } + }) +} + +export function useEditEventMutation() { + return useMutation({ + mutationKey: ["edit-event"], + retry: false, + mutationFn: AdminService.editEvent, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [ALL_EVENTS_QUERY_KEY] + }) + } }) } diff --git a/client/src/services/Admin/AdminQueries.ts b/client/src/services/Admin/AdminQueries.ts index 143c8f22..9e84d2c7 100644 --- a/client/src/services/Admin/AdminQueries.ts +++ b/client/src/services/Admin/AdminQueries.ts @@ -1,4 +1,4 @@ -import { useInfiniteQuery, useQuery } from "@tanstack/react-query" +import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query" import { Timestamp } from "firebase/firestore" import AdminService from "./AdminService" @@ -41,3 +41,14 @@ export function useBookingHistoryQuery() { getNextPageParam: (lastPage) => lastPage.nextCursor }) } + +export function useGetEventQuery() { + /** + * Need to use a mutation instead of query because + * we only want a manual trigger of the fetch + */ + return useMutation({ + mutationKey: ["single-event"], + mutationFn: AdminService.getEvent + }) +} diff --git a/client/src/services/Admin/AdminService.ts b/client/src/services/Admin/AdminService.ts index aa22c9c0..5f13aefb 100644 --- a/client/src/services/Admin/AdminService.ts +++ b/client/src/services/Admin/AdminService.ts @@ -2,7 +2,7 @@ import { Timestamp } from "firebase/firestore" import { UserAdditionalInfo } from "@/models/User" import fetchClient from "@/services/OpenApiFetchClient" import { MEMBER_TABLE_MAX_DATA } from "@/utils/Constants" -import { CreateEventBody } from "@/models/Events" +import { CreateEventBody, EditEventBody } from "@/models/Events" export type EditUsersBody = { uid: string @@ -202,6 +202,41 @@ const AdminService = { if (!response.ok) { throw new Error(`Failed to create the event ${data.title}`) } + }, + editEvent: async function ({ + eventId, + newData + }: { + eventId: string + newData: EditEventBody + }) { + const { response } = await fetchClient.PATCH("/admin/events/{id}", { + params: { + path: { + id: eventId + } + }, + body: { ...newData } + }) + + if (!response.ok) { + throw new Error( + `Failed to edit the event ${newData.title} with id ${eventId}` + ) + } + }, + getEvent: async function (eventId: string) { + const { response, data } = await fetchClient.GET("/admin/events/{id}", { + params: { + path: { id: eventId } + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch data for event with id ${eventId}`) + } + + return data?.data } } as const diff --git a/client/src/services/Event/EventQueries.ts b/client/src/services/Event/EventQueries.ts index 850d9d1f..41ed22be 100644 --- a/client/src/services/Event/EventQueries.ts +++ b/client/src/services/Event/EventQueries.ts @@ -1,13 +1,15 @@ import { useInfiniteQuery } from "@tanstack/react-query" import EventService from "./EventService" +export const ALL_EVENTS_QUERY_KEY = "fetch-all-events" as const + /** * A paginated query to fetch events (using a wrapper around the {@link EventService}) */ export function useLatestEventsQuery() { return useInfiniteQuery({ queryFn: EventService.getAllEvents, - queryKey: ["fetch-all-events"], + queryKey: [ALL_EVENTS_QUERY_KEY], staleTime: 0, // always poll initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor diff --git a/server/src/middleware/tests/EventController.test.ts b/server/src/middleware/tests/EventController.test.ts index 9f8d64cb..e516311e 100644 --- a/server/src/middleware/tests/EventController.test.ts +++ b/server/src/middleware/tests/EventController.test.ts @@ -34,7 +34,7 @@ const event3: Event = { title: "Another Event", location: "Krispy Kreme", physical_start_date: laterStartDate, - sign_up_start_date: earlierStartDate, + sign_up_start_date: laterStartDate, sign_up_end_date: earlierStartDate, google_forms_link: "https://random.com/event3" } diff --git a/server/src/service-layer/controllers/EventController.ts b/server/src/service-layer/controllers/EventController.ts index e868333e..c3456b6e 100644 --- a/server/src/service-layer/controllers/EventController.ts +++ b/server/src/service-layer/controllers/EventController.ts @@ -28,7 +28,7 @@ export class EventController extends Controller { const currentTime = Timestamp.now() res.events.forEach((event) => { - const eventStartTime = event.physical_start_date + const eventStartTime = event.sign_up_start_date const timeDifference = eventStartTime.toMillis() - currentTime.toMillis()