Skip to content

Commit

Permalink
feat(forms): improved state management and reacting to more changed p…
Browse files Browse the repository at this point in the history
…rops (#2882)

Fix limitations in the handling of changed external props, as well as the prioritization between different error sources.
  • Loading branch information
henit authored Nov 13, 2023
1 parent ce5c3fa commit 0ca9533
Show file tree
Hide file tree
Showing 24 changed files with 957 additions and 402 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,6 @@ export const TypeScriptElement = () => {
const ReactRouterDomLink: React.ForwardRefExoticComponent<
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> &
React.RefAttributes<HTMLAnchorElement>
> = null //This is "simulating" { Link } from 'react-router-dom'
> = null // This is "simulating" { Link } from 'react-router-dom'
return <Button element={ReactRouterDomLink} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import React, {
useRef,
useMemo,
useCallback,
useState,
useReducer,
} from 'react'
import pointer, { JsonObject } from 'json-pointer'
import { JSONSchema7 } from 'json-schema'
import { ValidateFunction } from 'ajv'
import ajv, { ajvErrorsToFormErrors } from '../../utils/ajv'
import { FormError } from '../../types'
import { useMountEffect, useUpdateEffect } from '../../hooks'
import Context, { ContextState } from '../Context'

/**
Expand Down Expand Up @@ -63,6 +65,7 @@ export default function Provider<Data extends JsonObject>({
children,
...rest
}: Props<Data>) {
const [, forceUpdate] = useReducer(() => ({}), {})
// Prop error handling
if (data !== undefined && sessionStorageId !== undefined) {
console.error(
Expand All @@ -71,7 +74,16 @@ export default function Provider<Data extends JsonObject>({
}

// State
const wasMounted = useRef(false)
const mountedFieldPathsRef = useRef<string[]>([])
// - Errors from provider validation (the whole data set)
const errorsRef = useRef<Record<string, FormError> | undefined>()
const showAllErrorsRef = useRef<boolean>(false)
const setShowAllErrors = useCallback((showAllErrors: boolean) => {
showAllErrorsRef.current = showAllErrors
}, [])
// - Errors reported by fields, based on their direct validation rules
const pathsWithErrorRef = useRef<string[]>([])
// - Data
const initialData = useMemo(() => {
if (sessionStorageId && typeof window !== 'undefined') {
const sessionDataJSON =
Expand All @@ -81,67 +93,51 @@ export default function Provider<Data extends JsonObject>({
}
}
return data ?? defaultData
}, [data, defaultData, sessionStorageId])
const ajvSchemaValidator = useMemo(
() => (schema ? ajv.compile(schema) : undefined),
[schema]
)
const [internalData, setInternalData] =
useState<Partial<Data>>(initialData)
const mountedFieldPathsRef = useRef<string[]>([])
// eslint-disable-next-line react-hooks/exhaustive-deps -- Avoid triggering code that should only run initially
}, [])
const internalDataRef = useRef<Partial<Data>>(initialData)
// - Validator
const ajvSchemaValidatorRef = useRef<ValidateFunction>()

// Errors from provider validation (the whole data set)
const errorsRef = useRef<Record<string, FormError>>({})
const [showAllErrors, setShowAllErrors] = useState<boolean>(false)
// Errors reported by fields, based on their direct validation rules
const pathsWithErrorRef = useRef<string[]>([])
const validateData = useCallback(() => {
if (!ajvSchemaValidatorRef.current) {
// No schema-based validator. Assume data is valid.
return
}

if (!ajvSchemaValidatorRef.current(internalDataRef.current)) {
// Errors found
const errors = ajvErrorsToFormErrors(
ajvSchemaValidatorRef.current.errors
)
errorsRef.current = errors
} else {
errorsRef.current = undefined
}
forceUpdate()
}, [])

useEffect(() => {
if (!schema) {
return
}
ajvSchemaValidatorRef.current = ajv.compile(schema)
validateData()
}, [schema, validateData])

// Error handling
const hasErrors = useCallback(
() =>
Boolean(
mountedFieldPathsRef.current.find(
(mountedFieldPath) =>
errorsRef.current[mountedFieldPath] !== undefined ||
errorsRef.current?.[mountedFieldPath] !== undefined ||
pathsWithErrorRef.current.includes(mountedFieldPath)
)
),
[]
)

useEffect(() => {
if (!wasMounted.current) {
wasMounted.current = true
return
}
// When receiving the initial data, or receiving updated data by props, update the internal data (controlled state)
setInternalData(data)
}, [data])

const validateBySchema = useCallback(
(data: Partial<Data>): Record<string, Error> | undefined => {
if (!ajvSchemaValidator) {
// No schema-based validator. Assume data is valid.
return
}

if (!ajvSchemaValidator(data)) {
// Errors found
const errors = ajvErrorsToFormErrors(ajvSchemaValidator.errors)
return errors
} else {
return
}
},
[ajvSchemaValidator]
)

const validateBySchemaAndUpdateState = useCallback(
(data: Partial<Data>) => {
errorsRef.current = validateBySchema(data) ?? {}
},
[validateBySchema]
)

const setPathWithError = useCallback(
(path: string, hasError: boolean) => {
pathsWithErrorRef.current = hasError
Expand All @@ -164,7 +160,8 @@ export default function Provider<Data extends JsonObject>({
? // When setting the root of the data, the whole data set should be the new value
value
: // For sub paths, use the the existing data set (or empty array/object), but modify it below (since pointer.set is not immutable)
internalData ?? (path.match(isArrayJsonPointer) ? [] : {})
internalDataRef.current ??
(path.match(isArrayJsonPointer) ? [] : {})
)
if (path !== '/') {
pointer.set(newData as Data, path, value)
Expand All @@ -179,13 +176,13 @@ export default function Provider<Data extends JsonObject>({
)
}

validateBySchemaAndUpdateState(newData)

setInternalData(newData)
internalDataRef.current = newData

setShowAllErrors(false)
validateData()
showAllErrorsRef.current = false
forceUpdate()
},
[internalData, onChange, onPathChange, validateBySchemaAndUpdateState]
[onChange, onPathChange, validateData, sessionStorageId]
)

// Mounted fields
Expand All @@ -209,7 +206,7 @@ export default function Provider<Data extends JsonObject>({
const handleSubmit = useCallback<ContextState['handleSubmit']>(
({ formElement = null } = {}) => {
if (!hasErrors()) {
onSubmit?.(internalData as Data)
onSubmit?.(internalDataRef.current as Data)

formElement?.reset?.()

Expand All @@ -223,32 +220,41 @@ export default function Provider<Data extends JsonObject>({
}
}
} else {
setShowAllErrors(true)
showAllErrorsRef.current = true
onSubmitRequest?.()
}
return internalData
return internalDataRef.current
},
[internalData, scrollTopOnSubmit, hasErrors, onSubmit, onSubmitRequest]
[
scrollTopOnSubmit,
hasErrors,
onSubmit,
onSubmitRequest,
sessionStorageId,
]
)

useEffect(() => {
// Mount procedure
if (initialData) {
// Validate the initial data to know if the user can submit, and to show errors if inputs are requested to with props
validateBySchemaAndUpdateState(initialData)
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run for mount and unmount
}, [])
useMountEffect(() => {
// Validate the initial data
validateData()
})

useUpdateEffect(() => {
// Update and validate changes to the external data set
internalDataRef.current = data
validateData()
forceUpdate()
}, [data, validateData, forceUpdate])

return (
<Context.Provider
value={{
data: internalData,
data: internalDataRef.current,
...rest,
handlePathChange,
handleSubmit,
errors: errorsRef.current,
showAllErrors,
showAllErrors: showAllErrorsRef.current,
setShowAllErrors,
mountedFieldPaths: mountedFieldPathsRef.current,
handleMountField,
Expand Down
Loading

0 comments on commit 0ca9533

Please sign in to comment.