Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding select and textarea. Also cleaning up typings for FieldChangeEventHandler. #15

Merged
merged 1 commit into from
Jun 6, 2024
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
2 changes: 1 addition & 1 deletion demo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@iwsio/forms-demo",
"version": "4.1.0",
"version": "4.2.0-alpha.1",
"private": true,
"devDependencies": {
"@babel/core": "^7.24.7",
Expand Down
4 changes: 2 additions & 2 deletions demo/src/samples/InvalidFeedback.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FieldManager, InputField, InvalidFeedbackForField, useFieldManager, ErrorMapping, emptyValidity } from '@iwsio/forms'
import { ErrorMapping, FieldChangeEventHandler, FieldManager, InputField, InvalidFeedbackForField, FieldChangeResult, useFieldManager } from '@iwsio/forms'
import { FC, useState } from 'react'

// NOTE: leaving customError excluded so they report directly as-is.
Expand All @@ -22,7 +22,7 @@ export const Field: FC<{name: string}> = ({ name }) => {
const fieldError = checkFieldError(name)

// this change event invokes AFTER the field manager change handler; so the state should be updated with a value along with any validity state from the base input
const handleChange = (e) => {
const handleChange: FieldChangeEventHandler = (e) => {
// NOTE: dont' use result.fields to refer to the the field values; state hasn't updated yet.
if (!e.target.validity.valid) return // already failed; likely required or pattern mismatch
// parsed
Expand Down
2 changes: 1 addition & 1 deletion forms/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@iwsio/forms",
"version": "4.1.0",
"version": "4.2.0-alpha.1",
"description": "Simple library with useful React forms components and browser validation.",
"main": "dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
46 changes: 46 additions & 0 deletions forms/src/Input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,52 @@ describe('Input', function() {

describe('InputField', () => {
it('should work as an controlled input and handle custom errors', async () => {
const CustomErrorField = () => {
const { setFieldError } = useFieldManager()
const handleChange = (results) => {
if (results.target.value === 'abc') setFieldError(results.target.name, "Cannot enter 'abc'.")
}
Comment on lines +201 to +202
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

key difference; Testing both: e.target.* and e.fields[name]

return <InputField name="field" onChange={handleChange} required data-testid="field" />
}

render(<CustomErrorField />, { wrapper: FieldManagerWrapper })

expect(screen.getByTestId('field')).to.be.ok
const input = screen.getByTestId('field') as HTMLInputElement

// basic validation fail
act(() => {
input.checkValidity()
})
expect(input.validity.valueMissing).to.be.true

await userEvent.clear(input)
await userEvent.type(input, 'ab')

// basic validation pass
expect(input.value).to.eq('ab')

act(() => { input.checkValidity() })

await userEvent.type(input, 'c')

// validation fail (from controlled state error)
expect(input.value).to.eq('abc')

act(() => { input.checkValidity() })

expect(input.validity.customError).to.be.true
expect(input.validationMessage).to.eq("Cannot enter 'abc'.")

await userEvent.type(input, 'c')

expect(input.value).to.eq('abcc')

act(() => { input.checkValidity() })

expect(input.validity.valid).to.be.true
})
it('should work as an controlled input and handle custom errors via field results', async () => {
const CustomErrorField = () => {
const { setFieldError } = useFieldManager()
const handleChange = (results) => {
Expand Down
9 changes: 4 additions & 5 deletions forms/src/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { forwardRef, useEffect, InputHTMLAttributes, ChangeEventHandler } from 'react'
import { useForwardRef } from './useForwardRef'
import { FieldValues, UpdatedFieldsOnChangeEvent, ValidationProps } from './types'
import { FieldChangeEventHandler, FieldValues, FieldChangeResult, ValidationProps } from './types'
import { useFieldManager } from './useFieldManager'

export type InputProps = ValidationProps & InputHTMLAttributes<HTMLInputElement>

type Ref = HTMLInputElement;

export type InputFieldProps = Omit<InputProps, 'DefaultValue' | 'onChange'> & { onChange?: FieldChangeEventHandler }

export const Input = forwardRef<Ref, InputProps>(({ onFieldError, fieldError, name, type = 'text', onChange, value, checked, onInvalid, ...other }, ref) => {
const localRef = useForwardRef<Ref>(ref)

Expand Down Expand Up @@ -53,15 +55,12 @@ export const Input = forwardRef<Ref, InputProps>(({ onFieldError, fieldError, na
})
Input.displayName = 'Input'

export type FieldOnChange = { onChange?: (e: UpdatedFieldsOnChangeEvent) => void }
export type InputFieldProps = Omit<InputProps, 'DefaultValue' | 'onChange'> & FieldOnChange

export const InputField = forwardRef<Ref, InputFieldProps>(({ type = 'text', name, onChange, value, ...other }, ref) => {
const localRef = useForwardRef<Ref>(ref)
const { handleChange: managerOnChange, fields, setFieldError, fieldErrors, mapError } = useFieldManager()

const handleOnChange: ChangeEventHandler<Ref> = (e) => {
const result = managerOnChange(e)
const result = managerOnChange<HTMLInputElement>(e)
if (onChange != null) onChange(result)
}

Expand Down
57 changes: 57 additions & 0 deletions forms/src/Select.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,63 @@ describe('SelectField', () => {

act(() => { select.checkValidity() })

expect(select.validity.valid).to.be.true
})
it('should work as an controlled input and handle custom errors via field results', async () => {
const CustomErrorSelect = () => {
const { setFieldError } = useFieldManager()
const handleChange = (e) => {
if (e.fields.field === '2') setFieldError('field', "Cannot select '2'.")
}
return (
<SelectField name="field" onChange={handleChange} required data-testid="field">
<option />
<option>1</option>
<option>2</option>
</SelectField>

)
}
render(<CustomErrorSelect />, { wrapper: FieldManagerWrapper })

expect(screen.getByTestId('field')).to.be.ok
const select = screen.getByTestId('field') as HTMLSelectElement

// basic validation fail

act(() => { select.checkValidity() })

expect(select.validity.valid).to.be.false

expect(select.validity.valueMissing).to.be.true

await userEvent.selectOptions(select, '1')

// basic validation pass
expect(select.value).to.eq('1')

act(() => { select.checkValidity() })

expect(select.validity.valid).to.be.true

await userEvent.selectOptions(select, '2')

// validation fail (from controlled state error)
expect(select.value).to.eq('2')

act(() => { select.checkValidity() })

expect(select.validity.valid).to.be.false

expect(select.validity.customError).to.be.true
expect(select.validationMessage).to.eq("Cannot select '2'.")

await userEvent.selectOptions(select, '1')

expect(select.value).to.eq('1')

act(() => { select.checkValidity() })

expect(select.validity.valid).to.be.true
})
})
14 changes: 8 additions & 6 deletions forms/src/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { forwardRef, useEffect, ChangeEventHandler, SelectHTMLAttributes, PropsWithChildren } from 'react'
import { useForwardRef } from './useForwardRef'
import { ValidationProps } from './types'
import { ChangeEventHandler, PropsWithChildren, SelectHTMLAttributes, forwardRef, useEffect } from 'react'
import { FieldChangeEventHandler, ValidationProps } from './types'
import { useFieldManager } from './useFieldManager'
import { useForwardRef } from './useForwardRef'

export type SelectProps = PropsWithChildren & ValidationProps & SelectHTMLAttributes<HTMLSelectElement>

export type SelectFieldProps = Omit<SelectProps, 'DefaultValue' | 'onChange'> & { onChange?: FieldChangeEventHandler }

type Ref = HTMLSelectElement;

export const Select = forwardRef<Ref, SelectProps>(({ onFieldError, onInvalid, fieldError, name, onChange, value, children, ...other }, ref) => {
Expand Down Expand Up @@ -52,13 +54,13 @@ export const Select = forwardRef<Ref, SelectProps>(({ onFieldError, onInvalid, f
})
Select.displayName = 'Select'

export const SelectField = forwardRef<Ref, Omit<SelectProps, 'DefaultValue'>>(({ name, onChange, ...other }, ref) => {
export const SelectField = forwardRef<Ref, Omit<SelectFieldProps, 'DefaultValue'>>(({ name, onChange, ...other }, ref) => {
const localRef = useForwardRef<Ref>(ref)
const { handleChange: managerOnChange, fields, setFieldError, fieldErrors, mapError } = useFieldManager()

const handleOnChange: ChangeEventHandler<Ref> = (e) => {
managerOnChange(e)
if (onChange != null) onChange(e)
const result = managerOnChange(e)
if (onChange != null) onChange(result)
}

const handleFieldError = (key, validity, message) => {
Expand Down
43 changes: 43 additions & 0 deletions forms/src/TextArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,49 @@ describe('TextAreaField', () => {

await userEvent.type(textarea, 'c')

expect(textarea.value).to.eq('abcc')
expect(textarea.checkValidity()).to.be.true
})
it('should work as an controlled input and handle custom errors via field results', async () => {
const CustomErrorField = () => {
const { setFieldError } = useFieldManager()
const handleChange = (e) => {
if (e.fields.field === 'abc') setFieldError('field', "Cannot enter 'abc'.")
}
return <TextAreaField name="field" onChange={handleChange} required data-testid="field" />
}

render(<CustomErrorField />, { wrapper: FieldManagerWrapper })

expect(screen.getByTestId('field')).to.be.ok
const textarea = screen.getByTestId('field') as HTMLInputElement

// basic validation fail
act(() => { textarea.checkValidity() })

expect(textarea.validity.valid).to.be.false
expect(textarea.validity.valueMissing).to.be.true

await userEvent.clear(textarea)
await userEvent.type(textarea, 'ab')

// basic validation pass
expect(textarea.value).to.eq('ab')
expect(textarea.checkValidity()).to.be.true

await userEvent.type(textarea, 'c')

// validation fail (from controlled state error)
expect(textarea.value).to.eq('abc')

act(() => { textarea.checkValidity() })

expect(textarea.validity.valid).to.be.false
expect(textarea.validity.customError).to.be.true
expect(textarea.validationMessage).to.eq("Cannot enter 'abc'.")

await userEvent.type(textarea, 'c')

expect(textarea.value).to.eq('abcc')
expect(textarea.checkValidity()).to.be.true
})
Expand Down
10 changes: 6 additions & 4 deletions forms/src/TextArea.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { forwardRef, useEffect, ChangeEventHandler, TextareaHTMLAttributes } from 'react'
import { useForwardRef } from './useForwardRef'
import { ValidationProps } from './types'
import { FieldChangeEventHandler, ValidationProps } from './types'
import { useFieldManager } from './useFieldManager'

export type TextAreaProps = ValidationProps & TextareaHTMLAttributes<HTMLTextAreaElement>

export type TextAreaFieldProps = Omit<TextAreaProps, 'DefaultValue' | 'onChange'> & { onChange?: FieldChangeEventHandler }

type Ref = HTMLTextAreaElement;

export const TextArea = forwardRef<Ref, TextAreaProps>(({ onFieldError, onInvalid, fieldError, name, onChange, value, ...other }, ref) => {
Expand Down Expand Up @@ -50,14 +52,14 @@ export const TextArea = forwardRef<Ref, TextAreaProps>(({ onFieldError, onInvali
})
TextArea.displayName = 'TextArea'

export const TextAreaField = forwardRef<Ref, Omit<TextAreaProps, 'DefaultValue'>>(({ name, onChange, ...other }, ref) => {
export const TextAreaField = forwardRef<Ref, Omit<TextAreaFieldProps, 'DefaultValue'>>(({ name, onChange, ...other }, ref) => {
const localRef = useForwardRef<Ref>(ref)

const { handleChange: managerOnChange, fields, setFieldError, fieldErrors, mapError } = useFieldManager()

const handleOnChange: ChangeEventHandler<Ref> = (e) => {
managerOnChange(e)
if (onChange != null) onChange(e)
const result = managerOnChange(e)
if (onChange != null) onChange(result)
}
const handleFieldError = (key, validity, message) => {
const mappedError = mapError(validity, message)
Expand Down
12 changes: 8 additions & 4 deletions forms/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ export type ValidationProps = {

export type FieldValues = Record<string, string>

export type UpdatedFieldsOnChangeEvent = {
export type FieldChangeResult<Element extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> = {
/**
* The updated field values after the change event.
*/
fields: FieldValues;
/**
* The target element that triggered the change event.
*/
target: EventTarget & (HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement);
target: EventTarget & Element;
};

export type FieldChangeEventHandler = <Element extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(e: FieldChangeResult<Element>) => void

export type FieldStateChangeEventHandler = <Element extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(e: ChangeEvent<Element>) => FieldChangeResult<Element>

export type UseFieldStateResult = {
/**
* Indicates whether InputFields within should render validation errors based on the fieldError state. This is unrelated to the native browser `reportValidity()` function.
Expand Down Expand Up @@ -73,13 +77,13 @@ export type UseFieldStateResult = {
* @param e passthrough of native change event arguments
* @returns Returns the latest field value state after change applied.
*/
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => UpdatedFieldsOnChangeEvent;
handleChange: FieldStateChangeEventHandler
/**
* @deprecated Please use handleChange
* @param e passthrough of native change event arguments
* @returns Returns the latest field value state after change applied.
*/
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => UpdatedFieldsOnChangeEvent;
onChange: FieldStateChangeEventHandler

/**
* Use this to change the default values after initialization.
Expand Down
6 changes: 3 additions & 3 deletions forms/src/useFieldState.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChangeEvent, useCallback, useState } from 'react'
import { omitBy } from './omitBy'
import { defaults } from './defaults'
import { FieldError, FieldValues, UpdatedFieldsOnChangeEvent, UseFieldStateResult } from './types'
import { omitBy } from './omitBy'
import { FieldChangeResult, FieldError, FieldStateChangeEventHandler, FieldValues, UseFieldStateResult } from './types'
import { ErrorMapping, useErrorMapping } from './useErrorMapping'
import { emptyValidity } from './validityState'

Expand Down Expand Up @@ -88,7 +88,7 @@ export const useFieldState: UseFieldStateMethod = (fields, options = {}) => {
setReportValidation((_old) => false)
}, [defaultFieldValues])

const handleChange: (_e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => UpdatedFieldsOnChangeEvent = useCallback((e) => {
const handleChange: FieldStateChangeEventHandler = useCallback((e) => {
let value = e.target.value
const name = e.target.name
const updatedFields = { ...fieldValues, [name]: value }
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@iwsio/forms-root",
"version": "4.1.0",
"version": "4.2.0-alpha.1",
"description": "Simple library with useful React forms components and browser validation.",
"files": [],
"engines": {
Expand Down
Loading