Skip to content

Commit 7b19561

Browse files
authored
Merge pull request #108 from lambda-curry/codegen-bot/form-error-handling-implementation-1753626962
2 parents d67deb7 + ab940aa commit 7b19561

15 files changed

Lines changed: 2180 additions & 414 deletions

apps/docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"react": "^19.0.0",
2020
"react-hook-form": "^7.51.0",
2121
"react-router": "^7.6.1",
22-
"remix-hook-form": "^7.0.1",
22+
"remix-hook-form": "^7.1.0",
2323
"storybook": "^9.0.6"
2424
},
2525
"devDependencies": {
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { zodResolver } from '@hookform/resolvers/zod';
2+
import { FormError, TextField } from '@lambdacurry/forms';
3+
import { Button } from '@lambdacurry/forms/ui/button';
4+
import type { Meta, StoryObj } from '@storybook/react-vite';
5+
import { expect, userEvent, within } from '@storybook/test';
6+
import { type ActionFunctionArgs, useFetcher } from 'react-router';
7+
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
8+
import { z } from 'zod';
9+
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
10+
11+
const formSchema = z.object({
12+
email: z.string().email('Please enter a valid email address'),
13+
password: z.string().min(6, 'Password must be at least 6 characters'),
14+
});
15+
16+
type FormData = z.infer<typeof formSchema>;
17+
18+
const BasicFormErrorExample = () => {
19+
const fetcher = useFetcher<{
20+
message?: string;
21+
errors?: Record<string, { message: string }>
22+
}>();
23+
24+
const methods = useRemixForm<FormData>({
25+
resolver: zodResolver(formSchema),
26+
defaultValues: {
27+
email: '',
28+
password: '',
29+
},
30+
fetcher,
31+
submitConfig: {
32+
action: '/',
33+
method: 'post',
34+
},
35+
});
36+
37+
const isSubmitting = fetcher.state === 'submitting';
38+
39+
return (
40+
<RemixFormProvider {...methods}>
41+
<fetcher.Form onSubmit={methods.handleSubmit} className="max-w-md mx-auto p-6 space-y-4">
42+
<h2 className="text-xl font-semibold text-gray-900">Login Form</h2>
43+
44+
{/* Form-level error display */}
45+
<FormError className="mb-4" />
46+
47+
<TextField
48+
name="email"
49+
type="email"
50+
label="Email Address"
51+
placeholder="Enter your email"
52+
disabled={isSubmitting}
53+
/>
54+
55+
<TextField
56+
name="password"
57+
type="password"
58+
label="Password"
59+
placeholder="Enter your password"
60+
disabled={isSubmitting}
61+
/>
62+
63+
<Button type="submit" disabled={isSubmitting} className="w-full">
64+
{isSubmitting ? 'Signing In...' : 'Sign In'}
65+
</Button>
66+
67+
{fetcher.data?.message && (
68+
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-md">
69+
<p className="text-green-700 font-medium">{fetcher.data.message}</p>
70+
</div>
71+
)}
72+
</fetcher.Form>
73+
</RemixFormProvider>
74+
);
75+
};
76+
77+
const handleFormSubmission = async (request: Request) => {
78+
const { data, errors } = await getValidatedFormData<FormData>(request, zodResolver(formSchema));
79+
80+
if (errors) {
81+
return { errors };
82+
}
83+
84+
// Simulate server-side authentication
85+
if (data.email === 'wrong@email.com' && data.password === 'wrongpass') {
86+
return {
87+
errors: {
88+
_form: { message: 'Invalid email or password. Please try again.' }
89+
}
90+
};
91+
}
92+
93+
if (data.email === 'user@example.com' && data.password === 'password123') {
94+
return { message: 'Login successful! Welcome back.' };
95+
}
96+
97+
return {
98+
errors: {
99+
_form: { message: 'Invalid email or password. Please try again.' }
100+
}
101+
};
102+
};
103+
104+
const meta: Meta<typeof FormError> = {
105+
title: 'RemixHookForm/FormError/Basic',
106+
component: FormError,
107+
parameters: {
108+
layout: 'centered',
109+
docs: {
110+
description: {
111+
component: `
112+
The FormError component provides standardized form-level error handling for server failures, authentication issues, and other form-wide errors.
113+
114+
**Key Features:**
115+
- Automatic integration with remix-hook-form context
116+
- Uses \`_form\` as the default error key
117+
- Flexible placement anywhere in forms
118+
- Component override support for custom styling
119+
`,
120+
},
121+
},
122+
},
123+
tags: ['autodocs'],
124+
decorators: [
125+
withReactRouterStubDecorator({
126+
routes: [
127+
{
128+
path: '/',
129+
Component: BasicFormErrorExample,
130+
action: async ({ request }: ActionFunctionArgs) => handleFormSubmission(request),
131+
},
132+
],
133+
}),
134+
],
135+
} satisfies Meta<typeof FormError>;
136+
137+
export default meta;
138+
type Story = StoryObj<typeof meta>;
139+
140+
export const Default: Story = {
141+
parameters: {
142+
docs: {
143+
description: {
144+
story: `
145+
Basic form error handling with server-side validation failure.
146+
147+
**Try this:**
148+
1. Click "Sign In" without filling fields (shows field-level errors)
149+
2. Enter invalid credentials like \`wrong@email.com\` and \`wrongpass\` (shows form-level error)
150+
3. Enter \`user@example.com\` and \`password123\` for success
151+
152+
The FormError component automatically displays when \`errors._form\` exists in the server response.
153+
`,
154+
},
155+
},
156+
},
157+
play: async ({ canvasElement, step }) => {
158+
const canvas = within(canvasElement);
159+
160+
await step('Verify initial state', async () => {
161+
const emailInput = canvas.getByLabelText(/email address/i);
162+
const passwordInput = canvas.getByLabelText(/password/i);
163+
const submitButton = canvas.getByRole('button', { name: /sign in/i });
164+
165+
expect(emailInput).toBeInTheDocument();
166+
expect(passwordInput).toBeInTheDocument();
167+
expect(submitButton).toBeInTheDocument();
168+
expect(canvas.queryByText(/invalid email or password/i)).not.toBeInTheDocument();
169+
});
170+
171+
await step('Test field-level validation errors', async () => {
172+
const submitButton = canvas.getByRole('button', { name: /sign in/i });
173+
await userEvent.click(submitButton);
174+
175+
await expect(canvas.findByText(/please enter a valid email address/i)).resolves.toBeInTheDocument();
176+
await expect(canvas.findByText(/password must be at least 6 characters/i)).resolves.toBeInTheDocument();
177+
expect(canvas.queryByText(/invalid email or password/i)).not.toBeInTheDocument();
178+
});
179+
180+
await step('Test form-level error with invalid credentials', async () => {
181+
const emailInput = canvas.getByLabelText(/email address/i);
182+
const passwordInput = canvas.getByLabelText(/password/i);
183+
184+
await userEvent.clear(emailInput);
185+
await userEvent.clear(passwordInput);
186+
await userEvent.type(emailInput, 'wrong@email.com');
187+
await userEvent.type(passwordInput, 'wrongpass');
188+
189+
const submitButton = canvas.getByRole('button', { name: /sign in/i });
190+
await userEvent.click(submitButton);
191+
192+
// Wait for form-level error to appear
193+
await expect(canvas.findByText(/invalid email or password/i)).resolves.toBeInTheDocument();
194+
195+
// Verify field-level errors are cleared
196+
expect(canvas.queryByText(/please enter a valid email address/i)).not.toBeInTheDocument();
197+
});
198+
},
199+
};

0 commit comments

Comments
 (0)