Skip to content

Commit c16560f

Browse files
committed
Fix controlled input transforms
1 parent fa9925b commit c16560f

5 files changed

Lines changed: 230 additions & 28 deletions

File tree

apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { zodResolver } from '@hookform/resolvers/zod';
22
import { ControlledCurrencyInput } from '@lambdacurry/medusa-forms/controlled/ControlledCurrencyInput';
33
import type { Meta, StoryObj } from '@storybook/react-vite';
4+
import { useEffect } from 'react';
45
import { FormProvider, useForm } from 'react-hook-form';
56
import { z } from 'zod';
67

@@ -21,7 +22,7 @@ export default meta;
2122
type Story = StoryObj<typeof meta>;
2223

2324
interface CurrencyFormData {
24-
price: string;
25+
price: string | number;
2526
}
2627

2728
// Base wrapper component for stories
@@ -61,6 +62,55 @@ const CurrencyInputWithHookForm = ({
6162
);
6263
};
6364

65+
const CurrencyInputWithRequiredError = () => {
66+
const form = useForm<CurrencyFormData>({
67+
defaultValues: { price: '' },
68+
mode: 'onChange',
69+
});
70+
71+
useEffect(() => {
72+
form.trigger('price').then(() => undefined);
73+
}, [form]);
74+
75+
return (
76+
<FormProvider {...form}>
77+
<div className="w-[400px]">
78+
<ControlledCurrencyInput
79+
name="price"
80+
label="Required price"
81+
symbol="$"
82+
code="usd"
83+
rules={{ required: 'Price is required' }}
84+
/>
85+
</div>
86+
</FormProvider>
87+
);
88+
};
89+
90+
const CurrencyInputWithValueAsNumber = () => {
91+
const form = useForm<CurrencyFormData>({
92+
defaultValues: { price: '' },
93+
});
94+
const price = form.watch('price');
95+
96+
return (
97+
<FormProvider {...form}>
98+
<div className="w-[400px] space-y-4">
99+
<ControlledCurrencyInput<CurrencyFormData>
100+
name="price"
101+
label="Numeric price"
102+
symbol="$"
103+
code="usd"
104+
rules={{ valueAsNumber: true }}
105+
/>
106+
<pre className="rounded bg-gray-100 p-2 text-xs">
107+
{JSON.stringify({ value: price, type: typeof price }, null, 2)}
108+
</pre>
109+
</div>
110+
</FormProvider>
111+
);
112+
};
113+
64114
// 1. Different Currency Symbols
65115
export const USDCurrency: Story = {
66116
args: {
@@ -194,6 +244,24 @@ export const RequiredFieldValidation: Story = {
194244
),
195245
};
196246

247+
export const RequiredRuleError: Story = {
248+
args: {
249+
name: 'price',
250+
symbol: '$',
251+
code: 'usd',
252+
},
253+
render: () => <CurrencyInputWithRequiredError />,
254+
};
255+
256+
export const ValueAsNumber: Story = {
257+
args: {
258+
name: 'price',
259+
symbol: '$',
260+
code: 'usd',
261+
},
262+
render: () => <CurrencyInputWithValueAsNumber />,
263+
};
264+
197265
const customValidationSchema = z.object({
198266
price: z.string().refine((val) => {
199267
const num = Number.parseFloat(val);

apps/docs/src/medusa-forms/ControlledInput.stories.tsx

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,18 @@ const meta = {
1414
export default meta;
1515
type Story = StoryObj<typeof meta>;
1616

17+
interface InputFormData {
18+
username: string;
19+
quantity: number | string;
20+
optionalAmount: number | null | string;
21+
}
22+
1723
const ControlledInputWithHookForm = () => {
18-
const form = useForm({
24+
const form = useForm<InputFormData>({
1925
defaultValues: {
2026
username: '',
27+
quantity: '',
28+
optionalAmount: '',
2129
},
2230
});
2331

@@ -30,6 +38,62 @@ const ControlledInputWithHookForm = () => {
3038
);
3139
};
3240

41+
const NumberInputWithHookForm = () => {
42+
const form = useForm<InputFormData>({
43+
defaultValues: {
44+
username: '',
45+
quantity: '',
46+
optionalAmount: '',
47+
},
48+
});
49+
const quantity = form.watch('quantity');
50+
51+
return (
52+
<FormProvider {...form}>
53+
<div className="w-[400px] space-y-4">
54+
<ControlledInput<InputFormData>
55+
name="quantity"
56+
type="number"
57+
label="Quantity"
58+
rules={{ valueAsNumber: true }}
59+
/>
60+
<pre className="rounded bg-gray-100 p-2 text-xs">
61+
{JSON.stringify({ value: quantity, type: typeof quantity }, null, 2)}
62+
</pre>
63+
</div>
64+
</FormProvider>
65+
);
66+
};
67+
68+
const NullableNumberInputWithHookForm = () => {
69+
const form = useForm<InputFormData>({
70+
defaultValues: {
71+
username: '',
72+
quantity: '',
73+
optionalAmount: '',
74+
},
75+
});
76+
const optionalAmount = form.watch('optionalAmount');
77+
78+
return (
79+
<FormProvider {...form}>
80+
<div className="w-[400px] space-y-4">
81+
<ControlledInput<InputFormData>
82+
name="optionalAmount"
83+
type="number"
84+
label="Optional amount"
85+
rules={{
86+
setValueAs: (value) => (value === '' ? null : Number(value)),
87+
}}
88+
/>
89+
<pre className="rounded bg-gray-100 p-2 text-xs">
90+
{JSON.stringify({ value: optionalAmount, type: typeof optionalAmount }, null, 2)}
91+
</pre>
92+
</div>
93+
</FormProvider>
94+
);
95+
};
96+
3397
export const WithReactHookForm: Story = {
3498
args: {
3599
name: 'username',
@@ -38,3 +102,11 @@ export const WithReactHookForm: Story = {
38102
},
39103
render: () => <ControlledInputWithHookForm />,
40104
};
105+
106+
export const ValueAsNumber: Story = {
107+
render: () => <NumberInputWithHookForm />,
108+
};
109+
110+
export const SetValueAs: Story = {
111+
render: () => <NullableNumberInputWithHookForm />,
112+
};

packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,45 @@
11
import type * as React from 'react';
2-
import {
3-
Controller,
4-
type ControllerProps,
5-
type FieldValues,
6-
type Path,
7-
type RegisterOptions,
8-
useFormContext,
9-
} from 'react-hook-form';
2+
import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form';
103
import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput';
4+
import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms';
115

126
export type ControlledCurrencyInputProps<T extends FieldValues> = CurrencyInputProps &
13-
Omit<ControllerProps, 'render' | 'control'> & {
7+
Omit<ControllerProps<T>, 'render' | 'control' | 'rules'> & {
148
name: Path<T>;
9+
rules?: ControlledRules<T>;
1510
};
1611

1712
export const ControlledCurrencyInput = <T extends FieldValues>({
1813
name,
1914
rules,
15+
onChange,
2016
...props
2117
}: ControlledCurrencyInputProps<T>) => {
22-
const { control } = useFormContext<T>();
18+
const {
19+
control,
20+
formState: { errors },
21+
} = useFormContext<T>();
22+
const { controllerRules, hasTransform } = splitTransformRules(rules);
2323

2424
return (
2525
<Controller<T>
2626
control={control}
2727
name={name}
28-
rules={rules as Omit<RegisterOptions<T, Path<T>>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>}
28+
rules={controllerRules}
2929
render={({ field }) => {
3030
return (
3131
<CurrencyInput
3232
{...field}
3333
{...props}
34+
formErrors={errors}
35+
{...(hasTransform ? { value: serializeDisplayValue(field.value, rules) } : {})}
3436
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
35-
field.onChange(e.target.value.replace(/[^0-9.-]+/g, ''));
37+
if (onChange) {
38+
onChange(e);
39+
}
40+
41+
const value = e.target.value.replace(/[^0-9.-]+/g, '');
42+
field.onChange(hasTransform ? transformValue(value, rules) : value);
3643
}}
3744
/>
3845
);

packages/medusa-forms/src/controlled/ControlledInput.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
1-
import type { ComponentProps } from 'react';
2-
import {
3-
Controller,
4-
type ControllerProps,
5-
type FieldValues,
6-
type Path,
7-
type RegisterOptions,
8-
useFormContext,
9-
} from 'react-hook-form';
1+
import type { ChangeEvent, ComponentProps } from 'react';
2+
import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form';
103
import { Input, type InputProps } from '../ui/Input';
4+
import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms';
115

126
export type ControlledInputProps<T extends FieldValues> = InputProps &
13-
Omit<ControllerProps, 'render'> & {
7+
Omit<ControllerProps<T>, 'render' | 'rules'> & {
148
name: Path<T>;
15-
rules?: Omit<RegisterOptions<T, Path<T>>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
9+
rules?: ControlledRules<T>;
1610
} & ComponentProps<typeof Input> &
17-
Omit<ControllerProps<T>, 'render'>;
11+
Omit<ControllerProps<T>, 'render' | 'rules'>;
12+
13+
const getEventValue = (evt: ChangeEvent<HTMLInputElement>) => evt.target.value;
1814

1915
export const ControlledInput = <T extends FieldValues>({
2016
name,
@@ -26,23 +22,25 @@ export const ControlledInput = <T extends FieldValues>({
2622
control,
2723
formState: { errors },
2824
} = useFormContext<T>();
25+
const { controllerRules, hasTransform } = splitTransformRules(rules);
2926

3027
return (
3128
<Controller
3229
control={control}
3330
name={name}
34-
rules={rules as Omit<RegisterOptions<T, Path<T>>, 'disabled' | 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>}
31+
rules={controllerRules}
3532
render={({ field }) => (
3633
<Input
3734
{...field}
3835
{...props}
3936
labelClassName={props.labelClassName}
4037
formErrors={errors}
38+
{...(hasTransform ? { value: serializeDisplayValue(field.value, rules) } : {})}
4139
onChange={(evt) => {
4240
if (onChange) {
4341
onChange(evt);
4442
}
45-
field.onChange(evt);
43+
field.onChange(hasTransform ? transformValue(getEventValue(evt), rules) : evt);
4644
}}
4745
/>
4846
)}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { FieldValues, Path, RegisterOptions } from 'react-hook-form';
2+
3+
export type ControlledRules<T extends FieldValues> = Omit<RegisterOptions<T, Path<T>>, 'disabled'>;
4+
type ControllerRules<T extends FieldValues> = Omit<ControlledRules<T>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
5+
6+
export const splitTransformRules = <T extends FieldValues>(
7+
rules: ControlledRules<T> | undefined,
8+
): { controllerRules: ControllerRules<T>; hasTransform: boolean } => {
9+
const { valueAsNumber, valueAsDate, setValueAs, ...controllerRules } = rules ?? {};
10+
11+
return {
12+
controllerRules,
13+
hasTransform: Boolean(valueAsNumber || valueAsDate || setValueAs),
14+
};
15+
};
16+
17+
export const transformValue = <T extends FieldValues>(value: string, rules: ControlledRules<T> | undefined) => {
18+
if (rules?.valueAsNumber) {
19+
return value === '' ? Number.NaN : +value;
20+
}
21+
22+
if (rules?.valueAsDate) {
23+
return new Date(value);
24+
}
25+
26+
if (typeof rules?.setValueAs === 'function') {
27+
return rules.setValueAs(value);
28+
}
29+
30+
return value;
31+
};
32+
33+
const isInvalidDate = (value: Date) => Number.isNaN(value.getTime());
34+
35+
export const serializeDisplayValue = <T extends FieldValues>(value: unknown, rules: ControlledRules<T> | undefined) => {
36+
if (value == null) {
37+
return '';
38+
}
39+
40+
if (rules?.valueAsNumber) {
41+
return typeof value === 'number' && Number.isNaN(value) ? '' : String(value);
42+
}
43+
44+
if (rules?.valueAsDate) {
45+
if (!(value instanceof Date) || isInvalidDate(value)) {
46+
return '';
47+
}
48+
49+
return value.toISOString().slice(0, 10);
50+
}
51+
52+
if (Array.isArray(value)) {
53+
return value.map((item) => String(item));
54+
}
55+
56+
return String(value);
57+
};

0 commit comments

Comments
 (0)