diff --git a/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/InsightRepoSection.module.scss b/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/InsightRepoSection.module.scss index 62f71d08c3e2..de8ed30163f1 100644 --- a/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/InsightRepoSection.module.scss +++ b/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/InsightRepoSection.module.scss @@ -97,6 +97,7 @@ &-content { grid-area: content; + min-width: 0; &--non-active { opacity: 0.5; diff --git a/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/use-repo-fields.ts b/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/use-repo-fields.ts index 5ce0baa5c661..816022cd2da9 100644 --- a/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/use-repo-fields.ts +++ b/client/web/src/enterprise/insights/components/creation-ui/insight-repo-section/use-repo-fields.ts @@ -82,7 +82,6 @@ export function useRepoFields(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, diff --git a/client/web/src/enterprise/insights/components/form/hooks/index.ts b/client/web/src/enterprise/insights/components/form/hooks/index.ts index 0876c7903dc2..a5878a784ff9 100644 --- a/client/web/src/enterprise/insights/components/form/hooks/index.ts +++ b/client/web/src/enterprise/insights/components/form/hooks/index.ts @@ -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' diff --git a/client/web/src/enterprise/insights/components/form/hooks/useField.ts b/client/web/src/enterprise/insights/components/form/hooks/useField.ts index f0e38abe20fb..fecad529afc5 100644 --- a/client/web/src/enterprise/insights/components/form/hooks/useField.ts +++ b/client/web/src/enterprise/insights/components/form/hooks/useField.ts @@ -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' @@ -24,7 +33,7 @@ export interface InputProps /** * 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 { /** @@ -71,24 +80,170 @@ export type UseFieldProps = { export function useField( props: UseFieldProps ): useFieldAPI { - 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 | 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 = (previousState: FieldState) => FieldState + +function useFormFieldState( + name: Key, + formAPI: FormAPI +): [FieldState, Dispatch>] { + 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) => { + setFieldState(name, transformer as FieldStateTransformer) + }, + [name, setFieldState] + ) + + return [state, setState] +} + +export type UseControlledFieldProps = { + value: Value + name: string + submitted: boolean + formTouched: boolean + validators?: Validators +} & InputProps + +/** + * 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(props: UseControlledFieldProps): useFieldAPI { + const { value, name, submitted, formTouched, validators, disabled = false, onChange = noop, ...inputProps } = props + + const [state, setState] = useState>({ + 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 | 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 + disabled: boolean + validators?: Validators + setValidationState: Dispatch<(previousState: FieldMetaState) => FieldMetaState> +} + +interface UseFieldValidationApi { + inputRef: RefObject +} + +/** + * 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(props: UseFieldValidationProps): UseFieldValidationApi { + const { value, disabled, validators, setValidationState } = props const { sync: syncValidator, async: asyncValidator } = validators ?? {} + const setState = useRef(setValidationState).current const inputReference = useRef(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 @@ -102,12 +257,12 @@ export function useField // 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() @@ -128,11 +283,11 @@ export function useField 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 => ({ @@ -161,63 +316,7 @@ export function useField 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 | 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 = (previousState: FieldState) => FieldState - -function useFormFieldState( - name: Key, - formAPI: FormAPI -): [FieldState, Dispatch>] { - 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) => { - setFieldStateReference.current(name, transformer as FieldStateTransformer) - }, - [name] - ) - - return [state, setState] + return { inputRef: inputReference } } diff --git a/client/web/src/enterprise/insights/components/form/hooks/useForm.ts b/client/web/src/enterprise/insights/components/form/hooks/useForm.ts index 47665fb63051..b1e1c7473827 100644 --- a/client/web/src/enterprise/insights/components/form/hooks/useForm.ts +++ b/client/web/src/enterprise/insights/components/form/hooks/useForm.ts @@ -325,7 +325,9 @@ export function useForm(props: UseFormProps { - formElement.querySelector(':invalid:not(fieldset) [aria-invalid="true"]')?.focus() + formElement + .querySelector(':invalid:not(fieldset), [aria-invalid="true"]') + ?.focus() }) } }, diff --git a/client/web/src/enterprise/insights/components/form/hooks/utils/use-async-validation.ts b/client/web/src/enterprise/insights/components/form/hooks/utils/use-async-validation.ts index db84a1991d1d..79faf6179b72 100644 --- a/client/web/src/enterprise/insights/components/form/hooks/utils/use-async-validation.ts +++ b/client/web/src/enterprise/insights/components/form/hooks/utils/use-async-validation.ts @@ -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 @@ -27,7 +27,7 @@ export interface UseAsyncValidationProps { * 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>) => void + onValidationChange: (state: Partial>) => void } /** diff --git a/client/web/src/enterprise/insights/pages/insights/creation/search-insight/components/use-insight-creation-form.ts b/client/web/src/enterprise/insights/pages/insights/creation/search-insight/components/use-insight-creation-form.ts index f71b2eddad58..0877f03ba139 100644 --- a/client/web/src/enterprise/insights/pages/insights/creation/search-insight/components/use-insight-creation-form.ts +++ b/client/web/src/enterprise/insights/pages/insights/creation/search-insight/components/use-insight-creation-form.ts @@ -66,9 +66,9 @@ export function useInsightCreationForm(props: UseInsightCreationFormProps): Insi const form = useForm({ initialValues, + touched, onSubmit, onChange, - touched, }) const { repoMode, repoQuery, repositories } = useRepoFields({ formApi: form.formAPI })