From 97a16a7e1fea2ef4058005a52451c4fa33121414 Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Tue, 31 Mar 2026 11:59:30 -0500 Subject: [PATCH 1/6] feat: abstract onboarding wizard into reusable FormWizard component Extract the ~400-line OnboardingForm wizard into a reusable form.Wizard and form.WizardStep component system that integrates with TanStack Form's formComponents registry. This enables multi-step wizards for onboarding, club match, club creation, and event creation forms. Key changes: - Create FormWizard/ directory with types, context, and components - Register Wizard and WizardStep as formComponents in form.ts - Fix validation to only block forward navigation (not backward) - Auto-advance to finish step after successful form submission - Rewrite OnboardingForm from ~400 lines to ~260 lines - Delete OnboardingFormStep (content moved inline to WizardStep children) Closes #588 --- src/components/form/FormWizard.tsx | 18 - src/components/form/FormWizard/FormWizard.tsx | 438 ++++++++++++++++ .../form/FormWizard/FormWizardStep.tsx | 21 + .../form/FormWizard/WizardContext.tsx | 19 + src/components/form/FormWizard/index.ts | 8 + src/components/form/FormWizard/types.ts | 67 +++ .../getting-started/OnboardingForm.tsx | 486 +++++++----------- .../getting-started/OnboardingFormStep.tsx | 221 -------- src/utils/form.ts | 4 +- 9 files changed, 730 insertions(+), 552 deletions(-) delete mode 100644 src/components/form/FormWizard.tsx create mode 100644 src/components/form/FormWizard/FormWizard.tsx create mode 100644 src/components/form/FormWizard/FormWizardStep.tsx create mode 100644 src/components/form/FormWizard/WizardContext.tsx create mode 100644 src/components/form/FormWizard/index.ts create mode 100644 src/components/form/FormWizard/types.ts delete mode 100644 src/components/getting-started/OnboardingFormStep.tsx 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..0185481eb --- /dev/null +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -0,0 +1,438 @@ +'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 { useStore } from '@tanstack/react-form'; +import { + Children, + isValidElement, + MouseEvent, + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { BaseCard } from '@src/components/common/BaseCard'; +import Panel from '@src/components/common/Panel'; +import { useFormContext } from '@src/utils/form'; +import { ActiveStep, FormWizardProps, StepConfig } from './types'; +import { useWizardContext, WizardContext } from './WizardContext'; + +/** + * Reusable multi-step form wizard that integrates with TanStack Form. + * + * Usage: + * ```tsx + * } finishStep={} onComplete={() => router.push('/')}> + * + * ...form fields... + * + * + * ``` + */ +export default function FormWizard({ + startStep, + finishStep, + onComplete, + autoAdvanceOnSubmit, + children, +}: FormWizardProps) { + const form = useFormContext(); + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const shouldAutoAdvance = autoAdvanceOnSubmit ?? !!finishStep; + + // Build step config from children + optional start/finish + const steps = useMemo(() => { + const result: StepConfig[] = []; + + if (startStep) { + result.push({ + label: 'Get Started', + fields: [], + content: startStep, + variant: 'start', + hidden: true, + }); + } + + Children.toArray(children).forEach((child) => { + if ( + isValidElement(child) && + typeof child.type !== 'string' && + '_isWizardStep' in child.type + ) { + const props = child.props as { + label: string; + fields?: string[]; + children: ReactNode; + }; + result.push({ + label: props.label, + fields: props.fields ?? [], + content: props.children, + variant: 'body', + hidden: false, + }); + } + }); + + if (finishStep) { + result.push({ + label: 'Finish', + fields: [], + content: finishStep, + variant: 'finish', + hidden: true, + }); + } + + return result; + }, [startStep, finishStep, children]); + + const hasStart = steps[0]?.variant === 'start'; + const hasFinish = steps[steps.length - 1]?.variant === 'finish'; + + // Step navigation state + const [navStep, setActiveStep] = useState({ + index: 0, + previous: undefined, + }); + + // Auto-advance to finish step after successful form submission. + // We track whether we already advanced so that navigating back from the + // finish step is not blocked by the permanently-true isSubmitSuccessful. + const isSubmitSuccessful = useStore( + form.store, + (state) => state.isSubmitSuccessful, + ); + const [hasAutoAdvanced, setHasAutoAdvanced] = useState(false); + + const activeStep = useMemo(() => { + if ( + shouldAutoAdvance && + isSubmitSuccessful && + hasFinish && + !hasAutoAdvanced + ) { + return { index: steps.length - 1, previous: navStep.index }; + } + return navStep; + }, [ + shouldAutoAdvance, + isSubmitSuccessful, + hasFinish, + hasAutoAdvanced, + steps.length, + navStep, + ]); + + // Mark auto-advance as consumed after the derived step is applied + if ( + shouldAutoAdvance && + isSubmitSuccessful && + hasFinish && + !hasAutoAdvanced + ) { + setHasAutoAdvanced(true); + } + + // Loading state to prevent flash before first render measurement + const [mounting, setMounting] = useState(true); + + // Dynamic height for absolutely-positioned step content + const [formHeight, setFormHeight] = useState(0); + + 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) { + setMounting(false); + setFormHeight(node.clientHeight); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setFormHeight(entry.contentRect.height); + }); + observer.observe(node); + observerRef.current = observer; + } + }, []); + + // Validation helpers + const validateStepFields = useCallback( + (stepIndex: number) => { + const step = steps[stepIndex]; + if (!step?.fields.length) return; + step.fields.forEach((field) => + form.validateField(field as never, 'change'), + ); + }, + [steps, form], + ); + + const areStepFieldsValid = useCallback( + (stepIndex: number) => { + const step = steps[stepIndex]; + if (!step?.fields.length) return true; + return step.fields.every( + (field) => + ( + form.state.fieldMeta as Record< + string, + { isValid?: boolean } | undefined + > + )[field]?.isValid ?? true, + ); + }, + [steps, form.state.fieldMeta], + ); + + // Last body step index (the step that triggers form submission) + const lastBodyIndex = hasFinish ? steps.length - 2 : steps.length - 1; + + // Navigation + const goNext = useCallback(() => { + // Validate current step fields (only blocks forward navigation) + validateStepFields(activeStep.index); + if (!areStepFieldsValid(activeStep.index)) return; + + if (activeStep.index < lastBodyIndex) { + setActiveStep((prev) => ({ + index: prev.index + 1, + previous: prev.index, + })); + } else if (hasFinish && activeStep.index === steps.length - 1) { + // "Continue" on finish screen + onComplete?.(); + } + }, [ + activeStep, + lastBodyIndex, + hasFinish, + steps.length, + onComplete, + validateStepFields, + areStepFieldsValid, + ]); + + const goBack = useCallback(() => { + // No validation on backward navigation + if (activeStep.index > 0) { + setActiveStep((prev) => ({ + index: prev.index - 1, + previous: prev.index, + })); + } + }, [activeStep]); + + const goToStep = useCallback( + (index: number) => { + if (index < activeStep.index) { + // Backward: always allowed + setActiveStep((prev) => ({ + index: index, + previous: prev.index, + })); + } else if (index > activeStep.index) { + // Forward: validate first + validateStepFields(activeStep.index); + if (!areStepFieldsValid(activeStep.index)) return; + // Only allow navigating one step ahead + if (index - 1 > activeStep.index) return; + setActiveStep((prev) => ({ + index: index, + previous: prev.index, + })); + } + }, + [activeStep, validateStepFields, areStepFieldsValid], + ); + + // Advance to finish step after successful form submission + const goToFinish = useCallback(() => { + if (hasFinish) { + setActiveStep((prev) => ({ + index: steps.length - 1, + previous: prev.index, + })); + } + }, [hasFinish, steps.length]); + + const handleNext = (event: MouseEvent) => { + // Always prevent native submit; we call form.handleSubmit() explicitly + event.preventDefault(); + + validateStepFields(activeStep.index); + if (!areStepFieldsValid(activeStep.index)) return; + + if (activeStep.index < lastBodyIndex) { + setActiveStep((prev) => ({ + index: prev.index + 1, + previous: prev.index, + })); + } else if (activeStep.index === lastBodyIndex) { + // Submit the form on the last body step + form.handleSubmit(); + } else if (hasFinish && activeStep.index === steps.length - 1) { + // "Continue" button on finish screen + onComplete?.(); + } + }; + + // Context value + const contextValue = useMemo( + () => ({ + activeStep, + steps, + goNext, + goBack, + goToStep, + goToFinish, + }), + [activeStep, steps, goNext, goBack, goToStep, goToFinish], + ); + + // Button labels and states + const isOnFinishStep = hasFinish && activeStep.index === steps.length - 1; + const isOnLastBodyStep = activeStep.index === lastBodyIndex; + const isOnStartStep = hasStart && activeStep.index === 0; + + const nextButtonLabel = isOnFinishStep + ? 'Continue' + : isOnLastBodyStep + ? 'Submit' + : isOnStartStep + ? 'Start' + : 'Next'; + + const currentFieldsValid = areStepFieldsValid(activeStep.index); + + return ( + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + className="flex flex-col gap-8 w-full" + noValidate + > + +
+ + {steps.map((step, index) => { + if (!step.hidden) { + return ( + + goToStep(index)} + disabled={ + index - 1 > activeStep.index || isOnFinishStep + } + > + + {step.label} + + + + ); + } + })} + +
+
+ +
+ {/* Hidden step for initial sizing */} +
+
{steps[0]?.content}
+
+ + {steps.map((step, index) => { + const isActive = activeStep.index === index; + const direction = + activeStep.previous !== undefined + ? activeStep.index > activeStep.previous + ? activeStep.index === index + ? 'left' + : 'right' + : activeStep.index === index + ? 'right' + : 'left' + : 'left'; + + return ( + +
+
{step.content}
+
+
+ ); + })} +
+
+ + +
+
+
+
+ ); +} + +// Re-export for consumers that need wizard context +export { useWizardContext }; diff --git a/src/components/form/FormWizard/FormWizardStep.tsx b/src/components/form/FormWizard/FormWizardStep.tsx new file mode 100644 index 000000000..4af7c434c --- /dev/null +++ b/src/components/form/FormWizard/FormWizardStep.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ReactNode } from 'react'; +import { FormWizardStepProps } from './types'; + +type WizardStepComponent = ((props: FormWizardStepProps) => ReactNode) & { + _isWizardStep: true; +}; + +/** + * Declarative step component for FormWizard. + * FormWizard reads its props via React.Children to build step configuration. + * The component itself simply renders its children when active. + */ +const FormWizardStep: WizardStepComponent = ({ children }) => { + 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..b561467cb --- /dev/null +++ b/src/components/form/FormWizard/WizardContext.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import { WizardContextType } from './types'; + +const defaultContext: WizardContextType = { + activeStep: { index: 0, previous: undefined }, + steps: [], + goNext: () => {}, + goBack: () => {}, + goToStep: () => {}, + goToFinish: () => {}, +}; + +export const WizardContext = createContext(defaultContext); + +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..2d5ef4260 --- /dev/null +++ b/src/components/form/FormWizard/index.ts @@ -0,0 +1,8 @@ +export { default as FormWizard } from './FormWizard'; +export { useWizardContext } from './WizardContext'; +export { default as FormWizardStep } from './FormWizardStep'; +export type { + WizardStepObject, + FormWizardProps, + FormWizardStepProps, +} from './types'; diff --git a/src/components/form/FormWizard/types.ts b/src/components/form/FormWizard/types.ts new file mode 100644 index 000000000..0a050d356 --- /dev/null +++ b/src/components/form/FormWizard/types.ts @@ -0,0 +1,67 @@ +import { ReactNode } from 'react'; + +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; + } + ); + +export type FormWizardStepProps = { + /** Label shown in the stepper */ + label: string; + /** Field names belonging to this step (used for validation) */ + fields?: string[]; + /** Step content */ + children: ReactNode; +}; + +export type FormWizardProps = { + /** Content for an optional intro screen (hidden from stepper) */ + startStep?: ReactNode; + /** Content for an optional completion screen (hidden from stepper) */ + finishStep?: ReactNode; + /** Called when the user clicks "Continue" on the finish step */ + onComplete?: () => void; + /** + * If true, automatically advances to the finish step after successful + * form submission. Defaults to true when finishStep is provided. + */ + autoAdvanceOnSubmit?: boolean; + /** Wizard step children */ + children: ReactNode; +}; + +export type StepConfig = { + label: string; + fields: string[]; + content: ReactNode; + variant: 'start' | 'body' | 'finish'; + hidden: boolean; +}; + +export type ActiveStep = { + index: number; + previous: number | undefined; +}; + +export type WizardContextType = { + activeStep: ActiveStep; + steps: StepConfig[]; + goNext: () => void; + goBack: () => void; + goToStep: (index: number) => void; + goToFinish: () => void; +}; diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index 7bbf29456..1f6f47472 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -1,57 +1,23 @@ '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 Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { MouseEvent, useCallback, useState } from 'react'; -import { BaseCard } from '@src/components/common/BaseCard'; -import Panel from '@src/components/common/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, } 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,9 +26,6 @@ export default function OnboardingForm({ withLayout = false, }: OnboardingFormProps) { const router = useRouter(); - - const theme = useTheme(); - const api = useTRPC(); const editAccountMutation = useMutation( @@ -77,8 +40,6 @@ export default function OnboardingForm({ 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() + @@ -105,12 +66,6 @@ export default function OnboardingForm({ ) : null, }; - - setActiveStep((prev) => ({ - current: steps.length - 1, - previous: prev.current, - })); - setDefaultValues(updatedFixed); formApi.reset(updatedFixed); } @@ -121,280 +76,187 @@ 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 OnboardingFormElement = ( -
{ - e.preventDefault(); - e.stopPropagation(); - form.handleSubmit(); - }} - className="flex flex-col gap-8 w-full" - noValidate + onComplete={() => router.push('/')} > - -
- + + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + + + + - {steps.map((step, index) => { - if (!step.hidden) { + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + {(field) => ( + + )} + + + {(field) => { 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} - - - + { + field.handleChange(value); + }} + value={field.state.value ?? null} + label="Graduation Date" + className="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-900 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" + /> ); - } - })} - -
-
- -
- {/* 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) => ( +
+
- - ); - })} -
-
- {BackButton} - {NextButton} -
- - + )} + + + + + ); 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 094895cf7..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="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-900 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/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, }, }); From 35bfb43e4c1a4ba4968e18478ef23c4f137cd166 Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Wed, 8 Apr 2026 22:59:11 -0500 Subject: [PATCH 2/6] chore: run Prettier formatting --- .../getting-started/OnboardingForm.tsx | 302 +++++++++--------- 1 file changed, 153 insertions(+), 149 deletions(-) diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index 7f8872cc7..4c35ef93e 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -78,162 +78,166 @@ export default function OnboardingForm({ const FormElement = ( - -
- - Get Started - - - Welcome to UTD Clubs! Let's get you set up. - + +
+ + Get Started + + + Welcome to UTD Clubs! Let's get you set up. + +
-
- } - finishStep={ -
-
- - Thank you! - - - You are now ready to use UTD Clubs. You can always change - everything later in your{' '} - +
+ - account settings - - . - + Thank you! + + + You are now ready to use UTD Clubs. You can always change + everything later in your{' '} + + account settings + + . + +
-
- } - onComplete={() => router.push('/')} - > - - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - - - router.push('/')} > - - - - {(field) => ( - - )} - - - {(field) => ( - - )} - - - - - {(field) => ( - - )} - - - {(field) => { - return ( - { - field.handleChange(value); - }} - value={field.state.value ?? null} - label="Graduation Date" - className="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-900 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" - /> - ); - }} - - - - - - - - - - {(field) => ( -
+ + + + + {(field) => ( -
- )} -
-
-
-
- + )} + + + {(field) => ( + + )} + + + + + + + + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + {(field) => ( + + )} + + + {(field) => { + return ( + { + field.handleChange(value); + }} + value={field.state.value ?? null} + label="Graduation Date" + className="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-900 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" + /> + ); + }} + + + + + + + + + + {(field) => ( +
+ +
+ )} +
+
+
+
+ ); From 636f97e56f6d4a10b1242fd90000135b336587dc Mon Sep 17 00:00:00 2001 From: SinhSinh An <106696583+SinhSinhAn@users.noreply.github.com> Date: Wed, 15 Apr 2026 02:01:41 -0500 Subject: [PATCH 3/6] Update src/components/getting-started/OnboardingForm.tsx Co-authored-by: Isoscelestial --- src/components/getting-started/OnboardingForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index 4c35ef93e..f60c2502b 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -188,7 +188,7 @@ export default function OnboardingForm({ }} value={field.state.value ?? null} label="Graduation Date" - className="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-900 w-64 grow" + className="[&>.MuiPickersInputBase-root]:bg-white dark:[&>.MuiPickersInputBase-root]:bg-neutral-800 w-64 grow" slotProps={{ actionBar: { actions: ['accept'], From 720e555822e97322dd2c2a38649392c8dc2a44e1 Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Wed, 15 Apr 2026 10:00:31 -0500 Subject: [PATCH 4/6] fix: address Isaac's PR review feedback on FormWizard Moved startStep and finishStep from Wizard props into WizardStep children using startStep and finishStep boolean props, so the API is consistent with how body steps are declared. Restored type safety on StepConfig using a discriminated union so start and finish variants cannot have fields. Fixed the Continue button on the finish screen not working, and made the wizard wait for the API call to finish before advancing to the finish step. Fixed date picker dark mode background from neutral-900 to neutral-800. --- src/components/form/FormWizard/FormWizard.tsx | 133 ++++++------------ src/components/form/FormWizard/types.ts | 22 +-- .../getting-started/OnboardingForm.tsx | 56 ++++---- 3 files changed, 86 insertions(+), 125 deletions(-) diff --git a/src/components/form/FormWizard/FormWizard.tsx b/src/components/form/FormWizard/FormWizard.tsx index 3809dca48..a0acddcfc 100644 --- a/src/components/form/FormWizard/FormWizard.tsx +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -8,7 +8,6 @@ import StepLabel from '@mui/material/StepLabel'; import Stepper from '@mui/material/Stepper'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { useStore } from '@tanstack/react-form'; import { Children, isValidElement, @@ -22,7 +21,7 @@ import { import { BaseCard } from '@src/components/common/BaseCard'; import Panel from '@src/components/common/Panel'; import { useFormContext } from '@src/utils/form'; -import { ActiveStep, FormWizardProps, StepConfig } from './types'; +import { ActiveStep, FormWizardProps, FormWizardStepProps, StepConfig } from './types'; import { useWizardContext, WizardContext } from './WizardContext'; /** @@ -30,16 +29,20 @@ import { useWizardContext, WizardContext } from './WizardContext'; * * Usage: * ```tsx - * } finishStep={} onComplete={() => router.push('/')}> + * router.push('/')}> + * + * + * * * ...form fields... * + * + * + * * * ``` */ export default function FormWizard({ - startStep, - finishStep, onComplete, autoAdvanceOnSubmit, children, @@ -47,103 +50,60 @@ export default function FormWizard({ const form = useFormContext(); const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); - const shouldAutoAdvance = autoAdvanceOnSubmit ?? !!finishStep; - // Build step config from children + optional start/finish + // Build step config from children const steps = useMemo(() => { const result: StepConfig[] = []; - if (startStep) { - result.push({ - label: 'Get Started', - fields: [], - content: startStep, - variant: 'start', - hidden: true, - }); - } - Children.toArray(children).forEach((child) => { if ( isValidElement(child) && typeof child.type !== 'string' && '_isWizardStep' in child.type ) { - const props = child.props as { - label: string; - fields?: string[]; + const props = child.props as FormWizardStepProps & { children: ReactNode; }; - result.push({ - label: props.label, - fields: props.fields ?? [], - content: props.children, - variant: 'body', - hidden: false, - }); + + if (props.startStep) { + result.push({ + label: props.label ?? 'Get Started', + content: props.children, + variant: 'start', + hidden: true, + }); + } else if (props.finishStep) { + result.push({ + label: props.label ?? 'Finish', + content: props.children, + variant: 'finish', + hidden: true, + }); + } else { + result.push({ + label: props.label ?? '', + fields: props.fields ?? [], + content: props.children, + variant: 'body', + hidden: false, + }); + } } }); - if (finishStep) { - result.push({ - label: 'Finish', - fields: [], - content: finishStep, - variant: 'finish', - hidden: true, - }); - } - return result; - }, [startStep, finishStep, children]); + }, [children]); const hasStart = steps[0]?.variant === 'start'; const hasFinish = steps[steps.length - 1]?.variant === 'finish'; + const shouldAutoAdvance = autoAdvanceOnSubmit ?? hasFinish; // Step navigation state - const [navStep, setActiveStep] = useState({ + const [activeStep, setActiveStep] = useState({ index: 0, previous: undefined, }); - // Auto-advance to finish step after successful form submission. - // We track whether we already advanced so that navigating back from the - // finish step is not blocked by the permanently-true isSubmitSuccessful. - const isSubmitSuccessful = useStore( - form.store, - (state) => state.isSubmitSuccessful, - ); - const [hasAutoAdvanced, setHasAutoAdvanced] = useState(false); - - const activeStep = useMemo(() => { - if ( - shouldAutoAdvance && - isSubmitSuccessful && - hasFinish && - !hasAutoAdvanced - ) { - return { index: steps.length - 1, previous: navStep.index }; - } - return navStep; - }, [ - shouldAutoAdvance, - isSubmitSuccessful, - hasFinish, - hasAutoAdvanced, - steps.length, - navStep, - ]); - - // Mark auto-advance as consumed after the derived step is applied - if ( - shouldAutoAdvance && - isSubmitSuccessful && - hasFinish && - !hasAutoAdvanced - ) { - setHasAutoAdvanced(true); - } - // Loading state to prevent flash before first render measurement const [mounting, setMounting] = useState(true); @@ -176,7 +136,7 @@ export default function FormWizard({ const validateStepFields = useCallback( (stepIndex: number) => { const step = steps[stepIndex]; - if (!step?.fields.length) return; + if (step?.variant !== 'body' || !step.fields.length) return; step.fields.forEach((field) => form.validateField(field as never, 'change'), ); @@ -187,7 +147,7 @@ export default function FormWizard({ const areStepFieldsValid = useCallback( (stepIndex: number) => { const step = steps[stepIndex]; - if (!step?.fields.length) return true; + if (step?.variant !== 'body' || !step.fields.length) return true; return step.fields.every( (field) => ( @@ -206,7 +166,6 @@ export default function FormWizard({ // Navigation const goNext = useCallback(() => { - // Validate current step fields (only blocks forward navigation) validateStepFields(activeStep.index); if (!areStepFieldsValid(activeStep.index)) return; @@ -216,7 +175,6 @@ export default function FormWizard({ previous: prev.index, })); } else if (hasFinish && activeStep.index === steps.length - 1) { - // "Continue" on finish screen onComplete?.(); } }, [ @@ -230,7 +188,6 @@ export default function FormWizard({ ]); const goBack = useCallback(() => { - // No validation on backward navigation if (activeStep.index > 0) { setActiveStep((prev) => ({ index: prev.index - 1, @@ -242,16 +199,13 @@ export default function FormWizard({ const goToStep = useCallback( (index: number) => { if (index < activeStep.index) { - // Backward: always allowed setActiveStep((prev) => ({ index: index, previous: prev.index, })); } else if (index > activeStep.index) { - // Forward: validate first validateStepFields(activeStep.index); if (!areStepFieldsValid(activeStep.index)) return; - // Only allow navigating one step ahead if (index - 1 > activeStep.index) return; setActiveStep((prev) => ({ index: index, @@ -285,8 +239,13 @@ export default function FormWizard({ previous: prev.index, })); } else if (activeStep.index === lastBodyIndex) { - // Submit the form on the last body step - form.handleSubmit(); + // Submit the form; only advance to the finish step once the API call + // resolves successfully so the step does not jump early + void form.handleSubmit().then(() => { + if (form.store.state.isSubmitSuccessful && shouldAutoAdvance && hasFinish) { + setActiveStep({ index: steps.length - 1, previous: activeStep.index }); + } + }); } else if (hasFinish && activeStep.index === steps.length - 1) { // "Continue" button on finish screen onComplete?.(); diff --git a/src/components/form/FormWizard/types.ts b/src/components/form/FormWizard/types.ts index 0a050d356..6b14ed278 100644 --- a/src/components/form/FormWizard/types.ts +++ b/src/components/form/FormWizard/types.ts @@ -20,38 +20,40 @@ export type WizardStepObject = WizardStepObjectBase & ); export type FormWizardStepProps = { - /** Label shown in the stepper */ - label: string; + /** Label shown in the stepper (required for body steps) */ + label?: string; /** Field names belonging to this step (used for validation) */ fields?: string[]; /** Step content */ children: ReactNode; + /** Marks this step as the intro screen (hidden from stepper, placed first) */ + startStep?: boolean; + /** Marks this step as the completion screen (hidden from stepper, placed last) */ + finishStep?: boolean; }; export type FormWizardProps = { - /** Content for an optional intro screen (hidden from stepper) */ - startStep?: ReactNode; - /** Content for an optional completion screen (hidden from stepper) */ - finishStep?: ReactNode; /** Called when the user clicks "Continue" on the finish step */ onComplete?: () => void; /** * If true, automatically advances to the finish step after successful - * form submission. Defaults to true when finishStep is provided. + * form submission. Defaults to true when a finishStep child is present. */ autoAdvanceOnSubmit?: boolean; /** Wizard step children */ children: ReactNode; }; -export type StepConfig = { +type StepConfigBase = { label: string; - fields: string[]; content: ReactNode; - variant: 'start' | 'body' | 'finish'; hidden: boolean; }; +export type StepConfig = + | (StepConfigBase & { variant: 'body'; fields: string[] }) + | (StepConfigBase & { variant: 'start' | 'finish' }); + export type ActiveStep = { index: number; previous: number | undefined; diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index f60c2502b..6862dfde1 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -78,8 +78,8 @@ export default function OnboardingForm({ const FormElement = ( - router.push('/')}> +
- } - finishStep={ -
-
- - Thank you! - - - You are now ready to use UTD Clubs. You can always change - everything later in your{' '} - - account settings - - . - -
-
- } - onComplete={() => router.push('/')} - > +
+ @@ -237,6 +213,30 @@ export default function OnboardingForm({ + + +
+
+ + Thank you! + + + You are now ready to use UTD Clubs. You can always change + everything later in your{' '} + + account settings + + . + +
+
+
); From cf2bee626376a4e4fd371d2b6015b4eaf310fbbe Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Wed, 15 Apr 2026 10:04:54 -0500 Subject: [PATCH 5/6] chore: fix Prettier formatting in FormWizard --- src/components/form/FormWizard/FormWizard.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/form/FormWizard/FormWizard.tsx b/src/components/form/FormWizard/FormWizard.tsx index a0acddcfc..eb515e2ad 100644 --- a/src/components/form/FormWizard/FormWizard.tsx +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -21,7 +21,12 @@ import { import { BaseCard } from '@src/components/common/BaseCard'; import Panel from '@src/components/common/Panel'; import { useFormContext } from '@src/utils/form'; -import { ActiveStep, FormWizardProps, FormWizardStepProps, StepConfig } from './types'; +import { + ActiveStep, + FormWizardProps, + FormWizardStepProps, + StepConfig, +} from './types'; import { useWizardContext, WizardContext } from './WizardContext'; /** @@ -242,8 +247,15 @@ export default function FormWizard({ // Submit the form; only advance to the finish step once the API call // resolves successfully so the step does not jump early void form.handleSubmit().then(() => { - if (form.store.state.isSubmitSuccessful && shouldAutoAdvance && hasFinish) { - setActiveStep({ index: steps.length - 1, previous: activeStep.index }); + if ( + form.store.state.isSubmitSuccessful && + shouldAutoAdvance && + hasFinish + ) { + setActiveStep({ + index: steps.length - 1, + previous: activeStep.index, + }); } }); } else if (hasFinish && activeStep.index === steps.length - 1) { From 13d0d97a53b71689a642c3913be69deb09e636cc Mon Sep 17 00:00:00 2001 From: SinhSinh An <106696583+SinhSinhAn@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:15:24 -0700 Subject: [PATCH 6/6] Update src/components/getting-started/OnboardingForm.tsx Co-authored-by: Isoscelestial --- src/components/getting-started/OnboardingForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index 6862dfde1..4e4294a79 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -73,7 +73,7 @@ export default function OnboardingForm({ console.error(e); } }, - validators: { onChange: accountOnboardingSchema }, + validators: { onSubmit: accountOnboardingSchema }, }); const FormElement = (