From 4a5abd4639de96891ef90df5b27ab660dd69e187 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Tue, 13 Oct 2026 01:32:59 -0400 Subject: [PATCH] Build the register form as three steps --- .../features/auth/components/LoginCallout.tsx | 18 -- .../auth/components/RegisterForm.stories.tsx | 144 ++++++++----- .../features/auth/components/RegisterForm.tsx | 194 ++++++++++-------- .../auth/components/RegisterStepProgress.tsx | 24 +++ .../features/auth/hooks/useRegisterWizard.ts | 74 +++++++ .../app/features/auth/routes/register.tsx | 82 +++----- 6 files changed, 335 insertions(+), 201 deletions(-) delete mode 100644 web/frontend/app/features/auth/components/LoginCallout.tsx create mode 100644 web/frontend/app/features/auth/components/RegisterStepProgress.tsx create mode 100644 web/frontend/app/features/auth/hooks/useRegisterWizard.ts diff --git a/web/frontend/app/features/auth/components/LoginCallout.tsx b/web/frontend/app/features/auth/components/LoginCallout.tsx deleted file mode 100644 index 1b3538c7..00000000 --- a/web/frontend/app/features/auth/components/LoginCallout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Link } from 'react-router'; - -const LoginCallout = () => ( - <> -
Already have an account?
- -
- - Sign in - - - Back to home - -
- -); - -export default LoginCallout; diff --git a/web/frontend/app/features/auth/components/RegisterForm.stories.tsx b/web/frontend/app/features/auth/components/RegisterForm.stories.tsx index d8dcafe0..58164f62 100644 --- a/web/frontend/app/features/auth/components/RegisterForm.stories.tsx +++ b/web/frontend/app/features/auth/components/RegisterForm.stories.tsx @@ -1,25 +1,30 @@ -import { zodResolver } from '@hookform/resolvers/zod'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; -import { useForm } from 'react-hook-form'; import { expect, fireEvent, userEvent, within } from 'storybook/test'; +import AuthCardHeading from './AuthCardHeading'; import RegisterForm from './RegisterForm'; -import { registerSchema, type RegisterSchema } from '../schemas'; +import RegisterStepProgress from './RegisterStepProgress'; +import { useRegisterWizard } from '../hooks/useRegisterWizard'; +import { type RegisterSchema } from '../schemas'; -const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`; +const registerFormDescription = `Renders the real \`RegisterForm\` driven by the real \`useRegisterWizard\` hook, so these stories exercise the production three-step flow: each step validates its own fields through \`registerSchema\` (zod) before the next one opens, entered values survive going back, and only the final step submits.`; const RegisterFormHarness = () => { const [submitted, setSubmitted] = useState(null); - const form = useForm({ resolver: zodResolver(registerSchema) }); + const wizard = useRegisterWizard(setSubmitted); return ( -
- setSubmitted(values))} - formState={form.formState} - register={form.register} - submitting={false} +
+ + + + + + {submitted ?

Registered {submitted.username}

: null}
); @@ -42,75 +47,120 @@ const meta = { export default meta; type Story = StoryObj; -export const Empty: Story = {}; +type Canvas = ReturnType; -const fillValidRegistration = async (canvas: ReturnType) => { +const adultDateOfBirth = '2000-01-15'; + +const underageDateOfBirth = () => { + const tenYearsAgo = new Date(); + tenYearsAgo.setFullYear(tenYearsAgo.getFullYear() - 10); + return tenYearsAgo.toISOString().slice(0, 10); +}; + +const completeAccountStep = async (canvas: Canvas) => { await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe'); + await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com'); + await userEvent.click(canvas.getByRole('button', { name: /continue/i })); + await canvas.findByLabelText(/first name/i); +}; + +const completeDetailsStep = async (canvas: Canvas, dateOfBirth = adultDateOfBirth) => { await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane'); await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe'); - await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com'); await fireEvent.change(canvas.getByLabelText(/date of birth/i), { - target: { value: '2000-01-15' }, + target: { value: dateOfBirth }, }); - await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!'); - await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!'); + await userEvent.click(canvas.getByRole('button', { name: /continue/i })); }; -export const RejectsEmptySubmission: Story = { +export const Empty: Story = {}; + +export const BlocksAdvanceOnInvalidAccount: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); + await userEvent.click(canvas.getByRole('button', { name: /continue/i })); await expect( - canvas.getByText(/username must be at least 3 characters/i), + await canvas.findByText(/username must be at least 3 characters/i), ).toBeInTheDocument(); - await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument(); - await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument(); - }, -}; - -export const RejectsInvalidEmail: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await fillValidRegistration(canvas); - await userEvent.clear(canvas.getByLabelText(/^email$/i)); - await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example'); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument(); - await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument(); + await expect(canvas.getByText(/step 1 of 3/i)).toBeInTheDocument(); }, }; -export const RejectsWeakPassword: Story = { +export const ClearsFieldErrorOnEdit: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await fillValidRegistration(canvas); - await userEvent.clear(canvas.getByLabelText('Password')); - await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword'); - await userEvent.clear(canvas.getByLabelText(/confirm password/i)); - await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword'); - await userEvent.click(canvas.getByRole('button', { name: /create account/i })); - await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument(); - await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole('button', { name: /continue/i })); + await canvas.findByText(/username must be at least 3 characters/i); + + await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe'); + await expect( + canvas.queryByText(/username must be at least 3 characters/i), + ).not.toBeInTheDocument(); + }, +}; + +export const AdvancesToPersonalDetails: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await completeAccountStep(canvas); + await expect(canvas.getByRole('heading', { name: 'About you' })).toBeInTheDocument(); + await expect(canvas.getByText(/step 2 of 3 · personal details/i)).toBeInTheDocument(); + }, +}; + +export const KeepsValuesWhenGoingBack: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await completeAccountStep(canvas); + await userEvent.click(canvas.getByRole('button', { name: /back/i })); + await expect(await canvas.findByLabelText(/^username$/i)).toHaveValue('jane_doe'); + await expect(canvas.getByLabelText(/^email$/i)).toHaveValue('jane@example.com'); + }, +}; + +export const RejectsUnderageDateOfBirth: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await completeAccountStep(canvas); + await completeDetailsStep(canvas, underageDateOfBirth()); + await expect(await canvas.findByText(/at least 19 years old/i)).toBeInTheDocument(); + await expect(canvas.getByText(/step 2 of 3/i)).toBeInTheDocument(); }, }; export const RejectsMismatchedConfirmPassword: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await fillValidRegistration(canvas); - await userEvent.clear(canvas.getByLabelText(/confirm password/i)); + await completeAccountStep(canvas); + await completeDetailsStep(canvas); + await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!'); await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!'); await userEvent.click(canvas.getByRole('button', { name: /create account/i })); - await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument(); + await expect(await canvas.findByText(/passwords must match/i)).toBeInTheDocument(); await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument(); }, }; +export const RevealsBothPasswords: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await completeAccountStep(canvas); + await completeDetailsStep(canvas); + await userEvent.click(await canvas.findByRole('button', { name: /show password/i })); + await expect(canvas.getByLabelText('Password')).toHaveAttribute('type', 'text'); + await expect(canvas.getByLabelText(/confirm password/i)).toHaveAttribute('type', 'text'); + }, +}; + export const AcceptsValidRegistration: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await fillValidRegistration(canvas); + await completeAccountStep(canvas); + await completeDetailsStep(canvas); + await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!'); + await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!'); await userEvent.click(canvas.getByRole('button', { name: /create account/i })); - await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe'); + await expect(await canvas.findByTestId('submit-result')).toHaveTextContent('jane_doe'); }, }; diff --git a/web/frontend/app/features/auth/components/RegisterForm.tsx b/web/frontend/app/features/auth/components/RegisterForm.tsx index f118d164..5a1b5aa7 100644 --- a/web/frontend/app/features/auth/components/RegisterForm.tsx +++ b/web/frontend/app/features/auth/components/RegisterForm.tsx @@ -1,97 +1,129 @@ -import { BaseSyntheticEvent } from 'react'; -import { FormState, UseFormRegister } from 'react-hook-form'; +import { Button } from '@headlessui/react'; +import { useState } from 'react'; import FormField from '../../../components/ui/forms/FormField'; import SubmitButton from '../../../components/ui/forms/SubmitButton'; -import type { RegisterSchema } from '../schemas'; +import type { RegisterWizard } from '../hooks/useRegisterWizard'; +import { minimumAgeYears } from '../schemas'; +import ShowPasswordToggle from './ShowPasswordToggle'; interface RegisterFormProps { - onSubmit: (e?: BaseSyntheticEvent) => Promise | undefined>; - formState: FormState; - register: UseFormRegister; + wizard: RegisterWizard; submitting: boolean; } -const RegisterForm = (props: RegisterFormProps) => ( -
- +const RegisterForm = ({ wizard, submitting }: RegisterFormProps) => { + const [showPassword, setShowPassword] = useState(false); + const { step, isLastStep, canGoBack, register, formState } = wizard; + const { errors } = formState; -
- + return ( + + {step === 1 && ( +
+ - -
+ +
+ )} - + {step === 2 && ( +
+
+ - + +
- + +
+ )} - + {step === 3 && ( +
+ - - -); + + + setShowPassword((shown) => !shown)} + /> +
+ )} + +
+ {canGoBack && ( + + )} + +
+ +
+
+ + ); +}; export default RegisterForm; diff --git a/web/frontend/app/features/auth/components/RegisterStepProgress.tsx b/web/frontend/app/features/auth/components/RegisterStepProgress.tsx new file mode 100644 index 00000000..54d0ba47 --- /dev/null +++ b/web/frontend/app/features/auth/components/RegisterStepProgress.tsx @@ -0,0 +1,24 @@ +interface RegisterStepProgressProps { + step: number; + totalSteps: number; + name: string; +} + +const RegisterStepProgress = ({ step, totalSteps, name }: RegisterStepProgressProps) => ( +
+ +); + +export default RegisterStepProgress; diff --git a/web/frontend/app/features/auth/hooks/useRegisterWizard.ts b/web/frontend/app/features/auth/hooks/useRegisterWizard.ts new file mode 100644 index 00000000..e38d6413 --- /dev/null +++ b/web/frontend/app/features/auth/hooks/useRegisterWizard.ts @@ -0,0 +1,74 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useState } from 'react'; +import { useForm, type FieldErrors, type Resolver } from 'react-hook-form'; +import { earliestStepForFields, lastRegisterStepIndex, registerSteps } from '../registerSteps'; +import { registerSchema, type RegisterSchema } from '../schemas'; + +const resolveRegisterSchema = zodResolver(registerSchema); + +/** + * Drives the multi-step register form: one react-hook-form instance spans every + * step so values survive going back and forward, and each step validates its own + * fields before the next one opens. + */ +export const useRegisterWizard = (onValid: (values: RegisterSchema) => void) => { + const [stepIndex, setStepIndex] = useState(0); + + const currentStep = registerSteps[stepIndex]; + const isLastStep = stepIndex === lastRegisterStepIndex; + + // Every step submits through `handleSubmit`, so each one has to report only its + // own fields: resolve the whole schema, then keep the errors this step owns. The + // last step keeps all of them, which is also what runs the password-match + // refinement across steps. + const resolver: Resolver = async (values, context, options) => { + const result = await resolveRegisterSchema(values, context, options); + if (isLastStep) return result; + + const errors = Object.fromEntries( + Object.entries(result.errors).filter(([name]) => + currentStep.fields.includes(name as keyof RegisterSchema), + ), + ) as FieldErrors; + + return Object.keys(errors).length ? { values: {}, errors } : { values, errors: {} }; + }; + + const { register, handleSubmit, formState } = useForm({ + resolver, + mode: 'onTouched', + }); + + const onSubmit = handleSubmit( + (values) => { + if (isLastStep) { + onValid(values); + return; + } + + setStepIndex((index) => index + 1); + }, + // The last step validates fields from earlier steps too, so open whichever + // step owns the problem rather than reporting it out of sight. + (errors: FieldErrors) => { + const step = earliestStepForFields(Object.keys(errors) as (keyof RegisterSchema)[]); + if (step !== null) setStepIndex(step); + }, + ); + + return { + step: stepIndex + 1, + totalSteps: registerSteps.length, + name: currentStep.name, + title: currentStep.title, + subtitle: currentStep.subtitle, + isLastStep, + canGoBack: stepIndex > 0, + back: () => setStepIndex((index) => Math.max(index - 1, 0)), + onSubmit, + register, + formState, + }; +}; + +export type RegisterWizard = ReturnType; diff --git a/web/frontend/app/features/auth/routes/register.tsx b/web/frontend/app/features/auth/routes/register.tsx index 1d14db2a..fd6492b7 100644 --- a/web/frontend/app/features/auth/routes/register.tsx +++ b/web/frontend/app/features/auth/routes/register.tsx @@ -1,11 +1,15 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; import { redirect, useNavigation, useSubmit } from 'react-router'; +import { z } from 'zod'; import { createAuthSession, getOptionalAuth, register } from '../auth.server'; -import LoginCallout from '../components/LoginCallout'; +import AuthCardHeading from '../components/AuthCardHeading'; +import AuthErrorAlert from '../components/AuthErrorAlert'; +import AuthSplitLayout from '../components/AuthSplitLayout'; +import AuthSwitchPrompt from '../components/AuthSwitchPrompt'; import RegisterForm from '../components/RegisterForm'; +import RegisterStepProgress from '../components/RegisterStepProgress'; import { useActionErrorToast } from '../hooks/useActionErrorToast'; -import { registerSchema, type RegisterSchema } from '../schemas'; +import { useRegisterWizard } from '../hooks/useRegisterWizard'; +import { registerSchema } from '../schemas'; import type { Route } from './+types/register'; export const meta = ({}: Route.MetaArgs) => { @@ -20,33 +24,14 @@ export const loader = async ({ request }: Route.LoaderArgs) => { export const action = async ({ request }: Route.ActionArgs) => { const formData = await request.formData(); - const result = registerSchema.safeParse({ - username: formData.get('username'), - firstName: formData.get('firstName'), - lastName: formData.get('lastName'), - email: formData.get('email'), - dateOfBirth: formData.get('dateOfBirth'), - password: formData.get('password'), - confirmPassword: formData.get('confirmPassword'), - }); + const result = registerSchema.safeParse(Object.fromEntries(formData)); if (!result.success) { - const fieldErrors = result.error.flatten().fieldErrors as Record< - keyof RegisterSchema, - string[] | undefined - >; - return { error: null, fieldErrors }; + return { error: null, fieldErrors: z.flattenError(result.error).fieldErrors }; } try { - const body = { - username: result.data.username, - firstName: result.data.firstName, - lastName: result.data.lastName, - email: result.data.email, - dateOfBirth: result.data.dateOfBirth, - password: result.data.password, - }; + const { confirmPassword: _confirmPassword, ...body } = result.data; const payload = await register(body); return createAuthSession(payload, '/dashboard'); } catch (err) { @@ -62,44 +47,31 @@ const Register = ({ actionData }: Route.ComponentProps) => { const submit = useSubmit(); const isSubmitting = navigation.state === 'submitting'; - const { - register: field, - handleSubmit, - formState, - } = useForm({ resolver: zodResolver(registerSchema) }); - - const onSubmit = handleSubmit((data) => { + const wizard = useRegisterWizard((data) => { submit(data, { method: 'post' }); }); useActionErrorToast(actionData?.error); return ( -
-
-
-
-

Register

-

Create your Biergarten account

-
+ + - {actionData?.error && ( -
- {actionData.error} -
- )} + - + {actionData?.error && } - -
-
-
+ + + + ); };