diff --git a/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx b/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx new file mode 100644 index 00000000..fb5f9d7f --- /dev/null +++ b/apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx @@ -0,0 +1,524 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@lambdacurry/forms/ui/button'; +import { useOnFormValueChange } from '@lambdacurry/forms/remix-hook-form/hooks/use-on-form-value-change'; +import { Select } from '@lambdacurry/forms/remix-hook-form/select'; +import { TextField } from '@lambdacurry/forms/remix-hook-form/text-field'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from '@storybook/test'; +import { useState } from 'react'; +import { type ActionFunctionArgs, useFetcher } from 'react-router'; +import { getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form'; +import { z } from 'zod'; +import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub'; + +/** + * # useOnFormValueChange Hook + * + * A hook that watches a specific form field and executes a callback when its value changes. + * This is particularly useful for creating reactive form behaviors where one field's value + * affects another field. + * + * ## Key Features + * - **Reactive Forms**: Make fields respond to changes in other fields + * - **Conditional Logic**: Show/hide or enable/disable fields based on other values + * - **Auto-calculations**: Automatically calculate derived values + * - **Data Synchronization**: Keep multiple fields in sync + * + * ## Common Use Cases + * - Cascading dropdowns (country → state → city) + * - Conditional field visibility + * - Auto-calculating totals or subtotals + * - Applying discounts based on order value + * - Formatting or transforming values + */ + +const meta: Meta = { + title: 'RemixHookForm/Hooks/useOnFormValueChange', + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'A hook that watches a specific form field and executes a callback when its value changes. Perfect for creating reactive, interdependent form fields.', + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// ============================================================================ +// Story 1: Country to State Cascading +// ============================================================================ +const countryStateSchema = z.object({ + country: z.string().min(1, 'Country is required'), + state: z.string().min(1, 'State is required'), + city: z.string().min(1, 'City is required'), +}); + +type CountryStateFormData = z.infer; + +const statesByCountry: Record = { + usa: ['California', 'Texas', 'New York', 'Florida'], + canada: ['Ontario', 'Quebec', 'British Columbia', 'Alberta'], + mexico: ['Mexico City', 'Jalisco', 'Nuevo León', 'Yucatán'], +}; + +const CascadingDropdownExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const [availableStates, setAvailableStates] = useState([]); + + const methods = useRemixForm({ + resolver: zodResolver(countryStateSchema), + defaultValues: { + country: '', + state: '', + city: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + // When country changes, update available states and reset state selection + useOnFormValueChange({ + name: 'country', + onChange: (value) => { + const states = statesByCountry[value] || []; + setAvailableStates(states); + // Reset state when country changes + methods.setValue('state', ''); + methods.setValue('city', ''); + }, + }); + + // Don't render if methods is not ready + if (!methods || !methods.handleSubmit) { + return
Loading...
; + } + + return ( + +
+
+ ({ + value: state.toLowerCase().replace(/\s+/g, '-'), + label: state, + }))} + /> + + + + + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+
+ ); +}; + +const handleCountryStateSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(countryStateSchema)); + + if (errors) { + return { errors }; + } + + return { message: `Location saved: ${data.city}, ${data.state}, ${data.country}` }; +}; + +export const CascadingDropdowns: Story = { + play: async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Select a country + const countryTrigger = canvas.getByRole('combobox', { name: /country/i }); + await userEvent.click(countryTrigger); + + // Wait for dropdown to open and select USA + const usaOption = await canvas.findByRole('option', { name: /united states/i }); + await userEvent.click(usaOption); + + // Verify state dropdown is now enabled + const stateTrigger = canvas.getByRole('combobox', { name: /state/i }); + expect(stateTrigger).not.toBeDisabled(); + + // Select a state + await userEvent.click(stateTrigger); + const californiaOption = await canvas.findByRole('option', { name: /california/i }); + await userEvent.click(californiaOption); + + // Enter city + const cityInput = canvas.getByLabelText(/city/i); + await userEvent.type(cityInput, 'San Francisco'); + + // Submit form + const submitButton = canvas.getByRole('button', { name: /submit location/i }); + await userEvent.click(submitButton); + + // Verify success message + const successMessage = await canvas.findByText(/location saved/i); + expect(successMessage).toBeInTheDocument(); + }, + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: CascadingDropdownExample, + action: async ({ request }: ActionFunctionArgs) => handleCountryStateSubmission(request), + }, + ], + }), + ], +}; + +// ============================================================================ +// Story 2: Auto-calculation with Discount +// ============================================================================ +const orderSchema = z.object({ + quantity: z.string().min(1, 'Quantity is required'), + pricePerUnit: z.string().min(1, 'Price per unit is required'), + discount: z.string(), + total: z.string(), +}); + +type OrderFormData = z.infer; + +const AutoCalculationExample = () => { + const fetcher = useFetcher<{ message: string }>(); + + const methods = useRemixForm({ + resolver: zodResolver(orderSchema), + defaultValues: { + quantity: '1', + pricePerUnit: '100', + discount: '0', + total: '100.00', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + const calculateTotal = () => { + const quantity = Number.parseFloat(methods.getValues('quantity') || '0'); + const pricePerUnit = Number.parseFloat(methods.getValues('pricePerUnit') || '0'); + const discount = Number.parseFloat(methods.getValues('discount') || '0'); + + const subtotal = quantity * pricePerUnit; + const total = subtotal - subtotal * (discount / 100); + methods.setValue('total', total.toFixed(2)); + }; + + // Recalculate when quantity changes + useOnFormValueChange({ + name: 'quantity', + onChange: calculateTotal, + }); + + // Recalculate when price changes + useOnFormValueChange({ + name: 'pricePerUnit', + onChange: calculateTotal, + }); + + // Recalculate when discount changes + useOnFormValueChange({ + name: 'discount', + onChange: calculateTotal, + }); + + // Don't render if methods is not ready + if (!methods || !methods.handleSubmit) { + return
Loading...
; + } + + return ( + +
+
+ + + + + + + + + + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+
+ ); +}; + +const handleOrderSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(orderSchema)); + + if (errors) { + return { errors }; + } + + return { message: `Order placed! Total: $${data.total}` }; +}; + +export const AutoCalculation: Story = { + play: async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Initial total should be calculated + const totalInput = canvas.getByLabelText(/^total$/i); + expect(totalInput).toHaveValue('100.00'); + + // Change quantity + const quantityInput = canvas.getByLabelText(/quantity/i); + await userEvent.clear(quantityInput); + await userEvent.type(quantityInput, '2'); + + // Total should update to 200.00 + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(totalInput).toHaveValue('200.00'); + + // Add discount + const discountInput = canvas.getByLabelText(/discount/i); + await userEvent.clear(discountInput); + await userEvent.type(discountInput, '10'); + + // Total should update to 180.00 (200 - 10%) + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(totalInput).toHaveValue('180.00'); + + // Submit form + const submitButton = canvas.getByRole('button', { name: /submit order/i }); + await userEvent.click(submitButton); + + // Verify success message + const successMessage = await canvas.findByText(/order placed/i); + expect(successMessage).toBeInTheDocument(); + }, + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: AutoCalculationExample, + action: async ({ request }: ActionFunctionArgs) => handleOrderSubmission(request), + }, + ], + }), + ], +}; + +// ============================================================================ +// Story 3: Conditional Field Visibility +// ============================================================================ +const shippingSchema = z.object({ + deliveryType: z.string().min(1, 'Delivery type is required'), + shippingAddress: z.string().optional(), + storeLocation: z.string().optional(), +}); + +type ShippingFormData = z.infer; + +const ConditionalFieldsExample = () => { + const fetcher = useFetcher<{ message: string }>(); + const [showShipping, setShowShipping] = useState(false); + const [showPickup, setShowPickup] = useState(false); + + const methods = useRemixForm({ + resolver: zodResolver(shippingSchema), + defaultValues: { + deliveryType: '', + shippingAddress: '', + storeLocation: '', + }, + fetcher, + submitConfig: { + action: '/', + method: 'post', + }, + }); + + // Show/hide fields based on delivery type + useOnFormValueChange({ + name: 'deliveryType', + onChange: (value) => { + setShowShipping(value === 'delivery'); + setShowPickup(value === 'pickup'); + + // Clear the other field when switching + if (value === 'delivery') { + methods.setValue('storeLocation', ''); + } else if (value === 'pickup') { + methods.setValue('shippingAddress', ''); + } + }, + }); + + // Don't render if methods is not ready + if (!methods || !methods.handleSubmit) { + return
Loading...
; + } + + return ( + +
+
+ + )} + + + {fetcher.data?.message &&

{fetcher.data.message}

} +
+
+
+ ); +}; + +const handleShippingSubmission = async (request: Request) => { + const { data, errors } = await getValidatedFormData(request, zodResolver(shippingSchema)); + + if (errors) { + return { errors }; + } + + const method = data.deliveryType === 'delivery' ? 'delivery' : 'pickup'; + return { message: `Order confirmed for ${method}!` }; +}; + +export const ConditionalFields: Story = { + play: async ({ canvasElement }: StoryContext) => { + const canvas = within(canvasElement); + + // Select delivery + const deliveryTypeTrigger = canvas.getByRole('combobox', { name: /delivery type/i }); + await userEvent.click(deliveryTypeTrigger); + + const deliveryOption = await canvas.findByRole('option', { name: /home delivery/i }); + await userEvent.click(deliveryOption); + + // Shipping address field should appear + const shippingInput = await canvas.findByLabelText(/shipping address/i); + expect(shippingInput).toBeInTheDocument(); + await userEvent.type(shippingInput, '123 Main St'); + + // Switch to pickup + await userEvent.click(deliveryTypeTrigger); + const pickupOption = await canvas.findByRole('option', { name: /store pickup/i }); + await userEvent.click(pickupOption); + + // Store location should appear, shipping address should be gone + const storeSelect = await canvas.findByRole('combobox', { name: /store location/i }); + expect(storeSelect).toBeInTheDocument(); + + // Select a store + await userEvent.click(storeSelect); + const mallOption = await canvas.findByRole('option', { name: /shopping mall/i }); + await userEvent.click(mallOption); + + // Submit form + const submitButton = canvas.getByRole('button', { name: /complete order/i }); + await userEvent.click(submitButton); + + // Verify success message + const successMessage = await canvas.findByText(/order confirmed/i); + expect(successMessage).toBeInTheDocument(); + }, + decorators: [ + withReactRouterStubDecorator({ + routes: [ + { + path: '/', + Component: ConditionalFieldsExample, + action: async ({ request }: ActionFunctionArgs) => handleShippingSubmission(request), + }, + ], + }), + ], +}; diff --git a/packages/components/src/remix-hook-form/hooks/index.ts b/packages/components/src/remix-hook-form/hooks/index.ts index 7fb50245..2bfaded3 100644 --- a/packages/components/src/remix-hook-form/hooks/index.ts +++ b/packages/components/src/remix-hook-form/hooks/index.ts @@ -1 +1,2 @@ +export * from './use-on-form-value-change'; export * from './useScrollToErrorOnSubmit'; diff --git a/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts b/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts new file mode 100644 index 00000000..cab75166 --- /dev/null +++ b/packages/components/src/remix-hook-form/hooks/use-on-form-value-change.ts @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; +import type { FieldPath, FieldValues, PathValue } from 'react-hook-form'; +import type { UseRemixFormReturn } from 'remix-hook-form'; +import { useRemixFormContext } from 'remix-hook-form'; + +export interface UseOnFormValueChangeOptions< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> { + /** + * The name of the form field to watch + */ + name: TName; + /** + * Callback function that runs when the field value changes + * @param value - The new value of the watched field + * @param prevValue - The previous value of the watched field + */ + onChange: (value: PathValue, prevValue: PathValue) => void; + /** + * Optional form methods if not using RemixFormProvider context + */ + methods?: UseRemixFormReturn; + /** + * Whether the hook is enabled (default: true) + */ + enabled?: boolean; +} + +/** + * A hook that watches a specific form field and executes a callback when its value changes. + * This is useful for creating reactive form behaviors where one field's value affects another field. + * + * @example + * ```tsx + * // Make a discount field appear when order total exceeds $100 + * useOnFormValueChange({ + * name: 'orderTotal', + * onChange: (value) => { + * if (value > 100) { + * methods.setValue('discountCode', ''); + * } + * } + * }); + * ``` + * + * @example + * ```tsx + * // Update a full name field when first or last name changes + * useOnFormValueChange({ + * name: 'firstName', + * onChange: (value) => { + * const lastName = methods.getValues('lastName'); + * methods.setValue('fullName', `${value} ${lastName}`); + * } + * }); + * ``` + */ +export const useOnFormValueChange = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>( + options: UseOnFormValueChangeOptions, +) => { + const { name, onChange, methods: providedMethods, enabled = true } = options; + + // Use provided methods or fall back to context + const contextMethods = useRemixFormContext(); + const formMethods = providedMethods || contextMethods; + + useEffect(() => { + // Early return if no form methods are available or hook is disabled + if (!enabled || !formMethods || !formMethods.watch || !formMethods.getValues) return; + + const { watch, getValues } = formMethods; + + // Subscribe to the field value changes + const subscription = watch((value, { name: changedFieldName }) => { + // Only trigger onChange if the watched field changed + if (changedFieldName === name) { + const currentValue = value[name] as PathValue; + // Get previous value from the form state + const prevValue = getValues(name); + + onChange(currentValue, prevValue); + } + }); + + // Cleanup subscription on unmount + return () => subscription.unsubscribe(); + }, [name, onChange, enabled, formMethods]); +}; diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts index 7b095538..7f86fbf6 100644 --- a/packages/components/src/remix-hook-form/index.ts +++ b/packages/components/src/remix-hook-form/index.ts @@ -10,6 +10,7 @@ export * from './data-table-router-toolbar'; export * from './date-picker'; export * from './form'; export * from './form-error'; +export * from './hooks/use-on-form-value-change'; export * from './hooks/useScrollToErrorOnSubmit'; export * from './otp-input'; export * from './password-field';