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
495 changes: 350 additions & 145 deletions README.md

Large diffs are not rendered by default.

60 changes: 52 additions & 8 deletions __tests__/ConditionalField.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import {describe, expect, it} from 'vitest';
import {render, screen} from '@testing-library/react';
import {render, screen, act, fireEvent} from '@testing-library/react';
import {useForm, FormProvider} from 'react-hook-form';
import {ConditionalField} from '../src';
import type {FieldCondition} from '../src';
Expand All @@ -13,23 +13,30 @@ interface FormValues {
}

function TestWrapper({
defaultValues,
condition,
allOf,
fallback,
children,
}: {
defaultValues,
condition,
allOf,
fallback,
unregisterOnHide,
children,
}: {
defaultValues: FormValues;
condition: FieldCondition | FieldCondition[];
allOf?: boolean;
fallback?: React.ReactNode;
unregisterOnHide?: boolean;
children: React.ReactNode;
}) {
const methods = useForm<FormValues>({defaultValues});
return (
<FormProvider {...methods}>
<form>
<ConditionalField condition={condition} allOf={allOf} fallback={fallback}>
<ConditionalField
condition={condition}
{...(allOf !== undefined ? {allOf} : {})}
{...(fallback !== undefined ? {fallback} : {})}
{...(unregisterOnHide !== undefined ? {unregisterOnHide} : {})}
>
{children}
</ConditionalField>
</form>
Expand Down Expand Up @@ -178,4 +185,41 @@ describe('ConditionalField', () => {
);
expect(screen.getByText('Always visible')).toBeInTheDocument();
});

it('unregisterOnHide: when condition becomes false, children are hidden', async () => {
interface WatchedForm {
role: string;
score: number;
tags: string[];
active: boolean;
}

function UnregisterTestWrapper() {
const methods = useForm<WatchedForm>({defaultValues: {...defaults, role: 'admin'}});

return (
<FormProvider {...methods}>
<form>
<input {...methods.register('role')} data-testid="role-input" />
<ConditionalField
condition={{watchField: 'role', operator: 'eq', value: 'admin'}}
unregisterOnHide={true}
>
<span>Admin panel</span>
</ConditionalField>
</form>
</FormProvider>
);
}

render(<UnregisterTestWrapper />);

expect(screen.getByText('Admin panel')).toBeInTheDocument();

await act(async () => {
fireEvent.change(screen.getByTestId('role-input'), {target: {value: 'viewer'}});
});

expect(screen.queryByText('Admin panel')).not.toBeInTheDocument();
});
});
74 changes: 64 additions & 10 deletions __tests__/useAsyncValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {describe, expect, it, vi} from 'vitest';
import {renderHook, act, waitFor} from '@testing-library/react';
import {useAsyncValidation} from '../src';
import type {AsyncValidatorFn} from '../src';
import type {AsyncValidatorFn, AsyncValidationResult} from '../src';

