mirror of
https://github.com/cupcakearmy/formhero.git
synced 2024-12-22 08:06:24 +00:00
testing
This commit is contained in:
parent
a45bfdfe08
commit
ae53882a82
65
lib/index.ts
65
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<G extends string = 'onChange', S extends string = 'value'> = {
|
||||
extractor?: useFormExtractor
|
||||
getter: G
|
||||
setter: S
|
||||
// Possible future ideas
|
||||
// TODO: Scroll to error field
|
||||
// TODO: Focus on error field
|
||||
|
||||
export type FieldOptions<G extends string = 'onChange', S extends string = 'value', T = any> = {
|
||||
extractor?: useFormExtractor<T> | null
|
||||
getter?: G
|
||||
setter?: S
|
||||
}
|
||||
|
||||
type RuleFunctionReturn = boolean | string
|
||||
type RuleFunction<I> = (value: I) => RuleFunctionReturn | Promise<RuleFunctionReturn>
|
||||
type Rule<I> = RuleFunction<I> | RegExp
|
||||
type RuleObject<I> = Rule<I> | { rule: Rule<I>; message: string }
|
||||
type RuleSet<I> = RuleObject<I> | RuleObject<I>[]
|
||||
type RuleFunction<I, F> = (value: I, data: F) => RuleFunctionReturn | Promise<RuleFunctionReturn>
|
||||
type Rule<I, F> = RuleFunction<I, F> | RegExp
|
||||
type RuleObject<I, F> = Rule<I, F> | { rule: Rule<I, F>; message: string }
|
||||
type RuleSet<I, F> = RuleObject<I, F> | RuleObject<I, F>[]
|
||||
|
||||
function isSimpleRule<I>(obj: RuleObject<I>): obj is Rule<I> {
|
||||
function isSimpleRule<I, F>(obj: RuleObject<I, F>): obj is Rule<I, F> {
|
||||
return obj instanceof RegExp || typeof obj === 'function'
|
||||
}
|
||||
|
||||
export type useFormExtractor = (from: any) => any
|
||||
export type useFormExtractor<T = any> = (from: any) => T
|
||||
export const NoExtractor: useFormExtractor = (v: unknown) => v
|
||||
export const HTMLInputExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.value
|
||||
export const HTMLCheckboxExtractor: useFormExtractor = (e: React.FormEvent<HTMLInputElement>) => e.currentTarget.checked
|
||||
|
||||
export type FormOptions<R> = {
|
||||
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 = <Form extends object, R extends { [K in keyof Form]?: RuleSet<Form[K]> }, E extends { [key in keyof R]?: RuleFunctionReturn }>(init: Form, options?: FormOptions<R>) => {
|
||||
export const useForm = <F extends object, R extends { [K in keyof F]?: RuleSet<F[K], F> }, E extends { [key in keyof R]?: RuleFunctionReturn }>(init: F, options?: FormOptions<R>) => {
|
||||
const validators: R = options?.rules ?? ({} as R)
|
||||
|
||||
const [form, setForm] = useState<Form>(init)
|
||||
const [form, setForm] = useState<F>(init)
|
||||
const [errors, setErrors] = useState<E>({} as E)
|
||||
const [isValid, setIsValid] = useState<boolean>(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 = <A extends keyof Form>(key: A, value: Form[A]) => {
|
||||
const setField = <A extends keyof F>(key: A, value: F[A]) => {
|
||||
setForm({
|
||||
...form,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
|
||||
async function applyRule<I>(value: any, rule: Rule<I>): Promise<RuleFunctionReturn> {
|
||||
if (typeof rule === 'function') return await rule(value)
|
||||
async function applyRule<I>(value: any, rule: Rule<I, F>): Promise<RuleFunctionReturn> {
|
||||
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<K extends keyof Form>(key: K, value: Form[K]) {
|
||||
const set: RuleSet<Form[K]> | undefined = validators[key] as any
|
||||
async function validate<K extends keyof F>(key: K, value: F[K]) {
|
||||
const set: RuleSet<F[K], F> | undefined = validators[key] as any
|
||||
if (!set) return
|
||||
|
||||
const rules = Array.isArray(set) ? set : [set]
|
||||
@ -74,18 +76,19 @@ export const useForm = <Form extends object, R extends { [K in keyof Form]?: Rul
|
||||
})
|
||||
}
|
||||
|
||||
function update<A extends keyof Form, RAW = any>(key: A, extractor?: (e: RAW) => Form[A]) {
|
||||
return (value: RAW) => {
|
||||
const extracted = extractor ? extractor(value) : HTMLInputExtractor(value)
|
||||
// Internal use
|
||||
function update<A extends keyof F>(key: A, extractor?: useFormExtractor<F[A]> | null) {
|
||||
return (value: any) => {
|
||||
const extracted = extractor ? extractor(value) : extractor === undefined ? HTMLInputExtractor(value) : value
|
||||
setField(key, extracted)
|
||||
validate(key, extracted)
|
||||
}
|
||||
}
|
||||
|
||||
type FieldReturn<K extends keyof Form, G extends string, S extends string> = { [getter in G]: ReturnType<typeof update<K>> } & { [setter in S]: Form[K] }
|
||||
function field<K extends keyof Form>(key: K): FieldReturn<K, 'onChange', 'value'>
|
||||
function field<K extends keyof Form, G extends string, S extends string>(key: K, opts: FieldOptions<G, S>): FieldReturn<K, G, S>
|
||||
function field<K extends keyof Form, G extends string, S extends string>(key: K, opts?: FieldOptions<G, S>): FieldReturn<K, G, S> {
|
||||
type FieldReturn<K extends keyof F, G extends string, S extends string> = { [getter in G]: ReturnType<typeof update<K>> } & { [setter in S]: F[K] }
|
||||
function field<K extends keyof F>(key: K): FieldReturn<K, 'onChange', 'value'>
|
||||
function field<K extends keyof F, G extends string, S extends string>(key: K, opts: FieldOptions<G, S, F[K]>): FieldReturn<K, G, S>
|
||||
function field<K extends keyof F, G extends string, S extends string>(key: K, opts?: FieldOptions<G, S, F[K]>): FieldReturn<K, G, S> {
|
||||
return {
|
||||
[opts?.getter || 'onChange']: update<K>(key, opts?.extractor),
|
||||
[opts?.setter || 'value']: form[key],
|
||||
|
@ -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 <div data-testid="result">{JSON.stringify(data)}</div>
|
||||
},
|
||||
async verify(obj: any) {
|
||||
const result = await screen.findByTestId('result')
|
||||
const data = JSON.parse(result.innerText)
|
||||
expect(data).toMatchObject(obj)
|
||||
},
|
||||
}
|
||||
|
||||
const Util = {
|
||||
find<E extends HTMLElement = HTMLInputElement>(id: string) {
|
||||
return screen.findByTestId<E>(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(<BasicForm />)
|
||||
render(<Component />)
|
||||
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 (
|
||||
<div>
|
||||
<input data-testid="field" {...field('username')}></input>
|
||||
<form>
|
||||
<input
|
||||
data-testid="field"
|
||||
type="checkbox"
|
||||
{...field('cool', {
|
||||
setter: 'checked',
|
||||
getter: 'onChange',
|
||||
extractor: (e) => e.target.checked,
|
||||
})}
|
||||
/>
|
||||
<Insight.Portal data={form} />
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
const node = await screen.findByTestId<HTMLInputElement>('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 (
|
||||
<>
|
||||
<select data-testid="field" {...field('letter')}>
|
||||
<option value="a">A</option>
|
||||
<option value="b">B</option>
|
||||
<option value="c">C</option>
|
||||
</select>
|
||||
<Insight.Portal data={form} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
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 (
|
||||
<form>
|
||||
@ -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 (
|
||||
<div>
|
||||
<input {...field('password')} data-testid="field" />
|
||||
<Insight.Portal data={errors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Component />)
|
||||
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 (
|
||||
<div>
|
||||
<input {...field('password')} data-testid="field" />
|
||||
<Insight.Portal data={errors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Component />)
|
||||
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
|
||||
|
11
test/blocks.tsx
Normal file
11
test/blocks.tsx
Normal file
@ -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 <input data-testid="field" value={props.number} onChange={(e) => 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 <input data-testid="field" value={props.value} onChange={(e) => props.onChange(e.target.value)} />
|
||||
}
|
57
test/options.test.tsx
Normal file
57
test/options.test.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<NumberField
|
||||
{...field('foo', {
|
||||
setter: 'number',
|
||||
getter: 'update',
|
||||
extractor: null,
|
||||
})}
|
||||
/>
|
||||
<Insight.Portal data={form} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
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 (
|
||||
<div>
|
||||
<DirectReturnInput {...field('username', { extractor: null })} />
|
||||
<Insight.Portal data={form} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
const node = await Util.find('field')
|
||||
await act(() => {
|
||||
Util.writeToField(node, '123')
|
||||
})
|
||||
Insight.verify({ username: '123' })
|
||||
})
|
||||
})
|
23
test/shared.tsx
Normal file
23
test/shared.tsx
Normal file
@ -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 <div data-testid="result">{JSON.stringify(data)}</div>
|
||||
},
|
||||
async verify(obj: any) {
|
||||
const result = await screen.findByTestId('result')
|
||||
const data = JSON.parse(result.innerText)
|
||||
expect(data).toMatchObject(obj)
|
||||
},
|
||||
}
|
||||
|
||||
export const Util = {
|
||||
find<E extends HTMLElement = HTMLInputElement>(id: string) {
|
||||
return screen.findByTestId<E>(id)
|
||||
},
|
||||
writeToField(node: HTMLInputElement, value: string) {
|
||||
fireEvent.change(node, { target: { value } })
|
||||
},
|
||||
}
|
53
test/utility.test.tsx
Normal file
53
test/utility.test.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<input data-testid="field" {...field('username')}></input>
|
||||
<Insight.Portal data={form} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Component />)
|
||||
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 (
|
||||
<form>
|
||||
<input data-testid="username" {...field('username')} />
|
||||
<Insight.Portal data={form} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
const node = await Util.find('username')
|
||||
expect(node.value).toBe(value)
|
||||
await Insight.verify({ username: value })
|
||||
})
|
||||
})
|
109
test/validation.test.tsx
Normal file
109
test/validation.test.tsx
Normal file
@ -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 (
|
||||
<div>
|
||||
<input {...field('password')} data-testid="field" />
|
||||
<Insight.Portal data={errors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Component />)
|
||||
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 (
|
||||
<div>
|
||||
<input {...field('password')} data-testid="field" />
|
||||
<Insight.Portal data={errors} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
render(<Component />)
|
||||
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 (
|
||||
<form>
|
||||
<input data-testid="field" {...field('username')} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
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 (
|
||||
<form>
|
||||
<input type="number" {...field('min')} />
|
||||
<input type="number" {...field('max')} data-testid="max" />
|
||||
<Insight.Portal data={errors} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
render(<Component />)
|
||||
const field = await Util.find('max')
|
||||
const value = 5
|
||||
await act(() => {
|
||||
fireEvent.change(field, { target: { value } })
|
||||
})
|
||||
await Insight.verify({ max: true })
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user