diff --git a/.cursor/rules/storybook-testing.mdc b/.cursor/rules/storybook-testing.mdc index f7a3a6a8..37f0caa4 100644 --- a/.cursor/rules/storybook-testing.mdc +++ b/.cursor/rules/storybook-testing.mdc @@ -17,8 +17,6 @@ This is a monorepo containing form components with comprehensive Storybook inter - Yarn 4.7.0 with corepack - TypeScript throughout -<<<<<<< HEAD -======= ## Project Structure ``` lambda-curry/forms/ @@ -446,7 +444,6 @@ const testConditionalFields = async ({ canvas }: StoryContext) => { - **Focused Testing**: Each story should test one primary workflow - **Efficient Selectors**: Use semantic queries (role, label) over CSS selectors ->>>>>>> cd5d1a2 (Enhance Storybook testing rules and examples) ### Local Development Workflow ```bash # Local development commands @@ -957,48 +954,28 @@ yarn dev # Then navigate to story and use Interactions panel ## Verification Checklist When creating or modifying Storybook interaction tests, ensure: -<<<<<<< HEAD -1. ✅ Story includes comprehensive play function with user interactions -2. ✅ Uses semantic queries (ByRole, ByLabelText) over CSS selectors -======= 1. ✅ Story includes all three test phases (default, invalid, valid) 2. ✅ Uses React Router stub decorator on individual stories (not meta) ->>>>>>> cd5d1a2 (Enhance Storybook testing rules and examples) 3. ✅ Follows click-before-clear pattern for inputs 4. ✅ Uses findBy* for async assertions 5. ✅ Tests both client-side and server-side validation 6. ✅ Includes proper error handling and success scenarios -<<<<<<< HEAD -7. ✅ Uses step function for complex workflows -8. ✅ Story serves as both documentation and test -9. ✅ Component is properly isolated and focused -10. ✅ Tests complete in reasonable time (< 10 seconds) -11. ✅ Uses React Router stub decorator for form handling -12. ✅ Includes accessibility considerations in queries -======= 7. ✅ Story serves as both documentation and test 8. ✅ Component is properly isolated and focused 9. ✅ Tests complete in reasonable time (< 10 seconds) 10. ✅ Uses semantic queries for better maintainability 11. ✅ Decorators are placed on individual stories for granular control 12. ✅ Meta configuration is kept clean and minimal ->>>>>>> cd5d1a2 (Enhance Storybook testing rules and examples) ## Team Workflow Integration ### Code Review Guidelines - Verify interaction tests cover happy path and error scenarios - Ensure stories are self-documenting and demonstrate component usage -<<<<<<< HEAD -- Check that tests follow semantic query patterns -- Validate that play functions are well-organized with step grouping -- Confirm tests don't introduce flaky behavior -======= - Check that tests follow established patterns and conventions - Validate that new tests don't introduce flaky behavior - **Verify decorators are on individual stories, not in meta** - Ensure each story has appropriate isolation and dependencies ->>>>>>> cd5d1a2 (Enhance Storybook testing rules and examples) ### Local Development Focus - Use Storybook UI for interactive development and debugging @@ -1008,8 +985,4 @@ When creating or modifying Storybook interaction tests, ensure: - Fast feedback loop optimized for developer productivity - Individual story decorators provide flexibility for different testing scenarios -<<<<<<< HEAD -Remember: Every story with a play function is both a test and living documentation. Focus on user behavior and accessibility. Use the step function to organize complex interactions. The Interactions panel in Storybook UI is your primary debugging tool for interaction tests. -======= Remember: Every story should test real user workflows and serve as living documentation. Focus on behavior, not implementation details. The testing infrastructure should be reliable, fast, and easy to maintain for local development and Codegen workflows. **Always place decorators on individual stories for maximum flexibility and clarity.** ->>>>>>> cd5d1a2 (Enhance Storybook testing rules and examples) diff --git a/apps/docs/src/remix-hook-form/textarea.stories.tsx b/apps/docs/src/remix-hook-form/textarea.stories.tsx index 19864f7f..f3105279 100644 --- a/apps/docs/src/remix-hook-form/textarea.stories.tsx +++ b/apps/docs/src/remix-hook-form/textarea.stories.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Textarea } from '@lambdacurry/forms/remix-hook-form/textarea'; import { Button } from '@lambdacurry/forms/ui/button'; -import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from '@storybook/test'; import { type ActionFunctionArgs, useFetcher } from 'react-router'; import { RemixFormProvider, createFormData, getValidatedFormData, useRemixForm } from 'remix-hook-form'; @@ -30,7 +30,7 @@ const ControlledTextareaExample = () => { onValid: (data) => { fetcher.submit( createFormData({ - submittedMessage: data.message, + message: data.message, }), { method: 'post', @@ -49,6 +49,7 @@ const ControlledTextareaExample = () => { + {fetcher.data?.message &&

