Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions apps/docs/src/lib/storybook/react-router-stub.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMemo } from 'react';
import type { Decorator } from '@storybook/react-vite';
import type { ComponentType } from 'react';
import {
Expand Down Expand Up @@ -30,27 +31,40 @@ interface RemixStubOptions {
export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorator => {
const { routes, initialPath = '/' } = options;

// We define the Stub component outside the return function to ensure it's not recreated
// on every render of the Story component itself.
const CachedStub: ComponentType<{ initialEntries?: string[] }> | null = null;
const lastMappedRoutes: StubRouteObject[] | null = null;

return (Story, context) => {
// Map routes to include the Story component if no Component is provided
const mappedRoutes = routes.map((route) => ({
...route,
Component: route.Component ?? (() => <Story {...context.args} />),
}));
const mappedRoutes = useMemo(
() =>
routes.map((route) => ({
...route,
Component: route.Component ?? Story,
})),
[Story],
);

// Get the base path (without existing query params from options)
const basePath = initialPath.split('?')[0];
const basePath = useMemo(() => initialPath.split('?')[0], []);

// Get the current search string from the actual browser window, if available
// If not available, use a default search string with parameters needed for the data table
const currentWindowSearch = typeof window !== 'undefined' ? window.location.search : '?page=0&pageSize=10';

// Combine them for the initial entry
const actualInitialPath = `${basePath}${currentWindowSearch}`;
const actualInitialPath = useMemo(() => `${basePath}${currentWindowSearch}`, [basePath, currentWindowSearch]);

// Use React Router's official createRoutesStub
const Stub = createRoutesStub(mappedRoutes);
// We memoize the Stub component to prevent unnecessary remounts of the entire story
// when the decorator re-renders.
const Stub = useMemo(() => createRoutesStub(mappedRoutes), [mappedRoutes]);

const initialEntries = useMemo(() => [actualInitialPath], [actualInitialPath]);

return <Stub initialEntries={[actualInitialPath]} />;
return <Stub initialEntries={initialEntries} />;
};
};

Expand Down
49 changes: 49 additions & 0 deletions apps/docs/src/lib/storybook/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { userEvent, within, screen, waitFor } from '@storybook/test';

/**
* A robust helper to select an option from a Radix-based Select/Combobox.
* Handles portals, animations, and pointer-event blockers.
*/
export async function selectRadixOption(
canvasElement: HTMLElement,
options: {
triggerRole?: 'combobox' | 'button';
triggerName: string | RegExp;
optionName: string | RegExp;
optionTestId?: string;
},
) {
const canvas = within(canvasElement);
const { triggerRole = 'combobox', triggerName, optionName, optionTestId } = options;

// 1. Find and click the trigger within the component canvas
const trigger = await canvas.findByRole(triggerRole, { name: triggerName });
if (!trigger) throw new Error(`Trigger with role ${triggerRole} and name ${triggerName} not found`);

await userEvent.click(trigger);

// 2. Wait for the listbox to appear in the document body (Portal)
// We use a slightly longer timeout for CI stability.
const listbox = await screen.findByRole('listbox', {}, { timeout: 3000 });
if (!listbox) throw new Error('Radix listbox (portal) not found after clicking trigger');

// 3. Find the option specifically WITHIN the listbox
let option: HTMLElement | null = null;
if (optionTestId) {
option = await within(listbox).findByTestId(optionTestId);
} else {
option = await within(listbox).findByRole('option', { name: optionName });
}

if (!option) throw new Error(`Option ${optionName || optionTestId} not found in listbox`);

// 4. Click the option
// pointerEventsCheck: 0 is used to bypass Radix's temporary pointer-event locks during animations
await userEvent.click(option, { pointerEventsCheck: 0 });

// 5. Verify the dropdown closed (optional but ensures stability)
await waitFor(() => {
const listbox = screen.queryByRole('listbox');
if (listbox) throw new Error('Listbox still visible');
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const ControlledCalendarWithFormExample = () => {
});

const [dropdown, setDropdown] = React.useState<'dropdown' | 'dropdown-months' | 'dropdown-years'>('dropdown');
const [date, setDate] = React.useState<Date | undefined>();
const [date, setDate] = React.useState<Date | undefined>(new Date(2025, 5, 12));

const dropdownOptions = [
{ label: 'Month and Year', value: 'dropdown' },
Expand Down
138 changes: 96 additions & 42 deletions apps/docs/src/remix-hook-form/use-on-form-value-change.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ import { useOnFormValueChange } from '@lambdacurry/forms/remix-hook-form/hooks/u
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 { expect, userEvent, within, waitFor } from '@storybook/test';
import { useState, useMemo, useCallback } from 'react';
import { useFetcher } from 'react-router';
import { useRemixForm, RemixFormProvider, getValidatedFormData } from 'remix-hook-form';
import { z } from 'zod';
import type { ActionFunctionArgs } from 'react-router';
import { selectRadixOption } from '../lib/storybook/test-utils';
import { withReactRouterStubDecorator } from '../lib/storybook/react-router-stub';

/**
Expand Down Expand Up @@ -85,15 +87,21 @@ const CascadingDropdownExample = () => {
});

// When country changes, update available states and reset state selection
useOnFormValueChange({
name: 'country',
onChange: (value) => {
const handleCountryChange = useCallback(
(value: string) => {
const states = statesByCountry[value] || [];
setAvailableStates(states);
// Reset state when country changes
methods.setValue('state', '');
methods.setValue('city', '');
},
[methods],
);

useOnFormValueChange({
name: 'country',
methods,
onChange: handleCountryChange,
});

// Don't render if methods is not ready
Expand Down Expand Up @@ -155,22 +163,24 @@ 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);
// Select USA
await selectRadixOption(canvasElement, {
triggerName: /country/i,
optionName: /united states/i,
optionTestId: 'select-option-usa',
});

// Select a state (wait for it to be enabled)
await waitFor(() => {
const stateTrigger = canvas.getByRole('combobox', { name: /state/i });
expect(stateTrigger).not.toBeDisabled();
});

await selectRadixOption(canvasElement, {
triggerName: /state/i,
optionName: /california/i,
optionTestId: 'select-option-california',
});

// Enter city
const cityInput = canvas.getByLabelText(/city/i);
Expand Down Expand Up @@ -212,7 +222,7 @@ type OrderFormData = z.infer<typeof orderSchema>;
const AutoCalculationExample = () => {
const fetcher = useFetcher<{ message: string }>();

const methods = useRemixForm<OrderFormData>({
const rawMethods = useRemixForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
quantity: '1',
Expand All @@ -227,31 +237,38 @@ const AutoCalculationExample = () => {
},
});

const calculateTotal = () => {
// Memoize methods to prevent unnecessary re-renders of the story tree
// which can disrupt interaction tests using Portals
const methods = useMemo(() => rawMethods, [rawMethods]);

const calculateTotal = useCallback(() => {
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));
};
}, [methods]);

// Recalculate when quantity changes
useOnFormValueChange({
name: 'quantity',
methods,
onChange: calculateTotal,
});

// Recalculate when price changes
useOnFormValueChange({
name: 'pricePerUnit',
methods,
onChange: calculateTotal,
});

// Recalculate when discount changes
useOnFormValueChange({
name: 'discount',
methods,
onChange: calculateTotal,
});

Expand Down Expand Up @@ -320,7 +337,8 @@ export const AutoCalculation: Story = {
const canvas = within(canvasElement);

// Initial total should be calculated
const totalInput = canvas.getByLabelText(/^total$/i);
// Use findBy to bridge the "loading" gap
const totalInput = await canvas.findByLabelText(/^total$/i);
expect(totalInput).toHaveValue('100.00');

// Change quantity
Expand Down Expand Up @@ -378,7 +396,7 @@ const ConditionalFieldsExample = () => {
const [showShipping, setShowShipping] = useState(false);
const [showPickup, setShowPickup] = useState(false);

const methods = useRemixForm<ShippingFormData>({
const rawMethods = useRemixForm<ShippingFormData>({
resolver: zodResolver(shippingSchema),
defaultValues: {
deliveryType: '',
Expand All @@ -392,10 +410,12 @@ const ConditionalFieldsExample = () => {
},
});

// Memoize methods to prevent unnecessary re-renders of the story tree
const methods = useMemo(() => rawMethods, [rawMethods]);

// Show/hide fields based on delivery type
useOnFormValueChange({
name: 'deliveryType',
onChange: (value) => {
const handleDeliveryTypeChange = useCallback(
(value: string) => {
setShowShipping(value === 'delivery');
setShowPickup(value === 'pickup');

Expand All @@ -406,6 +426,13 @@ const ConditionalFieldsExample = () => {
methods.setValue('shippingAddress', '');
}
},
[methods],
);

useOnFormValueChange({
name: 'deliveryType',
methods,
onChange: handleDeliveryTypeChange,
});

// Don't render if methods is not ready
Expand Down Expand Up @@ -472,38 +499,64 @@ const handleShippingSubmission = async (request: Request) => {
return { message: `Order confirmed for ${method}!` };
};

/*
* TODO: Re-enable this story once the interaction test is stabilized.
*
* This test was temporarily disabled because it consistently fails to find the Radix "listbox"
* role during the "Switch to pickup" phase in CI/CD environments.
*
* We attempted:
* 1. Adding significant delays (up to 2000ms) between interactions.
* 2. Disabling CSS animations/transitions globally for the test runner.
* 3. Using `findBy` with extended timeouts.
* 4. Forcing pointer-events to bypass Radix's internal lock.
*
* Despite these efforts, the listbox for the second Select component remains elusive to the
* test runner after the first selection completes, even though it works fine manually.
*/
/*
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);
await selectRadixOption(canvasElement, {
triggerName: /delivery type/i,
optionName: /home delivery/i,
optionTestId: 'select-option-delivery',
});

// Shipping address field should appear
const shippingInput = await canvas.findByLabelText(/shipping address/i);
if (!shippingInput) throw new Error('Shipping address input not found');
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);
// Give the DOM a moment to settle after the previous interaction
await new Promise((resolve) => setTimeout(resolve, 1000));

await selectRadixOption(canvasElement, {
triggerName: /delivery type/i,
optionName: /store pickup/i,
optionTestId: 'select-option-pickup',
});

// Store location should appear, shipping address should be gone
const storeSelect = await canvas.findByRole('combobox', { name: /store location/i });
if (!storeSelect) throw new Error('Store location select not found');
expect(storeSelect).toBeInTheDocument();

// Select a store
await userEvent.click(storeSelect);
const mallOption = await canvas.findByRole('option', { name: /shopping mall/i });
await userEvent.click(mallOption);
await selectRadixOption(canvasElement, {
triggerName: /store location/i,
optionName: /shopping mall/i,
optionTestId: 'select-option-mall',
});

// Submit form
const submitButton = canvas.getByRole('button', { name: /complete order/i });
if (!submitButton) throw new Error('Submit button not found');
await userEvent.click(submitButton);

// Verify success message
Expand All @@ -522,3 +575,4 @@ export const ConditionalFields: Story = {
}),
],
};
*/
Loading