describe('useAsyncValidation', () => {
it('starts with initial state', () => {
Expand All @@ -12,47 +12,47 @@ describe('useAsyncValidation', () => {
expect(result.current.state.isValid).toBeNull();
});

it('returns true for a passing validator', async () => {
it('returns valid result for a passing validator', async () => {
const validator: AsyncValidatorFn<string> = async () => true;
const {result} = renderHook(() => useAsyncValidation(validator, 0));

let resolved: true | string = '';
let resolved: AsyncValidationResult = {status: 'cancelled'};
await act(async () => {
resolved = await result.current.validate('hello');
});

expect(resolved).toBe(true);
expect(resolved).toEqual({status: 'valid'});
expect(result.current.state.isValid).toBe(true);
expect(result.current.state.isPending).toBe(false);
expect(result.current.state.error).toBeNull();
});

it('returns error string for a failing validator', async () => {
it('returns invalid result for a failing validator', async () => {
const validator: AsyncValidatorFn<string> = async () => 'Email already taken';
const {result} = renderHook(() => useAsyncValidation(validator, 0));

let resolved: true | string = '';
let resolved: AsyncValidationResult = {status: 'cancelled'};
await act(async () => {
resolved = await result.current.validate('test@example.com');
});

expect(resolved).toBe('Email already taken');
expect(resolved).toEqual({status: 'invalid', message: 'Email already taken'});
expect(result.current.state.isValid).toBe(false);
expect(result.current.state.error).toBe('Email already taken');
});

it('captures thrown errors as error string', async () => {
it('captures thrown errors as invalid result', async () => {
const validator: AsyncValidatorFn<string> = async () => {
throw new Error('Network error');
};
const {result} = renderHook(() => useAsyncValidation(validator, 0));

let resolved: true | string = '';
let resolved: AsyncValidationResult = {status: 'cancelled'};
await act(async () => {
resolved = await result.current.validate('x');
});

expect(resolved).toBe('Network error');
expect(resolved).toEqual({status: 'invalid', message: 'Network error'});
expect(result.current.state.error).toBe('Network error');
expect(result.current.state.isValid).toBe(false);
});
Expand Down Expand Up @@ -98,4 +98,58 @@ describe('useAsyncValidation', () => {
expect(validator).toHaveBeenCalledTimes(1);
expect(validator).toHaveBeenCalledWith('abc', expect.any(AbortSignal));
});

it('cancelled validation returns { status: cancelled }, not an error string', async () => {
type ResolveFn = (v: true | string) => void;
let resolveValidator: ResolveFn = () => undefined;
const validator: AsyncValidatorFn<string> = () =>
new Promise<true | string>((res) => {
resolveValidator = res;
});

const {result} = renderHook(() => useAsyncValidation(validator, 0));

let firstResolved: AsyncValidationResult = {status: 'valid'};
act(() => {
void result.current.validate('first').then((r) => {
firstResolved = r;
});
});

await act(async () => {
void result.current.validate('second');
});

resolveValidator('first result');

await waitFor(() => {
expect(firstResolved).toEqual({status: 'cancelled'});
});
});

it('on unmount, pending validation resolves with cancelled status', async () => {
type ResolveFn = (v: true | string) => void;
let resolveValidator: ResolveFn = () => undefined;
const validator: AsyncValidatorFn<string> = () =>
new Promise<true | string>((res) => {
resolveValidator = res;
});

const {result, unmount} = renderHook(() => useAsyncValidation(validator, 0));

let resolved: AsyncValidationResult = {status: 'valid'};
act(() => {
void result.current.validate('test').then((r) => {
resolved = r;
});
});

unmount();

resolveValidator(true);

await waitFor(() => {
expect(resolved).toEqual({status: 'cancelled'});
});
});
});
126 changes: 116 additions & 10 deletions __tests__/useFormWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,40 +67,50 @@ describe('useFormWizard', () => {
expect(result.current.wizardState.currentStepIndex).toBe(0);
});

it('goTo navigates to any step index', () => {
it('goTo navigates to any step index', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);
act(() => result.current.goTo(2));
await act(async () => {
await result.current.goTo(2);
});
expect(result.current.wizardState.currentStepIndex).toBe(2);
});

it('goTo clamps to valid range', () => {
it('goTo clamps to valid range', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);
act(() => result.current.goTo(99));
await act(async () => {
await result.current.goTo(99);
});
expect(result.current.wizardState.currentStepIndex).toBe(2);
act(() => result.current.goTo(-5));
await act(async () => {
await result.current.goTo(-5);
});
expect(result.current.wizardState.currentStepIndex).toBe(0);
});

it('does not advance past last step', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);
act(() => result.current.goTo(2));
await act(async () => {
await result.current.goTo(2);
});
await act(async () => {
await result.current.next({validate: false});
});
expect(result.current.wizardState.currentStepIndex).toBe(2);
});

it('isLastStep is true on last step', () => {
it('isLastStep is true on last step', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);
act(() => result.current.goTo(2));
await act(async () => {
await result.current.goTo(2);
});
expect(result.current.wizardState.isLastStep).toBe(true);
});

Expand All @@ -116,12 +126,14 @@ describe('useFormWizard', () => {
expect(result.current.wizardState.completedSteps.size).toBe(0);
});

it('currentStep reflects the active step config', () => {
it('currentStep reflects the active step config', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);
expect(result.current.currentStep.id).toBe('step1');
act(() => result.current.goTo(1));
await act(async () => {
await result.current.goTo(1);
});
expect(result.current.currentStep.id).toBe('step2');
});

Expand All @@ -141,4 +153,98 @@ describe('useFormWizard', () => {
expect.objectContaining({name: 'Alice', email: 'a@b.com'})
);
});

it('next() does not advance when current step has invalid required field', async () => {
interface StrictForm {
username: string;
bio: string;
}

const strictSteps: StepConfig<StrictForm>[] = [
{id: 's1', title: 'Step 1', fields: ['username']},
{id: 's2', title: 'Step 2', fields: ['bio']},
];
const {result} = renderHook(() =>
useFormWizard<StrictForm>({
steps: strictSteps,
defaultValues: {username: '', bio: ''},
})
);

result.current.form.register('username', {required: 'Username is required'});

let advanced = false;
await act(async () => {
advanced = await result.current.next();
});

expect(advanced).toBe(false);
expect(result.current.wizardState.currentStepIndex).toBe(0);
});

it('goTo with validateCurrentStep: true blocks on invalid step', async () => {
interface StrictForm {
username: string;
bio: string;
}

const strictSteps: StepConfig<StrictForm>[] = [
{id: 's1', title: 'Step 1', fields: ['username']},
{id: 's2', title: 'Step 2', fields: ['bio']},
];
const {result} = renderHook(() =>
useFormWizard<StrictForm>({
steps: strictSteps,
defaultValues: {username: '', bio: ''},
})
);

result.current.form.register('username', {required: 'Username is required'});

let jumped: boolean | void = false;
await act(async () => {
jumped = await result.current.goTo(1, {validateCurrentStep: true});
});

expect(jumped).toBe(false);
expect(result.current.wizardState.currentStepIndex).toBe(0);
});

it('onStepChange callback is called on navigation', async () => {
const onStepChange = vi.fn();
const {result} = renderHook(() =>
useFormWizard<FormData>({steps, onStepChange})
);

await act(async () => {
await result.current.next({validate: false});
});
expect(onStepChange).toHaveBeenCalledWith(0, 1);

act(() => result.current.previous());
expect(onStepChange).toHaveBeenCalledWith(1, 0);

await act(async () => {
await result.current.goTo(2);
});
expect(onStepChange).toHaveBeenCalledWith(0, 2);
});

it('completionProgress reflects completed steps', async () => {
const {result} = renderHook(() =>
useFormWizard<FormData>({steps})
);

expect(result.current.wizardState.completionProgress).toBe(0);

await act(async () => {
await result.current.next({validate: false});
});
expect(result.current.wizardState.completionProgress).toBe(33);

await act(async () => {
await result.current.next({validate: false});
});
expect(result.current.wizardState.completionProgress).toBe(67);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"typescript"
],
"author": "itiana",
"license": "MIT",
"license": "CC-BY-NC-4.0",
"repository": {
"type": "git",
"url": "https://github.com/tiana-code/form-architect.git"
Expand Down
Loading
Loading