diff --git a/lib/index.ts b/lib/index.ts index aaf7a11..0216a4d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,60 +1,62 @@ import * as React from 'react' -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' -export type FieldOptions = { - extractor?: useFormExtractor - getter: G - setter: S +// Possible future ideas +// TODO: Scroll to error field +// TODO: Focus on error field + +export type FieldOptions = { + extractor?: useFormExtractor | null + getter?: G + setter?: S } type RuleFunctionReturn = boolean | string -type RuleFunction = (value: I) => RuleFunctionReturn | Promise -type Rule = RuleFunction | RegExp -type RuleObject = Rule | { rule: Rule; message: string } -type RuleSet = RuleObject | RuleObject[] +type RuleFunction = (value: I, data: F) => RuleFunctionReturn | Promise +type Rule = RuleFunction | RegExp +type RuleObject = Rule | { rule: Rule; message: string } +type RuleSet = RuleObject | RuleObject[] -function isSimpleRule(obj: RuleObject): obj is Rule { +function isSimpleRule(obj: RuleObject): obj is Rule { return obj instanceof RegExp || typeof obj === 'function' } -export type useFormExtractor = (from: any) => any +export type useFormExtractor = (from: any) => T +export const NoExtractor: useFormExtractor = (v: unknown) => v export const HTMLInputExtractor: useFormExtractor = (e: React.FormEvent) => e.currentTarget.value export const HTMLCheckboxExtractor: useFormExtractor = (e: React.FormEvent) => e.currentTarget.checked export type FormOptions = { rules: R - // fields: FieldOptions } -// Form = Type of form +// F = Type of form // R = Rules, derived from F // E = Errors, derived from F -export const useForm =
}, E extends { [key in keyof R]?: RuleFunctionReturn }>(init: Form, options?: FormOptions) => { +export const useForm = }, E extends { [key in keyof R]?: RuleFunctionReturn }>(init: F, options?: FormOptions) => { const validators: R = options?.rules ?? ({} as R) - const [form, setForm] = useState(init) + const [form, setForm] = useState(init) const [errors, setErrors] = useState({} as E) - const [isValid, setIsValid] = useState(true) - - useEffect(() => { - setIsValid(!Object.values(errors).reduce((acc, cur) => acc || cur !== undefined, false)) + const isValid = useMemo(() => { + return !Object.values(errors).reduce((acc, cur) => acc || cur !== undefined, false) }, [errors]) - const setField = (key: A, value: Form[A]) => { + const setField = (key: A, value: F[A]) => { setForm({ ...form, [key]: value, }) } - async function applyRule(value: any, rule: Rule): Promise { - if (typeof rule === 'function') return await rule(value) + async function applyRule(value: any, rule: Rule): Promise { + if (typeof rule === 'function') return await rule(value, form) if (rule instanceof RegExp) return rule.test(value) throw new Error(`Unsupported validator: ${rule}`) } - async function validate(key: K, value: Form[K]) { - const set: RuleSet | undefined = validators[key] as any + async function validate(key: K, value: F[K]) { + const set: RuleSet | undefined = validators[key] as any if (!set) return const rules = Array.isArray(set) ? set : [set] @@ -74,18 +76,19 @@ export const useForm = (key: A, extractor?: (e: RAW) => Form[A]) { - return (value: RAW) => { - const extracted = extractor ? extractor(value) : HTMLInputExtractor(value) + // Internal use + function update(key: A, extractor?: useFormExtractor | null) { + return (value: any) => { + const extracted = extractor ? extractor(value) : extractor === undefined ? HTMLInputExtractor(value) : value setField(key, extracted) validate(key, extracted) } } - type FieldReturn = { [getter in G]: ReturnType> } & { [setter in S]: Form[K] } - function field(key: K): FieldReturn - function field(key: K, opts: FieldOptions): FieldReturn - function field(key: K, opts?: FieldOptions): FieldReturn { + type FieldReturn = { [getter in G]: ReturnType> } & { [setter in S]: F[K] } + function field(key: K): FieldReturn + function field(key: K, opts: FieldOptions): FieldReturn + function field(key: K, opts?: FieldOptions): FieldReturn { return { [opts?.getter || 'onChange']: update(key, opts?.extractor), [opts?.setter || 'value']: form[key], diff --git a/test/basic.test.tsx b/test/basic.test.tsx index e1aa707..f00165a 100644 --- a/test/basic.test.tsx +++ b/test/basic.test.tsx @@ -1,34 +1,15 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import React, { useEffect } from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import React from 'react' import { beforeEach, describe, expect, test } from 'vitest' import { useForm } from '../lib' +import { Insight, Util } from './shared' beforeEach(cleanup) -const Insight = { - Portal({ data }: { data: any }) { - return
{JSON.stringify(data)}
- }, - async verify(obj: any) { - const result = await screen.findByTestId('result') - const data = JSON.parse(result.innerText) - expect(data).toMatchObject(obj) - }, -} - -const Util = { - find(id: string) { - return screen.findByTestId(id) - }, - writeToField(node: HTMLInputElement, value: string) { - fireEvent.change(node, { target: { value } }) - }, -} - describe('Field', () => { test('Basic Form', async () => { - const BasicForm = () => { + function Component() { const form = useForm({ username: '', password: '' }) const { field } = form return ( @@ -47,7 +28,7 @@ describe('Field', () => { ) } - render() + render() async function inputIntoForm(id: string, value: string) { const node = await Util.find(id) await act(() => { @@ -60,27 +41,65 @@ describe('Field', () => { await inputIntoForm('password', 'bar') }) - test('setField', async () => { - const value = 'foo' - const Component = () => { - const { field, setField, form } = useForm({ username: '', password: '' }) - useEffect(() => setField('username', value), []) + test.skip('Checkbox', async () => { + function Component() { + const { field, form } = useForm({ cool: false }) return ( -
- + + e.target.checked, + })} + /> -
+ ) } + render() - const node = await screen.findByTestId('field') - expect(node.value).toBe(value) - Insight.verify({ username: value, password: '' }) + const field = await Util.find('field') + expect(field.checked).toBe(false) + await Insight.verify({ cool: false }) + await act(() => { + // Bugged for now + fireEvent.click(field) + }) + expect(field.checked).toBe(true) + await Insight.verify({ cool: true }) + }) + + test('Select', async () => { + function Component() { + const { form, field } = useForm({ letter: '' }) + return ( + <> + + + + ) + } + + render() + const field = await Util.find('field') + const value = 'b' + await act(() => { + fireEvent.change(field, { target: { value } }) + }) + expect(field.value).toBe(value) + await Insight.verify({ letter: value }) }) test('Field sync', async () => { const value = 'foo' - const Component = () => { + function Component() { const { field, form } = useForm({ name: '' }) return (
@@ -101,54 +120,3 @@ describe('Field', () => { expect(a.value).toBe(b.value) }) }) - -describe('Validation', () => { - test('Basic', async () => { - const Component = () => { - const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8] } }) - - return ( -
- - -
- ) - } - render() - const node = await Util.find('field') - await act(() => { - Util.writeToField(node, '123') - }) - Insight.verify({ password: true }) - }) - - test('Array of rules', async () => { - const Component = () => { - const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8, /#/] } }) - - return ( -
- - -
- ) - } - render() - const node = await Util.find('field') - await act(() => { - Util.writeToField(node, '12345678') - }) - Insight.verify({ password: true }) - await act(() => { - Util.writeToField(node, '1234#5678') - }) - Insight.verify({}) - }) -}) - -// Is valid -// Reset / setForm -// Set error -// Checkbox -// Extractor -// Custom extractor diff --git a/test/blocks.tsx b/test/blocks.tsx new file mode 100644 index 0000000..08d07c9 --- /dev/null +++ b/test/blocks.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +// Custom field with non standard setter and getter. Emulate custom component from a library +export function NumberField(props: { number: number; update: (value: number) => void }) { + return props.update(parseInt(e.target.value))} /> +} + +// Component that needs a different extractor, as it's returning the actual value and not the event. +export function DirectReturnInput(props: { value: string; onChange: (v: string) => void }) { + return props.onChange(e.target.value)} /> +} diff --git a/test/options.test.tsx b/test/options.test.tsx new file mode 100644 index 0000000..ab14a69 --- /dev/null +++ b/test/options.test.tsx @@ -0,0 +1,57 @@ +import { act, cleanup, render } from '@testing-library/react' +import React from 'react' +import { beforeEach, describe, test } from 'vitest' + +import { useForm } from '../lib' +import { DirectReturnInput, NumberField } from './blocks' +import { Insight, Util } from './shared' + +beforeEach(cleanup) + +describe('Options', () => { + test('Custom component props', async () => { + function Component() { + const { form, field } = useForm({ foo: 5 }) + + return ( +
+ + +
+ ) + } + + render() + const node = await Util.find('field') + await act(() => { + Util.writeToField(node, '123') + }) + Insight.verify({ foo: 123 }) + }) + + test('Disable default extractor', async () => { + function Component() { + const { form, field } = useForm({ username: '' }) + + return ( +
+ + +
+ ) + } + + render() + const node = await Util.find('field') + await act(() => { + Util.writeToField(node, '123') + }) + Insight.verify({ username: '123' }) + }) +}) diff --git a/test/shared.tsx b/test/shared.tsx new file mode 100644 index 0000000..53fb3ab --- /dev/null +++ b/test/shared.tsx @@ -0,0 +1,23 @@ +import { fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { expect } from 'vitest' + +export const Insight = { + Portal({ data }: { data: any }) { + return
{JSON.stringify(data)}
+ }, + async verify(obj: any) { + const result = await screen.findByTestId('result') + const data = JSON.parse(result.innerText) + expect(data).toMatchObject(obj) + }, +} + +export const Util = { + find(id: string) { + return screen.findByTestId(id) + }, + writeToField(node: HTMLInputElement, value: string) { + fireEvent.change(node, { target: { value } }) + }, +} diff --git a/test/utility.test.tsx b/test/utility.test.tsx new file mode 100644 index 0000000..fa8bc41 --- /dev/null +++ b/test/utility.test.tsx @@ -0,0 +1,53 @@ +import { cleanup, render } from '@testing-library/react' +import React, { useEffect } from 'react' +import { beforeEach, describe, expect, test } from 'vitest' + +import { useForm } from '../lib' +import { Insight, Util } from './shared' + +beforeEach(cleanup) + +describe('Utility', () => { + test('Manually set a single field', async () => { + const value = 'foo' + function Component() { + const { field, setField, form } = useForm({ username: '', password: '' }) + useEffect(() => setField('username', value), []) + return ( +
+ + +
+ ) + } + render() + const node = await Util.find('field') + expect(node.value).toBe(value) + Insight.verify({ username: value, password: '' }) + }) + + test('Manually set the form state later on', async () => { + const value = 'foo' + function Component() { + const { form, field, setForm } = useForm({ username: '' }) + + useEffect(() => { + setForm({ + username: value, + }) + }, []) + + return ( + + + + + ) + } + + render() + const node = await Util.find('username') + expect(node.value).toBe(value) + await Insight.verify({ username: value }) + }) +}) diff --git a/test/validation.test.tsx b/test/validation.test.tsx new file mode 100644 index 0000000..2f6d8e8 --- /dev/null +++ b/test/validation.test.tsx @@ -0,0 +1,109 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import React from 'react' +import { beforeEach, describe, test } from 'vitest' + +import { useForm } from '../lib' +import { Insight, Util } from './shared' + +beforeEach(cleanup) + +describe('Validation', () => { + test('Basic', async () => { + function Component() { + const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8] } }) + + return ( +
+ + +
+ ) + } + render() + const node = await Util.find('field') + await act(() => { + Util.writeToField(node, '123') + }) + Insight.verify({ password: true }) + }) + + test('Array of rules', async () => { + function Component() { + const { errors, field } = useForm({ password: '' }, { rules: { password: [(p) => p.length > 8, /#/] } }) + + return ( +
+ + +
+ ) + } + render() + const node = await Util.find('field') + await act(() => { + Util.writeToField(node, '12345678') + }) + Insight.verify({ password: true }) + await act(() => { + Util.writeToField(node, '1234#5678') + }) + Insight.verify({}) + }) + + // https://github.com/testing-library/react-testing-library/issues/828 + test.skip('Invalid rule', async () => { + function Component() { + const { field } = useForm( + { username: '' }, + { + rules: { + username: [ + // @ts-ignore Give an invalid rules and expect to fail + 5, + ], + }, + } + ) + + return ( +
+ +
+ ) + } + + render() + const field = await Util.find('field') + await act(() => { + Util.writeToField(field, 'abc') + }) + }) + + test('Invalid dependency on other component', async () => { + function Component() { + const { errors, field } = useForm( + { min: 10, max: 20 }, + { + rules: { + max: (value, form) => value > form.min, + }, + } + ) + return ( +
+ + + + + ) + } + + render() + const field = await Util.find('max') + const value = 5 + await act(() => { + fireEvent.change(field, { target: { value } }) + }) + await Insight.verify({ max: true }) + }) +})