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
6 changes: 6 additions & 0 deletions .changeset/heavy-dragons-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lambdacurry/forms": minor
"@lambdacurry/forms-docs": minor
---

Added remix textarea story and fixes a react error
4 changes: 0 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Send a Slack notification if a publish happens
if: steps.changesets.outputs.published == 'true'
run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!"
13 changes: 12 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
{
"cSpell.words": ["autodocs", "biomejs", "Filenaming", "hookform", "isbot", "lucide", "shadcn", "sonner"]
"cSpell.words": ["autodocs", "biomejs", "Filenaming", "hookform", "isbot", "lucide", "shadcn", "sonner"],
"editor.defaultFormatter": "biomejs.biome",
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Welcome!

Checkout our [Storybook Documentation](https://lambda-curry.github.io/forms/?path=/docs/helloworld-start-here--docs) to see the components in action and get started.
Checkout our [Storybook Documentation](https://lambda-curry.github.io/forms/?path=/docs/0-1-hello-world-start-here--docs) to see the components in action and get started.


## Getting Started
Expand All @@ -10,7 +10,7 @@ Step 1: Install the dependencies
yarn install
```

Note: You may need to enable corepack for yarn v4 by running `corepack enable` before intstalling the dependencies.
Note: You may need to enable corepack for yarn v4 by running `corepack enable` before installing the dependencies.


Step 2: Start Storybook
Expand Down
9 changes: 0 additions & 9 deletions apps/docs/CHANGELOG.md

This file was deleted.

15 changes: 9 additions & 6 deletions apps/docs/src/remix/remix-textarea.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { RemixTextarea } from '@lambdacurry/forms/remix/remix-textarea';
import { Button } from '@lambdacurry/forms/ui/button';
import type { ActionFunctionArgs } from '@remix-run/node';
import { useFetcher } from '@remix-run/react';
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { RemixFormProvider, getValidatedFormData, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
import { withRemixStubDecorator } from '../lib/storybook/remix-stub';
import { RemixTextarea } from '@lambdacurry/forms/remix/remix-textarea';
import { Button } from '@lambdacurry/forms/ui/button';

const formSchema = z.object({
comment: z.string().min(10, 'Comment must be at least 10 characters'),
Expand Down Expand Up @@ -37,9 +37,6 @@ const ControlledTextareaExample = () => {
Submit
</Button>
{fetcher.data?.message && <p className="mt-2 text-green-600">{fetcher.data.message}</p>}
{methods.formState.errors.comment && (
<p className="mt-2 text-red-600">{methods.formState.errors.comment.message}</p>
)}
</fetcher.Form>
</RemixFormProvider>
);
Expand All @@ -54,7 +51,7 @@ const handleFormSubmission = async (request: Request) => {
} = await getValidatedFormData<FormData>(request, zodResolver(formSchema));

if (errors) {
return { errors, defaultValues };
return { defaultValues };
}

if (data.comment.includes(BLOCKED_CONTENT)) {
Expand Down Expand Up @@ -108,6 +105,9 @@ const testInvalidSubmission = async ({ canvasElement }: { canvasElement: HTMLEle
await userEvent.type(textarea, 'short');
await userEvent.click(submitButton);

// Wait for any state updates
await new Promise((resolve) => setTimeout(resolve, 100));

expect(canvas.getByText((content) => content.includes('Comment must be at least 10 characters'))).toBeInTheDocument();
};

Expand Down Expand Up @@ -149,6 +149,9 @@ const testValidSubmission = async ({ canvasElement }: { canvasElement: HTMLEleme
await userEvent.type(textarea, 'This is a valid comment that is long enough');
await userEvent.click(submitButton);

// Wait for any state updates
await new Promise((resolve) => setTimeout(resolve, 100));

// Check for success message
await expect(canvas.getByText('Comment submitted successfully')).toBeInTheDocument();
};
Expand Down
10 changes: 4 additions & 6 deletions packages/components/src/remix/remix-checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { useRemixFormContext } from 'remix-hook-form'
import { Checkbox, type CheckboxProps } from '../ui/checkbox'
import { useRemixFormContext } from 'remix-hook-form';
import { Checkbox, type CheckboxProps } from '../ui/checkbox';
import { RemixFormControl, RemixFormDescription, RemixFormLabel, RemixFormMessage } from './remix-form';
import type { FieldComponents } from '../ui/form';

export type RemixCheckboxProps = Omit<CheckboxProps, 'control'>;

export function RemixCheckbox(props: RemixCheckboxProps) {
const { control } = useRemixFormContext();


const components: Partial<FieldComponents> = {
const components = {
FormDescription: RemixFormDescription,
FormControl: RemixFormControl,
FormLabel: RemixFormLabel,
FormMessage: RemixFormMessage,
};

return <Checkbox control={control} {...props} components={components} />;
}
}
63 changes: 31 additions & 32 deletions packages/components/src/remix/remix-form.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { type ReactNode, useContext, forwardRef } from 'react';
import type {
KeepStateOptions,
FieldValues,
UseFormReturn,
UseFormRegister,
} from 'react-hook-form';
import { type ReactNode, forwardRef, useContext } from 'react';
import type { BaseSyntheticEvent, ComponentPropsWithoutRef } from 'react';
import type { FieldValues, KeepStateOptions, UseFormRegister, UseFormReturn } from 'react-hook-form';
import { useRemixFormContext } from 'remix-hook-form';
import { FormLabel, FormControl, FormDescription, FormMessage, FormFieldContext, FormItemContext } from '../ui/form';
import type { BaseSyntheticEvent, ComponentPropsWithoutRef, } from 'react';
import { FormControl, FormDescription, FormFieldContext, FormItemContext, FormLabel, FormMessage } from '../ui/form';

export interface RemixFormProviderProps<T extends FieldValues>
extends Omit<UseFormReturn<T>, 'handleSubmit' | 'reset'> {
Expand Down Expand Up @@ -40,36 +35,40 @@ export const useRemixFormField = () => {
};
};


export const RemixFormLabel = forwardRef<HTMLLabelElement, ComponentPropsWithoutRef<typeof FormLabel>>((props, ref) => (
<FormLabel ref={ref} {...props} />
));
RemixFormLabel.displayName = 'RemixFormLabel';

export const RemixFormControl = forwardRef<HTMLElement, ComponentPropsWithoutRef<typeof FormControl>>((props, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useRemixFormField();
return (
<FormControl
ref={ref}
error={!!error}
formItemId={formItemId}
formDescriptionId={formDescriptionId}
formMessageId={formMessageId}
{...props}
/>
);
});
export const RemixFormControl = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof FormControl>>(
(props, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useRemixFormField();
return (
<FormControl
ref={ref}
error={!!error}
formItemId={formItemId}
formDescriptionId={formDescriptionId}
formMessageId={formMessageId}
{...props}
/>
);
},
);
RemixFormControl.displayName = 'RemixFormControl';

export const RemixFormDescription = forwardRef<HTMLParagraphElement, ComponentPropsWithoutRef<typeof FormDescription>>((props, ref) => {
const { formDescriptionId } = useRemixFormField();
return <FormDescription ref={ref} formDescriptionId={formDescriptionId} {...props} />;
});
export const RemixFormDescription = forwardRef<HTMLParagraphElement, ComponentPropsWithoutRef<typeof FormDescription>>(
(props, ref) => {
const { formDescriptionId } = useRemixFormField();
return <FormDescription ref={ref} formDescriptionId={formDescriptionId} {...props} />;
},
);
RemixFormDescription.displayName = 'RemixFormDescription';

export const RemixFormMessage = forwardRef<HTMLParagraphElement, ComponentPropsWithoutRef<typeof FormMessage>>((props, ref) => {
const { error, formMessageId } = useRemixFormField();
return <FormMessage ref={ref} formMessageId={formMessageId} error={error?.message} {...props} />;
});
export const RemixFormMessage = forwardRef<HTMLParagraphElement, ComponentPropsWithoutRef<typeof FormMessage>>(
(props, ref) => {
const { error, formMessageId } = useRemixFormField();
return <FormMessage ref={ref} formMessageId={formMessageId} error={error?.message} {...props} />;
},
);
RemixFormMessage.displayName = 'RemixFormMessage';

17 changes: 4 additions & 13 deletions packages/components/src/remix/remix-switch.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { ComponentPropsWithoutRef } from 'react';
import { useRemixFormContext } from 'remix-hook-form'
import { Switch } from '../ui/switch'
import { useRemixFormContext } from 'remix-hook-form';
import { Switch } from '../ui/switch';
import { RemixFormControl, RemixFormDescription, RemixFormLabel, RemixFormMessage } from './remix-form';
import type { FieldComponents } from '../ui/form';

export interface RemixSwitchProps extends Omit<ComponentPropsWithoutRef<typeof Switch>, 'control'> {
name: string;
Expand All @@ -13,22 +12,14 @@ export interface RemixSwitchProps extends Omit<ComponentPropsWithoutRef<typeof S
export function RemixSwitch({ name, label, description, ...props }: RemixSwitchProps) {
const { control } = useRemixFormContext();

const components: Partial<FieldComponents> = {
const components = {
FormDescription: RemixFormDescription,
FormControl: RemixFormControl,
FormLabel: RemixFormLabel,
FormMessage: RemixFormMessage,
};

return (
<Switch
control={control}
name={name}
label={label}
description={description}
components={components}
{...props}
/>
<Switch control={control} name={name} label={label} description={description} components={components} {...props} />
);
}

3 changes: 1 addition & 2 deletions packages/components/src/remix/remix-text-field.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { useRemixFormContext } from 'remix-hook-form';
import { TextField, type TextFieldProps } from '../ui/text-field';
import type { FieldComponents } from '../ui/form';
import { RemixFormControl, RemixFormDescription, RemixFormLabel, RemixFormMessage } from './remix-form';

export type RemixTextFieldProps = Omit<TextFieldProps, 'control'>;

export function RemixTextField(props: RemixTextFieldProps) {
const { control } = useRemixFormContext();

const components: Partial<FieldComponents> = {
const components = {
FormControl: RemixFormControl,
FormLabel: RemixFormLabel,
FormDescription: RemixFormDescription,
Expand Down
27 changes: 11 additions & 16 deletions packages/components/src/remix/remix-textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import { useRemixFormContext } from 'remix-hook-form';
import { Textarea, type TextareaProps } from '../ui/textarea';
import { FormField, FormItem } from '../ui/form';
export interface RemixTextareaProps extends Omit<TextareaProps, 'control'> {
label?: string;
description?: string;
}
import { RemixFormControl, RemixFormDescription, RemixFormLabel, RemixFormMessage } from './remix-form';

export type RemixTextareaProps = Omit<TextareaProps, 'control'>;

export function RemixTextarea(props: RemixTextareaProps) {
const { control } = useRemixFormContext();

return (
<FormField
control={control}
name={props.name ?? ''}
render={({ field }) => (
<FormItem>
<Textarea {...field} {...props} />
</FormItem>
)}
/>
);
const components = {
FormControl: RemixFormControl,
FormLabel: RemixFormLabel,
FormDescription: RemixFormDescription,
FormMessage: RemixFormMessage,
};
Comment on lines +10 to +15
Copy link

@coderabbitai coderabbitai bot Nov 26, 2024

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Memoize the components object to prevent unnecessary re-renders

Currently, the components object is recreated on every render, which can lead to unnecessary re-renders if the Textarea component relies on referential equality for optimizations. Consider wrapping it in a useMemo hook to ensure the object reference remains stable across renders.

Apply this diff to memoize the components object:

-  const components = {
+  const components = useMemo(() => ({
     FormControl: RemixFormControl,
     FormLabel: RemixFormLabel,
     FormDescription: RemixFormDescription,
     FormMessage: RemixFormMessage,
-  };
+  }), []);

Also, add the import statement at the top of the file:

import { useMemo } from 'react';

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You said this wouldn't add much performance enhancement, so I'm leaving as is.

Copy link

Choose a reason for hiding this comment

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

@jaruesink, understood. I'll keep this in mind for future reviews.


✏️ Learnings added
Learnt from: jaruesink
PR: lambda-curry/forms#14
File: packages/components/src/remix/remix-textarea.tsx:10-15
Timestamp: 2024-11-26T05:28:15.991Z
Learning: When reviewing components like `RemixTextarea` in `packages/components/src/remix/`, avoid suggesting to memoize the `components` object with `useMemo` if the performance benefit is minimal.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


return <Textarea control={control} components={components} {...props} />;
}
8 changes: 4 additions & 4 deletions packages/components/src/ui/calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { type CustomComponents, DayPicker } from 'react-day-picker';

import { cn } from '../../lib/utils';
import { buttonVariants } from './button';
Expand Down Expand Up @@ -42,9 +42,9 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" {...props} />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" {...props} />,
} as Partial<CustomComponents>}
{...props}
/>
);
Expand Down
8 changes: 4 additions & 4 deletions packages/components/src/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ComponentPropsWithoutRef, type ReactNode, forwardRef, type ElementRef } from 'react';
import { type ComponentPropsWithoutRef, type ReactNode, forwardRef, } from 'react';
// biome-ignore lint/style/noNamespaceImport: from Radix
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
Expand Down Expand Up @@ -26,16 +26,16 @@ export interface CheckboxProps<
components?: Partial<FieldComponents>;
}

const Checkbox = forwardRef<ElementRef<typeof CheckboxPrimitive.Root>, CheckboxProps>(
const Checkbox = forwardRef<HTMLDivElement, CheckboxProps>(
({ control, name, className, label, description, components, ...props }, ref) => (
<FormField
control={control}
name={name}
render={({ field, fieldState }) => (
<FormItem className={cn('flex flex-row items-start space-x-3 space-y-0', className)}>
<FormItem className={cn('flex flex-row items-start space-x-3 space-y-0', className)} ref={ref}>
<FormControl Component={components?.FormControl}>
<CheckboxPrimitive.Root
ref={ref}
ref={field.ref}
className="peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
checked={field.value}
onCheckedChange={field.onChange}
Expand Down
6 changes: 3 additions & 3 deletions packages/components/src/ui/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export interface DatePickerProps<
components?: Partial<FieldComponents>;
}

export const DatePicker = forwardRef<HTMLButtonElement, DatePickerProps>(
export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
({ control, name, label, description, className, labelClassName, buttonClassName, components }, ref) => {
return (
<FormField
control={control}
name={name}
render={({ field, fieldState }) => (
<FormItem className={className}>
<FormItem className={className} ref={ref}>
{label && (
<FormLabel Component={components?.FormLabel} className={labelClassName}>
{label}
Expand All @@ -47,7 +47,7 @@ export const DatePicker = forwardRef<HTMLButtonElement, DatePickerProps>(
<Popover>
<PopoverTrigger asChild>
<Button
ref={ref}
ref={field.ref}
variant="outline"
className={cn(
'w-[280px] justify-start text-left font-normal',
Expand Down
Loading