@coxy/react-validator 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rules.ts CHANGED
@@ -1,70 +1,9 @@
1
- import type { ValidatorRule } from './types'
1
+ import { z } from 'zod'
2
2
 
3
- // eslint-disable-next-line
4
- const emailReg =
5
- /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
6
-
7
- export type ValidatorRules = ValidatorRule[]
3
+ export type ValidatorRules = z.ZodType[] | z.ZodType
8
4
 
9
5
  export const rules = {
10
- notEmpty: [
11
- {
12
- rule: (value) => value !== '' && value.length > 0,
13
- message: 'Value is required',
14
- },
15
- ],
16
-
17
- bool: [
18
- {
19
- rule: (value) => !!value,
20
- message: 'Value is required',
21
- },
22
- ],
23
-
24
- password: [
25
- {
26
- rule: (value) => value.length > 0,
27
- message: 'Password field cannot be empty',
28
- },
29
- {
30
- rule: (value) => value.length > 5,
31
- message: 'Password field can not be less than 6 characters',
32
- },
33
- ],
34
-
35
- email: [
36
- {
37
- rule: (value) => !!value && value !== '' && value.length !== 0,
38
- message: 'Email is required',
39
- },
40
- {
41
- rule: (value) => emailReg.test(String(value).toLowerCase()),
42
- message: 'Email is invalid',
43
- },
44
- ],
45
-
46
- min: (min) => [
47
- {
48
- rule: (value) => Number.parseFloat(value) > min,
49
- message: `The value must be greater than ${min}`,
50
- },
51
- ],
52
-
53
- max: (max) => [
54
- {
55
- rule: (value) => Number.parseFloat(value) < max,
56
- message: `The value must be smaller ${max}`,
57
- },
58
- ],
59
-
60
- length: (min, max?) => [
61
- {
62
- rule: (value) => String(value).length >= min,
63
- message: `No less than ${min} symbols`,
64
- },
65
- {
66
- rule: (value) => (max !== undefined ? String(value).length <= max : true),
67
- message: `No more than ${max} symbols`,
68
- },
69
- ],
6
+ notEmpty: [z.string().min(1, { error: 'Field is required' })],
7
+ isTrue: [z.boolean({ error: 'Value is required' }).and(z.literal(true))],
8
+ email: [z.string().min(1, { error: 'Email is required' }), z.email({ message: 'Email is invalid' })],
70
9
  }
package/src/types.ts CHANGED
@@ -1,13 +1,7 @@
1
+ import type { ZodSafeParseResult } from 'zod/index'
1
2
  import type { ValidatorRules } from './rules'
2
3
  import type { Value } from './validator-field'
3
4
 
