diff --git a/package-lock.json b/package-lock.json index 0d0ebc67b..968923cb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,8 @@ "@sentry/nextjs": "^10.53.1", "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-devtools": "^0.10.5", - "@tanstack/react-form": "^1.32.0", - "@tanstack/react-form-devtools": "^0.2.27", + "@tanstack/react-form": "^1.33.2", + "@tanstack/react-form-devtools": "^0.2.31", "@tanstack/react-query": "^5.100.13", "@tanstack/react-query-devtools": "^5.100.13", "@tanstack/react-table": "^8.21.3", @@ -6209,14 +6209,14 @@ } }, "node_modules/@tanstack/form-core": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.32.0.tgz", - "integrity": "sha512-Tn5VRDSjyqjmaet2tJMuEWDRFyrCaon03vxXPlSSaiSs6C/N7lCIwGCXJbZXEUq1kTj8jYN9qyXHbsz4LQHcow==", + "version": "1.33.2", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.33.2.tgz", + "integrity": "sha512-F60zJd15bGrXKonc1kpRYnNRNfiES7F+hgvrPMrsZznPLqZtO2DIg76OU6R25kCYkqYQY5xvuKteuWcUsc587A==", "license": "MIT", "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", - "@tanstack/store": "^0.9.1" + "@tanstack/store": "^0.11.0" }, "funding": { "type": "github", @@ -6224,14 +6224,14 @@ } }, "node_modules/@tanstack/form-devtools": { - "version": "0.2.27", - "resolved": "https://registry.npmjs.org/@tanstack/form-devtools/-/form-devtools-0.2.27.tgz", - "integrity": "sha512-h91foKH1RtUwJC1079kwcNQlO1LYBKdIY5n7kHGGjNTQiH0wLSIBJnxZnOxOJ1qaSjErTqJw5e9sIj3O+zyRYg==", + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@tanstack/form-devtools/-/form-devtools-0.2.31.tgz", + "integrity": "sha512-nW/L4jUvpwS+29Lu8hzvqkaaP84tfvOYJUNCmRqciaSUzZiYItljRIwFLQKrN7QaftoEsv8Vlvu1BuGVq+g9Wg==", "license": "MIT", "dependencies": { "@tanstack/devtools-ui": "^0.5.1", "@tanstack/devtools-utils": "^0.4.0", - "@tanstack/form-core": "1.32.0", + "@tanstack/form-core": "1.33.2", "clsx": "^2.1.1", "dayjs": "^1.11.18", "goober": "^2.1.16" @@ -6316,13 +6316,13 @@ } }, "node_modules/@tanstack/react-form": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.32.0.tgz", - "integrity": "sha512-6WP5SQTA6/H9crCpvpq3ZppYWqtrdE5NjOy6ebABi6uAQPqhfTzrdjS9t40mCZCFtGI5585OhJV6zBP/KN2zcw==", + "version": "1.33.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.33.2.tgz", + "integrity": "sha512-nEfayOu+27q5cZ5E0G5dmnddqLcLjdFCatbL/LCs/iLD469a1o1yYJlr8RISV3GfnqsBpm0hf+8kM4okh5fPCw==", "license": "MIT", "dependencies": { - "@tanstack/form-core": "1.32.0", - "@tanstack/react-store": "^0.9.1" + "@tanstack/form-core": "1.33.2", + "@tanstack/react-store": "^0.11.0" }, "funding": { "type": "github", @@ -6338,13 +6338,13 @@ } }, "node_modules/@tanstack/react-form-devtools": { - "version": "0.2.27", - "resolved": "https://registry.npmjs.org/@tanstack/react-form-devtools/-/react-form-devtools-0.2.27.tgz", - "integrity": "sha512-c7ugxpwxPBrT2nMYFPaW448SVxYvdi+ruPU/UcFS6YYdQ5uJGqjOJDF+ObCHWxlNJQWIXQVIeqbDZ8UaNPOLTA==", + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/@tanstack/react-form-devtools/-/react-form-devtools-0.2.31.tgz", + "integrity": "sha512-p++EPC01hmadxfycUO+WyKWWeVgdAjKed7/58T4up4d58taGLIum2aSCpGyouSnnl3dMWWurs3LHCYorIOUDBA==", "license": "MIT", "dependencies": { "@tanstack/devtools-utils": "^0.4.0", - "@tanstack/form-devtools": "0.2.27" + "@tanstack/form-devtools": "0.2.31" }, "funding": { "type": "github", @@ -6388,12 +6388,12 @@ } }, "node_modules/@tanstack/react-store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", - "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.0.tgz", + "integrity": "sha512-tX4YXh3PDkmpvGQWkWqKpzs/MSqbtuwY9dWdWhtV9Q50PmO+jOkUKIWIX4G85dwt7lxdHLXsiaEKPdKmC8F41w==", "license": "MIT", "dependencies": { - "@tanstack/store": "0.9.3", + "@tanstack/store": "0.11.0", "use-sync-external-store": "^1.6.0" }, "funding": { @@ -6443,9 +6443,9 @@ } }, "node_modules/@tanstack/store": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", - "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.0.tgz", + "integrity": "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw==", "license": "MIT", "funding": { "type": "github", diff --git a/package.json b/package.json index 5420797e3..deb8f6759 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "@sentry/nextjs": "^10.53.1", "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-devtools": "^0.10.5", - "@tanstack/react-form": "^1.32.0", - "@tanstack/react-form-devtools": "^0.2.27", + "@tanstack/react-form": "^1.33.2", + "@tanstack/react-form-devtools": "^0.2.31", "@tanstack/react-query": "^5.100.13", "@tanstack/react-query-devtools": "^5.100.13", "@tanstack/react-table": "^8.21.3", @@ -54,6 +54,7 @@ "framer-motion": "^12.40.0", "google-auth-library": "^10.6.2", "googleapis": "^173.0.0", + "nebula-library": "file:src/nebula-library", "next": "^16.2.6", "postgres": "^3.4.9", "react": "^19.2.6", @@ -68,8 +69,7 @@ "superjson": "^2.2.6", "ws": "^8.21.0", "zod": "^4.4.3", - "zustand": "^5.0.13", - "nebula-library": "file:src/nebula-library" + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", diff --git a/src/app/directory/create/CreateClubForm.tsx b/src/app/directory/create/CreateClubForm.tsx index b528c6698..87aa0c9e7 100644 --- a/src/app/directory/create/CreateClubForm.tsx +++ b/src/app/directory/create/CreateClubForm.tsx @@ -1,30 +1,53 @@ 'use client'; +import ErrorIcon from '@mui/icons-material/Error'; +import Typography from '@mui/material/Typography'; import { useMutation } from '@tanstack/react-query'; +import { TRPCClientErrorLike } from '@trpc/client'; import { useRouter } from 'next/navigation'; -import { z } from 'zod'; -import Panel from '@nebula-library/components/Panel'; +import { useRef, useState } from 'react'; import ClubTagAutocomplete from '@src/components/club/ClubTagAutocomplete'; +import { WizardRef } from '@src/components/form/FormWizard'; +import { AppRouter } from '@src/server/api/root'; import { useTRPC } from '@src/trpc/react'; import { useAppForm } from '@src/utils/form'; -import { createClubSchema } from '@src/utils/formSchemas'; - -type CreateClubSchema = z.infer; +import { createClubSchema, CreateClubSchema } from '@src/utils/formSchemas'; const CreateClubForm = () => { const api = useTRPC(); const createClub = useMutation(api.club.create.mutationOptions({})); const router = useRouter(); + const [slug, setSlug] = useState(''); + const [error, setError] = useState< + TRPCClientErrorLike | undefined + >(); + + const wizardRef = useRef(null); + const form = useAppForm({ defaultValues: { - name: '', - description: '', - tags: [], + name: { name: '', alias: '' }, + meta: { + description: '', + tags: [], + }, } as CreateClubSchema, onSubmit: async ({ value }) => { - const slug = await createClub.mutateAsync(value); - router.push(`/manage/${slug}`); + const slug = await createClub.mutateAsync(value, { + onError: async (error) => { + setError(error); + const targetErrorSuccess = + await wizardRef.current?.dispatchWizardAction('target', { + targetStep: 'error', + noValidate: true, + allowDisabled: true, + }); + if (!targetErrorSuccess) + throw new Error('Failed to show step with error message'); + }, + }); + setSlug(slug); }, validators: { onChange: createClubSchema, @@ -32,82 +55,189 @@ const CreateClubForm = () => { }); return ( -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - > - -
-

- We'll start with the basics then get your organization's - logo, officers, contact information, and collaborators on the next - page. -

-
-
- - {(field) => ( - - )} - - - {(field) => ( - - We support{' '} - - Markdown - - ! - - } - /> - )} - - - {(field) => ( - { - field.handleChange(value); - }} - onBlur={field.handleBlur} - error={field.state.meta.isTouched && !field.state.meta.isValid} - helperText={ - field.state.meta.isTouched && !field.state.meta.isValid - ? field.state.meta.errors - .map((err) => err?.message) - .join('. ') + '.' - : undefined - } - /> - )} - -
-
- - - - - - -
-
-
+ + { + router.push(`/manage/${slug}`); + }} + ref={wizardRef} + > + + name="name" + label="Name" + backButtonConfig={{ + label: 'Cancel', + onClick: () => { + router.back(); + }, + }} + > + +
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + +
+
+ + + + name="meta" + label="Basic Info" + nextButtonConfig={{ label: 'Create', type: 'submitAndNext' }} + > + + + {(field) => ( + + We support{' '} + + Markdown + + ! + + } + /> + )} + + + {(field) => ( + { + field.handleChange(value); + }} + onBlur={field.handleBlur} + error={!field.state.meta.isValid} + helperText={ + !field.state.meta.isValid + ? field.state.meta.errors + .map((err) => err?.message) + .join('. ') + '.' + : undefined + } + /> + )} + + + + + + + +
+ + Organization successfully created! + + + Click continue to go to your organization's customization + page, where you can add additional organization information, list + your officers, and add contact info. + +
+
+
+
); }; export default CreateClubForm; diff --git a/src/app/directory/create/page.tsx b/src/app/directory/create/page.tsx index 0ab5df01e..e040d68f3 100644 --- a/src/app/directory/create/page.tsx +++ b/src/app/directory/create/page.tsx @@ -1,3 +1,4 @@ +import Typography from '@mui/material/Typography'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -28,7 +29,15 @@ export default async function Page() { <>
- +
+ + Create New Organization + + +
); diff --git a/src/components/form/FormAutocomplete.tsx b/src/components/form/FormAutocomplete.tsx index 901b7664e..c14151618 100644 --- a/src/components/form/FormAutocomplete.tsx +++ b/src/components/form/FormAutocomplete.tsx @@ -6,18 +6,21 @@ type FormAutocompleteFreeSoloProps = { label: string; options: string[]; className?: string; + required?: boolean; }; export function FormAutocompleteFreeSolo({ label, options, className, + required, ...props }: FormAutocompleteFreeSoloProps) { const field = useFieldContext(); return ( +
{question}
diff --git a/src/components/form/FormSelect.tsx b/src/components/form/FormSelect.tsx index 764868644..07f498214 100644 --- a/src/components/form/FormSelect.tsx +++ b/src/components/form/FormSelect.tsx @@ -47,7 +47,11 @@ export default function FormSelect({ return ( - {label ? {label} : null} + {label ? ( + + {label} + + ) : null} ))} - + {!field.state.meta.isValid ? field.state.meta.errors.map((err) => err?.message).join('. ') + '.' : undefined} diff --git a/src/components/form/FormWizard.tsx b/src/components/form/FormWizard.tsx deleted file mode 100644 index 711ae586e..000000000 --- a/src/components/form/FormWizard.tsx +++ /dev/null @@ -1,18 +0,0 @@ -type WizardStepObjectBase = { - label: string; - hidden?: boolean; -}; - -export type WizardStepObject = WizardStepObjectBase & - ( - | { - id: string | number; - variant?: 'body'; - fields: (keyof Fields)[]; - } - | { - id?: never; - variant: 'start' | 'finish'; - fields?: never; - } - ); diff --git a/src/components/form/FormWizard/FormWizard.tsx b/src/components/form/FormWizard/FormWizard.tsx new file mode 100644 index 000000000..3e2c176de --- /dev/null +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -0,0 +1,643 @@ +'use client'; + +import WarningIcon from '@mui/icons-material/Warning'; +import Button from '@mui/material/Button'; +import Slide, { SlideProps } from '@mui/material/Slide'; +import Step from '@mui/material/Step'; +import StepButton from '@mui/material/StepButton'; +import Stepper from '@mui/material/Stepper'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { AnyFormGroupApi } from '@tanstack/react-form'; +import { + Children, + forwardRef, + isValidElement, + MouseEvent, + ReactNode, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { BaseCard } from '@nebula-library/components/BaseCard'; +import Panel from '@nebula-library/components/Panel'; +import { useAppForm, useFormContext } from '@src/utils/form'; +import { TanstackSubscribe } from '@src/utils/Subscribe'; +import { useIsMounted } from '@src/utils/useIsMounted'; +import { + FormWizardProps, + FormWizardStepProps, + StepState, + StepStateItem, + WizardActionDispatcher, + WizardContextType, + WizardRef, + WizardStepConfig, +} from './types'; +import WizardContext from './WizardContext'; + +/** + * Reusable multi-step form wizard that integrates with TanStack Form. + * + * Must have `` components as direct descendants. + * + * @example + * const form = useAppForm({...}) + * + * router.push('/')}> + * + * + * ...Step 1 form fields... + * + * + * ...Step 2 form fields... + * + * + */ +const FormWizard = forwardRef(function FormWizard( + { children, onComplete, hideStepper }, + ref, +) { + const form = useFormContext() as unknown as ReturnType; + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const LTR = + typeof window !== 'undefined' + ? window.getComputedStyle(document.documentElement).direction === 'ltr' + : true; + + const steps = useMemo( + () => + Children.toArray(children).flatMap((child) => { + if ( + isValidElement(child) && + typeof child.type !== 'string' && + '_isWizardStep' in child.type + ) { + return [ + child.props as FormWizardStepProps & { + children: ReactNode; + }, + ]; + } else { + console.error( + `Invalid child ${isValidElement(child) ? `"${child.type}" ` : ''}in FormWizard. ` + + 'Only FormWizardSteps can be direct descendants of FormWizard.', + ); + return []; + } + }), + [children], + ); + + const firstStepIndex = steps.findIndex( + (step) => !step.disabled && !step.fake, + ); + const defaultStepState: StepState = useMemo( + () => ({ + current: { + config: steps[firstStepIndex], + index: firstStepIndex, + }, + previous: undefined, + furthest: { + config: steps[firstStepIndex], + index: firstStepIndex, + }, + }), + [firstStepIndex, steps], + ); + + const [stepState, setStepState] = useState(defaultStepState); + + const setCurrentStep = useCallback( + (value: React.SetStateAction) => { + setStepState((prev) => { + const config: WizardStepConfig | undefined = + typeof value === 'function' ? value(prev.current.config) : value; + const index = Math.max( + steps.findIndex((step) => step.name === config?.name), + 0, + ); + const newCurrent: StepStateItem = { config, index }; + + return { + current: newCurrent, + previous: prev.current, + furthest: + index > (prev.furthest.index ?? -1) ? newCurrent : prev.furthest, + }; + }); + }, + [steps], + ); + + const groupApi = useRef(undefined); + useEffect(() => { + groupApi.current = form.formGroupApis + .values() + .toArray() + .find((groupApi) => groupApi.name === stepState.current.config?.name); + }, [form.formGroupApis, stepState]); + + const currentStep = stepState.current.config; + + const currentStepIndex = stepState.current.index; + const previousStepIndex = stepState.previous?.index; + const furthestStepIndex = stepState.furthest.index; + + /** + * Index of the furthest future step that is accessible. This means: + * + * - User is allowed to advance up to this step + * - Step isn't disabled + * - The step right before doesn't require submitting the form first + * - The next button for the step right before isn't disabled or hidden + * + * This does NOT check if steps are valid; you should also use {@linkcode earliestInvalidStepIndex}. + */ + const latestAccessibleStepIndex = (() => { + if (currentStep?.noAdvance) return currentStepIndex; + const index = steps + .slice(currentStepIndex) + .findIndex( + (step) => + !step.disabled && + (step.nextButtonConfig?.disabled || + step.nextButtonConfig?.hidden || + step.nextButtonConfig?.type === 'submit' || + step.nextButtonConfig?.type === 'submitAndNext'), + ); + return index === -1 ? Infinity : index + currentStepIndex; + })(); + + /** + * Index of the earliest past step that is accessible. This means: + * - User is allowed to backtrack up to this step + * - Step isn't disabled + * - The back button for the step right after isn't disabled or hidden + */ + const earliestAccessibleStepIndex = currentStep?.noBacktrack + ? currentStepIndex + : steps + .slice(undefined, currentStepIndex + 1) + .findLastIndex( + (step) => + !step.disabled && + (step.backButtonConfig?.disabled || step.backButtonConfig?.hidden), + ); + + /** + * Index of the step right after the furthest step that isn't disabled or fake. + */ + const nextEnabledAfterFurthestStepIndex = + steps + .slice(furthestStepIndex + 1) + .findIndex((step) => !step.disabled && !step.fake) + + (furthestStepIndex + 1); + + /** + * Index of the first step that is invalid. + */ + const earliestInvalidStepIndex = form.state.isValid + ? Infinity + : steps.findIndex( + (step) => !(form.getFormGroupMeta(step.name)?.isValid ?? true), + ); + + /** + * Whether currently on the first step that isn't disabled or fake. + */ + const onFirstStep = currentStepIndex === firstStepIndex; + + /** + * Whether currently on the last step that isn't disabled or fake. + */ + const onLastStep = (() => { + const index = steps.findLastIndex((step) => !step.disabled && !step.fake); + return index === -1 ? true : currentStepIndex === index; + })(); + + // Dynamic height for absolutely-positioned step content + const [formHeight, setFormHeight] = useState(0); + const isMounted = useIsMounted(); + const observerRef = useRef(null); + + const measureFormStepRef = useCallback((node: HTMLDivElement | null) => { + // Disconnect previous observer when ref detaches (React calls with null) + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (node !== null) { + setFormHeight(node.clientHeight); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setFormHeight(entry.contentRect.height); + }); + observer.observe(node); + observerRef.current = observer; + } + }, []); + + const dispatchWizardAction: WizardActionDispatcher = useCallback( + async (action, options) => { + const noValidate = options?.noValidate; + const allowDisabled = options?.allowDisabled; + + // No navigation allowed while submitting form + if (form.state.isSubmitting && !noValidate) return false; + + const goNext = async (): Promise => { + if (!onLastStep && !currentStep?.noAdvance) { + const nextEnabledStep = steps + .slice(currentStepIndex + 1) + .find((step) => (!step.disabled || allowDisabled) && !step.fake); + if (!nextEnabledStep) return false; + setCurrentStep(nextEnabledStep); + return true; + } else { + onComplete?.(); + return true; + } + }; + const goBack = async (): Promise => { + if (!onFirstStep && !currentStep?.noBacktrack) { + const prevEnabledStep = steps + .slice(0, currentStepIndex) + .findLast( + (step) => (!step.disabled || allowDisabled) && !step.fake, + ); + if (!prevEnabledStep) return false; + setCurrentStep(prevEnabledStep); + return true; + } + return false; + }; + const goToTargetStep = async (): Promise => { + const targetStep: WizardStepConfig | undefined = + typeof options?.targetStep === 'string' + ? steps.find((step) => step.name === options?.targetStep) + : options?.targetStep; + + // Prevent targeting current step, disabled steps (unless allowDisabled), or fake steps + if ( + currentStep?.name === targetStep?.name || + (targetStep?.disabled && !allowDisabled) || + targetStep?.fake + ) + return false; + + const targetStepIndex = steps.findIndex( + (step) => step.name === targetStep?.name, + ); + + if (targetStepIndex > currentStepIndex && !currentStep?.noAdvance) { + const isValid = await validateStep(); + if (isValid) { + setCurrentStep(targetStep); + return true; + } + } else if ( + targetStepIndex < currentStepIndex && + !currentStep?.noBacktrack + ) { + setCurrentStep(targetStep); + return true; + } + return false; + }; + const validateStep = async (): Promise => { + if (noValidate) return true; + + const currentGroupApi = groupApi.current; + if (!currentGroupApi) { + console.error('Could not find currentGroupApi'); + throw new Error('Could not find currentGroupApi'); + } + await currentGroupApi?.handleSubmit(); + if (currentGroupApi.state.meta.isSubmitSuccessful) { + return true; + } else { + return false; + } + }; + const restart = (): boolean => { + // Set previousIndex to Infinity so transition slides in correct direction + setStepState({ ...defaultStepState, previous: { index: Infinity } }); + return true; + }; + + switch (action) { + case 'none': + return true; + case 'next': + const isValid = await validateStep(); + if (isValid) return goNext(); + return false; + case 'back': + if (!onFirstStep) goBack(); + return false; + case 'target': + return goToTargetStep(); + case 'submit': + await form.handleSubmit(); + if (form.state.isSubmitSuccessful) { + return true; + } + return false; + case 'submitAndNext': + await form.handleSubmit(); + if (form.state.isSubmitSuccessful) { + return goNext(); + } + return false; + case 'reset': + form.reset(); + return restart(); + case 'restart': + return restart(); + default: + console.error(`Unknown wizard action "${action}"`); + return false; + } + }, + [ + currentStep?.name, + currentStep?.noAdvance, + currentStep?.noBacktrack, + currentStepIndex, + defaultStepState, + form, + onComplete, + onFirstStep, + onLastStep, + setCurrentStep, + steps, + ], + ); + + const handleNextClick = (event: MouseEvent) => { + event.preventDefault(); + currentStep?.nextButtonConfig?.onClick?.(event); + dispatchWizardAction( + currentStep?.nextButtonConfig?.type ?? + (onLastStep ? 'submitAndNext' : 'next'), + { + targetStep: currentStep?.nextButtonConfig?.targetStepName, + }, + ); + }; + + const handleBackClick = (event: MouseEvent) => { + event.preventDefault(); + currentStep?.backButtonConfig?.onClick?.(event); + dispatchWizardAction(currentStep?.backButtonConfig?.type ?? 'back', { + targetStep: currentStep?.backButtonConfig?.targetStepName, + }); + }; + + const handleStepClick = ( + event: MouseEvent, + step: WizardStepConfig, + ) => { + event.preventDefault(); + step.onStepperClick?.(event); + dispatchWizardAction('target', { targetStep: step }); + }; + + const contextValue = useMemo( + () => ({ + dispatchWizardAction, + stepState, + steps, + meta: { + latestAccessibleStepIndex, + earliestAccessibleStepIndex, + nextEnabledAfterFurthestStepIndex, + earliestInvalidStepIndex, + onFirstStep, + onLastStep, + }, + }), + [ + dispatchWizardAction, + stepState, + steps, + latestAccessibleStepIndex, + earliestAccessibleStepIndex, + nextEnabledAfterFurthestStepIndex, + earliestInvalidStepIndex, + onFirstStep, + onLastStep, + ], + ); + + useImperativeHandle(ref, () => { + return contextValue; + }, [contextValue]); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="flex w-full flex-col gap-8" + noValidate + > + {!hideStepper && ( + + ({ + isSubmitting: state.isSubmitting, + })} + > + {({ isSubmitting }) => ( + + {steps.map((step, index) => { + if (step.disabled || step.hidden) return; + + const isValid = + index > furthestStepIndex || + (form.getFormGroupMeta(step.name)?.isValid ?? true); + const isDisabled = Boolean( + !isMounted || + isSubmitting || + (step.fake && !step.onStepperClick) || // Can't skip to fake steps. Enable just for onStepperClick functionality + index > latestAccessibleStepIndex || // Don't allow skipping forward if next button is disabled or hidden + index < earliestAccessibleStepIndex || // Don't allow skipping backward if back button is disabled or hidden + index > nextEnabledAfterFurthestStepIndex || // Skip only to the (enabled) step directly after the furthest visited step + index > earliestInvalidStepIndex, // Don't allow skipping forward past invalid steps + ); + + return ( + + handleStepClick(e, step)} + icon={ + isValid ? undefined : + } + aria-controls="form-wizard-content" + > + + {step.label} + + + + ); + })} + + )} + + + )} + +
+ {/* Hidden step for initial sizing */} + {!isMounted && ( +
+
{steps[firstStepIndex]?.children}
+
+ )} + + {steps.map((step, index) => { + // Don't mount if disabled (unless absolutely necessary) or fake + if ((step.disabled && currentStepIndex !== index) || step.fake) + return; + + const isActive = currentStepIndex === index; + + const fore = LTR ? 'left' : 'right'; + const aft = LTR ? 'right' : 'left'; + + // Determines the direction of the slide transition + const direction: SlideProps['direction'] = + previousStepIndex !== undefined + ? currentStepIndex > previousStepIndex + ? // on next + currentStepIndex === index + ? fore // entering + : aft // exiting + : // on back + currentStepIndex === index + ? aft // entering + : fore // exiting + : // on mount + fore; + + return ( + + {() => ( + +
+
{step.children}
+
+
+ )} +
+ ); + })} +
+ state.isSubmitting}> + {(isSubmitting) => ( + + state[currentStep?.name ?? '_unknown']?.isValid ?? true + } + > + {(isValid) => ( +
+ + +
+ )} +
+ )} +
+
+
+
+ ); +}); + +export default FormWizard; diff --git a/src/components/form/FormWizard/FormWizardStep.tsx b/src/components/form/FormWizard/FormWizardStep.tsx new file mode 100644 index 000000000..596a74a95 --- /dev/null +++ b/src/components/form/FormWizard/FormWizardStep.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { FormWizardStepProps } from './types'; + +/** + * A single customizable step for FormWizard. Corresponds to a FormGroup. + * + * Must be direct descendants of a single `` component. + * + * @example + * const schema = z.object({ + * step1: z.object({ + * name: z.string(), + * }), + * step2: z.object({ + * email: z.email(), + * }), + * }); + * + * type Schema = z.infer; + * + * const form = useAppForm({ + * validators: { onSubmit: schema }, + * }); + * + * return ( + * + * router.push('/')}> + * + * name="step1" label="Step 1"> + * ...Step 1 form fields... + * + * name="step2" label="Step 2"> + * ...Step 2 form fields... + * + * + * + * ); + */ +function FormWizardStep({ + children, +}: FormWizardStepProps) { + return <>{children}; +} + +FormWizardStep._isWizardStep = true; + +export default FormWizardStep; diff --git a/src/components/form/FormWizard/WizardContext.tsx b/src/components/form/FormWizard/WizardContext.tsx new file mode 100644 index 000000000..675fe23c5 --- /dev/null +++ b/src/components/form/FormWizard/WizardContext.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import { WizardContextType } from './types'; + +const defaultContext: WizardContextType = { + dispatchWizardAction: async () => false, + stepState: { + current: { index: 0 }, + previous: undefined, + furthest: { index: 0 }, + }, + steps: [], + meta: { + latestAccessibleStepIndex: Infinity, + earliestAccessibleStepIndex: -1, + nextEnabledAfterFurthestStepIndex: 1, + earliestInvalidStepIndex: Infinity, + onFirstStep: true, + onLastStep: false, + }, +}; + +const WizardContext = createContext(defaultContext); + +export default WizardContext; + +export function useWizardContext() { + return useContext(WizardContext); +} diff --git a/src/components/form/FormWizard/index.ts b/src/components/form/FormWizard/index.ts new file mode 100644 index 000000000..bda5dac93 --- /dev/null +++ b/src/components/form/FormWizard/index.ts @@ -0,0 +1,9 @@ +export { default as FormWizard } from './FormWizard'; +export { useWizardContext } from './WizardContext'; +export { default as FormWizardStep } from './FormWizardStep'; +export type { + FormWizardProps, + FormWizardStepProps, + WizardContextType, + WizardRef, +} from './types'; diff --git a/src/components/form/FormWizard/types.ts b/src/components/form/FormWizard/types.ts new file mode 100644 index 000000000..409c8463d --- /dev/null +++ b/src/components/form/FormWizard/types.ts @@ -0,0 +1,233 @@ +import { DeepKeys } from '@tanstack/react-form'; +import { MouseEventHandler, ReactElement, ReactNode, Ref } from 'react'; + +export type StepStateItem = { + config?: WizardStepConfig; + index: number; +}; + +export type StepState = { + /** Current step */ + current: StepStateItem; + /** Most recently visited step */ + previous: StepStateItem | undefined; + /** Furthest visited step */ + furthest: StepStateItem; +}; + +/** + * Actions that may be dispatched to the form wizard: + * - `"next"` Validates current step and goes to next step if successful *(default for next button)* + * - `"back"` Goes back to previous step *(default for back button)* + * - `"target"` Goes to the step whose name matches {@linkcode WizardStepButtonConfig.targetStepName | targetStepName} + * - `"submit"` Validates entire form and submits form if successful + * - `"submitAndNext"` Validates entire form, submits form if successful, then goes to next step *(default for next button on last step)* + * - `"reset"` Resets entire form and restarts wizard + * - `"restart"` Restarts wizard from the first step without resetting the form's data + * - `"none"` No action + */ +export type WizardAction = + | 'next' + | 'back' + | 'target' + | 'submit' + | 'submitAndNext' + | 'reset' + | 'restart' + | 'none'; + +/** + * Configurations for the wizard's buttons, as these may differ between steps. + */ +export type WizardStepButtonConfig = { + /** Label on button. */ + label?: string; + /** Action that takes place when interacting with the button. */ + type?: WizardAction; + /** + * Name of step to jump to if {@linkcode WizardStepButtonConfig.type | type} + * is `"target"` and button is interacted with. + */ + targetStepName?: string; + /** + * Whether the button is disabled. + * Also prevents using the stepper from skipping to steps past this button. + */ + disabled?: boolean; + /** + * Whether the button is completely hidden. + * Also prevents using the stepper from skipping to steps past this button. + */ + hidden?: boolean; + /** + * A function to be called before the default action(s), as configured + * through {@linkcode WizardStepButtonConfig.type | type}. + */ + onClick?: MouseEventHandler; +}; + +export type WizardStepConfig = { + /** Form fields for step. */ + children?: ReactNode; + /** + * Unique identifier for the form step. Also used as the `name` prop for + * `form.FormGroup`, which is used for validation and progression handling. + * + * **If this form step has fields that need to be validated, this prop MUST + * match the key of the step's object in your schema.** + * + * For type safety and IntelliSense, pass your form's schema type into this + * step's generic slot. Example below: + * + * @example + * const schema = z.object({ + * step1: z.object({ + * name: z.string(), + * }), + * step2: z.object({ + * email: z.email(), + * }), + * }); + * + * type Schema = z.infer; + * + * const form = useAppForm({ + * validators: { onSubmit: schema }, + * }); + * + * return ( + * + * + * name="step1"> + * // Notice this step has a "" type argument. This adds type + * // safety and IntelliSense options to the name prop. + * + * name="step2"> + * ... + * + * + * + * + * ); + */ + name: DeepKeys; + /** Label shown in stepper. */ + label?: string; + /** Disables step entirely. Use this instead of conditionally rendering the `` component. */ + disabled?: boolean; + /** Hides step from stepper. */ + hidden?: boolean; + /** Shows step in stepper, but functionally disables the step. */ + fake?: boolean; + /** Prevents retreating to previous steps. Disables back button and stepper buttons for previous steps. */ + noBacktrack?: boolean; + /** Prevents advancing to future steps. Disables next button and stepper buttons for future steps. */ + noAdvance?: boolean; + /** Configuration options for the next button for this step. */ + nextButtonConfig?: WizardStepButtonConfig; + /** Configuration options for the back button for this step. */ + backButtonConfig?: WizardStepButtonConfig; + /** + * A function to be called for this step's stepper item. Called before jumping to this step. + */ + onStepperClick?: MouseEventHandler; +}; + +export type FormWizardStepProps = + WizardStepConfig; + +export type FormWizardProps = { + /** Must be one or multiple ``s. */ + children: ReactElement | ReactElement[]; + /** A function to be called when interacting with the next button on the last step. */ + onComplete?: () => void; + /** Don't display the progress bar stepper on top */ + hideStepper?: boolean; + ref?: Ref; +}; + +export type WizardActionDispatcher = ( + action: WizardAction, + options?: { + /** + * Jump to this step if action is `"target"`. Can be a step object or a step name string. + */ + targetStep?: WizardStepConfig | string; + /** + * Dispatches event unconditionally without form validation (except for submission actions). + */ + noValidate?: boolean; + /** + * Allow jumping to a disabled step. + */ + allowDisabled?: boolean; + }, +) => Promise; + +export type WizardMetaValues = { + /** + * Index of the furthest future step that is accessible. This means: + * + * - User is allowed to advance up to this step + * - Step isn't disabled + * - The step right before doesn't require submitting the form first + * - The next button for the step right before isn't disabled or hidden + * + * This does NOT check if steps are valid; you should also use {@linkcode WizardMetaValues.earliestInvalidStepIndex | earliestInvalidStepIndex}. + */ + latestAccessibleStepIndex: number; + /** + * Index of the earliest past step that is accessible. This means: + * + * - User is allowed to backtrack up to this step + * - Step isn't disabled + * - The back button for the step right after isn't disabled or hidden + */ + earliestAccessibleStepIndex: number; + /** + * Index of the step right after the furthest step that isn't disabled or fake. + */ + nextEnabledAfterFurthestStepIndex: number; + /** + * Index of the first step that is invalid. + */ + earliestInvalidStepIndex: number; + /** + * Whether currently on the first step that isn't disabled or fake. + */ + onFirstStep: boolean; + /** + * Whether currently on the last step that isn't disabled or fake. + */ + onLastStep: boolean; +}; + +export interface WizardContextType { + /** + * Function to safely call an action from the wizard. + * + * @param {WizardAction} action The action to dispatch to the wizard. + * @param options Additional options that may be required depending on `action` + * + * @returns Whether the action was successful. + */ + dispatchWizardAction: WizardActionDispatcher; + /** + * Configuration object and index of the following steps: + * - Current step + * - Most recently visited step + * - Furthest visited step + */ + stepState: StepState; + /** + * Array of all steps registered to the wizard + */ + steps: WizardStepConfig[]; + meta: WizardMetaValues; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface WizardRef extends WizardContextType {} diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index b8e4ab324..e78257dd0 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -1,57 +1,25 @@ 'use client'; -import Button from '@mui/material/Button'; -import Slide from '@mui/material/Slide'; -import Step from '@mui/material/Step'; -import StepButton from '@mui/material/StepButton'; -import StepLabel from '@mui/material/StepLabel'; -import Stepper from '@mui/material/Stepper'; -import { useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/system/useMediaQuery'; +import Typography from '@mui/material/Typography'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { useMutation } from '@tanstack/react-query'; +import { add } from 'date-fns'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { MouseEvent, useCallback, useState } from 'react'; -import { BaseCard } from '@nebula-library/components/BaseCard'; -import Panel from '@nebula-library/components/Panel'; -import { WizardStepObject } from '@src/components/form/FormWizard'; +import { ReactNode, useState } from 'react'; +import { majors, minors } from '@src/constants/utdDegrees'; import { SelectUserMetadataWithClubs } from '@src/server/db/models'; +import { studentClassificationEnum } from '@src/server/db/schema/users'; import { useTRPC } from '@src/trpc/react'; import { useAppForm } from '@src/utils/form'; import { accountOnboardingSchema, AccountOnboardingSchema, + userMetadataToAccountOnboardingSchema, } from '@src/utils/formSchemas'; -import OnboardingFormStep from './OnboardingFormStep'; - -// "Source of truth" array that contains the actual steps of the form -const stepsBody = [ - { id: 1, label: 'Name', fields: ['firstName', 'lastName'] }, - { - id: 2, - label: 'College Info', - fields: ['major', 'minor', 'studentClassification', 'graduationDate'], - }, - { id: 3, label: 'Contact Email', fields: ['contactEmail'] }, -] as const satisfies readonly WizardStepObject[]; - -// Extracts a union of all the ids used in rawSteps -export type stepIds = (typeof stepsBody)[number]['id']; - -// Creates the generic array of WizardStepObjects -export const steps: readonly WizardStepObject[] = [ - { variant: 'start', label: 'Get Started', hidden: true }, - ...stepsBody, - { variant: 'finish', label: 'Finish', hidden: true }, -]; - -const hasStart = steps.find((step) => step.variant === 'start'); -const hasFinish = steps.find((step) => step.variant === 'finish'); type OnboardingFormProps = { userMetadata?: SelectUserMetadataWithClubs; - /** - * Include div's for centering and keeping content within a max width - */ withLayout?: boolean; }; @@ -60,59 +28,62 @@ export default function OnboardingForm({ withLayout = false, }: OnboardingFormProps) { const router = useRouter(); - - const theme = useTheme(); - const api = useTRPC(); const editAccountMutation = useMutation( api.userMetadata.updateById.mutationOptions({}), ); + // Don't use userMetadataToAccountOnboardingSchema.decode(userMetadata) + // because that causes an error for unset default values const [defaultValues, setDefaultValues] = useState< Partial >({ - firstName: userMetadata?.firstName, - lastName: userMetadata?.lastName, - major: userMetadata?.major, - minor: userMetadata?.minor, - studentClassification: userMetadata?.studentClassification, - // `userMetadata.graduation` is automatically set with a time zone, which shows the wrong month in the date picker - // Add the timezone offset (in milliseconds) to convert back to UTC - graduationDate: userMetadata?.graduationDate - ? new Date( - userMetadata?.graduationDate?.getTime() + - userMetadata?.graduationDate?.getTimezoneOffset() * 60 * 1000, - ) - : undefined, - contactEmail: userMetadata?.contactEmail ?? '', + name: { + firstName: userMetadata?.firstName ?? '', + lastName: userMetadata?.lastName, + }, + collegeInfo: { + major: userMetadata?.major ?? '', + minor: userMetadata?.minor, + studentClassification: userMetadata?.studentClassification ?? 'Student', + graduationDate: userMetadata?.graduationDate + ? new Date( + userMetadata?.graduationDate?.getTime() + + userMetadata?.graduationDate?.getTimezoneOffset() * 60 * 1000, + ) + : null, + }, + contactEmail: { + contactEmail: userMetadata?.contactEmail ?? '', + }, }); const form = useAppForm({ defaultValues, onSubmit: async ({ value, formApi }) => { try { - const updated = await editAccountMutation.mutateAsync({ - updateUser: value, + const encoded = userMetadataToAccountOnboardingSchema.encode(value); + const updatedUserMetadata = await editAccountMutation.mutateAsync({ + updateUser: encoded, }); - if (updated) { - const updatedFixed = { - ...updated, - graduationDate: updated?.graduationDate + if (updatedUserMetadata) { + const fixedUserMetadata: typeof updatedUserMetadata = { + ...updatedUserMetadata, + graduationDate: updatedUserMetadata.graduationDate ? new Date( - updated?.graduationDate?.getTime() + - updated?.graduationDate?.getTimezoneOffset() * 60 * 1000, + updatedUserMetadata.graduationDate.getTime() + + updatedUserMetadata.graduationDate.getTimezoneOffset() * + 60 * + 1000, ) : null, }; + const decodedFormData: Partial = + userMetadataToAccountOnboardingSchema.decode(fixedUserMetadata); - setActiveStep((prev) => ({ - current: steps.length - 1, - previous: prev.current, - })); - - setDefaultValues(updatedFixed); - formApi.reset(updatedFixed); + setDefaultValues(decodedFormData); + formApi.reset(decodedFormData); } } catch (e) { console.error(e); @@ -121,280 +92,249 @@ export default function OnboardingForm({ validators: { onChange: accountOnboardingSchema }, }); - /* - * Steps - */ - - // Is 0-indexed - const [activeStep, setActiveStep] = useState({ - current: 0, - previous: undefined as number | undefined, - }); - - // State that indicates the page is still loading, so temporarily hide everything - const [mounting, setMounting] = useState(true); - - // Because form steps are positioned absolutely on top of one another, they do - // not affect the document flow and thus the height of the parent does not - // adjust to the different height of steps. - // This variable is determined by the height of the active step, and is used - // to set the height of its parent. - const [formHeight, setFormHeight] = useState(0); - - // Ref that refers to the component for the active step - const measureFormStepRef = useCallback((node: HTMLDivElement | null) => { - if (node !== null) { - // Permanently removes the flag that the page is still loading - // Ideally, this would go in another location, but here works fine for now - setMounting(false); - - const height = node.clientHeight; - setFormHeight(height); - - // As user interacts with the form, the height of the component may change naturally. - // Thus, the parent height will need to be adjusted whenever the active step - // component changes size. - const observer = new ResizeObserver((entries) => { - entries.forEach((entry) => { - setFormHeight(entry.contentRect.height); - }); - }); - - observer.observe(node); - - return () => { - observer.disconnect(); - }; - } - }, []); - - const validateFields = () => { - const step = steps[activeStep.current]; - if (typeof step === 'undefined') { - return true; - } - const fields = step?.fields; - if (typeof fields === 'undefined') { - return true; - } - fields.forEach((step) => form.validateField(step, 'change')); - }; - - const currentFieldsValid = () => { - const step = steps[activeStep.current]; - if (typeof step === 'undefined') { - return true; - } - const fields = step?.fields; - if (typeof fields === 'undefined') { - return true; - } - return fields.every((step) => form.state.fieldMeta[step]?.isValid ?? true); - }; - - const handleNext = (event: MouseEvent) => { - // Validates mounted fields and prevents user from navigating if there exist errors - validateFields(); - if (!currentFieldsValid()) return; - - if (activeStep.current < steps.length - (hasFinish ? 2 : 1)) { - // Prevents submit button from activating prematurely when navigating - // from the penultimate step to the last step - event.preventDefault(); - setActiveStep((prev) => ({ - current: prev.current + 1, - previous: prev.current, - })); - } else if (activeStep.current === steps.length - (hasFinish ? 1 : 0)) { - // When user clicks Continue button on success screen - router.push('/'); - } - }; - - const handleBack = () => { - // Validates mounted fields and prevents user from navigating if there exist errors - validateFields(); - if (!currentFieldsValid()) return; - - if (activeStep.current > 0) { - setActiveStep((prev) => ({ - current: prev.current - 1, - previous: prev.current, - })); - } - }; - - const BackButton = ( - - ); - - const NextButton = ( - - ); + const FormElement = ( + + router.push('/')}> + - const OnboardingFormElement = ( -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="flex w-full flex-col gap-8" - noValidate - > - -
- - {steps.map((step, index) => { - if (!step.hidden) { - return ( - - { - // Validates mounted fields and prevents user from navigating if there exist errors - validateFields(); - if (!currentFieldsValid()) return; - - setActiveStep((prev) => ({ - current: index, - previous: prev.current, - })); - }} - disabled={ - index - 1 > activeStep.current || - activeStep.current === steps.length - 1 || - !currentFieldsValid() - } - > - - {step.label} - - - - ); + + + name="name" label="Name"> + + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + + + name="collegeInfo" + label="College Info" + > + + -
-
- -
+ + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + {(field) => ( + + )} + + + state.values.collegeInfo?.studentClassification } - } + > + {(studentClassification) => { + if ( + studentClassification && + ['Faculty', 'Staff'].includes(studentClassification) + ) + return
; + return ( + + {(field) => { + return ( + { + const selectedValue = value as Date; + + field.handleChange(selectedValue); + if (selectedValue < new Date()) { + form.setFieldValue( + 'collegeInfo.studentClassification', + 'Alum', + ); + } else if ( + selectedValue > new Date() && + form.getFieldValue( + 'collegeInfo.studentClassification', + ) === 'Alum' + ) { + form.setFieldValue( + 'collegeInfo.studentClassification', + 'Student', + ); + } + }} + value={field.state.value ?? null} + label="Graduation Date" + className="w-64 grow [&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-800" + slotProps={{ + actionBar: { + actions: ['accept'], + }, + textField: { + size: 'small', + error: !field.state.meta.isValid, + helperText: !field.state.meta.isValid + ? field.state.meta.errors + .map((err) => err?.message) + .join('. ') + '.' + : undefined, + required: true, + }, + }} + timezone="UTC" + views={['year', 'month']} + minDate={new Date(1973, 0, 1)} // Earliest UTD graduating class + maxDate={add(new Date(), { years: 9 })} // Divisible by the 3 years per row + yearsPerRow={3} + openTo="year" + /> + ); + }} + + ); + }} + + + + + + + name="contactEmail" + label="Contact Email" + nextButtonConfig={{ label: 'Submit', type: 'submitAndNext' }} > - {/* Hidden step component used only to correctly size the parent when the page loads */} -
- -
- - {steps.map((step, index) => { - const isActive = activeStep.current === index; - - // Determines the direction of the slide transition - const direction = - activeStep.previous !== undefined - ? activeStep.current > activeStep.previous - ? // on next - activeStep.current === index - ? // entering - 'left' - : // exiting - 'right' - : // on back - activeStep.current === index - ? // entering - 'right' - : // exiting - 'left' - : // on mount - 'left'; - - return ( - + + + {(field) => ( +
+ +
+ )} +
+
+ + + +
+ + + ); return withLayout ? (
-
{OnboardingFormElement}
+
{FormElement}
) : ( - <>{OnboardingFormElement} + <>{FormElement} + ); +} + +function FormStepContent({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+ + {title} + +
{children}
+
); } diff --git a/src/components/getting-started/OnboardingFormStep.tsx b/src/components/getting-started/OnboardingFormStep.tsx deleted file mode 100644 index 4d162f682..000000000 --- a/src/components/getting-started/OnboardingFormStep.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import Typography from '@mui/material/Typography'; -import { DatePicker } from '@mui/x-date-pickers/DatePicker'; -import Link from 'next/link'; -import { ReactNode } from 'react'; -import { WizardStepObject } from '@src/components/form/FormWizard'; -import { majors, minors } from '@src/constants/utdDegrees'; -import { studentClassificationEnum } from '@src/server/db/schema/users'; -import { withForm } from '@src/utils/form'; -import { AccountOnboardingSchema } from '@src/utils/formSchemas'; -import { stepIds } from './OnboardingForm'; - -type FormData = Partial; - -const OnboardingFormStep = withForm({ - defaultValues: {} as FormData, - props: { - step: {} as WizardStepObject | undefined, - active: false as boolean | undefined, - }, - render: function Render({ form, step, active }) { - let FormStepData: ReactNode; - - if (step?.id) { - switch (step?.id as stepIds) { - case 1: - FormStepData = ( - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - ); - break; - case 2: - FormStepData = ( - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - - {(field) => ( - - )} - - - {(field) => { - return ( - { - field.handleChange(value); - }} - value={field.state.value ?? null} - label="Graduation Date" - className="w-64 grow" - slotProps={{ - actionBar: { - actions: ['accept'], - }, - textField: { - size: 'small', - error: !field.state.meta.isValid, - helperText: !field.state.meta.isValid - ? ( - field.state.meta.errors as unknown as { - message: string; - }[] - ) - .map((err) => err?.message) - .join('. ') + '.' - : undefined, - required: true, - }, - }} - timezone="UTC" - views={['year', 'month']} - openTo="year" - /> - ); - }} - - - - ); - break; - case 3: - FormStepData = ( - - - - {(field) => ( -
- -
- )} -
-
-
- ); - break; - default: - FormStepData = ( -
- - Whoops! You aren't supposed to see this... - -
- ); - break; - } - } else if (step?.variant === 'start') { - FormStepData = ( -
-
- - Get Started - - - Welcome to UTD Clubs! Let's get you set up. - -
-
- ); - } else if (step?.variant === 'finish') { - FormStepData = ( -
-
- - Thank you! - - - You are now ready to use UTD Clubs. You can always change - everything later in your{' '} - - account settings - - . - -
-
- ); - } - - return ( -
- {FormStepData} -
- ); - }, -}); - -export default OnboardingFormStep; - -function FormStepContent({ - title, - children, -}: { - title: string; - children: ReactNode; -}) { - return ( -
- - {title} - -
{children}
-
- ); -} diff --git a/src/components/settings/forms/UserInfo.tsx b/src/components/settings/forms/UserInfo.tsx index 13c9d2e08..9a4fd61e2 100644 --- a/src/components/settings/forms/UserInfo.tsx +++ b/src/components/settings/forms/UserInfo.tsx @@ -5,6 +5,7 @@ import PersonIconOutlined from '@mui/icons-material/PersonOutlined'; import SchoolOutlinedIcon from '@mui/icons-material/SchoolOutlined'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { useMutation } from '@tanstack/react-query'; +import { add } from 'date-fns'; import { useState } from 'react'; import Panel from '@nebula-library/components/Panel'; import { majors, minors } from '@src/constants/utdDegrees'; @@ -95,12 +96,21 @@ export default function UserInfo({ user }: UserInfoProps) {
{(field) => ( - + )} {(field) => ( - + )}
@@ -118,6 +128,7 @@ export default function UserInfo({ user }: UserInfoProps) { label="Major" options={majors} className="grow" + required /> )} @@ -138,40 +149,76 @@ export default function UserInfo({ user }: UserInfoProps) { label="Classification" options={studentClassificationEnum.enumValues} className="grow" + required /> )} - - {(field) => { + state.values.studentClassification} + > + {(studentClassification) => { + if ( + studentClassification && + ['Faculty', 'Staff'].includes(studentClassification) + ) + return
; return ( - { - field.handleChange(value); - }} - value={field.state.value} - label="Graduation Date" - className="w-64 grow" - slotProps={{ - actionBar: { - actions: ['accept'], - }, - textField: { - size: 'small', - error: !field.state.meta.isValid, - helperText: !field.state.meta.isValid - ? field.state.meta.errors - .map((err) => err?.message) - .join('. ') + '.' - : undefined, - }, + + {(field) => { + return ( + { + const selectedValue = value as Date; + + field.handleChange(selectedValue); + if (selectedValue < new Date()) { + form.setFieldValue( + 'studentClassification', + 'Alum', + ); + } else if ( + selectedValue > new Date() && + form.getFieldValue( + 'studentClassification', + ) === 'Alum' + ) { + form.setFieldValue( + 'studentClassification', + 'Student', + ); + } + }} + value={field.state.value ?? null} + label="Graduation Date" + className="w-64 grow [&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-800" + slotProps={{ + actionBar: { + actions: ['accept'], + }, + textField: { + size: 'small', + error: !field.state.meta.isValid, + helperText: !field.state.meta.isValid + ? field.state.meta.errors + .map((err) => err?.message) + .join('. ') + '.' + : undefined, + required: true, + }, + }} + timezone="UTC" + views={['year', 'month']} + minDate={new Date(1973, 0, 1)} // Earliest UTD graduating class + maxDate={add(new Date(), { years: 9 })} // Divisible by the 3 years per row + yearsPerRow={3} + openTo="year" + /> + ); }} - timezone="UTC" - views={['year', 'month']} - openTo="year" - /> + ); }} - +
@@ -188,6 +235,8 @@ export default function UserInfo({ user }: UserInfoProps) { label="UTD Email" placeholder="abc123456@utdallas.edu" className="w-full" + required + autoComplete="email" />
)} diff --git a/src/server/api/routers/club.ts b/src/server/api/routers/club.ts index 0164e92bb..c2a9c80aa 100644 --- a/src/server/api/routers/club.ts +++ b/src/server/api/routers/club.ts @@ -281,7 +281,7 @@ export const clubRouter = createTRPCRouter({ .input(createClubSchema) .mutation(async ({ input, ctx }) => { //Create unique slug based on name - const baseSlug = input.name + const baseSlug = input.name.name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); @@ -299,9 +299,10 @@ export const clubRouter = createTRPCRouter({ const res = await ctx.db .insert(club) .values({ - name: input.name, - description: input.description, - tags: input.tags, + name: input.name.name, + alias: input.name.alias, + description: input.meta.description, + tags: input.meta.tags, updatedAt: new Date(), slug, }) diff --git a/src/utils/Subscribe.tsx b/src/utils/Subscribe.tsx new file mode 100644 index 000000000..dc874b0e6 --- /dev/null +++ b/src/utils/Subscribe.tsx @@ -0,0 +1,99 @@ +/** + * @file Subscribe components that allow its children to react to changes in + * stores from various state management libraries + */ + +'use client'; + +import { Store, useSelector } from '@tanstack/react-store'; +import { ReactNode, useMemo } from 'react'; +import { createStore, StoreApi, UseBoundStore, useStore } from 'zustand'; + +function getSelectorFn( + selector?: (state: TState) => TSelected, +): (state: TState) => TSelected { + return selector ?? ((state: TState) => state as unknown as TSelected); +} + +interface BaseSubscribeProps { + store?: TStore; + selector?: (state: TState) => TSelected; + children: (state: TSelected) => ReactNode; +} + +// +// Tanstack Store (used by Tanstack Form) +// + +const emptyTanstackStore = new Store>({}); + +type TanstackSelectionSource = { + get: () => T; + subscribe: (listener: (value: T) => void) => { + unsubscribe: () => void; + }; +}; + +export type TanstackSubscribeProps< + TState = unknown, + TSelected = TState, +> = BaseSubscribeProps, TState, TSelected>; + +/** + * Allows you to listen and react to changes in a Tanstack Store/Form store. + * Useful for executing side effects or rendering specific components in response to state updates. + * + * @example + * state.isDirty} + * > + * {(isDirty) => ( + * {isDirty ? "Dirty!" : "Clean!"} + * )} + * + */ +export function TanstackSubscribe({ + store, + selector, + children, +}: TanstackSubscribeProps) { + const resolvedStore = (store ?? + emptyTanstackStore) as TanstackSelectionSource; + const stableSelector = useMemo( + () => (store ? getSelectorFn(selector) : undefined), + [selector, store], + ); + const slice = useSelector(resolvedStore, stableSelector); + + if (!store) return <>{children({} as TSelected)}; + return <>{children(slice)}; +} + +// +// Zustand +// + +const emptyZustandStore = createStore>(() => ({})); + +export type ZustandSubscribeProps< + TState = unknown, + TSelected = TState, +> = BaseSubscribeProps>, TState, TSelected>; + +/** + * Allows you to listen and react to changes in a Zustand store. + * Useful for executing side effects or rendering specific components in response to state updates. + */ +export function ZustandSubscribe({ + store, + selector, + children, +}: ZustandSubscribeProps) { + const resolvedStore = (store ?? emptyZustandStore) as StoreApi; + const stableSelector = useMemo(() => getSelectorFn(selector), [selector]); + const slice = useStore(resolvedStore, stableSelector); + + if (!store) return <>{children({} as TSelected)}; + return <>{children(slice)}; +} diff --git a/src/utils/form.ts b/src/utils/form.ts index 3d2611303..bc01519f6 100644 --- a/src/utils/form.ts +++ b/src/utils/form.ts @@ -8,6 +8,7 @@ import FormFieldSet from '@src/components/form/FormFieldSet'; import FormQuestion from '@src/components/form/FormQuestion'; import FormSelect from '@src/components/form/FormSelect'; import FormTextField from '@src/components/form/FormTextField'; +import { FormWizard, FormWizardStep } from '@src/components/form/FormWizard'; // export useFieldContext for use in your custom components export const { fieldContext, useFieldContext, formContext, useFormContext } = @@ -16,7 +17,6 @@ export const { fieldContext, useFieldContext, formContext, useFormContext } = export const { useAppForm, withForm } = createFormHook({ fieldContext, formContext, - // We'll learn more about these options later fieldComponents: { TextField: FormTextField, Select: FormSelect, @@ -27,5 +27,7 @@ export const { useAppForm, withForm } = createFormHook({ SubmitButton: FormSubmitButton, FieldSet: FormFieldSet, Question: FormQuestion, + Wizard: FormWizard, + WizardStep: FormWizardStep, }, }); diff --git a/src/utils/formSchemas.ts b/src/utils/formSchemas.ts index 0d5354962..bd4490939 100644 --- a/src/utils/formSchemas.ts +++ b/src/utils/formSchemas.ts @@ -1,44 +1,103 @@ import { z } from 'zod'; +import { insertUserMetadata } from '@src/server/db/models'; import { studentClassificationEnum } from '@src/server/db/schema/users'; import { contactSchema } from './contact'; -export const accountSettingsSchema = z.object({ - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required'), +export const accountNameSchema = z.object({ + firstName: z.string().min(1, 'Name is required'), + lastName: z.string().optional(), +}); + +const accountCollegeInfoSchemaBase = z.object({ major: z.string().min(1, 'College major is required'), - minor: z.string().nullable(), - studentClassification: z.enum(studentClassificationEnum.enumValues), + minor: z.string().nullable().optional(), + studentClassification: z.enum(studentClassificationEnum.enumValues, { + error: 'Classification is required', + }), graduationDate: z.date().nullable(), +}); + +const accountCollegeInfoRefinement = ( + data: z.infer, + ctx: z.RefinementCtx, +) => { + if ( + !['Faculty', 'Staff'].includes(data.studentClassification) && + !data.graduationDate + ) { + ctx.addIssue({ + code: 'custom', + message: 'Graduation date is required', + path: ['graduationDate'], + }); + } +}; + +export const accountCollegeInfoSchema = + accountCollegeInfoSchemaBase.superRefine(accountCollegeInfoRefinement); + +export const accountContactEmailSchema = z.object({ contactEmail: z .email({ - error: 'Use your UT Dallas email', + error: 'Use your UT Dallas email ending with "@utdallas.edu"', pattern: /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i, }) + .min(1, 'Contact email is required') .nullable(), }); +export const accountSettingsSchema = accountNameSchema + .extend(accountCollegeInfoSchemaBase.shape) + .extend(accountContactEmailSchema.shape) + .superRefine(accountCollegeInfoRefinement); + export type AccountSettingsSchema = z.infer; export const accountOnboardingSchema = z.object({ - firstName: z.string().min(1, 'Name is required'), - lastName: z.string().optional(), - major: z.string().optional(), - minor: z.string().nullable().optional(), - studentClassification: z.enum(studentClassificationEnum.enumValues), - graduationDate: z.date({ error: 'Graduation date is required' }).nullable(), - contactEmail: z - .email({ - error: 'Use your UT Dallas email', - pattern: - /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_+-]@([a-z0-9][a-z0-9\-]*\.)*utdallas\.edu$/i, - }) - .min(1, 'Contact email is required') - .nullable(), + name: accountNameSchema, + collegeInfo: accountCollegeInfoSchema, + contactEmail: accountContactEmailSchema, }); export type AccountOnboardingSchema = z.infer; +export const userMetadataToAccountOnboardingSchema = z.codec( + insertUserMetadata.partial().omit({ id: true }), + accountOnboardingSchema.partial(), + { + decode: (userMetadata) => ({ + name: { + firstName: userMetadata?.firstName ?? '', + lastName: userMetadata?.lastName, + }, + collegeInfo: { + major: userMetadata?.major ?? '', + minor: userMetadata?.minor, + studentClassification: userMetadata?.studentClassification ?? 'Student', + graduationDate: userMetadata?.graduationDate + ? new Date( + userMetadata?.graduationDate?.getTime() + + userMetadata?.graduationDate?.getTimezoneOffset() * 60 * 1000, + ) + : null, + }, + contactEmail: { + contactEmail: userMetadata?.contactEmail ?? '', + }, + }), + encode: (form) => ({ + firstName: form.name?.firstName, + lastName: form.name?.lastName, + major: form.collegeInfo?.major, + minor: form.collegeInfo?.minor, + studentClassification: form.collegeInfo?.studentClassification, + graduationDate: form.collegeInfo?.graduationDate, + contactEmail: form.contactEmail?.contactEmail, + }), + }, +); + const tagsSchema = z .array(z.string()) .min(2, 'Select at least 2 tags') @@ -52,11 +111,30 @@ const tagsSchema = z ); export const createClubSchema = z.object({ - name: z.string().min(3, 'Name must be at least 3 characters'), - description: z.string().min(1, 'Description is required'), - tags: tagsSchema, + name: z.object({ + name: z + .string() + .min(3, 'Name must be at least 3 characters') + .max(100, 'Character limit reached'), + alias: z + .string() + .max(100, 'Character limit reached') + .optional() + .refine( + (val) => val === undefined || val.length === 0 || val.length >= 2, + { + message: 'Alias must be at least 2 characters', + }, + ), + }), + meta: z.object({ + description: z.string().min(1, 'Description is required'), + tags: tagsSchema, + }), }); +export type CreateClubSchema = z.infer; + export const editClubContactSchema = z.object({ contacts: contactSchema.array(), }); diff --git a/src/utils/useIsMounted.ts b/src/utils/useIsMounted.ts new file mode 100644 index 000000000..8d00a242f --- /dev/null +++ b/src/utils/useIsMounted.ts @@ -0,0 +1,20 @@ +import { useSyncExternalStore } from 'react'; + +/** + * Hook to return whether the page is finished mounting on the client. + * Use for conditional components and props instead of + * `typeof window !== 'undefined'`, which may cause hydration errors. + * + * @returns + * - `false` during SSR and initial client-side renders + * - `true` during subsequent client-side renders (once React has loaded) + */ +export function useIsMounted(): boolean { + const isMounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); + + return isMounted; +}