From 0df224029031e877a1787b5b187f194fb060910c Mon Sep 17 00:00:00 2001 From: Jeffery <61447509+jeffplays2005@users.noreply.github.com> Date: Mon, 2 Mar 2026 00:19:41 +1300 Subject: [PATCH 01/13] feat(ui): add create game schedule popup component - Also removed usage of deprecated `leftIcon` prop. - Added component test+stories for `CreateGameSchedulePopUp` - Added date util functions and tests relevant to this component. --- apps/backend/src/utils/date.test.ts | 65 +++++ packages/shared/src/enums/popup.ts | 2 + .../src/schemas/game-session-schedule.ts | 24 ++ packages/shared/src/utils/date.ts | 47 ++++ .../CreateGameSchedulePopUp.stories.tsx | 36 +++ .../CreateGameSchedulePopUp.test.tsx | 114 +++++++++ .../CreateGameSchedulePopUp.tsx | 236 ++++++++++++++++++ .../Generic/CreateGameSchedulePopUp/index.ts | 1 + .../CreateMemberPopUp/CreateMemberPopUp.tsx | 2 +- .../CreateSessionPopUp/CreateSessionPopUp.tsx | 2 +- packages/ui/src/components/Generic/index.ts | 1 + 11 files changed, 528 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.stories.tsx create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.test.tsx create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx create mode 100644 packages/ui/src/components/Generic/CreateGameSchedulePopUp/index.ts 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/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/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..a1eb78e3b --- /dev/null +++ b/packages/ui/src/components/Generic/CreateGameSchedulePopUp/CreateGameSchedulePopUp.tsx @@ -0,0 +1,236 @@ +"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, + MapPinIcon, + UserRoundIcon, +} from "@yamada-ui/lucide" +import { + ButtonGroup, + Center, + 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" + +export interface CreateGameSchedulePopUpProps extends DialogProps { + title?: string + scheduleToEdit?: GameSessionSchedule | null + onConfirm?: (data: CreateGameSchedulePopUpFormValues) => void +} + +const WeekdayOptions = Object.values(Weekday).map((day) => ({ + value: day, + label: day.charAt(0).toUpperCase() + day.slice(1), +})) + +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("location")} + /> + + + ( + } - items={WeekdayOptions} - {...field} - /> - )} - /> - - - } - type={InputType.Number} - {...register("capacity")} - /> - - - - - } - type={InputType.Number} - {...register("casualCapacity")} - /> - - - } - type={InputType.Time} - {...register("startTime")} - /> - - - } - type={InputType.Time} - {...register("endTime")} + + + + } + type={InputType.Text} + {...register("name")} + /> + + + + + ( +