{fetcher.data.message}

} {fetcher.data?.submittedMessage && (

Submitted message:

@@ -92,6 +93,36 @@ const meta: Meta = { export default meta; type Story = StoryObj; +// Test scenarios +const testInvalidSubmission = async ({ canvas }: StoryContext) => { + const messageInput = canvas.getByLabelText('Your message'); + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + + // Clear the textarea and enter text that's too short + await userEvent.click(messageInput); + await userEvent.clear(messageInput); + await userEvent.type(messageInput, 'Short'); + await userEvent.click(submitButton); + + // Check for validation error + await expect(await canvas.findByText('Message must be at least 10 characters')).toBeInTheDocument(); +}; + +const testValidSubmission = async ({ canvas }: StoryContext) => { + const messageInput = canvas.getByLabelText('Your message'); + const submitButton = canvas.getByRole('button', { name: 'Submit' }); + + // Clear and enter valid text + await userEvent.click(messageInput); + await userEvent.clear(messageInput); + await userEvent.type(messageInput, 'This is a test message that is longer than 10 characters.'); + await userEvent.click(submitButton); + + // Check for success message + const successMessage = await canvas.findByText('Message submitted successfully'); + expect(successMessage).toBeInTheDocument(); +}; + export const Default: Story = { parameters: { docs: { @@ -100,20 +131,8 @@ export const Default: Story = { }, }, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - // Enter text - const messageInput = canvas.getByLabelText('Your message'); - await userEvent.type(messageInput, 'This is a test message that is longer than 10 characters.'); - - // Submit the form - const submitButton = canvas.getByRole('button', { name: 'Submit' }); - await userEvent.click(submitButton); - - // Check if the submitted message is displayed - await expect( - await canvas.findByText('This is a test message that is longer than 10 characters.'), - ).toBeInTheDocument(); + play: async (storyContext) => { + await testInvalidSubmission(storyContext); + await testValidSubmission(storyContext); }, }; diff --git a/packages/components/package.json b/packages/components/package.json index 3536738d..12f634ed 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,33 +1,21 @@ { "name": "@lambdacurry/forms", - "version": "0.17.2", + "version": "0.17.3", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } + "types": "./dist/index.d.ts", + "import": "./dist/index.js" }, "./remix-hook-form": { - "import": { - "types": "./dist/remix-hook-form/index.d.ts", - "default": "./dist/remix-hook-form/index.js" - } + "types": "./dist/remix-hook-form/index.d.ts", + "import": "./dist/remix-hook-form/index.js" }, "./ui": { - "import": { - "types": "./dist/ui/index.d.ts", - "default": "./dist/ui/index.js" - } - }, - "./data-table": { - "import": { - "types": "./dist/data-table/index.d.ts", - "default": "./dist/data-table/index.js" - } + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" } }, "files": [ diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 005ba663..cdadc33c 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -1,6 +1,8 @@ -// Main entry point for @lambdacurry/forms -// Export UI components first -export * from './ui'; +// Main exports from both remix-hook-form and ui directories -// Export remix-hook-form components (some may override UI components intentionally) +// Export all components from remix-hook-form export * from './remix-hook-form'; + +// Explicitly export Textarea from both locations to handle naming conflicts +// The remix-hook-form Textarea is a form-aware wrapper +export { Textarea as TextareaField } from './remix-hook-form/textarea'; diff --git a/packages/components/src/remix-hook-form/text-field.tsx b/packages/components/src/remix-hook-form/text-field.tsx index 6ba79345..514abb1e 100644 --- a/packages/components/src/remix-hook-form/text-field.tsx +++ b/packages/components/src/remix-hook-form/text-field.tsx @@ -25,4 +25,4 @@ export const TextField = function RemixTextField(props: TextFieldProps & { ref?: return ; }; -TextField.displayName = 'RemixTextField'; +TextField.displayName = 'TextField'; diff --git a/packages/components/src/ui/button.tsx b/packages/components/src/ui/button.tsx index e636c664..993bd0d5 100644 --- a/packages/components/src/ui/button.tsx +++ b/packages/components/src/ui/button.tsx @@ -1,5 +1,6 @@ import { Slot } from '@radix-ui/react-slot'; import { type VariantProps, cva } from 'class-variance-authority'; +import * as React from 'react'; import type { ButtonHTMLAttributes } from 'react'; import { cn } from './utils'; @@ -33,11 +34,19 @@ const buttonVariants = cva( export interface ButtonProps extends ButtonHTMLAttributes, VariantProps { asChild?: boolean; + ref?: React.Ref; } -export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) { +export function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) { const Comp = asChild ? Slot : 'button'; - return ; + return ( + + ); } Button.displayName = 'Button'; diff --git a/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx b/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx index 5bb69599..6159044e 100644 --- a/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx +++ b/packages/components/src/ui/data-table-filter/hooks/use-debounce-callback.tsx @@ -14,17 +14,17 @@ type ControlFunctions = { isPending: () => boolean; }; -export type DebouncedState ReturnType> = (( +export type DebouncedState any> = (( ...args: Parameters ) => ReturnType | undefined) & ControlFunctions; -export function useDebounceCallback ReturnType>( +export function useDebounceCallback any>( func: T, delay = 500, options?: DebounceOptions, ): DebouncedState { - const debouncedFunc = useRef>(null); + const debouncedFunc = useRef<(((...args: Parameters) => ReturnType | undefined) & ControlFunctions) | null>(null); useUnmount(() => { if (debouncedFunc.current) { @@ -56,8 +56,8 @@ export function useDebounceCallback ReturnType>( // Update the debounced function ref whenever func, wait, or options change useEffect(() => { - debouncedFunc.current = debounce(func, delay, options); - }, [func, delay, options]); + debouncedFunc.current = debounced; + }, [debounced]); return debounced; } diff --git a/packages/components/src/ui/data-table-filter/lib/debounce.ts b/packages/components/src/ui/data-table-filter/lib/debounce.ts index a373d680..36b2db06 100644 --- a/packages/components/src/ui/data-table-filter/lib/debounce.ts +++ b/packages/components/src/ui/data-table-filter/lib/debounce.ts @@ -10,7 +10,7 @@ type DebounceOptions = { maxWait?: number; }; -export function debounce unknown>( +export function debounce any>( func: T, wait: number, options: DebounceOptions = {}, @@ -32,7 +32,7 @@ export function debounce unknown>( lastArgs = null; lastThis = null; lastInvokeTime = time; - result = func.apply(thisArg, args); + result = func.apply(thisArg, args) as ReturnType; return result; } diff --git a/packages/components/src/ui/debounced-input.tsx b/packages/components/src/ui/debounced-input.tsx index 23aae73d..3a633a27 100644 --- a/packages/components/src/ui/debounced-input.tsx +++ b/packages/components/src/ui/debounced-input.tsx @@ -22,7 +22,8 @@ export function DebouncedInput({ // Define the debounced function with useCallback // biome-ignore lint/correctness/useExhaustiveDependencies: from Bazza UI const debouncedOnChange = useCallback( - debounce((newValue: string | number) => { + debounce((...args: unknown[]) => { + const newValue = args[0] as string | number; onChange(newValue); }, debounceMs), // Pass the wait time here [debounceMs, onChange], // Dependencies diff --git a/packages/components/src/ui/textarea.tsx b/packages/components/src/ui/textarea.tsx index cfe50489..7f9a16b1 100644 --- a/packages/components/src/ui/textarea.tsx +++ b/packages/components/src/ui/textarea.tsx @@ -1,4 +1,4 @@ -import type * as React from 'react'; +import * as React from 'react'; import { cn } from './utils'; export interface TextareaProps extends React.TextareaHTMLAttributes { @@ -7,20 +7,23 @@ export interface TextareaProps extends React.TextareaHTMLAttributes; } -const Textarea = ({ className, CustomTextarea, ...props }: TextareaProps) => { - if (CustomTextarea) return ; +const Textarea = React.forwardRef( + ({ className, CustomTextarea, ...props }, ref) => { + if (CustomTextarea) return ; - return ( -