Skip to content

Commit 42da88b

Browse files
authored
Merge pull request #168 from lambda-curry/feature/uncontrolled-form-error
2 parents 82fbbb7 + a8d4061 commit 42da88b

File tree

5 files changed

+134
-5
lines changed

5 files changed

+134
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@lambdacurry/forms": patch
3+
---
4+
5+
Add uncontrolled mode to FormError component via a manual `message` prop.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { FormError } from '@lambdacurry/forms';
2+
import type { Meta, StoryObj } from '@storybook/react-vite';
3+
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
4+
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';
5+
6+
const UncontrolledFormErrorExample = ({ message }: { message?: string }) => {
7+
const methods = useRemixForm({
8+
defaultValues: {
9+
email: '',
10+
},
11+
});
12+
13+
return (
14+
<RemixFormProvider {...methods}>
15+
<div className="max-w-md mx-auto p-6 space-y-4 border rounded-lg shadow-sm">
16+
<h2 className="text-xl font-semibold text-gray-900">Uncontrolled Error</h2>
17+
<p className="text-sm text-gray-500 mb-4">
18+
This FormError is rendered with a manual message prop, bypassing the form state.
19+
</p>
20+
21+
<FormError message={message} />
22+
23+
<div className="p-4 bg-gray-50 rounded-md border border-gray-200">
24+
<p className="text-sm text-gray-600">
25+
The error above is not coming from <code>errors._form</code>. It's passed directly as a prop.
26+
</p>
27+
</div>
28+
</div>
29+
</RemixFormProvider>
30+
);
31+
};
32+
33+
const meta: Meta<typeof FormError> = {
34+
title: 'RemixHookForm/FormError/Uncontrolled',
35+
component: FormError,
36+
parameters: {
37+
layout: 'centered',
38+
docs: {
39+
description: {
40+
component: `
41+
The FormError component can be used in an "uncontrolled" mode by passing a \`message\` prop directly.
42+
This is useful for displaying errors that aren't managed by the form's validation state, such as generic network errors or manual UI feedback.
43+
44+
**Key Features of Uncontrolled Mode:**
45+
- Takes precedence over form-state errors
46+
- Does not require a \`name\` prop
47+
- Maintains consistent styling and accessibility
48+
`,
49+
},
50+
},
51+
},
52+
tags: ['autodocs'],
53+
decorators: [
54+
withReactRouterStubDecorator({
55+
routes: [
56+
{
57+
path: '/',
58+
Component: () => <UncontrolledFormErrorExample message="This is a manual, uncontrolled error message." />,
59+
},
60+
],
61+
}),
62+
],
63+
} satisfies Meta<typeof FormError>;
64+
65+
export default meta;
66+
type Story = StoryObj<typeof meta>;
67+
68+
export const Default: Story = {
69+
args: {
70+
message: 'A manual error message',
71+
},
72+
};
73+
74+
export const CustomStyling: Story = {
75+
args: {
76+
message: 'A manual error message with custom styling',
77+
className: 'bg-red-50 p-4 border border-red-200 rounded-md',
78+
},
79+
};

apps/docs/src/remix-hook-form/form-error.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ const TestFormWithError = ({
3030
formErrorName = '_form',
3131
customComponents = {},
3232
className = '',
33+
message = '',
3334
}: {
3435
initialErrors?: Record<string, { message: string }>;
3536
formErrorName?: string;
3637
customComponents?: { FormMessage?: React.ComponentType<FormMessageProps> };
3738
className?: string;
39+
message?: string;
3840
}) => {
3941
const mockFetcher = {
4042
data: { errors: initialErrors },
@@ -55,7 +57,7 @@ const TestFormWithError = ({
5557
return (
5658
<RemixFormProvider {...methods}>
5759
<form onSubmit={methods.handleSubmit}>
58-
<FormError name={formErrorName} className={className} components={customComponents} />
60+
<FormError name={formErrorName} className={className} components={customComponents} message={message} />
5961
<TextField name="email" label="Email" />
6062
<TextField name="password" label="Password" />
6163
<Button type="submit">Submit</Button>
@@ -313,6 +315,31 @@ describe('FormError Component', () => {
313315
});
314316
});
315317

318+
describe('Uncontrolled Mode', () => {
319+
it('displays manual message when provided', () => {
320+
render(<TestFormWithError message="Manual error message" />);
321+
322+
expect(screen.getByText('Manual error message')).toBeInTheDocument();
323+
});
324+
325+
it('manual message takes precedence over form state error', () => {
326+
const errors = {
327+
_form: { message: 'Form state error' },
328+
};
329+
330+
render(<TestFormWithError initialErrors={errors} message="Manual error message" />);
331+
332+
expect(screen.getByText('Manual error message')).toBeInTheDocument();
333+
expect(screen.queryByText('Form state error')).not.toBeInTheDocument();
334+
});
335+
336+
it('renders even when form context is missing if message is provided', () => {
337+
// Our implementation should be resilient.
338+
render(<FormError message="Context-less error" />);
339+
expect(screen.getByText('Context-less error')).toBeInTheDocument();
340+
});
341+
});
342+
316343
describe('Performance', () => {
317344
it('does not re-render unnecessarily when unrelated form state changes', () => {
318345
const renderSpy = jest.fn();

packages/components/src/remix-hook-form/form-error.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { FormErrorField } from '../ui/form-error-field';
44

55
export type FormErrorProps = {
66
name?: string;
7+
message?: string;
78
className?: string;
89
components?: Partial<FieldComponents>;
910
};
1011

11-
export function FormError({ name = '_form', ...props }: FormErrorProps) {
12-
const { control } = useRemixFormContext();
12+
export function FormError({ name, message, ...props }: FormErrorProps) {
13+
const context = useRemixFormContext();
14+
const control = context?.control;
1315

14-
return <FormErrorField control={control} name={name} {...props} />;
16+
const effectiveName = name ?? (message ? undefined : '_form');
17+
18+
return <FormErrorField control={control} name={effectiveName} message={message} {...props} />;
1519
}
1620

1721
FormError.displayName = 'FormError';

packages/components/src/ui/form-error-field.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export interface FormErrorFieldProps<
77
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
88
> {
99
control?: Control<TFieldValues>;
10-
name: TName;
10+
name?: TName;
11+
message?: string;
1112
className?: string;
1213
components?: Partial<FieldComponents>;
1314
}
@@ -18,9 +19,22 @@ export const FormErrorField = <
1819
>({
1920
control,
2021
name,
22+
message,
2123
className,
2224
components,
2325
}: FormErrorFieldProps<TFieldValues, TName>) => {
26+
if (message) {
27+
return (
28+
<FormItem className={className}>
29+
<FormMessage Component={components?.FormMessage}>{message}</FormMessage>
30+
</FormItem>
31+
);
32+
}
33+
34+
if (!name) {
35+
return null;
36+
}
37+
2438
return (
2539
<FormField
2640
control={control}

0 commit comments

Comments
 (0)