diff --git a/apps/blade/src/app/_components/issues/create-edit-dialog.tsx b/apps/blade/src/app/_components/issues/create-edit-dialog.tsx new file mode 100644 index 000000000..3db6441fd --- /dev/null +++ b/apps/blade/src/app/_components/issues/create-edit-dialog.tsx @@ -0,0 +1,1140 @@ +"use client"; + +import * as React from "react"; +import { Trash2, X } from "lucide-react"; +import { createPortal } from "react-dom"; + +import { EVENTS, ISSUE } from "@forge/consts"; +import { cn } from "@forge/ui"; +import { Button } from "@forge/ui/button"; +import { Checkbox } from "@forge/ui/checkbox"; +import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@forge/ui/select"; +import { Textarea } from "@forge/ui/textarea"; + +import { api } from "~/trpc/react"; + +const baseField = "w-full"; + +function getStatusLabel(status: string) { + return status + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function normalizeTaskDueDate(dateValue?: string | Date) { + const dueDate = dateValue ? new Date(dateValue) : new Date(); + if (Number.isNaN(dueDate.getTime())) { + const fallback = new Date(); + fallback.setHours(ISSUE.TASK_DUE_HOURS, ISSUE.TASK_DUE_MINUTES, 0, 0); + return fallback; + } + + dueDate.setHours(ISSUE.TASK_DUE_HOURS, ISSUE.TASK_DUE_MINUTES, 0, 0); + return dueDate; +} + +function getTaskDueDateInputValue(dateValue: Date) { + return normalizeTaskDueDate(dateValue).toISOString().slice(0, 10); +} + +function parseTimeTo12h(timeValue?: string): { + hour: string; + minute: string; + amPm: (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number]; +} { + const [hRaw, mRaw] = (timeValue ?? "").split(":"); + const h = Number(hRaw); + const m = Number(mRaw); + + if (Number.isNaN(h) || Number.isNaN(m)) { + return { + hour: "", + minute: "", + amPm: "PM" as (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number], + }; + } + + const amPm: (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number] = + h >= 12 ? "PM" : "AM"; + const hour24 = h % 12 || 12; + return { + hour: hour24.toString().padStart(2, "0"), + minute: m.toString().padStart(2, "0"), + amPm, + }; +} + +function to24h( + hour12: string, + amPm: (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number], +) { + let h = Number(hour12); + if (Number.isNaN(h)) { + h = 0; + } + if (amPm === "PM" && h < 12) { + h += 12; + } + if (amPm === "AM" && h === 12) { + h = 0; + } + return h.toString().padStart(2, "0"); +} + +function toAmPmValue( + value: string, +): (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number] { + return value === "AM" ? "AM" : "PM"; +} + +function parseEventDateTime(dateValue?: string, timeValue?: string) { + if (!dateValue || !timeValue) { + return null; + } + + const [year, month, day] = dateValue.split("-").map(Number); + const [hour, minute] = timeValue.split(":").map(Number); + if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute)) { + return null; + } + + const parsed = new Date(year, month - 1, day, hour, minute, 0, 0); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +interface IssueDialogFormValues { + id?: string; + status: (typeof ISSUE.ISSUE_STATUS)[number]; + name: string; + description: string; + links: string[]; + date: Date; + priority: (typeof ISSUE.PRIORITY)[number]; + team: string; + parent?: string; + isEvent: boolean; + event?: ISSUE.UUID | null; + eventData?: ISSUE.EventFormValues; + teamVisibilityIds?: string[]; + assigneeIds?: string[]; + roles: string[]; +} +type CreateEditDialogComponentProps = Omit< + ISSUE.CreateEditDialogProps, + "open" +> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; +}; + +const defaultEventForm = (): ISSUE.EventFormValues => { + const start = new Date(Date.now() + 60 * 60 * 1000); + const end = new Date(start.getTime() + 60 * 60 * 1000); + return { + discordId: "", + googleId: "", + name: "", + tag: EVENTS.EVENT_TAGS[0], + description: "", + startDate: formatDateForInput(start), + startTime: formatTimeForInput(start), + endDate: formatDateForInput(end), + endTime: formatTimeForInput(end), + location: "", + dues_paying: false, + points: undefined, + hackathonId: undefined, + }; +}; + +const defaultForm = (): IssueDialogFormValues => { + return { + status: ISSUE.ISSUE_STATUS[0], + name: "", + description: "", + links: [], + date: normalizeTaskDueDate(), + priority: ISSUE.PRIORITY[0], + team: "", + parent: undefined, + isEvent: false, + event: null, + eventData: undefined, + roles: [], + }; +}; + +export function CreateEditDialog(props: CreateEditDialogComponentProps) { + const { + open, + onOpenChange, + intent = "create", + initialValues, + onClose, + onDelete, + onSubmit, + children, + } = props; + const [internalOpen, setInternalOpen] = React.useState(false); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : internalOpen; + const rolesQuery = api.roles.getAllLinks.useQuery(); + const hackathonsQuery = api.hackathon.getHackathons.useQuery(); + const rolesData = rolesQuery.data; + const hackathons = hackathonsQuery.data; + const isRolesLoading = rolesQuery.isLoading; + const isHackathonsLoading = hackathonsQuery.isLoading; + const rolesError = rolesQuery.error; + const hackathonsError = hackathonsQuery.error; + const [portalElement, setPortalElement] = React.useState( + null, + ); + const buildInitialFormValues = React.useCallback(() => { + const defaults = defaultForm(); + const initial = (initialValues ?? {}) as Partial; + const resolvedEventData = initial.eventData; + const resolvedRoles = + initial.roles ?? initial.teamVisibilityIds ?? defaults.roles; + if (initial.isEvent) { + return { + ...defaults, + ...initial, + isEvent: true, + event: initial.event ?? defaults.event, + eventData: resolvedEventData ?? defaultEventForm(), + links: initial.links ?? defaults.links, + date: normalizeTaskDueDate(initial.date ?? defaults.date), + roles: resolvedRoles, + }; + } + return { + ...defaults, + ...initial, + isEvent: false, + event: initial.event ?? defaults.event, + eventData: undefined, + date: normalizeTaskDueDate(initial.date ?? defaults.date), + links: initial.links ?? defaults.links, + roles: resolvedRoles, + }; + }, [initialValues]); + const [formValues, setFormValues] = React.useState( + buildInitialFormValues, + ); + + const handleClose = React.useCallback(() => { + if (isControlled) { + if (onOpenChange) { + onOpenChange(false); + } + } else { + setInternalOpen(false); + } + onClose?.(); + }, [isControlled, onClose, onOpenChange]); + + const trigger = React.useMemo(() => { + if (!children || !React.isValidElement(children)) { + return null; + } + + const child = children as React.ReactElement<{ + onClick?: (event: React.MouseEvent) => void; + }>; + + return React.cloneElement(child, { + onClick: (event: React.MouseEvent) => { + child.props.onClick?.(event); + if (isControlled) { + if (onOpenChange) { + onOpenChange(true); + } + } else { + setInternalOpen(true); + } + }, + }); + }, [children, isControlled, onOpenChange]); + + const updateForm = ( + key: K, + value: IssueDialogFormValues[K], + ) => { + setFormValues((previous) => ({ + ...previous, + [key]: value, + })); + }; + + const baseId = React.useId(); + const startDateTime = parseEventDateTime( + formValues.eventData?.startDate, + formValues.eventData?.startTime, + ); + const endDateTime = parseEventDateTime( + formValues.eventData?.endDate, + formValues.eventData?.endTime, + ); + const nowTimestamp = Date.now(); + const isNameValid = formValues.name.trim().length > 0; + const isTeamValid = formValues.team.trim().length > 0; + const isDescriptionValid = formValues.description.trim().length > 0; + const isRolesValid = !rolesError; + const isTaskDateValid = !Number.isNaN(formValues.date.getTime()); + + const hasEventData = !!formValues.eventData; + const hasEventLocation = !!formValues.eventData?.location.trim(); + const hasEventDescription = !!formValues.eventData?.description.trim(); + const hasEventStartTime = !!startDateTime; + const hasEventEndTime = !!endDateTime; + const isEventTimingValid = + hasEventStartTime && hasEventEndTime && endDateTime > startDateTime; + const isEventStartInFuture = + hasEventStartTime && startDateTime.getTime() > nowTimestamp; + + const hasRequiredBaseFields = + isNameValid && isTeamValid && isDescriptionValid && isRolesValid; + const isTaskValid = isTaskDateValid; + const isEventValid = + hasEventData && + hasEventLocation && + hasEventDescription && + hasEventStartTime && + hasEventEndTime && + isEventTimingValid && + isEventStartInFuture; + + const isSubmitDisabled = + !hasRequiredBaseFields || + (formValues.isEvent ? !isEventValid : !isTaskValid); + const roleIdSet = React.useMemo( + () => new Set((rolesData ?? []).map((role) => role.id)), + [rolesData], + ); + const safeVisibilityIds = React.useMemo( + () => formValues.roles.filter((roleId) => roleIdSet.has(roleId)), + [formValues.roles, roleIdSet], + ); + + // Helper for event form + const updateEventData = ( + key: K, + value: ISSUE.EventFormValues[K], + ) => { + setFormValues((previous) => ({ + ...previous, + eventData: { + ...(previous.eventData ?? defaultEventForm()), + [key]: value, + }, + })); + }; + + const updateEventTimePart = ( + which: "start" | "end", + part: "hour" | "minute" | "amPm", + value: string, + ) => { + const key = which === "start" ? "startTime" : "endTime"; + const parsed = parseTimeTo12h(formValues.eventData?.[key]); + const next: { + hour: string; + minute: string; + amPm: (typeof ISSUE.EVENT_TIME_AM_PM_OPTIONS)[number]; + } = { + hour: part === "hour" ? value : parsed.hour, + minute: part === "minute" ? value : parsed.minute, + amPm: part === "amPm" ? toAmPmValue(value) : parsed.amPm, + }; + + if (!next.hour || !next.minute) { + updateEventData(key, ""); + return; + } + + updateEventData(key, `${to24h(next.hour, next.amPm)}:${next.minute}`); + }; + + React.useEffect(() => { + setPortalElement(document.body); + }, []); + + React.useEffect(() => { + if (!isOpen) { + return; + } + + setFormValues(buildInitialFormValues()); + }, [buildInitialFormValues, isOpen]); + + React.useEffect(() => { + if (!isOpen) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + handleClose(); + } + }; + + window.addEventListener("keydown", handleKeydown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", handleKeydown); + }; + }, [handleClose, isOpen]); + + React.useEffect(() => { + if (!isOpen || formValues.team || !rolesData?.length) { + return; + } + + const firstRole = rolesData[0]; + if (!firstRole) { + return; + } + + updateForm("team", firstRole.id); + }, [formValues.team, isOpen, rolesData]); + + const handleOverlayPointerDown = ( + event: React.MouseEvent, + ) => { + if (event.target === event.currentTarget) { + handleClose(); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (isSubmitDisabled) { + return; + } + + const toSubmitValues = ( + date: Date, + eventDataValue?: ISSUE.EventFormValues, + ): ISSUE.IssueSubmitValues => ({ + id: formValues.id, + status: formValues.status, + name: formValues.name, + description: formValues.description.trim(), + links: formValues.links, + date, + priority: formValues.priority, + team: formValues.team, + parent: formValues.parent, + isEvent: formValues.isEvent, + event: formValues.event ?? null, + eventData: eventDataValue, + teamVisibilityIds: + safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined, + assigneeIds: formValues.assigneeIds, + }); + + // If not event, clear event field + if (!formValues.isEvent) { + onSubmit?.( + toSubmitValues(normalizeTaskDueDate(formValues.date), undefined), + ); + if (!isControlled) { + setInternalOpen(false); + } + } else { + const startDate = formValues.eventData?.startDate; + const startTime = formValues.eventData?.startTime; + const linkedIssueDate = parseEventDateTime(startDate, startTime); + + if ( + !linkedIssueDate || + !isEventTimingValid || + linkedIssueDate.getTime() <= Date.now() + ) { + return; + } + + onSubmit?.( + toSubmitValues(linkedIssueDate, { + ...(formValues.eventData ?? defaultEventForm()), + name: formValues.name.trim(), + description: (formValues.eventData?.description ?? "").trim(), + }), + ); + if (!isControlled) { + setInternalOpen(false); + } + } + }; + + const handleDelete = () => { + if (intent === "edit") { + onDelete?.({ + id: formValues.id, + status: formValues.status, + name: formValues.name, + description: formValues.description, + links: formValues.links, + date: formValues.date, + priority: formValues.priority, + team: formValues.team, + parent: formValues.parent, + isEvent: formValues.isEvent, + event: formValues.event ?? null, + eventData: formValues.eventData, + teamVisibilityIds: + safeVisibilityIds.length > 0 ? safeVisibilityIds : undefined, + assigneeIds: formValues.assigneeIds, + }); + } + }; + + if (!portalElement) { + return trigger; + } + + return ( + <> + {trigger} + {isOpen && + createPortal( +
+
event.stopPropagation()} + > + + +
+

+ {intent === "edit" + ? formValues.isEvent + ? "Edit Event" + : "Edit Task" + : "Create Issue"} +

+

+ {intent === "edit" + ? formValues.isEvent + ? "Update the event details below" + : "Update the task details below" + : "Enter the issue details below"} +

+
+ +
+
+
+ + + + updateForm("name", event.target.value) + } + /> +
+ +
+ +
+ { + const nextIsEvent = checked === true; + if (nextIsEvent) { + setFormValues((previous) => ({ + ...previous, + isEvent: true, + eventData: + previous.eventData ?? defaultEventForm(), + })); + return; + } + + setFormValues((previous) => ({ + ...previous, + isEvent: false, + eventData: undefined, + })); + }} + /> +
+
+ +
+ + +
+ + {/* Date/Time fields */} + {formValues.isEvent ? ( + <> +
+ + +
+ +
+ + +
+ +
+ + + updateEventData("startDate", e.target.value) + } + /> +
+ +
+ +
+ + + : + + + + +
+
+ +
+ + + updateEventData("endDate", e.target.value) + } + /> +
+ +
+ +
+ + + : + + + + +
+
+ +
+ + + updateEventData("location", e.target.value) + } + /> +
+ +
+ +
+ + updateEventData("dues_paying", checked === true) + } + /> +
+
+ + ) : ( + <> +
+ + + updateForm( + "date", + normalizeTaskDueDate(e.target.value), + ) + } + /> +
+ +
+ + +
+ + )} + +
+ + +
+ +
+ + +
+ +
+ +