Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@

&-content {
grid-area: content;
min-width: 0;

&--non-active {
opacity: 0.5;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@ export function useRepoFields<FormFields extends RepositoriesFields>(props: Inpu
formApi,
name: 'repositories',
disabled: !isURLsListMode,
required: isRepoURLsListRequired,
validators: {
// Turn off any validations for the repositories' field in we are in all repos mode
sync: isRepoURLsListRequired ? insightRepositoriesValidator : undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
export { useForm, FORM_ERROR } from './useForm'
export type { Form, SubmissionErrors, FormChangeEvent } from './useForm'

export { useField } from './useField'
export { useField, useControlledField } from './useField'
export type { useFieldAPI } from './useField'

export { useCheckboxes } from './useCheckboxes'
Expand Down
245 changes: 172 additions & 73 deletions client/web/src/enterprise/insights/components/form/hooks/useField.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { ChangeEvent, Dispatch, InputHTMLAttributes, RefObject, useCallback, useLayoutEffect, useRef } from 'react'
import {
ChangeEvent,
Dispatch,
InputHTMLAttributes,
RefObject,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react'

import { noop } from 'rxjs'

import { FieldState, FormAPI } from './useForm'
import { FieldMetaState, FieldState, FormAPI } from './useForm'
import { getEventValue } from './utils/get-event-value'
import { useAsyncValidation } from './utils/use-async-validation'
import { AsyncValidator, getCustomValidationContext, getCustomValidationMessage, Validator } from './validators'
Expand All @@ -24,7 +33,7 @@ export interface InputProps<Value>
/**
* Public API for input element. Contains all handlers and props for
* native input element and expose meta state of input like touched,
* validState and etc.
* validState etc.
*/
export interface useFieldAPI<FieldValue, ErrorContext = unknown> {
/**
Expand Down Expand Up @@ -71,24 +80,170 @@ export type UseFieldProps<FormValues, Key, Value, ErrorContext> = {
export function useField<ErrorContext, FormValues, Key extends keyof FormValues>(
props: UseFieldProps<FormValues, Key, FormValues[Key], ErrorContext>
): useFieldAPI<FormValues[Key], ErrorContext> {
const { formApi, name, validators, onChange = noop, disabled, ...inputProps } = props
const { formApi, name, validators, onChange = noop, disabled = false, ...inputProps } = props
const { submitted, touched: formTouched } = formApi

const [state, setState] = useFormFieldState(name, formApi)

const { inputRef } = useFieldValidation({
value: state.value,
disabled,
validators,
setValidationState: dispatch => setState(state => ({ ...state, ...dispatch(state) })),
})

const handleBlur = useCallback(() => setState(state => ({ ...state, touched: true })), [setState])
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement> | FormValues[Key]) => {
const value = getEventValue(event)

onChange(value)
setState(state => ({ ...state, value, dirty: true }))
},
[onChange, setState]
)

return {
input: {
name: name.toString(),
ref: inputRef,
value: state.value,
onBlur: handleBlur,
onChange: handleChange,
disabled,
...inputProps,
},
meta: {
...state,
validationContext: state.errorContext as ErrorContext,
touched: state.touched || submitted || formTouched,
// Set state dispatcher gives access to set inner state of useField.
// Useful for complex cases when you need to deal with custom react input
// components.
setState: dispatch => {
setState(state => ({ ...dispatch(state) }))
},
},
}
}

type FieldStateTransformer<Value> = (previousState: FieldState<Value>) => FieldState<Value>

function useFormFieldState<FormValues, Key extends keyof FormValues>(
name: Key,
formAPI: FormAPI<FormValues>
): [FieldState<FormValues[Key]>, Dispatch<FieldStateTransformer<FormValues[Key]>>] {
const { fields, setFieldState: setFormFieldState } = formAPI
const state = fields[name]

// Use useRef for form api handler in order to avoid unnecessary
// calls if API handler has been changed.
const setFieldState = useRef(setFormFieldState).current

const setState = useCallback(
(transformer: FieldStateTransformer<FormValues[Key]>) => {
setFieldState(name, transformer as FieldStateTransformer<unknown>)
},
[name, setFieldState]
)

return [state, setState]
}

export type UseControlledFieldProps<Value> = {
value: Value
name: string
submitted: boolean
formTouched: boolean
validators?: Validators<Value, unknown>
} & InputProps<Value>

/**
* React hook to manage validation of a single form input field.
* `useInputValidation` helps with coordinating the constraint validation API
* and custom synchronous and asynchronous validators.
*
* Should be used with useForm hook to connect field and form component's states.
*/
export function useControlledField<Value>(props: UseControlledFieldProps<Value>): useFieldAPI<Value, unknown> {
const { value, name, submitted, formTouched, validators, disabled = false, onChange = noop, ...inputProps } = props

const [state, setState] = useState<FieldMetaState<Value>>({
touched: false,
dirty: false,
validState: 'NOT_VALIDATED',
validity: null,
initialValue: value,
})

const { inputRef } = useFieldValidation({
value,
disabled,
validators,
setValidationState: setState,
})

return {
input: {
value,
name: name.toString(),
ref: inputRef,
onBlur: useCallback(() => setState(state => ({ ...state, touched: true })), [setState]),
onChange: useCallback(
(event: ChangeEvent<HTMLInputElement> | Value) => {
const value = getEventValue(event)

onChange(value)
setState(state => ({ ...state, dirty: true }))
},
[onChange, setState]
),
disabled,
...inputProps,
},
meta: {
...state,
value,
validationContext: state.errorContext,
touched: state.touched || submitted || formTouched,
// Set state dispatcher gives access to set inner state of useField.
// Useful for complex cases when you need to deal with custom react input
// components.
setState: dispatch => {
setState(state => ({ ...dispatch({ value, ...state }) }))
},
},
}
}

interface UseFieldValidationProps<Value> {
value: Value
disabled: boolean
validators?: Validators<Value, unknown>
setValidationState: Dispatch<(previousState: FieldMetaState<Value>) => FieldMetaState<Value>>
}

interface UseFieldValidationApi {
inputRef: RefObject<HTMLInputElement & HTMLFieldSetElement>
}

/**
* Unified validation logic, it observes field's value, validators and native validation state,
* starts validation pipeline and sets validation state, it's used in useField and useControlledField
* hooks.
*/
function useFieldValidation<Value>(props: UseFieldValidationProps<Value>): UseFieldValidationApi {
const { value, disabled, validators, setValidationState } = props
const { sync: syncValidator, async: asyncValidator } = validators ?? {}

const setState = useRef(setValidationState).current
const inputReference = useRef<HTMLInputElement & HTMLFieldSetElement>(null)

const [state, setState] = useFormFieldState(name, formApi)
const { start: startAsyncValidation, cancel: cancelAsyncValidation } = useAsyncValidation({
inputReference,
asyncValidator,
onValidationChange: asyncState => setState(previousState => ({ ...previousState, ...asyncState })),
})

// Since validation logic wants to use sync state update we use `useLayoutEffect` instead of
// `useEffect` in order to synchronously re-render after value setState updates, but before
// the browser has painted DOM updates. This prevents users from seeing inconsistent states
// where changes handled by React have been painted, but DOM manipulation handled by these
// effects are painted on the next tick.
useLayoutEffect(() => {
const inputElement = inputReference.current

Expand All @@ -102,12 +257,12 @@ export function useField<ErrorContext, FormValues, Key extends keyof FormValues>
// standard validation error if validator doesn't provide message we fall back
// on standard validationMessage string [1] (ex. Please fill in input.)
const nativeErrorMessage = inputElement?.validationMessage ?? ''
const customValidationResult = syncValidator ? syncValidator(state.value, validity) : undefined
const customValidationResult = syncValidator ? syncValidator(value, validity) : undefined

const customValidationMessage = getCustomValidationMessage(customValidationResult)
const customValidationContext = getCustomValidationContext(customValidationResult)

if (customValidationMessage) {
if (customValidationMessage || (!customValidationResult && nativeErrorMessage)) {
// We have to cancel async validation from previous call
// if we got sync validation native or custom.
cancelAsyncValidation()
Expand All @@ -128,11 +283,11 @@ export function useField<ErrorContext, FormValues, Key extends keyof FormValues>

if (asyncValidator) {
// Due to the call of start async validation in useLayoutEffect we have to
// schedule the async validation event in the next tick to be able run
// schedule the async validation event in the next tick to be able to run
// observable pipeline validation since useAsyncValidation hook use
// useObservable hook internally which calls '.subscribe' in useEffect.
requestAnimationFrame(() => {
startAsyncValidation({ value: state.value, validity })
startAsyncValidation({ value, validity })
})

return setState(state => ({
Expand Down Expand Up @@ -161,63 +316,7 @@ export function useField<ErrorContext, FormValues, Key extends keyof FormValues>
errorContext: customValidationContext,
validity,
}))
}, [state.value, syncValidator, startAsyncValidation, asyncValidator, cancelAsyncValidation, setState, disabled])

const handleBlur = useCallback(() => setState(state => ({ ...state, touched: true })), [setState])
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement> | FormValues[Key]) => {
const value = getEventValue(event)
}, [value, syncValidator, startAsyncValidation, asyncValidator, cancelAsyncValidation, setState, disabled])

setState(state => ({ ...state, value, dirty: true }))
onChange(value)
},
[onChange, setState]
)

return {
input: {
name: name.toString(),
ref: inputReference,
value: state.value,
onBlur: handleBlur,
onChange: handleChange,
disabled,
...inputProps,
},
meta: {
...state,
validationContext: state.errorContext as ErrorContext,
touched: state.touched || submitted || formTouched,
// Set state dispatcher gives access to set inner state of useField.
// Useful for complex cases when you need to deal with custom react input
// components.
setState: dispatch => {
setState(state => ({ ...dispatch(state) }))
},
},
}
}

type FieldStateTransformer<Value> = (previousState: FieldState<Value>) => FieldState<Value>

function useFormFieldState<FormValues, Key extends keyof FormValues>(
name: Key,
formAPI: FormAPI<FormValues>
): [FieldState<FormValues[Key]>, Dispatch<FieldStateTransformer<FormValues[Key]>>] {
const { fields, setFieldState } = formAPI
const state = fields[name]

// Use useRef for form api handler in order to avoid unnecessary
// calls if API handler has been changed.
const setFieldStateReference = useRef(setFieldState)
setFieldStateReference.current = setFieldState

const setState = useCallback(
(transformer: FieldStateTransformer<FormValues[Key]>) => {
setFieldStateReference.current(name, transformer as FieldStateTransformer<unknown>)
},
[name]
)

return [state, setState]
return { inputRef: inputReference }
}
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,9 @@ export function useForm<FormValues extends object>(props: UseFormProps<FormValue
// properly updated with aria invalid attributes (it happens when user touched fields
// or when user hits submit button)
requestAnimationFrame(() => {
formElement.querySelector<HTMLInputElement>(':invalid:not(fieldset) [aria-invalid="true"]')?.focus()
formElement
.querySelector<HTMLInputElement>(':invalid:not(fieldset), [aria-invalid="true"]')
?.focus()
})
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { debounceTime, switchMap, tap } from 'rxjs/operators'

import { useEventObservable } from '@sourcegraph/wildcard'

import { FieldState } from '../useForm'
import { FieldMetaState } from '../useForm'
import { AsyncValidator, getCustomValidationContext, getCustomValidationMessage } from '../validators'

const ASYNC_VALIDATION_DEBOUNCE_TIME = 500
Expand All @@ -27,7 +27,7 @@ export interface UseAsyncValidationProps<FieldValue> {
* Validation state change handler. Used below to update state of consumer
* according to async logic aspects (mark field as touched, set validity state, etc).
* */
onValidationChange: (state: Partial<FieldState<FieldValue>>) => void
onValidationChange: (state: Partial<FieldMetaState<FieldValue>>) => void
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export function useInsightCreationForm(props: UseInsightCreationFormProps): Insi

const form = useForm<CreateInsightFormFields>({
initialValues,
touched,
onSubmit,
onChange,
touched,
})

const { repoMode, repoQuery, repositories } = useRepoFields({ formApi: form.formAPI })
Expand Down