4
- type Fn = (value: Value) => string
5
-
6
- export interface ValidatorRule {
7
- rule: (value: Value) => boolean
8
- message: string | Fn
9
- }
10
-
11
5
  export interface ErrorMessage {
12
6
  message: string
13
7
  isValid: boolean
@@ -16,7 +10,7 @@ export interface ErrorMessage {
16
10
  export interface Validity {
17
11
  message: string
18
12
  isValid: boolean
19
- errors?: ErrorMessage[]
13
+ result: ZodSafeParseResult<unknown>
20
14
  id?: string | number
21
15
  }
22
16
 
@@ -2,8 +2,7 @@
2
2
  * @jest-environment jsdom
3
3
  */
4
4
 
5
- import { render, screen } from '@testing-library/react'
6
- import { act } from '@testing-library/react'
5
+ import { act, render, screen } from '@testing-library/react'
7
6
  import { useEffect, useState } from 'react'
8
7
 
9
8
  import { rules } from './rules'
@@ -25,14 +24,14 @@ jest.useFakeTimers()
25
24
  it('check state change and hide field', () => {
26
25
  function Comp() {
27
26
  const [value, setValue] = useState(false)
28
- const [isValid, validateObject] = useValidator(value, rules.bool)
27
+ const [isValid, validateObject] = useValidator(value, rules.isTrue)
29
28
 
30
29
  useEffect(() => {
31
30
  setTimeout(() => {
32
31
  act(() => {
33
32
  setValue(true)
34
33
  })
35
- }, 100)
34
+ }, 200)
36
35
  }, [])
37
36
 
38
37
  return (
@@ -47,7 +46,7 @@ it('check state change and hide field', () => {
47
46
  })
48
47
 
49
48
  expect(screen.getByTestId('test1').textContent).toContain('false')
50
- expect(screen.getByTestId('test2').textContent).toContain('Value is required')
49
+ expect(screen.getByTestId('test2').textContent).toContain('Invalid input: expected true')
51
50
 
52
51
  jest.runAllTimers()
53
52
 
@@ -3,7 +3,7 @@ import type { Validity } from './types'
3
3
  import { Validator } from './validator'
4
4
  import type { Value } from './validator-field'
5
5
 
6
- export function useValidator(value: Value, rules: ValidatorRules): [boolean, Pick<Validity, 'message' | 'errors'>] {
6
+ export function useValidator(value: Value, rules: ValidatorRules): [boolean, Pick<Validity, 'message'>] {
7
7
  const validator = new Validator()
8
8
  validator.addField({ value, rules })
9
9
  const { isValid, ...validateObject } = validator.validate()
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { act, render } from '@testing-library/react'
6
6
  import { createRef, useEffect, useState } from 'react'
7
-
8
7
  import { rules } from './rules'
9
8
  import { ValidatorField } from './validator-field'
10
9
  import { ValidatorWrapper } from './validator-wrapper'
@@ -21,7 +20,7 @@ it('check context validator', () => {
21
20
  const validator = createRef<ValidatorWrapper>()
22
21
  render(
23
22
  <ValidatorWrapper ref={validator}>
24
- <ValidatorField rules={[]} />
23
+ <ValidatorField rules={[]} value="" />
25
24
  </ValidatorWrapper>,
26
25
  )
27
26
 
@@ -51,13 +50,11 @@ it('check failed validation', () => {
51
50
 
52
51
  expect(validateResult1.isValid).toBe(false)
53
52
  expect(validateResult1.message).toBe('Email is invalid')
54
- expect(validateResult1.errors.length).toBe(1)
55
53
 
56
54
  const validateResult2 = validator2.current.validate()
57
55
 
58
56
  expect(validateResult2.isValid).toBe(false)
59
57
  expect(validateResult2.message).toBe('Email is required')
60
- expect(validateResult2.errors.length).toBe(1)
61
58
  })
62
59
  })
63
60
 
@@ -93,7 +90,6 @@ it('check state change and hide field', () => {
93
90
 
94
91
  expect(validateResult1.isValid).toBe(false)
95
92
  expect(validateResult1.message).toBe('Email is invalid')
96
- expect(validateResult1.errors.length).toBe(1)
97
93
  })
98
94
 
99
95
  it('check success validation', () => {
@@ -126,24 +122,33 @@ it('check success validation fot child function', () => {
126
122
  expect(validateResult.message).toBe('')
127
123
  })
128
124
 
129
- it('check custom rule message function', () => {
125
+ jest.useFakeTimers()
126
+
127
+ it('re-renders the same field to cover handleRef initialization false branch and else-validate path', () => {
130
128
  const validator = createRef<ValidatorWrapper>()
131
- const rule = [
132
- {
133
- rule: (value) => value !== 'test',
134
- message: (value) => `test message ${value}`,
135
- },
136
- ]
137
- render(
138
- <ValidatorWrapper ref={validator}>
139
- <ValidatorField rules={rule} value="test">
140
- {({ isValid, message }) => <>{!isValid && <div>{message}</div>}</>}
141
- </ValidatorField>
142
- </ValidatorWrapper>,
143
- )
144
129
 
145
- const validateResult = validator.current.validate()
130
+ function Comp() {
131
+ const [val, setVal] = useState('')
132
+ useEffect(() => {
133
+ setTimeout(() => {
134
+ act(() => setVal('abc'))
135
+ }, 50)
136
+ }, [])
137
+ return (
138
+ <ValidatorWrapper ref={validator}>
139
+ <ValidatorField id="rerender" rules={rules.notEmpty} value={val}>
140
+ {() => null}
141
+ </ValidatorField>
142
+ </ValidatorWrapper>
143
+ )
144
+ }
146
145
 
147
- expect(validateResult.isValid).toBe(false)
148
- expect(validateResult.message).toBe('test message test')
146
+ render(<Comp />)
147
+ // initial: invalid
148
+ let field = validator.current.getField('rerender')
149
+ expect(field.validate().isValid).toBe(false)
150
+
151
+ jest.runAllTimers()
152
+ field = validator.current.getField('rerender')
153
+ expect(field.validate().isValid).toBe(true)
149
154
  })
@@ -1,69 +1,52 @@
1
- import { Component, type ReactNode } from 'react'
2
- import type { Validity } from 'types'
1
+ import { forwardRef, type ReactNode, useContext, useEffect, useRef } from 'react'
2
+ import type { FieldParams, Validity } from 'types'
3
3
 
4
- import { Context } from './context'
5
- import type { ValidatorRules } from './rules'
4
+ import { Context, type RegisteredFieldHandle } from './context'
6
5
  import { Field } from './validator'
7
6
 
8
- // biome-ignore lint/suspicious/noExplicitAny: <explanation>
7
+ // biome-ignore lint/suspicious/noExplicitAny: <need>
9
8
  export type Value = any
10
9
 
11
10
  type Fn = (validity: Validity, value: Value) => ReactNode
12
11
 
13
- interface Props {
14
- rules?: ValidatorRules
15
- required?: boolean
16
- value?: Value
17
- id?: string | number
12
+ type Props = FieldParams & {
18
13
  children?: ReactNode | Fn
19
- unregisterField: (val: Value) => void
20
- registerField: (val: Value) => void
21
- customErrors: Array<Validity>
22
14
  }
23
15
 
24
- class ValidationFieldWrapper extends Component<Props> {
25
- componentWillUnmount() {
26
- this.props.unregisterField(this)
27
- }
28
-
29
- componentDidMount() {
30
- this.props.registerField(this)
16
+ export const ValidatorField = forwardRef<unknown, Props>(function ValidatorField(props: Props, _ref) {
17
+ const { children, value } = props
18
+ const { registerField, unregisterField } = useContext(Context)
19
+
20
+ const propsRef = useRef(props)
21
+ propsRef.current = props
22
+
23
+ const handleRef = useRef<RegisteredFieldHandle | null>(null)
24
+ if (!handleRef.current) {
25
+ handleRef.current = {
26
+ get props() {
27
+ return propsRef.current
28
+ },
29
+ validate: () => {
30
+ const curr = propsRef.current
31
+ const field = new Field({
32
+ rules: curr.rules,
33
+ required: curr.required,
34
+ value: curr.value,
35
+ id: curr.id,
36
+ })
37
+ return field.validate()
38
+ },
39
+ }
31
40
  }
32
41
 
33
- validate(): Validity {
34
- const props = this.props
35
- const customError = props.customErrors.find((item) => item.id === props.id)
36
- if (customError) {
37
- return customError
42
+ useEffect(() => {
43
+ registerField(handleRef.current as RegisteredFieldHandle)
44
+ return () => {
45
+ unregisterField(handleRef.current as RegisteredFieldHandle)
38
46
  }
47
+ }, [registerField, unregisterField])
39
48
 
40
- const field = new Field({
41
- rules: props.rules,
42
- required: props.required,
43
- value: props.value,
44
- id: props.id,
45
- })
46
- return field.validate()
47
- }
48
-
49
- render() {
50
- const { children, value } = this.props
51
- const validity = this.validate()
52
- return typeof children === 'function' ? children(validity, value) : children
53
- }
54
- }
49
+ const validity = handleRef.current.validate()
55
50
 
56
- export function ValidatorField(props: Omit<Props, 'registerField' | 'unregisterField' | 'customErrors'>) {
57
- return (
58
- <Context.Consumer>
59
- {(data) => (
60
- <ValidationFieldWrapper
61
- {...props}
62
- customErrors={data.customErrors}
63
- registerField={data.registerField}
64
- unregisterField={data.unregisterField}
65
- />
66
- )}
67
- </Context.Consumer>
68
- )
69
- }
51
+ return typeof children === 'function' ? (children as Fn)(validity, value) : (children as ReactNode)
52
+ })
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { render } from '@testing-library/react'
6
6
  import { createRef } from 'react'
7
-
7
+ import type { Validity } from 'types'
8
8
  import { rules } from './rules'
9
9
  import { ValidatorField } from './validator-field'
10
10
  import { ValidatorWrapper } from './validator-wrapper'
@@ -13,12 +13,12 @@ it('check wrapper validator', () => {
13
13
  const validator = createRef<ValidatorWrapper>()
14
14
  render(
15
15
  <ValidatorWrapper ref={validator}>
16
- <ValidatorField rules={[]} />
17
- <ValidatorField rules={[]} />
18
- <ValidatorField rules={[]} />
19
- <ValidatorField rules={[]} />
20
- <ValidatorField rules={[]} />
21
- <ValidatorField rules={[]} />
16
+ <ValidatorField rules={[]} value="" />
17
+ <ValidatorField rules={[]} value="" />
18
+ <ValidatorField rules={[]} value="" />
19
+ <ValidatorField rules={[]} value="" />
20
+ <ValidatorField rules={[]} value="" />
21
+ <ValidatorField rules={[]} value="" />
22
22
  </ValidatorWrapper>,
23
23
  )
24
24
 
@@ -30,8 +30,8 @@ it('check getField validator', () => {
30
30
  const validator = createRef<ValidatorWrapper>()
31
31
  render(
32
32
  <ValidatorWrapper ref={validator}>
33
- <ValidatorField rules={[]} id="test" />
34
- <ValidatorField rules={[]} id="test-fields" />
33
+ <ValidatorField rules={[]} id="test" value="" />
34
+ <ValidatorField rules={[]} id="test-fields" value="" />
35
35
  </ValidatorWrapper>,
36
36
  )
37
37
  expect(typeof validator.current.getField).toBe('function')
@@ -48,7 +48,7 @@ it('check getField undefined field', () => {
48
48
  const validator = createRef<ValidatorWrapper>()
49
49
  render(
50
50
  <ValidatorWrapper ref={validator}>
51
- <ValidatorField rules={[]} id="test-empty-field" />
51
+ <ValidatorField rules={[]} id="test-empty-field" value="" />
52
52
  </ValidatorWrapper>,
53
53
  )
54
54
 
@@ -62,20 +62,20 @@ it('check stopAtFirstError validator', () => {
62
62
  <ValidatorWrapper ref={validator} stopAtFirstError>
63
63
  <ValidatorField rules={[]} value="test" />
64
64
  <ValidatorField rules={rules.email} value="test" />
65
- <ValidatorField rules={rules.password} value="" />
65
+ <ValidatorField rules={rules.isTrue} value={false} />
66
66
  </ValidatorWrapper>,
67
67
  )
68
+
68
69
  const fieldValidate = validator.current.validate()
69
70
  expect(fieldValidate.isValid).toBe(false)
70
71
  expect(fieldValidate.message).toBe('Email is invalid')
71
- expect(fieldValidate.errors.length).toBe(1)
72
72
  })
73
73
 
74
74
  it('check unregisterField, registerField', () => {
75
75
  const validator = createRef<ValidatorWrapper>()
76
76
  render(
77
77
  <ValidatorWrapper ref={validator}>
78
- <ValidatorField rules={[]} id="test-register-field" />
78
+ <ValidatorField rules={[]} id="test-register-field" value="" />
79
79
  </ValidatorWrapper>,
80
80
  )
81
81
 
@@ -87,9 +87,9 @@ it('check filed in field', () => {
87
87
  const validator = createRef<ValidatorWrapper>()
88
88
  render(
89
89
  <ValidatorWrapper ref={validator}>
90
- <ValidatorField rules={[]}>
91
- <ValidatorField rules={[]} id="check-validate-field-1" />
92
- <ValidatorField rules={[]} id="check-validate-field-2" />
90
+ <ValidatorField rules={[]} value="">
91
+ <ValidatorField rules={[]} id="check-validate-field-1" value="" />
92
+ <ValidatorField rules={[]} id="check-validate-field-2" value="" />
93
93
  </ValidatorField>
94
94
  </ValidatorWrapper>,
95
95
  )
@@ -107,7 +107,7 @@ it('check wrapper in wrapper', () => {
107
107
  <ValidatorWrapper ref={validatorOut}>
108
108
  <ValidatorField rules={rules.email} value="" />
109
109
  <ValidatorWrapper ref={validatorIn}>
110
- <ValidatorField rules={rules.password} value="successpasswword" />
110
+ <ValidatorField rules={rules.isTrue} value={true} />
111
111
  </ValidatorWrapper>
112
112
  </ValidatorWrapper>,
113
113
  )
@@ -121,7 +121,7 @@ it('check two validators', () => {
121
121
  render(
122
122
  <>
123
123
  <ValidatorWrapper ref={validatorSuccess}>
124
- <ValidatorField rules={rules.password} value="successpasswword" />
124
+ <ValidatorField rules={rules.notEmpty} value="successpasswword" />
125
125
  </ValidatorWrapper>
126
126
  <ValidatorWrapper ref={validatorFailed}>
127
127
  <ValidatorField rules={rules.email} value="" />
@@ -132,3 +132,21 @@ it('check two validators', () => {
132
132
  expect(validatorFailed.current.validate().isValid).toBe(false)
133
133
  expect(validatorSuccess.current.validate().isValid).toBe(true)
134
134
  })
135
+
136
+ it('covers registerField duplicate and unregisterField non-existing branches', () => {
137
+ const validator = createRef<ValidatorWrapper>()
138
+ render(
139
+ <ValidatorWrapper ref={validator}>
140
+ <ValidatorField rules={[]} id="dup-field" value="" />
141
+ </ValidatorWrapper>,
142
+ )
143
+
144
+ const handle = validator.current.getField('dup-field')
145
+ validator.current.registerField(handle)
146
+ validator.current.unregisterField(handle)
147
+ const dummy = {
148
+ props: { value: '', rules: [], id: 'dummy' },
149
+ validate: (): Validity => ({ isValid: true, message: '', result: { success: true, data: null } }),
150
+ }
151
+ validator.current.unregisterField(dummy)
152
+ })
@@ -1,75 +1,62 @@
1
- import { Component, type ReactNode, type RefObject } from 'react'
1
+ import { forwardRef, type ReactNode, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
2
2
 
3
- import { Context } from './context'
3
+ import { Context, type RegisteredFieldHandle } from './context'
4
4
  import type { Validity } from './types'
5
- import { type Field, Validator } from './validator'
5
+ import { Validator } from './validator'
6
6
 
7
7
  interface ComponentProps {
8
8
  children?: ReactNode
9
9
  stopAtFirstError?: boolean
10
- ref?: RefObject<ValidatorWrapper>
11
10
  }
12
11
 
13
- export class ValidatorWrapper extends Component<ComponentProps> {
14
- fields = []
15
- state = {
16
- customErrors: [],
17
- }
18
-
19
- constructor(props) {
20
- super(props)
21
- this.registerField = this.registerField.bind(this)
22
- this.unregisterField = this.unregisterField.bind(this)
23
- }
12
+ export interface ValidatorWrapper {
13
+ validate: () => Validity
14
+ getField: (id: string | number) => RegisteredFieldHandle | null
15
+ registerField: (field: RegisteredFieldHandle) => void
16
+ unregisterField: (field: RegisteredFieldHandle) => void
17
+ }
24
18
 
25
- componentWillUnmount() {
26
- this.fields = []
27
- }
19
+ export const ValidatorWrapper = forwardRef<ValidatorWrapper, ComponentProps>(function ValidatorWrapper(
20
+ { children, stopAtFirstError },
21
+ ref,
22
+ ) {
23
+ const fieldsRef = useRef<RegisteredFieldHandle[]>([])
28
24
 
29
- registerField(field) {
30
- if (field && !this.fields.includes(field)) {
31
- this.fields.push(field)
25
+ const registerField = useCallback((field: RegisteredFieldHandle) => {
26
+ if (field && !fieldsRef.current.includes(field)) {
27
+ fieldsRef.current.push(field)
32
28
  }
33
- }
34
-
35
- unregisterField(field) {
36
- const index = this.fields.indexOf(field)
37
- if (index > -1) this.fields.splice(index, 1)
38
- }
29
+ }, [])
39
30
 
40
- getField(id): Field | null {
41
- return this.fields.find((field) => field.props.id === id) || null
42
- }
31
+ const unregisterField = useCallback((field: RegisteredFieldHandle) => {
32
+ const index = fieldsRef.current.indexOf(field)
33
+ if (index > -1) fieldsRef.current.splice(index, 1)
34
+ }, [])
43
35
 
44
- setCustomError(customError: Validity) {
45
- this.setState({
46
- customErrors: [...this.state.customErrors, customError],
47
- })
48
- }
36
+ const getField = useCallback<ValidatorWrapper['getField']>((id) => {
37
+ return fieldsRef.current.find((field) => field?.props?.id === id) || null
38
+ }, [])
49
39
 
50
- clearCustomErrors() {
51
- this.setState({ customErrors: [] })
52
- }
53
-
54
- validate(): Validity {
55
- const validator = new Validator({ stopAtFirstError: this.props.stopAtFirstError })
56
- for (const comp of this.fields) {
40
+ const validate = useCallback<ValidatorWrapper['validate']>(() => {
41
+ const validator = new Validator({ stopAtFirstError })
42
+ for (const comp of fieldsRef.current) {
57
43
  validator.addField(comp.props)
58
44
  }
59
45
  return validator.validate()
60
- }
61
-
62
- render(): ReactNode {
63
- return (
64
- <Context.Provider
65
- value={{
66
- customErrors: this.state.customErrors,
67
- registerField: this.registerField,
68
- unregisterField: this.unregisterField,
69
- }}
70
- >
71
- {this.props.children}
72
- </Context.Provider>
73
- )
74
- }
75
- }
46
+ }, [stopAtFirstError])
47
+
48
+ useImperativeHandle(
49
+ ref,
50
+ () => ({
51
+ validate,
52
+ getField,
53
+ registerField,
54
+ unregisterField,
55
+ }),
56
+ [validate, getField, registerField, unregisterField],
57
+ )
58
+
59
+ const contextValue = useMemo(() => ({ registerField, unregisterField }), [registerField, unregisterField])
60
+
61
+ return <Context.Provider value={contextValue}>{children}</Context.Provider>
62
+ })
@@ -10,7 +10,7 @@ it('check normal create validator', () => {
10
10
  it('check normal add and remove fields', () => {
11
11
  const validator = new Validator({ stopAtFirstError: true })
12
12
  const fieldPassword = validator.addField({
13
- rules: rules.password,
13
+ rules: rules.email,
14
14
  value: '',
15
15
  id: 'for-remove',
16
16
  })
@@ -28,3 +28,13 @@ it('check normal add and remove fields', () => {
28
28
  newFieldSearchPassword = validator.getField('for-remove')
29
29
  expect(newFieldSearchPassword === null).toBe(true)
30
30
  })
31
+
32
+ it('removeField does nothing when field is not registered', () => {
33
+ const validator = new Validator()
34
+ // create a field-like object that validator doesn't know about
35
+ // import Field from the module is possible, but we can emulate shape using any
36
+ // Should not throw and should not alter state
37
+ // @ts-expect-error.
38
+ expect(() => validator.removeField({})).not.toThrow()
39
+ expect(validator.getField('unknown')).toBe(null)
40
+ })