From 51d8074d13395928405e70b6634021cef6ee8b23 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 26 Jan 2025 11:15:06 +1100 Subject: [PATCH] Add zod-validation-error package and refactor event forms to use updated structure --- .../events/[eventId]/edit/page.tsx | 29 +- .../projects/[projectId]/events/actions.ts | 234 +++++++------- .../projects/[projectId]/events/new/page.tsx | 34 +- components/form/event.tsx | 291 ++++++++++-------- components/nav-user.tsx | 30 +- .../project/tasklist/task/task-item.tsx | 12 +- package.json | 3 +- pnpm-lock.yaml | 13 + 8 files changed, 331 insertions(+), 315 deletions(-) diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/events/[eventId]/edit/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/events/[eventId]/edit/page.tsx index 0994586..e3e465c 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/events/[eventId]/edit/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/events/[eventId]/edit/page.tsx @@ -1,17 +1,11 @@ import PageSection from "@/components/core/section"; -import { SaveButton } from "@/components/form/button"; import EventForm from "@/components/form/event"; import PageTitle from "@/components/layout/page-title"; -import { buttonVariants } from "@/components/ui/button"; -import { CardContent, CardFooter } from "@/components/ui/card"; import { calendarEvent } from "@/drizzle/schema"; import { database } from "@/lib/utils/useDatabase"; -import { getOwner } from "@/lib/utils/useOwner"; import { allUsers } from "@/lib/utils/useUser"; import { eq } from "drizzle-orm"; -import Link from "next/link"; import { notFound } from "next/navigation"; -import { updateEvent } from "../../actions"; type Props = { params: Promise<{ @@ -22,7 +16,6 @@ type Props = { export default async function EditEvent(props: Props) { const params = await props.params; - const { orgSlug } = await getOwner(); const { projectId, eventId } = params; const users = await allUsers(); @@ -54,32 +47,12 @@ export default async function EditEvent(props: Props) { return notFound(); } - const backUrl = `/${orgSlug}/projects/${projectId}/events`; - return ( <> -
- - - - - - -
- - Cancel - - -
-
-
+
); diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/events/actions.ts b/app/(dashboard)/[tenant]/projects/[projectId]/events/actions.ts index 82d3e60..94b36ef 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/events/actions.ts +++ b/app/(dashboard)/[tenant]/projects/[projectId]/events/actions.ts @@ -5,11 +5,13 @@ import { generateObjectDiffMessage, logActivity } from "@/lib/activity"; import { toEndOfDay, toMachineDateString } from "@/lib/utils/date"; import { database } from "@/lib/utils/useDatabase"; import { getOwner, getTimezone } from "@/lib/utils/useOwner"; +import { isAfter } from "date-fns"; import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { type Frequency, RRule } from "rrule"; import { ZodError, ZodIssueCode, z } from "zod"; +import { fromError } from "zod-validation-error"; const eventInputSchema = z.object({ projectId: z.string(), @@ -71,7 +73,7 @@ function handleEventPayload(payload: FormData): { event.end = toEndOfDay(event.end); } - if (event.end && event.end > event.start) { + if (event.end && isAfter(event.start, event.end)) { throw new ZodError([ { message: "End date must be after start date", @@ -84,138 +86,150 @@ function handleEventPayload(payload: FormData): { return event; } -export async function createEvent(payload: FormData) { +export async function createEvent(_: unknown, payload: FormData) { const { userId, orgSlug } = await getOwner(); - - const { - projectId, - name, - description, - start, - end, - allDay, - repeatRule, - invites, - } = handleEventPayload(payload); - - const db = await database(); - const createdEvent = db - .insert(calendarEvent) - .values({ + let redirectPath: string; + try { + const { + projectId, name, description, start, end, allDay, repeatRule, - projectId, - createdByUser: userId, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - .get(); + invites, + } = handleEventPayload(payload); - for (const userId of invites) { - db.insert(eventInvite) + const db = await database(); + const createdEvent = db + .insert(calendarEvent) .values({ - eventId: createdEvent.id, - userId, - status: "invited", + name, + description, + start, + end, + allDay, + repeatRule, + projectId, + createdByUser: userId, + createdAt: new Date(), + updatedAt: new Date(), }) - .run(); - } + .returning() + .get(); + + for (const userId of invites) { + db.insert(eventInvite) + .values({ + eventId: createdEvent.id, + userId, + status: "invited", + }) + .run(); + } - await logActivity({ - action: "created", - type: "event", - message: `Created event ${name}`, - projectId: +projectId, - }); + await logActivity({ + action: "created", + type: "event", + message: `Created event ${name}`, + projectId: +projectId, + }); - const timezone = await getTimezone(); + const timezone = await getTimezone(); + redirectPath = `/${orgSlug}/projects/${projectId}/events?on=${toMachineDateString(start, timezone)}`; + revalidatePath(`/${orgSlug}/projects/${projectId}/events`); + } catch (error) { + console.error(error); + return { + message: "An error occurred while creating the event", + }; + } - revalidatePath(`/${orgSlug}/projects/${projectId}/events`); - redirect( - `/${orgSlug}/projects/${projectId}/events?on=${toMachineDateString(start, timezone)}`, - ); + redirect(redirectPath); } -export async function updateEvent(payload: FormData) { - const { orgSlug } = await getOwner(); - const id = +(payload.get("id") as string); +export async function updateEvent(_: unknown, payload: FormData) { + let redirectPath: string; + try { + const { orgSlug } = await getOwner(); + const id = +(payload.get("id") as string); - const { - projectId, - name, - description, - start, - end, - allDay, - repeatRule, - invites, - } = handleEventPayload(payload); - - const db = await database(); - db.delete(eventInvite).where(eq(eventInvite.eventId, id)).run(); - - for (const userId of invites) { - db.insert(eventInvite) - .values({ - eventId: id, - userId, - status: "invited", - }) - .run(); - } - - const currentEvent = await db.query.calendarEvent - .findFirst({ - where: and( - eq(calendarEvent.id, id), - eq(calendarEvent.projectId, +projectId), - ), - }) - .execute(); - - db.update(calendarEvent) - .set({ + const { + projectId, name, description, start, end, allDay, - repeatRule: repeatRule?.toString(), - updatedAt: new Date(), - }) - .where( - and(eq(calendarEvent.id, id), eq(calendarEvent.projectId, +projectId)), - ) - .run(); - - if (currentEvent) - await logActivity({ - action: "updated", - type: "event", - message: `Updated event ${name}, ${generateObjectDiffMessage( - currentEvent, - { - name, - description, - start, - end, - allDay, - }, - )}`, - projectId: +projectId, - }); + repeatRule, + invites, + } = handleEventPayload(payload); + + const db = await database(); + db.delete(eventInvite).where(eq(eventInvite.eventId, id)).run(); + + for (const userId of invites) { + db.insert(eventInvite) + .values({ + eventId: id, + userId, + status: "invited", + }) + .run(); + } + + const currentEvent = await db.query.calendarEvent + .findFirst({ + where: and( + eq(calendarEvent.id, id), + eq(calendarEvent.projectId, +projectId), + ), + }) + .execute(); + + db.update(calendarEvent) + .set({ + name, + description, + start, + end, + allDay, + repeatRule: repeatRule?.toString(), + updatedAt: new Date(), + }) + .where( + and(eq(calendarEvent.id, id), eq(calendarEvent.projectId, +projectId)), + ) + .run(); - const timezone = await getTimezone(); + if (currentEvent) + await logActivity({ + action: "updated", + type: "event", + message: `Updated event ${name}, ${generateObjectDiffMessage( + currentEvent, + { + name, + description, + start, + end, + allDay, + }, + )}`, + projectId: +projectId, + }); + + const timezone = await getTimezone(); + revalidatePath(`/${orgSlug}/projects/${projectId}/events`); + redirectPath = `/${orgSlug}/projects/${projectId}/events?on=${toMachineDateString(start, timezone)}`; + } catch (error) { + return { + message: fromError(error).toString(), + }; + } - revalidatePath(`/${orgSlug}/projects/${projectId}/events`); - redirect( - `/${orgSlug}/projects/${projectId}/events?on=${toMachineDateString(start, timezone)}`, - ); + redirect(redirectPath); } export async function deleteEvent(payload: FormData) { diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/events/new/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/events/new/page.tsx index b9b7c8f..ee17322 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/events/new/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/events/new/page.tsx @@ -1,13 +1,8 @@ import PageSection from "@/components/core/section"; -import { SaveButton } from "@/components/form/button"; import EventForm from "@/components/form/event"; import PageTitle from "@/components/layout/page-title"; -import { buttonVariants } from "@/components/ui/button"; -import { CardContent, CardFooter } from "@/components/ui/card"; import { getOwner } from "@/lib/utils/useOwner"; import { allUsers } from "@/lib/utils/useUser"; -import Link from "next/link"; -import { createEvent } from "../actions"; type Props = { params: Promise<{ @@ -21,8 +16,6 @@ type Props = { export default async function CreateEvent(props: Props) { const params = await props.params; const searchParams = await props.searchParams; - const { orgSlug } = await getOwner(); - const backUrl = `/${orgSlug}/projects/${params.projectId}/events`; const users = await allUsers(); @@ -31,28 +24,11 @@ export default async function CreateEvent(props: Props) { -
- - - - - -
- - Cancel - - -
-
-
+
); diff --git a/components/form/event.tsx b/components/form/event.tsx index 5b4e795..8e2c781 100644 --- a/components/form/event.tsx +++ b/components/form/event.tsx @@ -1,15 +1,22 @@ "use client"; +import { + createEvent, + updateEvent, +} from "@/app/(dashboard)/[tenant]/projects/[projectId]/events/actions"; import { Input } from "@/components/ui/input"; import type { EventWithInvites, User } from "@/drizzle/types"; import { Trash2Icon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; import { RRule, rrulestr } from "rrule"; +import { notifyError } from "../core/toast"; import MarkdownEditor from "../editor"; import { DateTimePicker } from "../project/events/date-time-picker"; import { Assignee } from "../project/shared/assigee"; import { MultiUserSelect } from "../project/shared/multi-user-select"; import { Button } from "../ui/button"; +import { CardContent, CardFooter } from "../ui/card"; import { Label } from "../ui/label"; import { Select, @@ -19,16 +26,25 @@ import { SelectValue, } from "../ui/select"; import { Switch } from "../ui/switch"; +import { SaveButton } from "./button"; export default function EventForm({ item, on, users, + projectId, }: { item?: EventWithInvites | null; + projectId: string; on?: string; users: User[]; }) { + const router = useRouter(); + const [state, formAction] = useActionState( + item ? updateEvent : createEvent, + null, + ); + const [allDay, setAllDay] = useState(item?.allDay ?? false); const [invites, setInvites] = useState( item?.invites?.map((invite) => invite.userId) ?? [], @@ -44,140 +60,167 @@ export default function EventForm({ start = new Date(date.setHours(start.getHours(), start.getMinutes())); } + useEffect(() => { + if (state?.message) { + notifyError(state.message); + } + }, [state]); + return ( -
-
- -
- -
-
+
+ +
+ {item ? ( + + ) : null} + +
+ +
+ +
+
-
-
- - -
-
- - -
-
+
+
+ + +
+
+ + +
+
-
-
- -
-
+
+
+ +
+
-
-
- - -
-
- - -
-
+
+
+ + +
+
+ + +
+
- {users.length ? ( -
- -
- - {invites.length ? ( -
- {invites.map((userId) => ( -
- user.id === userId)!} - /> - + {users.length ? ( +
+ +
+ + {invites.length ? ( +
+ {invites.map((userId) => ( +
+ user.id === userId)!} + /> + +
+ ))}
- ))} + ) : null} + + {users.filter((user) => !invites.includes(user.id)).length ? ( + { + setInvites((invites) => [...invites, userId]); + }} + /> + ) : null}
- ) : null} +
+ ) : null} - {users.filter((user) => !invites.includes(user.id)).length ? ( - { - setInvites((invites) => [...invites, userId]); - }} +
+ +
+ - ) : null} +
- ) : null} - -
- -
- + + +
+ +
-
-
+ + ); } diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 6359294..72d5a46 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -1,18 +1,6 @@ "use client"; -import { - BadgeCheck, - Bell, - ChevronsUpDown, - CreditCard, - HelpCircle, - LogOut, - Settings, - Sparkles, -} from "lucide-react"; - import { logout } from "@/app/(dashboard)/[tenant]/settings/actions"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -28,6 +16,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { ChevronsUpDown, HelpCircle, LogOut, Settings } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { UserAvatar } from "./core/user-avatar"; @@ -81,30 +70,33 @@ export function NavUser({ setOpenMobile(false)}> - + Settings - + Support - setOpenMobile(false)}> - -
+ setOpenMobile(false)} + className="w-full" + > + +
diff --git a/components/project/tasklist/task/task-item.tsx b/components/project/tasklist/task/task-item.tsx index a97c404..a51e81e 100644 --- a/components/project/tasklist/task/task-item.tsx +++ b/components/project/tasklist/task/task-item.tsx @@ -382,13 +382,17 @@ export const TaskItem = ({ ) : null} {name} {task.dueDate ? ( -
+ - {toDateStringWithDay(task.dueDate, timezone)} -
+ + {toDateStringWithDay(task.dueDate, timezone)} + + ) : null} {task.description ? ( - + + + ) : null}
diff --git a/package.json b/package.json index f26288c..a09929a 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "tailwindcss-animate": "^1.0.7", "use-debounce": "^10.0.2", "uuid": "^9.0.1", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@aws-sdk/s3-request-presigner": "^3.623.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8f0446..413360b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: zod: specifier: ^3.23.8 version: 3.23.8 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.23.8) devDependencies: '@aws-sdk/s3-request-presigner': specifier: ^3.623.0 @@ -4869,6 +4872,12 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-validation-error@3.4.0: + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -10316,6 +10325,10 @@ snapshots: yocto-queue@0.1.0: {} + zod-validation-error@3.4.0(zod@3.23.8): + dependencies: + zod: 3.23.8 + zod@3.23.8: {} zwitch@2.0.4: {}