diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 01c6946a..7bc9ce25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,17 +34,6 @@ jobs: - name: Install Playwright Chromium run: npx playwright install chromium - - name: Build Storybook - run: yarn build-storybook - - - name: Start Storybook server - run: yarn workspace @lambdacurry/forms-docs storybook --ci --port 6006 & - env: - NODE_OPTIONS: --max-old-space-size=4096 - - - name: Wait for Storybook to be ready - run: npx wait-on http://localhost:6006 - - name: Run tests run: yarn test diff --git a/.vscode/settings.json b/.vscode/settings.json index d07ff312..f8b9823a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "hookform", "isbot", "lucide", + "Nuqs", "shadcn", "sonner" ], @@ -20,5 +21,6 @@ "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" - } + }, + "tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"] } diff --git a/apps/docs/package.json b/apps/docs/package.json index 5bd6c758..2376e1b1 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -6,7 +6,9 @@ "build": "storybook build", "build-storybook": "storybook build", "storybook": "storybook dev -p 6006", - "test": "test-storybook" + "serve": "http-server ./storybook-static -p 6006 -s", + "test": "start-server-and-test serve http://127.0.0.1:6006 'test-storybook --url http://127.0.0.1:6006'", + "test:local": "test-storybook" }, "dependencies": { "@lambdacurry/forms": "*", @@ -28,12 +30,15 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.4.20", + "http-server": "^14.1.1", "react": "^19.0.0", "react-router": "^7.0.0", "react-router-dom": "^7.0.0", + "start-server-and-test": "^2.0.11", "tailwindcss": "^4.0.0", "typescript": "^5.7.2", "vite": "^6.2.2", - "vite-tsconfig-paths": "^5.1.4" + "vite-tsconfig-paths": "^5.1.4", + "wait-on": "^8.0.3" } } diff --git a/apps/docs/src/examples/middleware-example.tsx b/apps/docs/src/examples/middleware-example.tsx new file mode 100644 index 00000000..10d968bc --- /dev/null +++ b/apps/docs/src/examples/middleware-example.tsx @@ -0,0 +1,79 @@ +// Example of using the new middleware feature in remix-hook-form v7.0.0 +import { Form } from 'react-router'; +import { RemixFormProvider, useRemixForm } from 'remix-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as zod from 'zod'; +import { TextField } from '@lambdacurry/forms/remix-hook-form'; +import { getValidatedFormData } from 'remix-hook-form/middleware'; +import type { ActionFunctionArgs } from 'react-router'; + +// Define schema and types +const schema = zod.object({ + name: zod.string().min(1, "Name is required"), + email: zod.string().email("Invalid email format").min(1, "Email is required"), +}); + +type FormData = zod.infer; +const resolver = zodResolver(schema); + +// Action function using the new middleware +export const action = async ({ context }: ActionFunctionArgs) => { + // Use the middleware to extract and validate form data + const { errors, data, receivedValues } = await getValidatedFormData( + context, + resolver + ); + + if (errors) { + return { errors, defaultValues: receivedValues }; + } + + // Process the validated data + console.log('Processing data:', data); + + return { success: true, data }; +}; + +// Component +export default function MiddlewareExample() { + const { + handleSubmit, + formState: { errors }, + register, + } = useRemixForm({ + mode: "onSubmit", + resolver, + }); + + return ( +
+

Remix Hook Form v7 Middleware Example

+ + +
+
+ + + + + +
+
+
+
+ ); +} diff --git a/apps/docs/src/examples/root-example.tsx b/apps/docs/src/examples/root-example.tsx new file mode 100644 index 00000000..586a1a50 --- /dev/null +++ b/apps/docs/src/examples/root-example.tsx @@ -0,0 +1,23 @@ +// Example of setting up the middleware in root.tsx +import { unstable_extractFormDataMiddleware } from 'remix-hook-form/middleware'; +import { Outlet } from 'react-router-dom'; + +// Export the middleware for React Router 7 +export const unstable_middleware = [unstable_extractFormDataMiddleware()]; + +export default function Root() { + return ( + + + + + Remix Hook Form v7 Example + + +
+ +
+ + + ); +} diff --git a/apps/docs/src/lib/storybook/react-router-stub.tsx b/apps/docs/src/lib/storybook/react-router-stub.tsx index 6421d7ab..901b3f02 100644 --- a/apps/docs/src/lib/storybook/react-router-stub.tsx +++ b/apps/docs/src/lib/storybook/react-router-stub.tsx @@ -7,7 +7,8 @@ import { type LoaderFunction, type MetaFunction, type NonIndexRouteObject, - createRoutesStub, + RouterProvider, + createMemoryRouter, } from 'react-router-dom'; export type StubRouteObject = StubIndexRouteObject | StubNonIndexRouteObject; @@ -36,21 +37,49 @@ interface StubIndexRouteObject interface RemixStubOptions { routes: StubRouteObject[]; + initialPath?: string; } export const withReactRouterStubDecorator = (options: RemixStubOptions): Decorator => { - const { routes } = options; - return (Story) => { - // Map routes to include Story component as fallback if no Component provided + const { routes, initialPath = '/' } = options; + // This outer function runs once when Storybook loads the story meta + + return (Story, context) => { + // This inner function runs when the story component actually renders const mappedRoutes = routes.map((route) => ({ ...route, - Component: route.Component ?? (() => ), + Component: route.Component ?? (() => ), })); - // Use more specific type assertion to fix the incompatibility - // @ts-ignore - Types from createRoutesStub are incompatible but the code works at runtime - const RemixStub = createRoutesStub(mappedRoutes); + // Get the base path (without existing query params from options) + const basePath = 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}`; + + // Create a memory router, initializing it with the path derived from the window's search params + // biome-ignore lint/suspicious/noExplicitAny: + const router = createMemoryRouter(mappedRoutes as any, { + initialEntries: [actualInitialPath], // Use the path combined with window.location.search + }); - return ; + return ; }; }; + +/** + * A decorator that provides URL state management for stories + * Use this when you need URL query parameters in your stories + */ +export const withURLState = (initialPath = '/'): Decorator => { + return withReactRouterStubDecorator({ + routes: [{ path: '/' }], + initialPath, + }); +}; diff --git a/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx index 94506165..08da48f5 100644 --- a/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx +++ b/apps/docs/src/remix-hook-form/checkbox-custom.stories.tsx @@ -5,7 +5,7 @@ import { Button } from '@lambdacurry/forms/ui/button'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; -import * as React from 'react'; +import type * as React from 'react'; import type { ActionFunctionArgs } from 'react-router'; import { useFetcher } from 'react-router'; import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form'; @@ -21,62 +21,44 @@ const formSchema = z.object({ type FormData = z.infer; // Custom checkbox component -const PurpleCheckbox = React.forwardRef< - HTMLButtonElement, - React.ComponentPropsWithoutRef ->((props, ref) => ( +const PurpleCheckbox = (props: React.ComponentPropsWithoutRef) => ( {props.children} -)); +); PurpleCheckbox.displayName = 'PurpleCheckbox'; // Custom indicator -const PurpleIndicator = React.forwardRef< - HTMLDivElement, - React.ComponentPropsWithoutRef ->((props, ref) => ( - +const PurpleIndicator = (props: React.ComponentPropsWithoutRef) => ( + -)); +); PurpleIndicator.displayName = 'PurpleIndicator'; // Custom form label component -const CustomLabel = React.forwardRef>( - ({ className, htmlFor, ...props }, ref) => ( -