import { InputBaseProps } from '@mui/material'; import { debounce } from 'lodash'; import { cloneElement, ForwardedRef, forwardRef, ReactElement, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react'; import createInputOnChangeHandler from '../lib/createInputOnChangeHandler'; import { createTestInputFunction } from '../lib/test_input'; type InputWithRefOptionalPropsWithDefault< TypeName extends keyof MapToInputType, > = { createInputOnChangeHandlerOptions?: CreateInputOnChangeHandlerOptions; required?: boolean; valueType?: TypeName; }; type InputWithRefOptionalPropsWithoutDefault< TypeName extends keyof MapToInputType, > = { debounceWait?: number; inputTestBatch?: InputTestBatch; onBlurAppend?: InputBaseProps['onBlur']; onFirstRender?: InputFirstRenderFunction; onFocusAppend?: InputBaseProps['onFocus']; onUnmount?: () => void; valueKey?: CreateInputOnChangeHandlerOptions['valueKey']; }; type InputWithRefOptionalProps = InputWithRefOptionalPropsWithDefault & InputWithRefOptionalPropsWithoutDefault; type InputWithRefProps< TypeName extends keyof MapToInputType, InputComponent extends ReactElement, > = InputWithRefOptionalProps & { input: InputComponent; }; type InputForwardedRefContent = { getIsChangedByUser?: () => boolean; getValue?: () => MapToInputType[TypeName]; isValid?: () => boolean; setValue?: StateSetter; }; const INPUT_TEST_ID = 'input'; const MAP_TO_INITIAL_VALUE: MapToInputType = { boolean: false, number: 0, string: '', }; const INPUT_WITH_REF_DEFAULT_PROPS: Required< InputWithRefOptionalPropsWithDefault<'string'> > & InputWithRefOptionalPropsWithoutDefault<'string'> = { createInputOnChangeHandlerOptions: {}, debounceWait: 500, required: false, valueType: 'string', }; const InputWithRef = forwardRef( ( { debounceWait = INPUT_WITH_REF_DEFAULT_PROPS.debounceWait, input, inputTestBatch, onBlurAppend, onFirstRender, onFocusAppend, onUnmount, required: isRequired = INPUT_WITH_REF_DEFAULT_PROPS.required, valueKey, valueType = INPUT_WITH_REF_DEFAULT_PROPS.valueType as TypeName, // Props with initial value that depend on others. createInputOnChangeHandlerOptions: { postSet: postSetAppend, valueKey: onChangeValueKey = valueKey, ...restCreateInputOnChangeHandlerOptions } = INPUT_WITH_REF_DEFAULT_PROPS.createInputOnChangeHandlerOptions as CreateInputOnChangeHandlerOptions, }: InputWithRefProps, ref: ForwardedRef>, ) => { const { props: inputProps } = input; const vKey = useMemo( () => onChangeValueKey ?? ('checked' in inputProps ? 'checked' : 'value'), [inputProps, onChangeValueKey], ); const { onBlur: initOnBlur, onChange: initOnChange, onFocus: initOnFocus, [vKey]: initValue = MAP_TO_INITIAL_VALUE[valueType], ...restInitProps } = inputProps; const [inputValue, setInputValue] = useState(initValue); const [isChangedByUser, setIsChangedByUser] = useState(false); const [isInputValid, setIsInputValid] = useState(false); const setValue: StateSetter = useCallback((value) => { setInputValue(value as MapToInputType[TypeName]); }, []); const testInput: TestInputFunction | undefined = useMemo(() => { let result; if (inputTestBatch) { inputTestBatch.isRequired = isRequired; result = createTestInputFunction({ [INPUT_TEST_ID]: inputTestBatch, }); } return result; }, [inputTestBatch, isRequired]); const doTestAndSet = useCallback( (value: MapToInputType[TypeName]) => { const valid = testInput?.call(null, { inputs: { [INPUT_TEST_ID]: { value } }, isIgnoreOnCallbacks: true, }) ?? false; onFirstRender?.call(null, { isValid: valid }); setIsInputValid(valid); }, [onFirstRender, testInput], ); const debounceDoTestAndSet = useMemo( () => debounce(doTestAndSet, debounceWait), [debounceWait, doTestAndSet], ); const onBlur = useMemo( () => initOnBlur ?? (testInput && ((...args) => { const { 0: { target: { value }, }, } = args; const isValid = testInput({ inputs: { [INPUT_TEST_ID]: { value } }, }); setIsInputValid(isValid); onBlurAppend?.call(null, ...args); })), [initOnBlur, onBlurAppend, testInput], ); const onChange = useMemo( () => createInputOnChangeHandler({ postSet: (...args) => { setIsChangedByUser(true); initOnChange?.call(null, ...args); postSetAppend?.call(null, ...args); }, set: (value) => { setValue(value); debounceDoTestAndSet(value as MapToInputType[TypeName]); }, setType: valueType, valueKey: vKey, ...restCreateInputOnChangeHandlerOptions, }), [ debounceDoTestAndSet, initOnChange, postSetAppend, restCreateInputOnChangeHandlerOptions, setValue, vKey, valueType, ], ); const onFocus = useMemo( () => initOnFocus ?? (inputTestBatch && ((...args) => { inputTestBatch.defaults?.onSuccess?.call(null, { append: {} }); onFocusAppend?.call(null, ...args); })), [initOnFocus, inputTestBatch, onFocusAppend], ); /** * Using any setState function synchronously in the render function * directly will trigger the 'cannot update a component while readering a * different component' warning. This can be solved by wrapping the * setState call(s) in a useEffect hook because it executes **after** the * render function completes. */ useEffect(() => { doTestAndSet(inputValue); return onUnmount; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** * Update the input value to the init value until it's changed by the user. * This allows us to populate the input based on value from other field(s). */ useEffect(() => { if (isChangedByUser || inputValue === initValue || !initValue) return; doTestAndSet(initValue); setInputValue(initValue); }, [doTestAndSet, initValue, inputValue, isChangedByUser]); useImperativeHandle( ref, () => ({ getIsChangedByUser: () => isChangedByUser, getValue: () => inputValue, isValid: () => isInputValid, setValue, }), [inputValue, isChangedByUser, isInputValid, setValue], ); return cloneElement(input, { ...restInitProps, onBlur, onChange, onFocus, required: isRequired, [vKey]: inputValue, }); }, ); InputWithRef.defaultProps = INPUT_WITH_REF_DEFAULT_PROPS; InputWithRef.displayName = 'InputWithRef'; export type { InputForwardedRefContent, InputWithRefProps }; export default InputWithRef;