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
66 changes: 66 additions & 0 deletions .github/workflows/pr-quality-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: PR Quality Checks

on:
pull_request:
paths:
- 'apps/**'
- 'packages/**'
- '.github/workflows/**'
- '*.json'
- '*.js'
- '*.ts'
- '*.tsx'
- 'yarn.lock'
- 'turbo.json'
- 'biome.json'
- '!**/*.md'
- '!**/*.txt'
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
quality-checks:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '22.9.0'

- name: Setup Yarn Corepack
run: corepack enable

- name: Install dependencies
run: yarn install

- name: Cache Turbo
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.ref_name }}-${{ hashFiles('**/yarn.lock') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.ref_name }}-${{ hashFiles('**/yarn.lock') }}-
${{ runner.os }}-turbo-${{ github.ref_name }}-
${{ runner.os }}-turbo-

- name: Run Turbo lint
run: yarn turbo run lint

- name: Run Turbo typecheck
run: yarn turbo run type-check --filter=@lambdacurry/forms

- name: Summary
run: |
echo "## PR Quality Checks Summary" >> $GITHUB_STEP_SUMMARY
echo "✅ Linting passed (Biome)" >> $GITHUB_STEP_SUMMARY
echo "✅ TypeScript compilation passed" >> $GITHUB_STEP_SUMMARY
echo "✅ All checks completed with Turbo caching" >> $GITHUB_STEP_SUMMARY
13 changes: 5 additions & 8 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"tailwindCSS.classAttributes": [
"class",
"className",
"ngClass",
"class:list",
"wrapperClassName"
]
}
"tailwindCSS.classAttributes": ["class", "className", "ngClass", "class:list", "wrapperClassName"],
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
3 changes: 3 additions & 0 deletions apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
"@storybook/testing-library": "^0.2.2",
"@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
Expand Down
14 changes: 5 additions & 9 deletions apps/docs/src/examples/middleware-example.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,7 @@ export const action = async ({ context }: ActionFunctionArgs) => {

// Component
export default function MiddlewareExample() {
const {
handleSubmit,
formState: { errors },
register,
} = useRemixForm<FormData>({
const methods = useRemixForm<FormData>({
mode: 'onSubmit',
resolver,
});
Expand All @@ -46,12 +42,12 @@ export default function MiddlewareExample() {
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Remix Hook Form v7 Middleware Example</h1>

<RemixFormProvider>
<Form method="POST" onSubmit={handleSubmit}>
<RemixFormProvider {...methods}>
<Form method="POST" onSubmit={methods.handleSubmit}>
<div className="space-y-4">
<TextField label="Name" {...register('name')} error={errors.name?.message} />
<TextField name="name" label="Name" />

<TextField label="Email" type="email" {...register('email')} error={errors.email?.message} />
<TextField name="email" type="email" label="Email" />

<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
Submit
Expand Down
15 changes: 8 additions & 7 deletions apps/docs/src/remix-hook-form/checkbox-list.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Checkbox } from '@lambdacurry/forms/remix-hook-form/checkbox';
import { FormMessage } from '@lambdacurry/forms/remix-hook-form/form';
import { Button } from '@lambdacurry/forms/ui/button';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, type within } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import { type ActionFunctionArgs, Form, useFetcher } from 'react-router';
import { createFormData, getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
Expand Down Expand Up @@ -137,18 +137,18 @@ const meta: Meta<typeof Checkbox> = {
export default meta;
type Story = StoryObj<typeof meta>;

interface StoryContext {
canvas: ReturnType<typeof within>;
}
type StoryContext = { canvasElement: HTMLElement };

const testDefaultValues = ({ canvas }: StoryContext) => {
const testDefaultValues = ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
AVAILABLE_COLORS.forEach(({ label }) => {
const checkbox = canvas.getByLabelText(label);
expect(checkbox).not.toBeChecked();
});
};

const testErrorState = async ({ canvas }: StoryContext) => {
const testErrorState = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
// Submit form without selecting any colors
const submitButton = canvas.getByRole('button', { name: 'Submit' });
await userEvent.click(submitButton);
Expand All @@ -157,7 +157,8 @@ const testErrorState = async ({ canvas }: StoryContext) => {
await expect(await canvas.findByText('Please select at least one color')).toBeInTheDocument();
};

const testColorSelection = async ({ canvas }: StoryContext) => {
const testColorSelection = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
// Select two colors
const redCheckbox = canvas.getByLabelText('Red');
const blueCheckbox = canvas.getByLabelText('Blue');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useDataTableFilters } from '@lambdacurry/forms/ui/data-table-filter/hoo
import { useFilterSync } from '@lambdacurry/forms/ui/utils/use-filter-sync';
import { CheckCircledIcon, PersonIcon, StarIcon, TextIcon } from '@radix-ui/react-icons';
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
import { expect } from '@storybook/test';
import { expect, within } from '@storybook/test';
import { withReactRouterStubDecorator } from '../../lib/storybook/react-router-stub';

/**
Expand Down Expand Up @@ -228,15 +228,17 @@ type Story = StoryObj<typeof meta>;
/**
* Test functions for accessibility testing
*/
const testBasicRendering = ({ canvas }: StoryContext) => {
const testBasicRendering = ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const title = canvas.getByText('Data Table Filter Accessibility Test');
expect(title).toBeInTheDocument();

const filterInterface = canvas.getByText('Filter Interface');
expect(filterInterface).toBeInTheDocument();
};

const testKeyboardNavigation = async ({ canvas }: StoryContext) => {
const testKeyboardNavigation = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
// Look for filter-related buttons or elements
const buttons = canvas.getAllByRole('button');
await expect(buttons.length).toBeGreaterThan(0);
Expand All @@ -248,7 +250,8 @@ const testKeyboardNavigation = async ({ canvas }: StoryContext) => {
}
};

const testAriaAttributes = async ({ canvas }: StoryContext) => {
const testAriaAttributes = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
// Test that interactive elements have proper roles
const buttons = canvas.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,8 @@ export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {} satisfies Record<string, unknown>, // Args for DataTableRouterForm if needed, handled by Example component
// biome-ignore lint/suspicious/noExplicitAny: any for flexibility
args: {} as any,
render: () => <DataTableRouterFormExample />,
parameters: {
docs: {
Expand Down
20 changes: 11 additions & 9 deletions apps/docs/src/remix-hook-form/form-error.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { render, screen } from '@testing-library/react';
import { useFetcher } from 'react-router';
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
import type { ElementType, PropsWithChildren } from 'react';
import type { ElementType } from 'react';
import type { FetcherWithComponents } from 'react-router';
import type { FormMessageProps } from '@lambdacurry/forms/ui/form';

// Mock useFetcher
jest.mock('react-router', () => ({
Expand All @@ -31,15 +33,15 @@ const TestFormWithError = ({
}: {
initialErrors?: Record<string, { message: string }>;
formErrorName?: string;
customComponents?: { FormMessage?: React.ComponentType<PropsWithChildren<Record<string, unknown>>> };
customComponents?: { FormMessage?: React.ComponentType<FormMessageProps> };
className?: string;
}) => {
const mockFetcher = {
data: { errors: initialErrors },
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
};
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

Expand Down Expand Up @@ -143,9 +145,9 @@ describe('FormError Component', () => {

describe('Component Customization', () => {
it('uses custom FormMessage component when provided', () => {
const CustomFormMessage = ({ children, ...props }: PropsWithChildren<Record<string, unknown>>) => (
const CustomFormMessage = (props: FormMessageProps) => (
<div data-testid="custom-form-message" className="custom-message" {...props}>
Custom: {children}
Custom: {props.children}
</div>
);

Expand Down Expand Up @@ -218,7 +220,7 @@ describe('FormError Component', () => {
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
};
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

Expand Down Expand Up @@ -315,9 +317,9 @@ describe('FormError Component', () => {
it('does not re-render unnecessarily when unrelated form state changes', () => {
const renderSpy = jest.fn();

const CustomFormMessage = ({ children, ...props }: PropsWithChildren<Record<string, unknown>>) => {
const CustomFormMessage = (props: FormMessageProps) => {
renderSpy();
return <div {...props}>{children}</div>;
return <div {...props}>{props.children}</div>;
};

const errors = {
Expand Down Expand Up @@ -346,7 +348,7 @@ describe('FormError Integration Tests', () => {
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
};
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

Expand Down
20 changes: 13 additions & 7 deletions apps/docs/src/remix-hook-form/password-field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { PasswordField } from '@lambdacurry/forms/remix-hook-form/password-field';
import { Button } from '@lambdacurry/forms/ui/button';
import type { Meta, StoryContext, StoryObj } from '@storybook/react-vite';
import { expect, userEvent } from '@storybook/test';
import { expect, userEvent, within } from '@storybook/test';
import { useRef } from 'react';
import { type ActionFunctionArgs, useFetcher } from 'react-router';
import { getValidatedFormData, RemixFormProvider, useRemixForm } from 'remix-hook-form';
Expand Down Expand Up @@ -114,14 +114,16 @@ export default meta;
type Story = StoryObj<typeof meta>;

// Test scenarios
const testDefaultValues = ({ canvas }: StoryContext) => {
const testDefaultValues = ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password');
const confirmInput = canvas.getByLabelText('Confirm Password');
expect(passwordInput).toHaveValue(INITIAL_PASSWORD);
expect(confirmInput).toHaveValue(INITIAL_PASSWORD);
};

const testPasswordVisibilityToggle = async ({ canvas }: StoryContext) => {
const testPasswordVisibilityToggle = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password');

// Find the toggle button within the same form item as the password input
Expand Down Expand Up @@ -150,7 +152,8 @@ const testPasswordVisibilityToggle = async ({ canvas }: StoryContext) => {
expect(showButtonAgain).toBeInTheDocument();
};

const testWeakPasswordValidation = async ({ canvas }: StoryContext) => {
const testWeakPasswordValidation = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password');
const submitButton = canvas.getByRole('button', { name: 'Create Account' });

Expand All @@ -162,7 +165,8 @@ const testWeakPasswordValidation = async ({ canvas }: StoryContext) => {
await expect(await canvas.findByText(WEAK_PASSWORD_ERROR)).toBeInTheDocument();
};

const testPasswordMismatchValidation = async ({ canvas }: StoryContext) => {
const testPasswordMismatchValidation = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password');
const confirmInput = canvas.getByLabelText('Confirm Password');
const submitButton = canvas.getByRole('button', { name: 'Create Account' });
Expand All @@ -180,7 +184,8 @@ const testPasswordMismatchValidation = async ({ canvas }: StoryContext) => {
await expect(await canvas.findByText(MISMATCH_PASSWORD_ERROR)).toBeInTheDocument();
};

const testValidSubmission = async ({ canvas }: StoryContext) => {
const testValidSubmission = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const passwordInput = canvas.getByLabelText('Password');
const confirmInput = canvas.getByLabelText('Confirm Password');
const submitButton = canvas.getByRole('button', { name: 'Create Account' });
Expand All @@ -199,7 +204,8 @@ const testValidSubmission = async ({ canvas }: StoryContext) => {
expect(successMessage).toBeInTheDocument();
};

const testRefFunctionality = async ({ canvas }: StoryContext) => {
const testRefFunctionality = async ({ canvasElement }: StoryContext) => {
const canvas = within(canvasElement);
const refInput = canvas.getByLabelText('Ref Example');
const focusButton = canvas.getByRole('button', { name: 'Focus' });

Expand Down
12 changes: 7 additions & 5 deletions apps/docs/src/remix-hook-form/phone-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import userEvent from '@testing-library/user-event';
import { useFetcher } from 'react-router';
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
import type { ElementType, PropsWithChildren } from 'react';
import type { ElementType } from 'react';
import type { FetcherWithComponents } from 'react-router';
import type { FormMessageProps } from '@lambdacurry/forms/ui/form';

// Mock useFetcher
jest.mock('react-router', () => ({
Expand All @@ -29,14 +31,14 @@ const TestPhoneInputForm = ({
customComponents = {},
}: {
initialErrors?: Record<string, { message: string }>;
customComponents?: { FormMessage?: React.ComponentType<PropsWithChildren<Record<string, unknown>>> };
customComponents?: { FormMessage?: React.ComponentType<FormMessageProps> };
}) => {
const mockFetcher = {
data: { errors: initialErrors },
state: 'idle' as const,
submit: jest.fn(),
Form: 'form' as ElementType,
};
} as unknown as FetcherWithComponents<unknown>;

mockUseFetcher.mockReturnValue(mockFetcher);

Expand Down Expand Up @@ -152,9 +154,9 @@ describe('PhoneInput Component', () => {

describe('Component Customization', () => {
it('uses custom FormMessage component when provided', () => {
const CustomFormMessage = ({ children, ...props }: PropsWithChildren<Record<string, unknown>>) => (
const CustomFormMessage = (props: FormMessageProps) => (
<div data-testid="custom-form-message" className="custom-message" {...props}>
Custom: {children}
Custom: {props.children}
</div>
);

Expand Down
Loading
Loading