From 97a16a7e1fea2ef4058005a52451c4fa33121414 Mon Sep 17 00:00:00 2001 From: SinhSinh An Date: Tue, 31 Mar 2026 11:59:30 -0500 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 = ( From 66c2cb2618062b763d3c83713782fe93128c71e0 Mon Sep 17 00:00:00 2001 From: Isosc <171582725+Isoscelestial@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:32:17 -0500 Subject: [PATCH 07/28] refactor: format, fix imports from nebula library --- src/components/form/FormWizard/FormWizard.tsx | 12 ++++++------ src/components/getting-started/OnboardingForm.tsx | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/form/FormWizard/FormWizard.tsx b/src/components/form/FormWizard/FormWizard.tsx index eb515e2ad..5d9eef02b 100644 --- a/src/components/form/FormWizard/FormWizard.tsx +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -18,8 +18,8 @@ import { useRef, useState, } from 'react'; -import { BaseCard } from '@src/components/common/BaseCard'; -import Panel from '@src/components/common/Panel'; +import { BaseCard } from '@nebula-library/components/BaseCard'; +import Panel from '@nebula-library/components/Panel'; import { useFormContext } from '@src/utils/form'; import { ActiveStep, @@ -300,10 +300,10 @@ export default function FormWizard({ e.stopPropagation(); form.handleSubmit(); }} - className="flex flex-col gap-8 w-full" + className="flex w-full flex-col gap-8" noValidate > - +
{steps.map((step, index) => { @@ -337,7 +337,7 @@ export default function FormWizard({
- +
-
+
diff --git a/src/components/form/FormWizard/WizardContext.tsx b/src/components/form/FormWizard/WizardContext.tsx index b561467cb..46054709d 100644 --- a/src/components/form/FormWizard/WizardContext.tsx +++ b/src/components/form/FormWizard/WizardContext.tsx @@ -4,7 +4,8 @@ import { createContext, useContext } from 'react'; import { WizardContextType } from './types'; const defaultContext: WizardContextType = { - activeStep: { index: 0, previous: undefined }, + activeStep: 0, + previousStep: undefined, steps: [], goNext: () => {}, goBack: () => {}, diff --git a/src/components/form/FormWizard/types.ts b/src/components/form/FormWizard/types.ts index 6b14ed278..5f7cb896e 100644 --- a/src/components/form/FormWizard/types.ts +++ b/src/components/form/FormWizard/types.ts @@ -54,13 +54,9 @@ export type StepConfig = | (StepConfigBase & { variant: 'body'; fields: string[] }) | (StepConfigBase & { variant: 'start' | 'finish' }); -export type ActiveStep = { - index: number; - previous: number | undefined; -}; - export type WizardContextType = { - activeStep: ActiveStep; + activeStep: number; + previousStep: number | undefined; steps: StepConfig[]; goNext: () => void; goBack: () => void; From ef8e00a9521ccbfc87534848f2e3f9cc813e12d3 Mon Sep 17 00:00:00 2001 From: Isosc <171582725+Isoscelestial@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:20:09 -0500 Subject: [PATCH 10/28] feat: update Tanstack Form for form groups --- package-lock.json | 52 +++++++++++++++++++++++------------------------ package.json | 8 ++++---- 2 files changed, 30 insertions(+), 30 deletions(-) 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", From 2b91602a9e47b1015beba3a4fb88829e2b664c9a Mon Sep 17 00:00:00 2001 From: Isosc <171582725+Isoscelestial@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:25:34 -0500 Subject: [PATCH 11/28] feat: remove broken validation, update types, begin adding FormGroup, begin refactoring onboarding form and schema --- src/components/form/FormWizard/FormWizard.tsx | 123 ++++++++---------- src/components/form/FormWizard/index.ts | 1 - src/components/form/FormWizard/types.ts | 76 ++++++----- .../getting-started/OnboardingForm.tsx | 103 ++++++++------- src/utils/formSchemas.ts | 14 +- 5 files changed, 166 insertions(+), 151 deletions(-) diff --git a/src/components/form/FormWizard/FormWizard.tsx b/src/components/form/FormWizard/FormWizard.tsx index 74f1b1e7a..c10ee6105 100644 --- a/src/components/form/FormWizard/FormWizard.tsx +++ b/src/components/form/FormWizard/FormWizard.tsx @@ -65,37 +65,23 @@ export default function FormWizard({ children: ReactNode; }; - 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, - }); - } + result.push({ + name: props.name, + label: props.label ?? props.name ?? '', + render: props.children, + hidden: props.hidden ?? false, + }); } }); return result; }, [children]); - const hasStart = steps[0]?.variant === 'start'; - const hasFinish = steps[steps.length - 1]?.variant === 'finish'; + const hasStart = true; + const hasFinish = true; + + // const hasStart = steps[0]?.variant === 'start'; + // const hasFinish = steps[steps.length - 1]?.variant === 'end'; const shouldAutoAdvanceOnSubmit = autoAdvanceOnSubmit ?? hasFinish; // Step navigation state @@ -145,49 +131,17 @@ export default function FormWizard({ } }, []); - // Validation helpers - const validateStepFields = useCallback( - (stepIndex: number) => { - const step = steps[stepIndex]; - if (step?.variant !== 'body' || !step.fields.length) return; - step.fields.forEach((field) => form.validateField(field, 'change')); - }, - [steps, form], - ); - - const areStepFieldsValid = useCallback( - (stepIndex: number) => { - const step = steps[stepIndex]; - if (step?.variant !== 'body' || !step.fields.length) return true; - return step.fields.every( - (field) => form.state.fieldMeta[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(() => { - validateStepFields(activeStep); - if (!areStepFieldsValid(activeStep)) return; - if (activeStep < lastBodyIndex) { setActiveStep((prev) => prev + 1); } else if (hasFinish && activeStep === steps.length - 1) { onComplete?.(); } - }, [ - activeStep, - lastBodyIndex, - hasFinish, - steps.length, - onComplete, - validateStepFields, - areStepFieldsValid, - ]); + }, [activeStep, lastBodyIndex, hasFinish, steps.length, onComplete]); const goBack = useCallback(() => { if (activeStep > 0) { @@ -200,13 +154,11 @@ export default function FormWizard({ if (index < activeStep) { setActiveStep(index); } else if (index > activeStep) { - validateStepFields(activeStep); - if (!areStepFieldsValid(activeStep)) return; if (index - 1 > activeStep) return; setActiveStep(index); } }, - [activeStep, validateStepFields, areStepFieldsValid], + [activeStep], ); // Advance to finish step after successful form submission @@ -220,9 +172,6 @@ export default function FormWizard({ // Always prevent native submit; we call form.handleSubmit() explicitly event.preventDefault(); - validateStepFields(activeStep); - if (!areStepFieldsValid(activeStep)) return; - if (activeStep < lastBodyIndex) { setActiveStep((prev) => prev + 1); } else if (activeStep === lastBodyIndex) { @@ -270,7 +219,9 @@ export default function FormWizard({ ? 'Start' : 'Next'; - const currentFieldsValid = areStepFieldsValid(activeStep); + const currentFieldsValid = true; + + const formGroupApis = form.formGroupApis.values().toArray(); return ( @@ -283,6 +234,38 @@ export default function FormWizard({ className="flex w-full flex-col gap-8" noValidate > +
+ + +
+
@@ -325,7 +308,7 @@ export default function FormWizard({ {/* Hidden step for initial sizing */} {mounting && (
-
{steps[0]?.content}
+
{steps[0]?.render}
)} @@ -358,7 +341,13 @@ export default function FormWizard({ className={`absolute top-0 left-0 ${mounting ? 'invisible' : ''}`} >
-
{step.content}
+
+ + {() => <>{step.render}} + +
); diff --git a/src/components/form/FormWizard/index.ts b/src/components/form/FormWizard/index.ts index 2d5ef4260..e171495c6 100644 --- a/src/components/form/FormWizard/index.ts +++ b/src/components/form/FormWizard/index.ts @@ -2,7 +2,6 @@ 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 index 5f7cb896e..bd56b75b1 100644 --- a/src/components/form/FormWizard/types.ts +++ b/src/components/form/FormWizard/types.ts @@ -1,37 +1,50 @@ -import { ReactNode } from 'react'; +import { DeepKeys } from '@tanstack/react-form'; +import { MouseEventHandler, ReactNode } from 'react'; -type WizardStepObjectBase = { - label: string; +/** + * Configurations for the wizard buttons that may differ between steps + */ +export type WizardStepButtonConfig = { + /** Label on button. */ + label?: string; + /** + * Action that takes place when interacting with the button. + * - `"next"` Validates current step and goes to next step if successful + * - `"back"` Goes back to previous step + * - `"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 + * - `"reset"` Resets the current step + */ + type?: 'next' | 'back' | 'target' | 'submit' | 'submitAndNext' | 'reset'; + /** Step's name to jump to, if {@linkcode WizardStepButtonConfig.type | type} is `"target"`. */ + targetStepName?: string; + /** Whether the button is disabled. */ + disabled?: boolean; + /** Whether the button is completely hidden. */ hidden?: boolean; + /** A function to be called before the default action(s), as configured through {@linkcode WizardStepButtonConfig.type | type}. */ + onClick?: MouseEventHandler; }; -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 (required for body steps) */ - label?: string; - /** Field names belonging to this step (used for validation) */ - fields?: string[]; +type FormWizardStepPropsBase = { /** 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; + children?: ReactNode; + /** Name of form group */ + name: DeepKeys; + /** Label shown in the stepper */ + label?: string; + /** Hides step from stepper */ + hidden?: boolean; + /** Configuration options for the next button for this step. */ + nextButtonConfig?: WizardStepButtonConfig; + /** Configuration options for the back button for this step. */ + backButtonConfig?: WizardStepButtonConfig; }; +export type FormWizardStepProps = + FormWizardStepPropsBase; + export type FormWizardProps = { /** Called when the user clicks "Continue" on the finish step */ onComplete?: () => void; @@ -44,15 +57,14 @@ export type FormWizardProps = { children: ReactNode; }; -type StepConfigBase = { +type StepConfigBase = { + render: ReactNode; + name: DeepKeys; label: string; - content: ReactNode; hidden: boolean; }; -export type StepConfig = - | (StepConfigBase & { variant: 'body'; fields: string[] }) - | (StepConfigBase & { variant: 'start' | 'finish' }); +export type StepConfig = StepConfigBase; export type WizardContextType = { activeStep: number; diff --git a/src/components/getting-started/OnboardingForm.tsx b/src/components/getting-started/OnboardingForm.tsx index 4af9fe049..b1d13310c 100644 --- a/src/components/getting-started/OnboardingForm.tsx +++ b/src/components/getting-started/OnboardingForm.tsx @@ -35,51 +35,57 @@ export default function OnboardingForm({ const [defaultValues, setDefaultValues] = useState< Partial >({ - firstName: userMetadata?.firstName, - lastName: userMetadata?.lastName, - major: userMetadata?.major, - minor: userMetadata?.minor, - studentClassification: userMetadata?.studentClassification, - 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, - }); - if (updated) { - const updatedFixed = { - ...updated, - graduationDate: updated?.graduationDate - ? new Date( - updated?.graduationDate?.getTime() + - updated?.graduationDate?.getTimezoneOffset() * 60 * 1000, - ) - : null, - }; - setDefaultValues(updatedFixed); - formApi.reset(updatedFixed); - } - } catch (e) { - console.error(e); - } - }, - validators: { onSubmit: accountOnboardingSchema }, + // onSubmit: async ({ value, formApi }) => { + // try { + // const updated = await editAccountMutation.mutateAsync({ + // updateUser: value, + // }); + // if (updated) { + // const updatedFixed = { + // ...updated, + // graduationDate: updated?.graduationDate + // ? new Date( + // updated?.graduationDate?.getTime() + + // updated?.graduationDate?.getTimezoneOffset() * 60 * 1000, + // ) + // : null, + // }; + // setDefaultValues(updatedFixed); + // formApi.reset(updatedFixed); + // } + // } catch (e) { + // console.error(e); + // } + // }, + validators: { onChange: accountOnboardingSchema }, }); const FormElement = ( router.push('/')}> - +