From 9b5e949c6fc090d14f2ff875cc8ad0ec9862df5d Mon Sep 17 00:00:00 2001 From: Aneesh Reddy Date: Wed, 22 Jan 2025 10:56:33 -0500 Subject: [PATCH] Fixed all errors observed on next. Needs production testing since server actions behave a little differently. --- actions/event.ts | 21 ++++- actions/eventPosition.ts | 50 ++++++++++-- components/Event/EventForm.tsx | 76 ++++++++++--------- .../EventManager/EventPositionEditButton.tsx | 10 ++- .../EventPositionPublishAllButton.tsx | 12 ++- .../EventPositionPublishButton.tsx | 8 +- types/index.ts | 3 +- types/zod.ts | 7 ++ 8 files changed, 133 insertions(+), 54 deletions(-) create mode 100644 types/zod.ts diff --git a/actions/event.ts b/actions/event.ts index c1052f9..0488fb3 100644 --- a/actions/event.ts +++ b/actions/event.ts @@ -7,11 +7,12 @@ import { GridFilterItem } from "@mui/x-data-grid"; import { GridPaginationModel } from "@mui/x-data-grid"; import { GridSortModel } from "@mui/x-data-grid"; import { EventType, Prisma } from "@prisma/client"; -import { z } from "zod"; +import { SafeParseReturnType, z } from "zod"; import { UTApi } from "uploadthing/server"; import { revalidatePath } from "next/cache"; import { sendEventPositionRemovalEmail } from "./mail/event"; import { User } from "next-auth"; +import { ZodErrorSlimResponse } from "@/types"; const MAX_FILE_SIZE = 1024 * 1024 * 4; const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif']; @@ -69,7 +70,7 @@ const getWhere = (filter?: GridFilterItem): Prisma.EventWhereInput => { } } -export const validateEvent = async (input: { [key: string]: any }) => { +export const validateEvent = async (input: { [key: string]: any }, zodResponse?: boolean): Promise> => { const isAfterToday = (date: Date) => { const today = new Date(); @@ -125,7 +126,19 @@ export const validateEvent = async (input: { [key: string]: any }) => { path: ["bannerImage", "bannerUrl"], }) - return eventZ.safeParse(input); + const data = eventZ.safeParse(input); + + if (zodResponse) { + return data; + } + + return { + success: data.success, + errors: data.error ? data.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })) : [], + } } export const upsertEvent = async (formData: FormData) => { @@ -140,7 +153,7 @@ export const upsertEvent = async (formData: FormData) => { bannerImage: formData.get('bannerImage') as File, bannerUrl: (formData.get('bannerUrl') as string) || undefined, featuredFields: JSON.parse(formData.get('featuredFields') as string), - }); + }, true) as SafeParseReturnType; if (!result.success) { return { errors: result.error.errors }; diff --git a/actions/eventPosition.ts b/actions/eventPosition.ts index 0b3e469..62a1e40 100644 --- a/actions/eventPosition.ts +++ b/actions/eventPosition.ts @@ -5,10 +5,11 @@ import prisma from "@/lib/db"; import { Event, EventPosition } from "@prisma/client"; import { getServerSession, User } from "next-auth"; import { after } from "next/server"; -import { z } from "zod"; +import { SafeParseReturnType, z } from "zod"; import { log } from "./log"; import { revalidatePath } from "next/cache"; import { sendEventPositionEmail, sendEventPositionRemovalEmail, sendEventPositionRequestDeletedEmail } from "./mail/event"; +import { ZodErrorSlimResponse } from "@/types"; export const toggleManualPositionOpen = async (event: Event) => { @@ -171,7 +172,7 @@ export const deleteEventPosition = async (event: Event, eventPositionId: string, } -export const validateFinalEventPosition = async (event: Event, formData: FormData) => { +export const validateFinalEventPosition = async (event: Event, formData: FormData, zodResponse?: boolean): Promise> => { const eventPositionZ = z.object({ finalPosition: z.string().min(1, { message: 'Final Position is required and could not be autofilled.' }).max(50, { message: 'Final Position must be less than 50 characters' }), finalStartTime: z.date().min(event.start, { message: 'Final time must be within the event' }).max(event.end, { message: 'Final time must be within the event' }), @@ -185,17 +186,29 @@ export const validateFinalEventPosition = async (event: Event, formData: FormDat finalPosition = requestedPosition; } - return eventPositionZ.safeParse({ + const data = eventPositionZ.safeParse({ finalPosition, finalStartTime: new Date(formData.get('finalStartTime') as string), finalEndTime: new Date(formData.get('finalEndTime') as string), finalNotes: formData.get('finalNotes'), }); + + if (zodResponse) { + return data; + } + + return { + success: data.success, + errors: data.error ? data.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })) : [], + }; } export const adminSaveEventPosition = async (event: Event, position: EventPosition, formData: FormData) => { - const result = await validateFinalEventPosition(event, formData); + const result = await validateFinalEventPosition(event, formData, true) as SafeParseReturnType; if (!result.success) { return { errors: result.error.errors }; @@ -232,12 +245,37 @@ export const adminSaveEventPosition = async (event: Event, position: EventPositi } export const publishEventPosition = async (event: Event, position: EventPosition) => { + + const formData = new FormData(); + let finalPosition = position.finalPosition || ''; + + if (!finalPosition && event.presetPositions.includes(position.requestedPosition)) { + finalPosition = position.requestedPosition; + } + + formData.set('finalPosition', finalPosition); + formData.set('finalStartTime', position.finalStartTime?.toISOString() || position.requestedStartTime?.toISOString() || ''); + formData.set('finalEndTime', position.finalEndTime?.toISOString() || position.requestedEndTime?.toISOString() || ''); + formData.set('finalNotes', position.finalNotes || ''); + + const result = await validateFinalEventPosition(event, formData, true) as SafeParseReturnType; + + if (!result.success) { + return { error: { + success: false, + errors: result.error.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + })), + } as ZodErrorSlimResponse }; + } + const eventPosition = await prisma.eventPosition.update({ where: { id: position.id, }, data: { - finalPosition: position.finalPosition, + finalPosition, published: true, }, include: { @@ -274,6 +312,8 @@ export const unpublishEventPosition = async (event: Event, position: EventPositi after(async () => { if (eventPosition) { await log("UPDATE", "EVENT_POSITION", `Unpublished event position for ${eventPosition.user?.firstName} ${eventPosition.user?.lastName} for ${eventPosition.finalPosition} from ${eventPosition.finalStartTime?.toUTCString()} to ${eventPosition.finalEndTime?.toUTCString()}`); + + sendEventPositionRemovalEmail(eventPosition.user as User, eventPosition, event); } }); diff --git a/components/Event/EventForm.tsx b/components/Event/EventForm.tsx index 6c74d45..0ba8f4a 100644 --- a/components/Event/EventForm.tsx +++ b/components/Event/EventForm.tsx @@ -10,13 +10,14 @@ import MarkdownEditor from "@uiw/react-markdown-editor"; import dayjs, { Dayjs } from "dayjs"; import utc from "dayjs/plugin/utc"; import Form from "next/form"; -import { ReactNode, useCallback, useEffect, useState } from "react"; +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import FormSaveButton from "../Form/FormSaveButton"; import { upsertEvent, validateEvent } from "@/actions/event"; import { toast } from "react-toastify"; import { useRouter } from "next/navigation"; import { SafeParseReturnType, ZodIssue } from "zod"; import Markdown from "react-markdown"; +import { ZodErrorSlimResponse } from "@/types"; export default function EventForm({ event }: { event?: Event, }) { @@ -44,32 +45,42 @@ export default function EventForm({ event }: { event?: Event, }) { , ]); - const updateStatus = useCallback(async () => { + const debounce = (func: Function, wait: number) => { + let timeout: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; + }; - const res = await validateEvent({ - id: event?.id, - name, - start: start?.toDate(), - end: end?.toDate(), - type: type?.toString() || '', - description, - bannerUrl, - featuredFields, - }); + const debouncedUpdateStatus = useMemo( + () => debounce(async () => { + const res = await validateEvent({ + id: event?.id, + name, + start: start?.toDate(), + end: end?.toDate(), + type: type?.toString() || '', + description, + bannerUrl, + featuredFields, + }) as ZodErrorSlimResponse; - const firstStep = await getStepStatus(res, { name, start: start?.toDate(), end: end?.toDate(), }); + const firstStep = await getStepStatus(res, { name, start: start?.toDate(), end: end?.toDate(), }); - if (event?.archived) { - setStatus([, , , , ]); - return; - } - - const secondStep = await getStepStatus(res, { type: type?.toString() || '' }); - const thirdStep = await getStepStatus(res, { description }); - const fourthStep = await getStepStatus(res, { bannerUrl }); - const fifthStep = await getStepStatus(res, { featuredFields }); - setStatus([firstStep, secondStep, thirdStep, fourthStep, fifthStep]); - }, [event?.archived, event?.id, name, start, end, type, description, bannerUrl, featuredFields]); + if (event?.archived) { + setStatus([, , , , ]); + return; + } + + const secondStep = await getStepStatus(res, { type: type?.toString() || '' }); + const thirdStep = await getStepStatus(res, { description }); + const fourthStep = await getStepStatus(res, { bannerUrl }); + const fifthStep = await getStepStatus(res, { featuredFields }); + setStatus([firstStep, secondStep, thirdStep, fourthStep, fifthStep]); + }, 500), + [event?.archived, event?.id, name, start, end, type, description, bannerUrl, featuredFields] + ); const handleSubmit = async (formData: FormData) => { @@ -101,8 +112,8 @@ export default function EventForm({ event }: { event?: Event, }) { } useEffect(() => { - updateStatus(); - }, [updateStatus]); + debouncedUpdateStatus(); + }, [debouncedUpdateStatus]); const handleOpen = (panel: number) => (event: React.SyntheticEvent, isExpanded: boolean) => { setOpen(isExpanded ? panel : -1); @@ -110,12 +121,12 @@ export default function EventForm({ event }: { event?: Event, }) { const back = () => { setOpen((prev) => prev - 1); - updateStatus(); + debouncedUpdateStatus(); } const forward = () => { setOpen((prev) => prev + 1); - updateStatus(); + debouncedUpdateStatus(); } const NextButton = @@ -334,16 +345,13 @@ const getDescription = (type: EventType) => { } } -const getStepStatus = async (parse: SafeParseReturnType, input: { [key: string]: any }) => { +const getStepStatus = async (parse: ZodErrorSlimResponse, input: { [key: string]: any }) => { - if (parse.success) { + if (parse.success || parse.errors.length === 0) { return ; } - - const error = parse.error as Error; - const errors = JSON.parse(error.message) as ZodIssue[]; - if (errors.filter((error) => Object.keys(input).includes(error.path[0] + '')).length > 0) { + if (parse.errors.filter((error) => Object.keys(input).includes(error.path)).length > 0) { return ; } else { return ; diff --git a/components/EventManager/EventPositionEditButton.tsx b/components/EventManager/EventPositionEditButton.tsx index c4817f2..7ca2363 100644 --- a/components/EventManager/EventPositionEditButton.tsx +++ b/components/EventManager/EventPositionEditButton.tsx @@ -44,7 +44,7 @@ export default function EventPositionEditButton({ event, position, }: { event: E formData.set('finalEndTime', finalEndTime!.toISOString()); formData.set('finalNotes', finalNotes); - const { errors } = await adminSaveEventPosition(event, position, formData); + const { eventPosition, errors } = await adminSaveEventPosition(event, position, formData); if (errors) { toast.error(errors.map((error) => error.message).join('. ')); @@ -54,7 +54,13 @@ export default function EventPositionEditButton({ event, position, }: { event: E toast.success('Position saved successfully!'); if (publish) { - await publishEventPosition(event, position); + const {error} = await publishEventPosition(event, eventPosition); + + if (error) { + toast.error(error.errors.map((error) => error.message).join('. ')); + return; + } + toast.success('Position published successfully!'); } diff --git a/components/EventManager/EventPositionPublishAllButton.tsx b/components/EventManager/EventPositionPublishAllButton.tsx index e919cca..2010298 100644 --- a/components/EventManager/EventPositionPublishAllButton.tsx +++ b/components/EventManager/EventPositionPublishAllButton.tsx @@ -1,6 +1,7 @@ 'use client'; import { publishEventPosition, unpublishEventPosition, validateFinalEventPosition } from "@/actions/eventPosition"; import { EventPositionWithSolo } from "@/app/events/admin/events/[id]/manager/page"; +import { ZodErrorSlimResponse } from "@/types"; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from "@mui/material"; import { Event, } from "@prisma/client"; import { User } from "next-auth"; @@ -117,20 +118,17 @@ const getErrors = async (event: Event, positions: EventPositionWithSolo[]): Prom formData.set('finalEndTime', modPosition.finalEndTime.toISOString()); formData.set('finalNotes', modPosition.finalNotes); - const parse = await validateFinalEventPosition(event, formData); + const parse = await validateFinalEventPosition(event, formData) as ZodErrorSlimResponse; if (parse.success) { continue; } - const error = parse.error as Error; - const zodErrors = JSON.parse(error.message) as ZodIssue[]; - - for (const zodError of zodErrors) { + for (const error of parse.errors) { if (errors.find((error) => error.user === position.user)) { - errors.find((error) => error.user === position.user)?.errors.push(zodError.message); + errors.find((error) => error.user === position.user)?.errors.push(error.message); } else { - errors.push({ user: position.user as User, errors: [zodError.message] }); + errors.push({ user: position.user as User, errors: [error.message] }); } } } diff --git a/components/EventManager/EventPositionPublishButton.tsx b/components/EventManager/EventPositionPublishButton.tsx index 037c56c..f33d47e 100644 --- a/components/EventManager/EventPositionPublishButton.tsx +++ b/components/EventManager/EventPositionPublishButton.tsx @@ -16,7 +16,13 @@ export default function EventPositionPublishButton({ event, position, }: { event return; } - await publishEventPosition(event, position); + const {error} = await publishEventPosition(event, position); + + if (error) { + toast.error(error.errors.map((error) => error.message).join('. ')); + return; + } + toast.success('Position published successfully!'); } diff --git a/types/index.ts b/types/index.ts index dde0e0b..408feb3 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1 +1,2 @@ -export * from './external'; \ No newline at end of file +export * from './external'; +export * from './zod'; \ No newline at end of file diff --git a/types/zod.ts b/types/zod.ts new file mode 100644 index 0000000..08f893a --- /dev/null +++ b/types/zod.ts @@ -0,0 +1,7 @@ +export type ZodErrorSlimResponse = { + success: boolean; + errors: { + path: string, + message: string, + }[], +}; \ No newline at end of file