Skip to content

Commit

Permalink
Merge pull request #699 from contember/fix/field-validation
Browse files Browse the repository at this point in the history
form fixes
  • Loading branch information
matej21 authored May 10, 2024
2 parents 13c1150 + 32a8ff5 commit 84a5c17
Show file tree
Hide file tree
Showing 8 changed files with 84 additions and 47 deletions.
11 changes: 11 additions & 0 deletions build/api/react-form.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ export type FormHasOneRelationScopeProps = {
// @public (undocumented)
export const FormInput: React_2.NamedExoticComponent<FormInputProps>;

// @public (undocumented)
export type FormInputHandler = {
parseValue: (value: string) => any;
formatValue: (value: any) => string;
defaultInputProps?: React_2.InputHTMLAttributes<HTMLInputElement>;
};

// @public (undocumented)
export interface FormInputProps {
// (undocumented)
Expand All @@ -83,7 +90,11 @@ export interface FormInputProps {
// (undocumented)
field: SugaredRelativeSingleField['field'];
// (undocumented)
formatValue?: FormInputHandler['formatValue'];
// (undocumented)
isNonbearing?: boolean;
// (undocumented)
parseValue?: FormInputHandler['parseValue'];
}

// @public (undocumented)
Expand Down
7 changes: 6 additions & 1 deletion packages/playground/admin/app/pages/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,13 @@ export const enumRadio = () => <>

const FillValue = () => {
const field = useField('textValue')
const checkbox = useField('boolValue')
return <>
<Button onClick={() => field.updateValue('123')}>Fill invalid</Button>
<Button onClick={() => field.updateValue('abc')}>Fill valid</Button>
<Button onClick={() => checkbox.updateValue(null)}>Set checkbox null</Button>
<Button onClick={() => checkbox.updateValue(true)}>Set checkbox true</Button>
<Button onClick={() => checkbox.updateValue(false)}>Set checkbox false</Button>
</>
}
export const clientValidation = () => <>
Expand All @@ -107,9 +111,10 @@ export const clientValidation = () => <>
<div className={'pl-52 space-x-4'}>
<FillValue />
</div>
<InputField field={'textValue'} label={'Name'} inputProps={{ pattern: '[a-z]+' }} />
<InputField field={'textValue'} label={'Name'} required inputProps={{ pattern: '[a-z]+' }} />
<InputField field={'intValue'} label={'Number'} inputProps={{ required: true, max: 100 }} />
<CheckboxField field={'boolValue'} label={'Some boolean'} description={'Hello world'} inputProps={{ required: true }} />
<InputField field={'uuidValue'} label={'UUID'} />
</div>
</EntitySubTree>
</Binding>
Expand Down
10 changes: 5 additions & 5 deletions packages/react-form/src/components/FormCheckbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ export const FormCheckbox = Component<FormCheckboxProps>(({ field, isNonbearing,
if (!checkboxRef) {
return
}
(ref as any).current = checkboxRef
if (value === null) {
checkboxRef.indeterminate = true
}
checkboxRef.indeterminate = value === null
}, [checkboxRef, ref, value])

return (
<SlotInput
ref={setCheckboxRef}
ref={it => {
(ref as any).current = checkboxRef
setCheckboxRef(it)
}}
type="checkbox"
checked={accessor.value === true}
data-state={value === null ? 'indeterminate' : (value ? 'checked' : 'unchecked')}
Expand Down
7 changes: 5 additions & 2 deletions packages/react-form/src/components/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { dataAttribute } from '@contember/utilities'
import { useFormInputHandler } from '../internal/useFormInputHandler'
import { useFormError, useFormFieldId } from '../contexts'
import { useFormInputValidationHandler } from '../hooks/useFormInputValidationHandler'
import { FormInputHandler } from '../types'

type InputProps = React.JSX.IntrinsicElements['input']
const SlotInput = Slot as ComponentType<InputProps>
Expand All @@ -15,13 +16,15 @@ export interface FormInputProps {
isNonbearing?: boolean
defaultValue?: OptionallyVariableFieldValue
children: React.ReactElement
formatValue?: FormInputHandler['formatValue']
parseValue?: FormInputHandler['parseValue']
}

export const FormInput = Component<FormInputProps>(({ field, isNonbearing, defaultValue, ...props }) => {
export const FormInput = Component<FormInputProps>(({ field, isNonbearing, defaultValue, formatValue: formatValueIn, parseValue: parseValueIn, ...props }) => {
const id = useFormFieldId()
const accessor = useField(field)
const errors = useFormError() ?? accessor.errors?.errors ?? []
const { parseValue, formatValue, defaultInputProps } = useFormInputHandler(accessor)
const { parseValue, formatValue, defaultInputProps } = useFormInputHandler(accessor, { formatValue: formatValueIn, parseValue: parseValueIn })
const accessorGetter = accessor.getAccessor
const { ref, onFocus, onBlur } = useFormInputValidationHandler(accessor)

Expand Down
31 changes: 23 additions & 8 deletions packages/react-form/src/hooks/useFormInputValidationHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,32 @@ export const useFormInputValidationHandler = (field: FieldAccessor<any>) => {
if (!inputRef.current) {
return
}

const input = inputRef.current
const valid = input.validity?.valid

const message = valid ? undefined : input?.validationMessage
if (message !== validationMessage.current) {
validationMessage.current = message
if (!message || !focus) {
accessorGetter().clearErrors()
}
if (!focus && message) {
accessorGetter().addError(message)
}
const previousMessage = validationMessage.current
validationMessage.current = message

// if there is no message, we want to clear the error
if (!message) {
accessorGetter().clearErrors()
return
}
if (message === previousMessage) {
return
}
// if the input is not touched, we don't want to show the error message
if (!field.isTouched) {
return
}

// if the input is not focused, we want to show the error message
// also, even the input is focused, we want to replace the previous message
if (!focus || !!previousMessage) {
accessorGetter().clearErrors()
accessorGetter().addError(message)
}
})

Expand Down
1 change: 1 addition & 0 deletions packages/react-form/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './components'
export * from './hooks'
export * from './contexts'
export * from './types'
57 changes: 26 additions & 31 deletions packages/react-form/src/internal/useFormInputHandler.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import { FieldAccessor, SchemaColumn, SchemaKnownColumnType } from '@contember/react-binding'
import * as React from 'react'
import { useMemo } from 'react'
import { FormInputHandler } from '../types'

export const useFormInputHandler = (field: FieldAccessor) => {
export const useFormInputHandler = (field: FieldAccessor, { formatValue, parseValue }: Partial<FormInputHandler>): FormInputHandler => {
return useMemo(() => {
const schema = field.schema
const columnType = schema.type
const handlerFactory = ColumnTypeHandlerFactories[columnType as SchemaKnownColumnType]
if (!handlerFactory) {
throw new Error(`Column type ${columnType} is not supported yet`)
const handlerFactory = ColumnTypeHandlerFactories[columnType as SchemaKnownColumnType] ?? defaultHandlerFactory
const handler = handlerFactory(schema, field.getAccessor)
return {
defaultInputProps: handler.defaultInputProps,
formatValue: formatValue ?? handler.formatValue,
parseValue: parseValue ?? handler.parseValue,
}
return handlerFactory(schema, field.getAccessor)
}, [field.getAccessor, field.schema])
}, [field.getAccessor, field.schema, formatValue, parseValue])
}

type ColumnTypeHandlerFactory = (column: SchemaColumn, getAccessor: FieldAccessor.GetFieldAccessor) => {
parseValue: (value: string) => any
formatValue: (value: any) => string
defaultInputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
type ColumnTypeHandlerFactory = (column: SchemaColumn, getAccessor: FieldAccessor.GetFieldAccessor) => FormInputHandler

const defaultHandlerFactory: ColumnTypeHandlerFactory = (schema, field) => ({
parseValue: (value: string) => {
if (value === '' && schema.nullable && field().valueOnServer === null) {
return null
}
return value
},
formatValue: (value: string | null) => value ?? '',
})

const ColumnTypeHandlerFactories: Record<SchemaKnownColumnType, ColumnTypeHandlerFactory> = {
const ColumnTypeHandlerFactories: Record<SchemaKnownColumnType, ColumnTypeHandlerFactory | undefined> = {
String: defaultHandlerFactory,
Integer: () => ({
parseValue: (value: string) => {
if (value === '') {
Expand Down Expand Up @@ -51,15 +60,7 @@ const ColumnTypeHandlerFactories: Record<SchemaKnownColumnType, ColumnTypeHandle
type: 'number',
},
}),
String: (schema, field) => ({
parseValue: (value: string) => {
if (value === '' && schema.nullable && field().valueOnServer === null) {
return null
}
return value
},
formatValue: (value: string | null) => value ?? '',
}),

Date: () => ({
parseValue: (value: string) => {
if (value === '') {
Expand Down Expand Up @@ -93,15 +94,9 @@ const ColumnTypeHandlerFactories: Record<SchemaKnownColumnType, ColumnTypeHandle
type: 'datetime-local',
},
}),
Bool: () => {
throw new Error('Boolean column type is not supported yet')
},
Enum: () => {
throw new Error('Enum column type is not supported yet')
},
Uuid: () => {
throw new Error('UUID column type is not supported yet')
},
Bool: undefined,
Enum: undefined,
Uuid: undefined,
}

const toLocalDate = (date: Date) => {
Expand Down
7 changes: 7 additions & 0 deletions packages/react-form/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react'

export type FormInputHandler = {
parseValue: (value: string) => any
formatValue: (value: any) => string
defaultInputProps?: React.InputHTMLAttributes<HTMLInputElement>
}

0 comments on commit 84a5c17

Please sign in to comment.