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
27 changes: 0 additions & 27 deletions .cursor/rules/storybook-testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
53 changes: 36 additions & 17 deletions apps/docs/src/remix-hook-form/textarea.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,7 +30,7 @@ const ControlledTextareaExample = () => {
onValid: (data) => {
fetcher.submit(
createFormData({
submittedMessage: data.message,
message: data.message,
}),
{
method: 'post',
Expand All @@ -49,6 +49,7 @@ const ControlledTextareaExample = () => {
<Button type="submit" className="mt-4">
Submit
</Button>
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
{fetcher.data?.submittedMessage && (
<div className="mt-4">
<p className="text-sm font-medium">Submitted message:</p>
Expand Down Expand Up @@ -92,6 +93,36 @@ const meta: Meta<typeof Textarea> = {
export default meta;
type Story = StoryObj<typeof meta>;

// 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: {
Expand All @@ -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);
},
};
26 changes: 7 additions & 19 deletions packages/components/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
10 changes: 6 additions & 4 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion packages/components/src/remix-hook-form/text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ export const TextField = function RemixTextField(props: TextFieldProps & { ref?:
return <BaseTextField control={control} components={components} {...props} />;
};

TextField.displayName = 'RemixTextField';
TextField.displayName = 'TextField';
13 changes: 11 additions & 2 deletions packages/components/src/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -33,11 +34,19 @@ const buttonVariants = cva(

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
ref?: React.Ref<HTMLButtonElement>;
}

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 <Comp className={cn(buttonVariants({ variant, size, className }))} data-slot="button" {...props} />;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
data-slot="button"
ref={ref}
{...props}
/>
);
}

Button.displayName = 'Button';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ type ControlFunctions = {
isPending: () => boolean;
};

export type DebouncedState<T extends (...args: any) => ReturnType<T>> = ((
export type DebouncedState<T extends (...args: any[]) => any> = ((
...args: Parameters<T>
) => ReturnType<T> | undefined) &
ControlFunctions;

export function useDebounceCallback<T extends (...args: any) => ReturnType<T>>(
export function useDebounceCallback<T extends (...args: any[]) => any>(
func: T,
delay = 500,
options?: DebounceOptions,
): DebouncedState<T> {
const debouncedFunc = useRef<ReturnType<typeof debounce>>(null);
const debouncedFunc = useRef<(((...args: Parameters<T>) => ReturnType<T> | undefined) & ControlFunctions) | null>(null);

useUnmount(() => {
if (debouncedFunc.current) {
Expand Down Expand Up @@ -56,8 +56,8 @@ export function useDebounceCallback<T extends (...args: any) => ReturnType<T>>(

// 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;
}
4 changes: 2 additions & 2 deletions packages/components/src/ui/data-table-filter/lib/debounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type DebounceOptions = {
maxWait?: number;
};

export function debounce<T extends (...args: unknown[]) => unknown>(
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
options: DebounceOptions = {},
Expand All @@ -32,7 +32,7 @@ export function debounce<T extends (...args: unknown[]) => unknown>(
lastArgs = null;
lastThis = null;
lastInvokeTime = time;
result = func.apply(thisArg, args);
result = func.apply(thisArg, args) as ReturnType<T>;
return result;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/components/src/ui/debounced-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +25 to 27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider a more type-safe approach to maintain flexibility without losing compile-time safety.

The change from a typed parameter to rest parameters with casting reduces type safety. While this makes the debounce utility more generic, the runtime cast args[0] as string | number could fail if unexpected argument types are passed.

Consider maintaining type safety while preserving flexibility:

-    debounce((...args: unknown[]) => {
-      const newValue = args[0] as string | number;
+    debounce((newValue: string | number) => {
       onChange(newValue);
     }, debounceMs),

If generic flexibility is required, consider using a proper generic constraint in the debounce utility instead of runtime casting.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
debounce((...args: unknown[]) => {
const newValue = args[0] as string | number;
onChange(newValue);
debounce((newValue: string | number) => {
onChange(newValue);
}, debounceMs),
🤖 Prompt for AI Agents
In packages/components/src/ui/debounced-input.tsx around lines 25 to 27, the
current use of rest parameters with a runtime cast to string | number reduces
type safety and risks runtime errors. To fix this, update the debounce function
to use a generic type parameter that enforces the expected argument type at
compile time. Replace the rest parameter and casting with a properly typed
single parameter matching the generic constraint, ensuring onChange receives a
correctly typed value without unsafe casting.

}, debounceMs), // Pass the wait time here
[debounceMs, onChange], // Dependencies
Expand Down
31 changes: 17 additions & 14 deletions packages/components/src/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement> {
Expand All @@ -7,20 +7,23 @@ export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextArea
>;
}

const Textarea = ({ className, CustomTextarea, ...props }: TextareaProps) => {
if (CustomTextarea) return <CustomTextarea className={className} {...props} />;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, CustomTextarea, ...props }, ref) => {
if (CustomTextarea) return <CustomTextarea ref={ref} className={className} {...props} />;

return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
data-slot="textarea"
/>
);
};
return (
<textarea
ref={ref}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
data-slot="textarea"
/>
);
}
);
Textarea.displayName = 'Textarea';

export { Textarea };
5 changes: 2 additions & 3 deletions packages/components/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@ export default defineConfig({
lib: {
entry: {
index: './src/index.ts',
'remix-hook-form/index': './src/remix-hook-form/index.ts',
'ui/index': './src/ui/index.ts',
'data-table/index': './src/data-table/index.ts',
'remix-hook-form': './src/remix-hook-form/index.ts',
ui: './src/ui/index.ts',
},
formats: ['es'],
},
Expand Down
Loading