diff --git a/apps/backend/src/data-layer/utils/DateUtils.test.ts b/apps/backend/src/data-layer/utils/DateUtils.test.ts index 2a3fead8d..43ea6713a 100644 --- a/apps/backend/src/data-layer/utils/DateUtils.test.ts +++ b/apps/backend/src/data-layer/utils/DateUtils.test.ts @@ -33,6 +33,33 @@ describe("DateUtils", () => { ) }) + it("returns dates that all fall on the correct UTC weekday", () => { + const semester = { + ...semesterMock, + startDate: "2025-02-24", + endDate: "2025-05-30", + breakStart: "2025-04-14", + breakEnd: "2025-04-25", + } + + const result = getWeeklySessionDates("friday" as Weekday, semester) + + expect(result.length).toBeGreaterThan(0) + for (const date of result) { + expect(date.getUTCDay()).toBe(5) // 5 is friday index + } + }) + + it("returns dates with exactly 7 days between consecutive sessions", () => { + const result = getWeeklySessionDates("wednesday" as Weekday, semesterMock) + + for (let i = 1; i < result.length; i++) { + const diff = result[i].getTime() - result[i - 1].getTime() + const daysDiff = diff / (1000 * 60 * 60 * 24) + expect(daysDiff === 7 || daysDiff > 7).toBe(true) + } + }) + it("returns empty array if no sessions fall outside break", () => { const semester = { ...semesterMock, diff --git a/apps/backend/src/data-layer/utils/DateUtils.ts b/apps/backend/src/data-layer/utils/DateUtils.ts index 48d198c2b..df2290f46 100644 --- a/apps/backend/src/data-layer/utils/DateUtils.ts +++ b/apps/backend/src/data-layer/utils/DateUtils.ts @@ -18,13 +18,13 @@ export function getWeeklySessionDates(day: Weekday, semester: Semester): Date[] const sessionDate = new Date(semesterStart) const dayOffSet = getDaysBetweenWeekdays(Object.values(Weekday)[sessionDate.getUTCDay()], day) - sessionDate.setDate(sessionDate.getUTCDate() + dayOffSet) + sessionDate.setUTCDate(sessionDate.getUTCDate() + dayOffSet) while (sessionDate <= semesterEnd) { if (sessionDate < breakStart || sessionDate > breakEnd) { dates.push(new Date(sessionDate)) } - sessionDate.setDate(sessionDate.getUTCDate() + 7) + sessionDate.setUTCDate(sessionDate.getUTCDate() + 7) } return dates @@ -40,7 +40,7 @@ export function getWeeklySessionDates(day: Weekday, semester: Semester): Date[] export function createGameSessionTimes(schedule: GameSessionSchedule, date: Date) { const day = date.getUTCDate() const month = date.getUTCMonth() - const year = date.getFullYear() + const year = date.getUTCFullYear() const start = new Date(schedule.startTime) const end = new Date(schedule.endTime) diff --git a/apps/backend/src/utils/date.test.ts b/apps/backend/src/utils/date.test.ts index 1a4482321..b743222b5 100644 --- a/apps/backend/src/utils/date.test.ts +++ b/apps/backend/src/utils/date.test.ts @@ -5,13 +5,78 @@ import { getDateTimeStatus, getDaysBetweenWeekdays, getGameSessionOpenDay, + isoToTimeInput, isSameDate, + timeInputToIso, Weekday, } from "@repo/shared" import { semesterMock } from "@repo/shared/mocks" import type { Semester } from "@repo/shared/payload-types" import { vi } from "vitest" +describe("isoToTimeInput", () => { + it("should return empty string for undefined", () => { + expect(isoToTimeInput(undefined)).toBe("") + }) + + it("should return empty string for empty string", () => { + expect(isoToTimeInput("")).toBe("") + }) + + it("should return empty string for an invalid date string", () => { + expect(isoToTimeInput("not-a-date")).toBe("") + }) + + it("should format a UTC morning time correctly", () => { + expect(isoToTimeInput("2024-01-01T09:30:00.000Z")).toBe("09:30") + }) + + it("should format a UTC afternoon time correctly", () => { + expect(isoToTimeInput("2024-01-01T14:05:00.000Z")).toBe("14:05") + }) + + it("should format midnight correctly", () => { + expect(isoToTimeInput("2024-01-01T00:00:00.000Z")).toBe("00:00") + }) + + it("should format the end of day correctly", () => { + expect(isoToTimeInput("2024-01-01T23:59:00.000Z")).toBe("23:59") + }) + + it("should pad single-digit hours and minutes with a leading zero", () => { + expect(isoToTimeInput("2024-06-15T08:05:00.000Z")).toBe("08:05") + }) + + it("should use UTC time regardless of the date's timezone offset", () => { + expect(isoToTimeInput("2024-01-01T09:00:00+05:00")).toBe("04:00") + }) +}) + +describe("timeInputToIso", () => { + it("should convert a morning time to ISO string", () => { + expect(timeInputToIso("09:30")).toBe("1970-01-01T09:30:00.000Z") + }) + + it("should convert an afternoon time to ISO string", () => { + expect(timeInputToIso("14:05")).toBe("1970-01-01T14:05:00.000Z") + }) + + it("should convert midnight to ISO string", () => { + expect(timeInputToIso("00:00")).toBe("1970-01-01T00:00:00.000Z") + }) + + it("should convert end of day to ISO string", () => { + expect(timeInputToIso("23:59")).toBe("1970-01-01T23:59:00.000Z") + }) + + it("should be the inverse of isoToTimeInput", () => { + const times = ["00:00", "09:30", "12:00", "14:05", "23:59"] + for (const time of times) { + expect(isoToTimeInput(timeInputToIso(time))).toBe(time) + } + }) +}) + describe("getDaysBetweenWeekdays", () => { it("should return 0 when fromDay and toDay are the same", () => { expect(getDaysBetweenWeekdays(Weekday.monday, Weekday.monday)).toBe(0) diff --git a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx index ef4eb21ed..3bdaf8641 100644 --- a/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx +++ b/apps/frontend/src/components/client/admin/tabs/admin-semesters/AdminSemesters.tsx @@ -1,13 +1,21 @@ import { Popup } from "@repo/shared/enums" +import type { GameSessionSchedule, Semester } from "@repo/shared/payload-types" +import type { CreateGameSchedulePopUpFormValues } from "@repo/shared/schemas" import type { CreateSemesterData } from "@repo/shared/types" -import { CreateSemesterPopUpFlow } from "@repo/ui/components/Generic" +import { timeInputToIso } from "@repo/shared/utils" +import { CreateGameSchedulePopUp, CreateSemesterPopUpFlow } from "@repo/ui/components/Generic" import { Accordion, Button, Center, Dialog, useDisclosure, useNotice } from "@yamada-ui/react" import { parseAsBoolean, useQueryState } from "nuqs" import { useCallback, useState } from "react" -import { useDeleteGameSessionSchedule } from "@/services/admin/game-session-schedule/AdminGameSessionScheduleMutations" +import { + useCreateGameSessionSchedule, + useDeleteGameSessionSchedule, + useUpdateGameSessionSchedule, +} from "@/services/admin/game-session-schedule/AdminGameSessionScheduleMutations" import { useCreateSemester, useDeleteSemester, + useUpdateSemester, } from "@/services/admin/semester/AdminSemesterMutations" import { useGetAllSemesters } from "@/services/semester/SemesterQueries" import { SemesterScheduleAccordionItem } from "./SemesterScheduleAccordionItem" @@ -79,6 +87,156 @@ export const AdminSemesters = () => { [createSemesterMutation, notice], ) + // Edit semester logic + const [semesterToEdit, setSemesterToEdit] = useState(null) + const { + open: openUpdateSemester, + onOpen: onOpenUpdateSemester, + onClose: onCloseUpdateSemester, + } = useDisclosure() + const updateSemesterMutation = useUpdateSemester() + const handleEditSemester = useCallback( + (semester: Semester) => { + setSemesterToEdit(semester) + onOpenUpdateSemester() + }, + [onOpenUpdateSemester], + ) + const handleEditSemesterConfirm = useCallback( + (data: CreateSemesterData) => { + if (!semesterToEdit) { + return + } + updateSemesterMutation.mutate( + { + id: semesterToEdit.id, + data, + }, + { + onSuccess: () => { + notice({ + title: "Update successful", + description: "Semester has been updated", + status: "success", + }) + onCloseUpdateSemester() + setSemesterToEdit(null) + }, + onError: () => { + notice({ + title: "Update failed", + description: "Failed to update semester", + status: "error", + }) + }, + }, + ) + }, + [updateSemesterMutation, semesterToEdit, notice, onCloseUpdateSemester], + ) + + // Create game session schedule logic + const [scheduleTargetSemesterId, setScheduleTargetSemesterId] = useState(null) + const { + open: openCreateSchedule, + onOpen: onOpenCreateSchedule, + onClose: onCloseCreateSchedule, + } = useDisclosure() + const createScheduleMutation = useCreateGameSessionSchedule() + const handleAddSchedule = useCallback( + (semesterId: string) => { + setScheduleTargetSemesterId(semesterId) + onOpenCreateSchedule() + }, + [onOpenCreateSchedule], + ) + const handleCreateScheduleConfirm = useCallback( + (data: CreateGameSchedulePopUpFormValues) => { + if (!scheduleTargetSemesterId) { + return + } + createScheduleMutation.mutate( + { + ...data, + semester: scheduleTargetSemesterId, + startTime: timeInputToIso(data.startTime), + endTime: timeInputToIso(data.endTime), + }, + { + onSuccess: () => { + notice({ + title: "Creation successful", + description: "Session schedule has been created", + status: "success", + }) + onCloseCreateSchedule() + setScheduleTargetSemesterId(null) + }, + onError: () => { + notice({ + title: "Creation failed", + description: "Failed to create session schedule", + status: "error", + }) + }, + }, + ) + }, + [createScheduleMutation, scheduleTargetSemesterId, notice, onCloseCreateSchedule], + ) + + // Edit game session schedule logic + const [scheduleToEdit, setScheduleToEdit] = useState(null) + const { + open: openEditSchedule, + onOpen: onOpenEditSchedule, + onClose: onCloseEditSchedule, + } = useDisclosure() + const updateScheduleMutation = useUpdateGameSessionSchedule() + const handleEditSchedule = useCallback( + (schedule: GameSessionSchedule) => { + setScheduleToEdit(schedule) + onOpenEditSchedule() + }, + [onOpenEditSchedule], + ) + const handleEditScheduleConfirm = useCallback( + (data: CreateGameSchedulePopUpFormValues) => { + if (!scheduleToEdit) { + return + } + updateScheduleMutation.mutate( + { + id: scheduleToEdit.id, + data: { + ...data, + startTime: timeInputToIso(data.startTime), + endTime: timeInputToIso(data.endTime), + }, + }, + { + onSuccess: () => { + notice({ + title: "Update successful", + description: "Session schedule has been updated", + status: "success", + }) + onCloseEditSchedule() + setScheduleToEdit(null) + }, + onError: () => { + notice({ + title: "Update failed", + description: "Failed to update session schedule", + status: "error", + }) + }, + }, + ) + }, + [updateScheduleMutation, scheduleToEdit, notice, onCloseEditSchedule], + ) + // Delete game session logic const deleteScheduleMutation = useDeleteGameSessionSchedule() const handleDeleteSchedule = useCallback( @@ -127,11 +285,14 @@ export const AdminSemesters = () => { handleAddSchedule(sem.id)} onDeleteSchedule={handleDeleteSchedule} onDeleteSemester={(semId) => { setSemesterToDelete(semId) onOpenDeleteSemester() }} + onEditSchedule={handleEditSchedule} + onEditSemester={handleEditSemester} semester={sem} /> )) @@ -140,6 +301,23 @@ export const AdminSemesters = () => { )} {/* Action flows and confirmations */} + + { + onCloseEditSchedule() + setScheduleToEdit(null) + }} + onConfirm={handleEditScheduleConfirm} + open={openEditSchedule} + scheduleToEdit={scheduleToEdit} // Editing schedule + title="Edit Game Schedule" + /> { setOpenCreateSemester(false) @@ -147,6 +325,24 @@ export const AdminSemesters = () => { onComplete={handleCreateSemester} open={!!openCreateSemester} /> + { + onCloseUpdateSemester() + setSemesterToEdit(null) + }} + onComplete={handleEditSemesterConfirm} + open={openUpdateSemester} + /> void + onEditSemester?: (semester: Semester) => void /** * Callback function to delete the semester by its ID. */ diff --git a/apps/frontend/src/components/client/user/ProfileSection.tsx b/apps/frontend/src/components/client/user/ProfileSection.tsx index 370ba18c9..ddf6b5feb 100644 --- a/apps/frontend/src/components/client/user/ProfileSection.tsx +++ b/apps/frontend/src/components/client/user/ProfileSection.tsx @@ -29,7 +29,7 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => { const deleteBookingMutation = useDeleteBooking() const notice = useNotice() const [bookingId, setBookingId] = useState(null) - const { isOpen, onOpen, onClose } = useDisclosure() + const { open, onOpen, onClose } = useDisclosure() const handleDeleteBooking = async (bookingId: string) => { setBookingId(bookingId) @@ -139,7 +139,7 @@ export const ProfileSection = memo(({ auth }: { auth: AuthContextValue }) => { loading: deleteBookingMutation.isPending, }} onClose={cancelDelete} - open={isOpen} + open={open} subtitle="Are you sure you want to delete this booking? This action cannot be undone." title="Delete Booking" /> diff --git a/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts b/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts index 16107ff0e..ca673969b 100644 --- a/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts +++ b/apps/frontend/src/services/admin/game-session-schedule/AdminGameSessionScheduleMutations.ts @@ -67,7 +67,7 @@ export const useDeleteGameSessionSchedule = () => { onSuccess: () => { queryClient.invalidateQueries({ // TODO: when get by id is implemented, only invalidate the updated id for get by id - queryKey: [QueryKeys.GAME_SESSION_SCHEDULE_QUERY_KEY], + queryKey: [QueryKeys.GAME_SESSION_SCHEDULE_QUERY_KEY, QueryKeys.GAME_SESSION_QUERY_KEY], }) queryClient.invalidateQueries({ queryKey: [QueryKeys.GAME_SESSION_QUERY_KEY], diff --git a/apps/frontend/src/services/admin/semester/AdminSemesterService.ts b/apps/frontend/src/services/admin/semester/AdminSemesterService.ts index b71f70e85..3cf583323 100644 --- a/apps/frontend/src/services/admin/semester/AdminSemesterService.ts +++ b/apps/frontend/src/services/admin/semester/AdminSemesterService.ts @@ -38,7 +38,7 @@ const AdminSemesterService = { data: UpdateSemesterRequest token: string | null }) => { - const response = await apiClient.put( + const response = await apiClient.patch( `/api/admin/semesters/${id}`, data, GetSemesterResponseSchema, diff --git a/packages/shared/src/enums/popup.ts b/packages/shared/src/enums/popup.ts index b83284b2d..7b6481099 100644 --- a/packages/shared/src/enums/popup.ts +++ b/packages/shared/src/enums/popup.ts @@ -5,4 +5,6 @@ export enum Popup { CHANGE_SESSION_FLOW = "change-session-flow", CREATE_MEMBER = "create-member", CREATE_SEMESTER = "create-semester", + CREATE_GAME_SCHEDULE = "create-game-schedule", + EDIT_GAME_SCHEDULE = "edit-game-schedule", } diff --git a/packages/shared/src/schemas/game-session-schedule.ts b/packages/shared/src/schemas/game-session-schedule.ts index 5f1f30c6e..ffebd0a91 100644 --- a/packages/shared/src/schemas/game-session-schedule.ts +++ b/packages/shared/src/schemas/game-session-schedule.ts @@ -5,6 +5,30 @@ import { WeekdayZodEnum } from "../types" import { PaginationDataSchema } from "./query" import { SemesterSchema } from "./semester" +export const CreateGameSchedulePopUpFormDataSchema = z + .object({ + name: z.string().min(1, "Name is required"), + location: z.string().min(1, "Location is required"), + day: WeekdayZodEnum, + startTime: z.string().min(1, "Start time is required"), + endTime: z.string().min(1, "End time is required"), + capacity: z.coerce.number().int().min(1, "Capacity must be at least 1"), + casualCapacity: z.coerce.number().int().min(1, "Casual capacity must be at least 1"), + }) + .refine( + ({ startTime, endTime }) => { + if (!startTime || !endTime) { + return true + } + return endTime > startTime + }, + { message: "End time must be after start time", path: ["endTime"] }, + ) + +export type CreateGameSchedulePopUpFormValues = z.infer< + typeof CreateGameSchedulePopUpFormDataSchema +> + export const GameSessionScheduleSchema = z.object({ id: z.string(), semester: z.union([z.string(), SemesterSchema]), diff --git a/packages/shared/src/utils/date.ts b/packages/shared/src/utils/date.ts index c4a5122c9..27cc42248 100644 --- a/packages/shared/src/utils/date.ts +++ b/packages/shared/src/utils/date.ts @@ -182,6 +182,53 @@ export function formatDateToString(date: Date): string { return dayjs(date).format("YYYY-MM-DD") } +/** + * Converts an ISO 8601 datetime string to a time input value in HH:MM format (UTC). + * + * Intended for populating HTML `` elements from stored ISO timestamps. + * + * @param iso The ISO 8601 datetime string to convert + * @returns Time string in HH:MM format, or empty string if input is falsy or invalid + * + * @example + * ```ts + * isoToTimeInput("2024-01-01T09:30:00.000Z") // Returns: "09:30" + * isoToTimeInput("2024-01-01T14:05:00.000Z") // Returns: "14:05" + * isoToTimeInput("") // Returns: "" + * isoToTimeInput(undefined) // Returns: "" + * ``` + */ +export function isoToTimeInput(iso?: string): string { + if (!iso) return "" + const date = new Date(iso) + if (Number.isNaN(date.getTime())) { + return "" + } + const hours = String(date.getUTCHours()).padStart(2, "0") + const minutes = String(date.getUTCMinutes()).padStart(2, "0") + return `${hours}:${minutes}` +} + +/** + * Converts an HTML `` value (HH:MM) to an ISO 8601 datetime string. + * + * The date portion is fixed to 1970-01-01 UTC to match the backend's time field normalization. + * + * @param time The time string in HH:MM format + * @returns ISO 8601 datetime string + * + * @example + * ```ts + * timeInputToIso("09:30") // Returns: "1970-01-01T09:30:00.000Z" + * timeInputToIso("14:05") // Returns: "1970-01-01T14:05:00.000Z" + * timeInputToIso("00:00") // Returns: "1970-01-01T00:00:00.000Z" + * ``` + */ +export function timeInputToIso(time: string): string { + const [hours, minutes] = time.split(":") + return new Date(Date.UTC(1970, 0, 1, Number(hours), Number(minutes))).toISOString() +} + /** * Method used to get the status of a date * diff --git a/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.test.tsx b/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.test.tsx index 09773742c..28bf57935 100644 --- a/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.test.tsx +++ b/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.test.tsx @@ -63,7 +63,7 @@ describe("", () => { await user.click(screen.getByRole("button", { name: /semester actions/i })) await user.click(screen.getByText("Edit")) - expect(onEditSemester).toHaveBeenCalledWith(semesterMock.id) + expect(onEditSemester).toHaveBeenCalledWith(semesterMock) }) it("calls onDeleteSemester with semester id when Delete is clicked", async () => { diff --git a/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.tsx b/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.tsx index 34390ebda..1d2bfe7da 100644 --- a/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.tsx +++ b/packages/ui/src/components/Composite/AdminSemestersAccordionItem/AdminSemestersAccordionItem.tsx @@ -45,7 +45,7 @@ export interface AdminSemestersAccordionItemProps { /** * Callback function to edit the semester. */ - onEditSemester?: (semesterId: string) => void + onEditSemester?: (semester: Semester) => void /** * Callback function to delete the semester by its ID. */ @@ -86,7 +86,7 @@ export const AdminSemestersAccordionItem = ({ variant="ghost" /> e.stopPropagation()}> - onEditSemester?.(semester.id)}>Edit + onEditSemester?.(semester)}>Edit onDeleteSemester?.(semester.id)}>Delete diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx new file mode 100644 index 000000000..49a0bae08 --- /dev/null +++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx @@ -0,0 +1,36 @@ +import { gameSessionScheduleMock } from "@repo/shared/mocks" +import type { Meta, StoryFn } from "@storybook/nextjs-vite" +import { + CreateGameSchedulePopUp, + type CreateGameSchedulePopUpProps, +} from "./CreateGameSchedulePopUp" + +const meta: Meta = { + title: "Generic Components / CreateGameSchedulePopUp", + component: CreateGameSchedulePopUp, + argTypes: { + title: { control: "text", description: "The dialog header title" }, + scheduleToEdit: { control: "object", description: "Object to pre-fill input fields" }, + onConfirm: { action: "confirmed" }, + }, + args: { + title: "Create Game Schedule", + }, +} + +export default meta +type Story = StoryFn + +export const Default: Story = () => { + return console.log("Confirmed:", val)} open /> +} + +export const PrefilledData: Story = () => { + return ( + console.log("Confirmed:", val)} + open + scheduleToEdit={gameSessionScheduleMock} + /> + ) +} diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx new file mode 100644 index 000000000..ca3312bd6 --- /dev/null +++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx @@ -0,0 +1,114 @@ +import type { CreateGameSchedulePopUpFormValues } from "@repo/shared" +import { gameSessionScheduleMock } from "@repo/shared/mocks" +import { render, screen } from "@repo/ui/test-utils" +import { noop } from "@yamada-ui/react" +import { isValidElement, useState } from "react" +import { Button } from "../../Primitive" +import { + CreateGameSchedulePopUp, + type CreateGameSchedulePopUpProps, +} from "./CreateGameSchedulePopUp" +import * as CreateGameSchedulePopUpModule from "./index" + +const CreateGameSchedulePopUpExample = (props: CreateGameSchedulePopUpProps) => { + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(false)} open={open} {...props} /> + + ) +} + +describe("", () => { + it("should re-export the CreateGameSchedulePopUp component and check if it exists", () => { + expect(CreateGameSchedulePopUpModule.CreateGameSchedulePopUp).toBeDefined() + expect(isValidElement()).toBeTruthy() + }) + + it("should have correct displayName", () => { + expect(CreateGameSchedulePopUp.displayName).toBe("CreateGameSchedulePopUp") + }) + + it("should render the dialog with default title", async () => { + const { user } = render() + await user.click(screen.getByText("Open pop up")) + + expect(screen.getByText("Create Game Schedule")).toBeInTheDocument() + }) + + it("should render the dialog with a custom title", async () => { + const { user } = render() + await user.click(screen.getByText("Open pop up")) + + expect(screen.getByText("Edit Game Schedule")).toBeInTheDocument() + }) + + it("should call onConfirm when submitted with valid data", async () => { + const handleConfirm = vi.fn((data: CreateGameSchedulePopUpFormValues) => data) + + const { user } = render( + , + ) + await user.click(screen.getByText("Open pop up")) + + await user.click(screen.getByTestId("submit")) + expect(handleConfirm).toBeCalled() + }) + + it("should call onClose when the back button is clicked", async () => { + const handleClose = vi.fn(noop) + + const { user } = render() + await user.click(screen.getByText("Open pop up")) + + await user.click(screen.getByTestId("back")) + + expect(handleClose).toBeCalled() + }) + + it("should not submit when required fields are empty", async () => { + const handleConfirm = vi.fn((data: CreateGameSchedulePopUpFormValues) => data) + + const { user } = render() + await user.click(screen.getByText("Open pop up")) + + await user.click(screen.getByTestId("submit")) + + expect(handleConfirm).not.toBeCalled() + expect(screen.getByText("Name is required")).toBeInTheDocument() + }) + + it("should pre-fill fields when scheduleToEdit is provided", async () => { + const { user } = render( + , + ) + await user.click(screen.getByText("Open pop up")) + + expect(screen.getByTestId("name")).toHaveValue(gameSessionScheduleMock.name) + expect(screen.getByTestId("location")).toHaveValue(gameSessionScheduleMock.location) + expect(screen.getByTestId("capacity")).toHaveValue(gameSessionScheduleMock.capacity) + expect(screen.getByTestId("casual-capacity")).toHaveValue( + gameSessionScheduleMock.casualCapacity, + ) + expect(screen.getByTestId("start-time")).toHaveValue("10:00") + expect(screen.getByTestId("end-time")).toHaveValue("12:00") + }) + + it("should reset form when closed and reopened", async () => { + const { user } = render() + await user.click(screen.getByText("Open pop up")) + + await user.type(screen.getByTestId("name"), "Test Name") + expect(screen.getByTestId("name")).toHaveValue("Test Name") + + await user.click(screen.getByTestId("back")) + await user.click(screen.getByText("Open pop up")) + + expect(screen.getByTestId("name")).toHaveValue("") + }) +}) diff --git a/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx new file mode 100644 index 000000000..0b2da125f --- /dev/null +++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx @@ -0,0 +1,283 @@ +"use client" + +import { zodResolver } from "@hookform/resolvers/zod" +import { + CreateGameSchedulePopUpFormDataSchema, + type CreateGameSchedulePopUpFormValues, + isoToTimeInput, + Weekday, +} from "@repo/shared" +import type { GameSessionSchedule } from "@repo/shared/payload-types" +import { Button, Heading, Select, TextInput } from "@repo/ui/components/Primitive" +import { InputType } from "@repo/ui/components/Primitive/TextInput/types" +import { + ArrowLeftIcon, + CalendarDaysIcon, + Clock3Icon, + Clock9Icon, + IdCardIcon, + MapPinIcon, + UserRoundIcon, +} from "@yamada-ui/lucide" +import { + ButtonGroup, + Dialog, + DialogBody, + DialogCloseButton, + DialogFooter, + DialogHeader, + DialogOverlay, + type DialogProps, + FormControl, + Grid, + GridItem, + VStack, +} from "@yamada-ui/react" +import type { FC } from "react" +import { Controller, type SubmitHandler, useForm } from "react-hook-form" + +/** + * Props for the CreateGameSchedulePopUp component. + * @extends DialogProps + */ +export interface CreateGameSchedulePopUpProps extends DialogProps { + /** + * Optional title for the popup. + */ + title?: string + /** + * Optional schedule to edit. + */ + scheduleToEdit?: GameSessionSchedule | null + /** + * Callback when confirming the form. + */ + onConfirm?: (data: CreateGameSchedulePopUpFormValues) => void +} + +/** + * Options for weekdays, formatted for Select component. + */ +const WeekdayOptions = Object.values(Weekday).map((day) => ({ + value: day, + label: day.charAt(0).toUpperCase() + day.slice(1), +})) + +/** + * A popup dialog for creating or editing a game schedule. + * @param props - The props for the component. + * @returns The rendered component. + */ +export const CreateGameSchedulePopUp: FC = ({ + title = "Create Game Schedule", + scheduleToEdit, + onConfirm, + ...props +}) => { + const { + register, + control, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(CreateGameSchedulePopUpFormDataSchema), + defaultValues: scheduleToEdit + ? { + name: scheduleToEdit.name, + location: scheduleToEdit.location, + day: scheduleToEdit.day, + capacity: scheduleToEdit.capacity, + casualCapacity: scheduleToEdit.casualCapacity, + startTime: isoToTimeInput(scheduleToEdit.startTime), + endTime: isoToTimeInput(scheduleToEdit.endTime), + } + : undefined, + }) + + const handleClose = () => { + reset() + props.onClose?.() + } + + const onSubmit: SubmitHandler = (data) => { + onConfirm?.(data) + handleClose() + } + + return ( + + + + + + + {title} + + + + + + + + } + type={InputType.Text} + {...register("name")} + /> + + + + + ( +