diff --git a/src/form/Form.tsx b/src/form/Form.tsx index 8b9380bda..2e1fe83d9 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -1,32 +1,21 @@ -import React, { ForwardedRef, useImperativeHandle, useRef } from 'react'; +import React, { ForwardedRef, useEffect, useImperativeHandle, useRef, useMemo } from 'react'; import classNames from 'classnames'; -import { isArray, isBoolean, isEmpty, isFunction } from 'lodash-es'; import noop from '../_util/noop'; import forwardRefWithStatics from '../_util/forwardRefWithStatics'; -import { - Data, - FormInstanceFunctions, - FormResetParams, - FormValidateMessage, - FormValidateParams, - FormValidateResult, - TdFormProps, - ValidateResultList, -} from './type'; -import { FormResetEvent, FormSubmitEvent, StyledProps } from '../common'; -import FormItem, { FormItemValidateResult } from './FormItem'; -import { formItemDefaultProps } from './defaultProps'; +import { FormInstanceFunctions, TdFormProps } from './type'; +import { StyledProps } from '../common'; +import FormItem from './FormItem'; +import { formDefaultProps } from './defaultProps'; import useDefaultProps from '../hooks/useDefaultProps'; import { usePrefixClass } from '../hooks/useClass'; import useConfig from '../hooks/useConfig'; -import useForm from './hooks/useForm'; +import useForm, { HOOK_MARK } from './hooks/useForm'; +import useWatch from './hooks/useWatch'; +import useInstance from './hooks/useInstance'; import { FormContext } from './FormContext'; -import { FormItemContext } from './const'; - -type Result = FormValidateResult; export interface FormProps extends TdFormProps, StyledProps { - children?: React.ReactElement[] | React.ReactElement; + children?: React.ReactNode; } export const requestSubmit = (target: HTMLFormElement) => { @@ -52,234 +41,164 @@ const Form = forwardRefWithStatics( labelWidth, labelAlign, colon, - requiredMark = globalFormConfig.requiredMark, - requiredMarkPosition = globalFormConfig.requiredMarkPosition, + initialData, + requiredMark = globalFormConfig?.requiredMark, + requiredMarkPosition = globalFormConfig?.requiredMarkPosition, scrollToFirstError, showErrorMessage, resetType, rules, - errorMessage = globalFormConfig.errorMessage, + errorMessage = globalFormConfig?.errorMessage, preventSubmitDefault, disabled, + readonly, children, id, onSubmit: onSubmitCustom, onValidate, onReset: onResetCustom, onValuesChange = noop, - } = useDefaultProps(props, formItemDefaultProps); - const submitParams = useRef>({}); - const resetParams = useRef>({}); + } = useDefaultProps(props, formDefaultProps); + const formRef = useRef(null); - const [form] = useForm(); + const formMapRef = useRef(new Map()); // 收集所有包含 name 属性 formItem 实例 + const floatingFormDataRef = useRef>({}); // 储存游离值的 formData + const [form] = useForm(props.form); // 内部与外部共享 form 实例,外部不传则内部创建 const formContentClass = classNames(formClass, className); - useImperativeHandle(ref, () => ({ - validate, - submit, - reset, - clearValidate, - setValidateMessage, - validateOnly, - })); - - function needValidate(name: string | number, fields: string[] | undefined) { - if (!fields || !isArray(fields)) return true; - return fields.indexOf(`${name}`) !== -1; - } - - function formatValidateResult(validateResultList: FormItemValidateResult[]) { - const result = validateResultList.reduce((r, err) => Object.assign(r || {}, err), {}); - Object.keys(result).forEach((key) => { - if (result[key] === true) { - delete result[key]; - } - }); - return isEmpty(result) ? true : result; - } - - async function validate(param?: FormValidateParams): Promise { - const { fields, trigger = 'all', showErrorMessage } = param || {}; - const list = React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.validate) && - needValidate(String(child.props.name), fields), - ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.validate(trigger, showErrorMessage); - } - return null; - }); - const arr = await Promise.all(list); - const result = formatValidateResult(arr); - onValidate?.({ - validateResult: result, - }); - return result; - } - - // 校验不通过时,滚动到第一个错误表单 - function scrollTo(selector: string) { - const doms = formRef.current.getElementsByClassName(selector); - const dom = doms[0]; - const behavior = scrollToFirstError; - if (behavior && dom) { - dom.scrollIntoView({ behavior }); - } - } + // 使用 useInstance 获取所有实例方法(对齐桌面端架构) + const formInstance = useInstance( + { + ...props, + scrollToFirstError, + preventSubmitDefault, + onSubmit: onSubmitCustom, + onValidate, + onReset: onResetCustom, + onValuesChange, + }, + formRef, + formMapRef, + floatingFormDataRef, + form, + formClass, + ); - function getFirstError(result: Result) { - if (isBoolean(result)) { - return ''; - } + // 关键:将实例方法同步到 form 对象上,使外部通过 form.getFieldsValue() 等可直接调用 + useImperativeHandle(ref, () => formInstance as unknown as FormInstanceFunctions); + Object.assign(form, formInstance); + form?.getInternalHooks?.(HOOK_MARK)?.setForm?.(formInstance); + + // form 初始化后清空队列 + useEffect(() => { + form?.getInternalHooks?.(HOOK_MARK)?.flashQueue?.(); + }, [form]); + + // 使用 useMemo 缓存 context 值 + const formContextValue = useMemo( + () => ({ + disabled, + readonly, + form, + labelWidth, + labelAlign, + colon, + initialData, + requiredMark, + requiredMarkPosition, + scrollToFirstError, + errorMessage, + showErrorMessage, + resetType, + rules, + formMapRef, + floatingFormDataRef, + onFormItemValueChange: formInstance.onFormItemValueChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + disabled, + readonly, + form, + labelWidth, + labelAlign, + colon, + initialData, + requiredMark, + requiredMarkPosition, + scrollToFirstError, + errorMessage, + showErrorMessage, + resetType, + rules, + ], + ); - const [firstKey] = Object.keys(result); - if (scrollToFirstError) { - const tmpClassName = `${formClass}-item__${firstKey}`; - scrollTo(tmpClassName); - } - const resArr = result[firstKey] as ValidateResultList; - if (!isArray(resArr)) { - return ''; + function onResetHandler(e?: React.FormEvent) { + if (preventSubmitDefault && e) { + e.preventDefault(); + e.stopPropagation(); } - return result?.[Object.keys(result)?.[0]]?.[0]?.message || ''; - } - - async function validateOnly(params?: Omit) { - const { fields, trigger = 'all' } = params || {}; - const list = React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.validateOnly) && - needValidate(String(child.props.name), fields), - ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.validateOnly(trigger); - } - return null; - }); - const arr = await Promise.all(list); - return formatValidateResult(arr); + [...formMapRef.current.values()].forEach((formItemRef) => { + formItemRef?.current?.resetField?.(); + }); + form?.getInternalHooks?.(HOOK_MARK)?.notifyWatch?.([]); + form.store = {}; + floatingFormDataRef.current = {}; + onResetCustom?.({}); } - function onSubmit(e?: FormSubmitEvent) { + function onSubmit(e?: React.FormEvent) { if (preventSubmitDefault && e) { e.preventDefault(); e.stopPropagation(); } - validate(submitParams.current).then((r) => { + formInstance.validate().then((r) => { const firstError = getFirstError(r); onSubmitCustom?.({ validateResult: r, firstError, }); }); - submitParams.current = undefined; } - async function submit(params?: Pick) { - submitParams.current = params; - requestSubmit(formRef.current); - } - - function onReset(e?: FormResetEvent) { - if (preventSubmitDefault && e) { - e.preventDefault(); - e.stopPropagation(); + function getFirstError(result: any) { + if (typeof result === 'boolean') { + return ''; } - React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.resetField) && - needValidate(String(child.props.name), resetParams.current?.fields as string[]), - ) - .forEach((child: React.ReactElement) => { - if (React.isValidElement(child)) { - child.props.resetField(resetParams.current?.type); - } - }); - resetParams.current = undefined; - onResetCustom?.({ e }); - } - - function reset(params?: FormResetParams) { - (resetParams.current as any) = params; - formRef.current.reset(); - } - - function clearValidate(fields?: Array) { - React.Children.toArray(children).forEach((child: React.ReactElement) => { - if ( - React.isValidElement(child) && - isFunction(child.props.resetHandler) && - needValidate(String(child.props.name), fields) - ) { - child.props.resetHandler(); + const keys = Object.keys(result); + const [firstKey] = keys; + if (scrollToFirstError && firstKey) { + const tmpClassName = `${formClass}-item__${firstKey}`; + const doms = formRef.current?.getElementsByClassName(tmpClassName); + const dom = doms?.[0]; + if (dom) { + dom.scrollIntoView({ behavior: scrollToFirstError as ScrollBehavior }); } - }); - } - - function setValidateMessage(validateMessage: FormValidateMessage) { - const keys = Object.keys(validateMessage); - if (!keys.length) return; - const list = React.Children.toArray(children) - .filter( - (child: React.ReactElement) => - React.isValidElement(child) && - isFunction(child.props.setValidateMessage) && - keys.includes(`${child.props.name}`), - ) - .map((child: React.ReactElement) => { - if (React.isValidElement(child)) { - return child.props.setValidateMessage(validateMessage[`${child.props.name}`]); - } - return null; - }); - Promise.all(list); - } - - function onFormItemValueChange(changedValue: Record) { - const allFields = formRef.current.getFieldsValue(true); - onValuesChange(changedValue, allFields); + } + const resArr = result[firstKey]; + if (!Array.isArray(resArr)) { + return ''; + } + return resArr?.[0]?.message || ''; } return ( - +
onSubmit(e)} - onReset={(e) => onReset(e)} + onReset={(e) => onResetHandler(e)} > {children}
); }, - { FormItem }, + { useForm, useWatch, FormItem }, ); export default Form; diff --git a/src/form/FormContext.tsx b/src/form/FormContext.tsx index b47b40fe9..0a2ed989c 100644 --- a/src/form/FormContext.tsx +++ b/src/form/FormContext.tsx @@ -1,6 +1,6 @@ -import { createContext, useContext } from 'react'; +import React, { createContext, useContext, MutableRefObject } from 'react'; import { TdFormProps } from './type'; -import { FormItemContext } from './const'; +import { FormItemInstance } from './const'; import { InternalFormInstance } from './hooks/interface'; export interface FormContextType { @@ -9,14 +9,18 @@ export interface FormContextType { labelAlign?: TdFormProps['labelAlign']; contentAlign?: TdFormProps['contentAlign']; colon?: TdFormProps['colon']; + initialData?: TdFormProps['initialData']; requiredMark?: TdFormProps['requiredMark']; requiredMarkPosition?: TdFormProps['requiredMarkPosition']; + scrollToFirstError?: TdFormProps['scrollToFirstError']; rules?: TdFormProps['rules']; errorMessage?: TdFormProps['errorMessage']; resetType?: TdFormProps['resetType']; - children?: FormItemContext[]; form?: InternalFormInstance; disabled?: TdFormProps['disabled']; + readonly?: TdFormProps['readonly']; + formMapRef?: MutableRefObject>>; + floatingFormDataRef?: MutableRefObject>; onFormItemValueChange: (changedValue: Record) => void; } diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index c8a290466..fc4bdfc6a 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -1,30 +1,36 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { cloneDeep, get as lodashGet, isArray, isBoolean, + isEqual, isFunction, isNumber, + isObject, isString, set as lodashSet, template as lodashTemplate, } from 'lodash-es'; import { ChevronRightIcon } from 'tdesign-icons-react'; import { validate } from './formModel'; +import { HOOK_MARK } from './hooks/useForm'; import { AllValidateResult, Data, + FieldData, FormErrorMessage, + FormInstanceFunctions, FormItemValidateMessage, FormRule, ValidateTriggerType, ValueType, TdFormItemProps, + TdFormProps, } from './type'; -import { AnalysisValidateResult, ErrorListType, FormItemContext, SuccessListType } from './const'; +import { AnalysisValidateResult, ErrorListType, FormItemInstance, SuccessListType } from './const'; import { usePrefixClass } from '../hooks/useClass'; import useConfig from '../hooks/useConfig'; import { useLocaleReceiver } from '../locale/LocalReceiver'; @@ -32,12 +38,12 @@ import { useFormContext } from './FormContext'; import { StyledProps } from '../common'; export interface FormItemProps extends TdFormItemProps, StyledProps { - children?: React.ReactNode; + children?: React.ReactNode | React.ReactNode[] | ((form: FormInstanceFunctions) => React.ReactElement); } export type FormItemValidateResult = { [key in keyof T]: boolean | AllValidateResult[] }; -const FormItem: React.FC = (props) => { +const FormItem = forwardRef((props, ref) => { const [locale, t] = useLocaleReceiver('form'); const { form: globalFormConfig } = useConfig(); const formClass = usePrefixClass('form'); @@ -49,6 +55,7 @@ const FormItem: React.FC = (props) => { form, colon, disabled: disabledFromContext, + readonly: readonlyFromContext, requiredMark: requiredMarkFromContext, requiredMarkPosition, labelAlign: labelAlignFromContext, @@ -58,12 +65,17 @@ const FormItem: React.FC = (props) => { resetType: resetTypeFromContext, rules: rulesFromContext, errorMessage, + initialData: initialDataFromContext, + formMapRef, + floatingFormDataRef, + onFormItemValueChange, } = formContext; const { arrow = false, for: htmlFor = '', help, + initialData, label, labelAlign = labelAlignFromContext, labelWidth = labelWidthFromContext, @@ -71,20 +83,58 @@ const FormItem: React.FC = (props) => { name, requiredMark = requiredMarkFromContext, rules = [], + shouldUpdate, showErrorMessage, + className, + style, children, } = props; + // 计算默认初始数据:优先级 floatingFormData > FormItem.initialData > Form.initialData > store 中的值 + const defaultInitialData = useMemo(() => { + // 先检查 floatingFormData + if (name && floatingFormDataRef?.current) { + const floatingValue = lodashGet(floatingFormDataRef.current, name); + if (typeof floatingValue !== 'undefined') { + return floatingValue; + } + } + // FormItem 自身的 initialData 优先级最高 + if (typeof initialData !== 'undefined') { + return initialData; + } + // Form 级别的 initialData + if (name && initialDataFromContext) { + const contextValue = lodashGet(initialDataFromContext, name); + if (typeof contextValue !== 'undefined') { + return contextValue; + } + } + // store 中的已有值 + if (name) { + return lodashGet(form?.store, name); + } + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [errorList, setErrorList] = useState([]); const [successList, setSuccessList] = useState([]); const [resetValidating, setResetValidating] = useState(false); const [needResetField, setNeedResetField] = useState(false); const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined); - const [formValue, setFormValue] = useState(lodashGet(form?.store, name)); - const initialValue = useRef(''); - const hasInit = useRef(false); - const contextRef = useRef(null); - const rulesMemoStr = useMemo(() => JSON.stringify(rules), [rules]); + const [formValue, setFormValue] = useState(defaultInitialData); + const [, forceUpdate] = useState({}); // 用于 shouldUpdate 强制渲染 + + const formItemRef = useRef(null); // 当前 formItem 实例 + const shouldEmitChangeRef = useRef(false); // onChange 冒泡开关 + const shouldValidate = useRef(false); // 校验开关 + const valueRef = useRef(formValue); // 当前最新值 + + const snakeName = useMemo(() => { + if (name === undefined) return ''; + return Array.isArray(name) ? name.join('.') : String(name); + }, [name]); const shouldShowErrorMessage = useMemo(() => { if (isBoolean(freeShowErrorMessage)) return freeShowErrorMessage; @@ -145,6 +195,7 @@ const FormItem: React.FC = (props) => { `${formClass}--${labelAlign || 'right'}`, `${formClass}-item__${name}`, ], + className, { [`${formClass}__item-with-help`]: help }, ); const formItemWrapperClasses = classNames([`${formItemClass}-wrap`, `${formItemClass}--${labelAlign}`]); @@ -184,187 +235,272 @@ const FormItem: React.FC = (props) => { return style; }, [labelWidth, labelAlign]); - const errorMessages = useMemo( - () => errorMessage ?? globalFormConfig.errorMessage, + const errorMessages = useMemo( + () => errorMessage ?? globalFormConfig?.errorMessage, [errorMessage, globalFormConfig], ); + // 更新 form 表单字段(对齐桌面端 updateFormValue) + const updateFormValue = (newVal: any, doValidate = true, shouldEmitChange = false) => { + const { setPrevStore } = form?.getInternalHooks?.(HOOK_MARK) || {}; + setPrevStore?.(form?.getFieldsValue?.(true)); + shouldEmitChangeRef.current = shouldEmitChange; + shouldValidate.current = doValidate; + valueRef.current = newVal; + const fieldValue = name ? lodashGet(form?.store, name) : undefined; + if (isEqual(fieldValue, newVal)) return; + if (name && form?.store) { + lodashSet(form.store, name, newVal); + } + setFormValue(newVal); + }; + // 方法定义 - const resetHandler = useCallback(() => { + function resetHandler() { setNeedResetField(false); setErrorList([]); setSuccessList([]); - }, []); + } - const getEmptyValue = useCallback((): ValueType => { - const value = lodashGet(form.store, name); - if (typeof value === 'string') return ''; - if (Array.isArray(value)) return []; - if (typeof value === 'object') return {}; - return undefined; - }, [form.store, name]); - - const resetField = useCallback( - async (resetType: 'initial' | 'empty' | undefined = resetTypeFromContext): Promise => { - if (!name) { - return null; - } - if (resetType === 'empty') { - lodashSet(form.store, name, getEmptyValue()); - } else if (resetType === 'initial') { - lodashSet(form.store, name, initialValue.current); - } - if (resetValidating) { - setNeedResetField(true); - } else { - resetHandler(); - } - }, - [resetTypeFromContext, name, resetValidating, form.store, getEmptyValue, initialValue, resetHandler], - ); - - const analysisValidateResult = useCallback( - async (trigger: ValidateTriggerType): Promise => { - const value = lodashGet(form.store, name); - const result: AnalysisValidateResult = { - successList: [], - errorList: [], - rules: [], - resultList: [], - allowSetValue: false, - }; - result.rules = - trigger === 'all' ? innerRules : innerRules.filter((item) => (item.trigger || 'change') === trigger); - if (innerRules.length && !result.rules?.length) { - return result; - } - result.allowSetValue = true; - result.resultList = await validate(value, result.rules); - result.errorList = result.resultList - .filter((item) => item.result !== true) - .map((item) => { - const newItem = { ...item }; - Object.keys(newItem).forEach((key) => { - if (!newItem.message && errorMessages[key]) { - const compiled = lodashTemplate(errorMessages[key]); - const labelName = isString(label) ? label : name; - newItem.message = compiled({ - name: labelName, - validate: newItem[key], - }); - } - }); - return newItem as ErrorListType; - }); - // 仅有自定义校验方法才会存在 successList - result.successList = result.resultList.filter( - (item) => item.result === true && item.message && item.type === 'success', - ) as SuccessListType[]; + function getResetValue(resetType: TdFormProps['resetType']): ValueType { + if (resetType === 'initial') { + return defaultInitialData; + } + let emptyValue: ValueType; + if (Array.isArray(formValue)) { + emptyValue = []; + } else if (isObject(formValue)) { + emptyValue = {}; + } else if (isString(formValue)) { + emptyValue = ''; + } + return emptyValue; + } + + function resetField(type?: TdFormProps['resetType']) { + if (typeof name === 'undefined') return; + const resetType = type || resetTypeFromContext; + const resetValue = getResetValue(resetType); + // reset 不校验 + updateFormValue(resetValue, false); + if (resetValidating) { + setNeedResetField(true); + } else { + resetHandler(); + } + } + + async function analysisValidateResult(trigger: ValidateTriggerType): Promise { + const value = name ? lodashGet(form?.store, name) : undefined; + const result: AnalysisValidateResult = { + successList: [], + errorList: [], + rules: [], + resultList: [], + allowSetValue: false, + }; + result.rules = trigger === 'all' ? innerRules : innerRules.filter((item) => (item.trigger || 'change') === trigger); + if (innerRules.length && !result.rules?.length) { return result; - }, - [form.store, name, innerRules, errorMessages, label], - ); - - const validateHandler = useCallback( - async ( - trigger: ValidateTriggerType, - showErrorMessage?: boolean, - ): Promise> => { - setResetValidating(true); - setFreeShowErrorMessage(showErrorMessage); - - const { - successList: innerSuccessList, - errorList: innerErrorList, - resultList, - allowSetValue, - } = await analysisValidateResult(trigger); - - if (allowSetValue) { - setSuccessList(innerSuccessList || []); - setErrorList(innerErrorList || []); - } - // 重置处理 - if (needResetField) { - resetHandler(); + } + result.allowSetValue = true; + // 处理自定义校验规则的 context 参数 + const rulesWithContext = result.rules.map((rule) => { + if (rule.validator) { + return { + ...rule, + validator: (val: ValueType) => { + const context = { formData: form?.store || {}, name: String(name) }; + return rule.validator!(val, context); + }, + }; } - setResetValidating(false); - - return { - [`${name}`]: innerErrorList?.length === 0 ? true : resultList, - } as FormItemValidateResult; - }, - [analysisValidateResult, needResetField, resetHandler, name], - ); + return rule; + }); + result.resultList = await validate(value, rulesWithContext); + result.errorList = result.resultList + .filter((item) => item.result !== true) + .map((item) => { + const newItem = { ...item }; + Object.keys(newItem).forEach((key) => { + if (!newItem.message && errorMessages?.[key as keyof FormErrorMessage]) { + const compiled = lodashTemplate(errorMessages[key as keyof FormErrorMessage] as string); + const labelName = isString(label) ? label : name; + newItem.message = compiled({ + name: labelName, + validate: newItem[key as keyof typeof newItem], + }); + } + }); + return newItem as ErrorListType; + }); + // 仅有自定义校验方法才会存在 successList + result.successList = result.resultList.filter( + (item) => item.result === true && item.message && item.type === 'success', + ) as SuccessListType[]; + return result; + } + + async function validateHandler( + trigger: ValidateTriggerType, + showErrorMsg?: boolean, + ): Promise> { + setResetValidating(true); + setFreeShowErrorMessage(showErrorMsg); + + const { + successList: innerSuccessList, + errorList: innerErrorList, + resultList, + allowSetValue, + } = await analysisValidateResult(trigger); + + if (allowSetValue) { + setSuccessList(innerSuccessList || []); + setErrorList(innerErrorList || []); + } + // 重置处理 + if (needResetField) { + resetHandler(); + } + setResetValidating(false); - const validateOnly = useCallback( - async (trigger: ValidateTriggerType): Promise> => { - const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); - return { - [name]: innerErrorList.length === 0 ? true : resultList, - } as FormItemValidateResult; - }, - [analysisValidateResult, name], - ); + const result = {} as FormItemValidateResult; + if (name !== undefined) { + (result as Record)[snakeName] = + innerErrorList?.length === 0 ? true : resultList; + } + return result; + } + + async function validateOnly(trigger: ValidateTriggerType): Promise> { + const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); + const result = {} as FormItemValidateResult; + if (name !== undefined && innerErrorList) { + (result as Record)[snakeName] = + innerErrorList.length === 0 ? true : resultList; + } + return result; + } - const setValidateMessage = useCallback((validateMessage: FormItemValidateMessage[]) => { + function setValidateMessage(validateMessage: FormItemValidateMessage[]) { if (!validateMessage && !isArray(validateMessage)) return; if (validateMessage.length === 0) { setErrorList([]); } setErrorList(validateMessage.map((item) => ({ ...item, result: false }))); - }, []); + } + + function getValidateMessage() { + if (errorList.length === 0) return []; + return errorList.map((item) => ({ + type: item.type || 'error', + message: item.message, + })); + } + + function setField(field: Omit) { + const { value, status, validateMessage } = field; + if (typeof value !== 'undefined') { + // 手动设置 status 则不需要校验,交给用户判断 + updateFormValue(value, typeof status === 'undefined', true); + } + if (status) { + // 可以扩展状态处理逻辑 + } + if (validateMessage) { + setValidateMessage([validateMessage as FormItemValidateMessage]); + } + } - const handleBlur = useCallback(async () => { - await validateHandler('blur'); - }, [validateHandler]); - - // 创建 context 对象 - const context: FormItemContext = useMemo( - () => ({ - name, - resetHandler, - resetField, - validate: validateHandler, - validateOnly, - setValidateMessage, - value: formValue, - }), - [name, resetHandler, resetField, validateHandler, validateOnly, setValidateMessage, formValue], - ); + // blur 下触发校验 + function handleItemBlur() { + const filterRules = innerRules.filter((item) => item.trigger === 'blur'); + if (filterRules.length) { + validateHandler('blur'); + } + } + // shouldUpdate: 注册自定义更新回调 useEffect(() => { - if (initialValue.current || !name) { - return; - } - initialValue.current = lodashGet(form.store, name); - }, [form.store, name]); + if (!shouldUpdate || !form) return; + + const { getPrevStore, registerWatch } = form?.getInternalHooks?.(HOOK_MARK) || {}; + + const cancelRegister = registerWatch?.(() => { + const currStore = form?.getFieldsValue?.(true) || {}; + let updateFlag = shouldUpdate as boolean; + if (isFunction(shouldUpdate)) + updateFlag = (shouldUpdate as (prev: any, cur: any) => boolean)(getPrevStore?.(), currStore); + + if (updateFlag) forceUpdate({}); + }); - // 生命周期 + return cancelRegister; + }, [shouldUpdate, form]); + + // 注册到 formMapRef useEffect(() => { - if (formContext?.children) { - formContext.children.push(context); + if (typeof name === 'undefined') return; + if (!formMapRef?.current) return; + + const mapRef = formMapRef.current; + + // 注册实例 + mapRef.set(snakeName, formItemRef); + + // 初始化 + if (typeof defaultInitialData !== 'undefined' && form?.store) { + lodashSet(form.store, name, defaultInitialData); } - contextRef.current = context; + setFormValue(defaultInitialData); + return () => { - if (formContext?.children) { - formContext.children = formContext.children.filter((ctx) => ctx !== contextRef.current); - } + mapRef.delete(snakeName); }; - }, [context, form.store, formContext, name]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snakeName]); - // 监听规则变化 + // 合并后的 formValue 变化 useEffect(对齐桌面端) useEffect(() => { - if (!hasInit.current) { - // 仅在用户有交互后进行校验 - if (formValue) { - hasInit.current = true; - } - return; + if (typeof name === 'undefined') return; + + // value 变化通知 watch 事件 + form?.getInternalHooks?.(HOOK_MARK)?.notifyWatch?.(name); + + // 控制是否需要校验 + if (!shouldValidate.current) return; + + if (shouldEmitChangeRef.current) { + const fieldValue: Record = {}; + fieldValue[snakeName] = formValue; + onFormItemValueChange?.(fieldValue); + } + + const filterRules = innerRules.filter((item) => (item.trigger || 'change') === 'change'); + if (filterRules.length) { + validateHandler('change'); } - validateHandler('change'); - // eslint-disable-next-line - }, [formValue, name, rulesMemoStr]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formValue, snakeName]); + + // 暴露 ref 实例方法(对齐桌面端) + const instance: FormItemInstance = { + name, + value: formValue, + initialData, + getValue: () => cloneDeep(valueRef.current), + setValue: (newVal: any) => updateFormValue(newVal, true, true), + setField, + validate: validateHandler, + validateOnly, + resetField, + setValidateMessage, + getValidateMessage, + resetValidate: resetHandler, + }; + useImperativeHandle(ref, (): FormItemInstance => instance); + useImperativeHandle(formItemRef, (): FormItemInstance => instance); // 渲染函数 const renderRightIconContent = () => { @@ -399,35 +535,44 @@ const FormItem: React.FC = (props) => { return
{extraNode}
; }; + // 支持函数渲染 children(对齐桌面端) + if (isFunction(children)) + return (children as (form: FormInstanceFunctions) => React.ReactElement)(form as FormInstanceFunctions); + return ( -
+
- {colon && t(locale.colonText)} + {colon && locale?.colonText && t(locale.colonText)}
- { - // 受控模式下,children 应该是 input 并传递 value/onChange - React.isValidElement(children) - ? React.cloneElement(children as React.ReactElement, { - ...(children as React.ReactElement).props, - value: formValue, - disabled: disabledFromContext, - onChange: (value: any, ...args) => { - const newValue = cloneDeep(value); - lodashSet(form?.store, name, newValue); - setFormValue(newValue); - (children as React.ReactElement).props?.onChange?.call?.(null, value, ...args); - }, - onBlur: (value: any, ...args: any[]) => { - handleBlur(); - (children as React.ReactElement).props?.onBlur?.call?.(null, value, ...args); - }, - }) - : children - } + {React.Children.map(children, (child) => { + if (!child) return null; + + // Fragment 或非 React Element 直接返回 + if (!React.isValidElement(child) || child.type === React.Fragment) return child; + + const childProps = child.props as any; + + return React.cloneElement(child as React.ReactElement, { + disabled: disabledFromContext, + readOnly: readonlyFromContext, + ...childProps, + value: formValue, + onChange: (value: any, ...args: any[]) => { + if (readonlyFromContext) return; + const newValue = cloneDeep(value); + updateFormValue(newValue, true, true); + childProps?.onChange?.call?.(null, value, ...args); + }, + onBlur: (value: any, ...args: any[]) => { + handleItemBlur(); + childProps?.onBlur?.call?.(null, value, ...args); + }, + }); + })}
{renderHelpNode()} {renderExtraNode()} @@ -436,6 +581,8 @@ const FormItem: React.FC = (props) => { {renderRightIconContent()}
); -}; +}); + +FormItem.displayName = 'FormItem'; export default FormItem; diff --git a/src/form/_example/horizontal.tsx b/src/form/_example/horizontal.tsx index f8d7f6917..2f9754ee5 100644 --- a/src/form/_example/horizontal.tsx +++ b/src/form/_example/horizontal.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Button, Form, Input, Radio, RadioGroup, Rate, Stepper, Textarea } from 'tdesign-mobile-react'; import { BrowseOffIcon } from 'tdesign-icons-react'; const RATE_GAP = 8; export default function HorizontalForm({ disabled }) { const formRef = useRef(null); - const isInit = useRef(false); const rules = useMemo( () => ({ name: [{ validator: (val: any) => val?.length === 8, message: '只能输入8个字符英文' }], @@ -19,14 +18,6 @@ export default function HorizontalForm({ disabled }) { [], ); - useEffect(() => { - if (isInit.current) { - return; - } - formRef.current?.setValidateMessage(rules); - isInit.current = true; - }, [rules]); - const onReset = () => { console.log('===onReset'); }; diff --git a/src/form/_example/vertical.tsx b/src/form/_example/vertical.tsx index fffddb9d0..436dc2967 100644 --- a/src/form/_example/vertical.tsx +++ b/src/form/_example/vertical.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Button, Form, Input, Radio, RadioGroup, Rate, Stepper, Textarea } from 'tdesign-mobile-react'; import { BrowseOffIcon } from 'tdesign-icons-react'; const RATE_GAP = 8; export default function VerticalForm({ disabled }) { const formRef = useRef(null); - const isInit = useRef(false); const rules = useMemo( () => ({ name: [{ validator: (val: any) => val?.length === 8, message: '只能输入8个字符英文' }], @@ -19,14 +18,6 @@ export default function VerticalForm({ disabled }) { [], ); - useEffect(() => { - if (isInit.current) { - return; - } - formRef.current?.setValidateMessage(rules); - isInit.current = true; - }, [rules]); - const onReset = () => { console.log('===onReset'); }; diff --git a/src/form/const.ts b/src/form/const.ts index 801406c11..79113113e 100644 --- a/src/form/const.ts +++ b/src/form/const.ts @@ -1,10 +1,11 @@ -import { FormItemValidateResult } from './FormItem'; import { AllValidateResult, - Data, + FieldData, FormItemValidateMessage, + FormInstanceFunctions, FormRule, - TdFormItemProps, + NamePath, + TdFormProps, ValidateResultType, ValidateTriggerType, } from './type'; @@ -39,18 +40,17 @@ export interface AnalysisValidateResult { allowSetValue: boolean; } -export interface FormItemContext { - name: TdFormItemProps['name']; - resetHandler: () => void; - resetField: (resetType?: 'initial' | 'empty') => void; - validate: ( - trigger: ValidateTriggerType, - showErrorMessage?: boolean, - ) => Promise>; - validateOnly: (trigger: ValidateTriggerType) => Promise>; - setValidateMessage: (validateMessage: FormItemValidateMessage[]) => void; - disabled?: boolean; - onChange?: (value: any, ...args) => void; - onBlur?: (value: any, ...args) => void; +export interface FormItemInstance { + name?: NamePath; value?: any; + initialData?: any; + getValue?: () => any; + setValue?: (newVal: any) => void; + setField?: (field: Omit) => void; + validate?: (trigger?: ValidateTriggerType, showErrorMessage?: boolean) => Promise>; + validateOnly?: (trigger?: ValidateTriggerType) => Promise>; + resetField?: (type?: TdFormProps['resetType']) => void; + setValidateMessage?: (message: FormItemValidateMessage[]) => void; + getValidateMessage?: FormInstanceFunctions['getValidateMessage']; + resetValidate?: () => void; } diff --git a/src/form/defaultProps.ts b/src/form/defaultProps.ts index 500dc7caa..91bf7a0fe 100644 --- a/src/form/defaultProps.ts +++ b/src/form/defaultProps.ts @@ -12,6 +12,7 @@ export const formDefaultProps: TdFormProps = { labelAlign: 'right', labelWidth: '81px', preventSubmitDefault: true, + readonly: undefined, requiredMark: undefined, resetType: 'empty', showErrorMessage: true, @@ -22,5 +23,6 @@ export const formItemDefaultProps: TdFormItemProps = { arrow: false, label: '', requiredMark: undefined, + shouldUpdate: false, showErrorMessage: undefined, }; diff --git a/src/form/form.en-US.md b/src/form/form.en-US.md index 7df62a2e7..149690e45 100644 --- a/src/form/form.en-US.md +++ b/src/form/form.en-US.md @@ -12,10 +12,13 @@ colon | Boolean | false | \- | N contentAlign | String | left | options: left/right | N disabled | Boolean | undefined | \- | N errorMessage | Object | - | Typescript: `FormErrorMessage` | N +form | Object | - | Typescript: `FormInstanceFunctions` | N id | String | undefined | native id attribute of the form,which supports being used in conjunction with non-form buttons through the form attribute to trigger form events | N +initialData | Object | - | \- | N labelAlign | String | right | options: left/right/top | N labelWidth | String / Number | '81px' | \- | N preventSubmitDefault | Boolean | true | \- | N +readonly | Boolean | undefined | \- | N requiredMark | Boolean | true | \- | N requiredMarkPosition | String | left | Display position of required symbols。options: left/right | N resetType | String | empty | options: empty/initial | N @@ -35,7 +38,14 @@ name | params | return | description className | String | - | className of component | N style | Object | - | CSS(Cascading Style Sheets),Typescript: `React.CSSProperties` | N clearValidate | `(fields?: Array)` | \- | required +currentElement | \- | `HTMLFormElement` | \- +getCurrentElement | \- | `HTMLFormElement` | \- +getFieldValue | `(field: NamePath) ` | `unknown` | required +getFieldsValue | `(nameList: string[] \| boolean)` | `getFieldsValue` | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface getFieldsValue{ (nameList: true): T; (nameList: any[]): Record;}`
+getValidateMessage | `(fields?: Array)` | `Array \| void` | required reset | `(params?: FormResetParams)` | \- | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface FormResetParams { type?: 'initial' \| 'empty'; fields?: Array }`
+setFields | `(fields: FieldData[])` | \- | required。Typescript: `(fields: FieldData[]) => void` `interface FieldData { name: NamePath; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) +setFieldsValue | `(field: Data)` | \- | required setValidateMessage | `(message: FormValidateMessage)` | \- | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`type FormValidateMessage = { [field in keyof FormData]: FormItemValidateMessage[] }`

`interface FormItemValidateMessage { type: 'warning' \| 'error'; message: string }`
submit | `(params?: { showErrorMessage?: boolean })` | \- | required validate | `(params?: FormValidateParams)` | `Promise>` | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface FormValidateParams { fields?: Array; showErrorMessage?: boolean; trigger?: ValidateTriggerType }`

`type ValidateTriggerType = 'blur' \| 'change' \| 'submit' \| 'all'`
@@ -52,12 +62,14 @@ arrow | Boolean | false | \- | N contentAlign | String | - | options: left/right | N for | String | - | \- | N help | TNode | - | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N +initialData | String / Number / Object / Array | - | Typescript: `InitialData` `type InitialData = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N label | TNode | '' | Typescript: `string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/blob/develop/src/common.ts) | N labelAlign | String | - | options: left/right/top | N labelWidth | String / Number | - | \- | N -name | String | - | \- | N +name | String / Number / Array | - | Typescript: `NamePath` `type NamePath = string \| number \| Array`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N requiredMark | Boolean | undefined | \- | N rules | Array | - | Typescript: `Array` | N +shouldUpdate | Boolean / Function | false | Typescript: `boolean \| ((prevValue, curValue) => boolean)` | N showErrorMessage | Boolean | undefined | \- | N ### FormRule @@ -80,7 +92,7 @@ telnumber | Boolean | - | \- | N trigger | String | change | Typescript: `ValidateTriggerType` | N type | String | error | options: error/warning | N url | Boolean / Object | - | Typescript: `boolean \| IsURLOptions` `import { IsURLOptions } from 'validator/es/lib/isURL'`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N -validator | Function | - | Typescript: `CustomValidator` `type CustomValidator = (val: ValueType) => CustomValidateResolveType \| Promise` `type CustomValidateResolveType = boolean \| CustomValidateObj` `interface CustomValidateObj { result: boolean; message: string; type?: 'error' \| 'warning' \| 'success' }` `type ValueType = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N +validator | Function | - | Typescript: `CustomValidator` `type CustomValidator = (val: ValueType, context?: { formData: Data , name: string }) => CustomValidateResolveType \| Promise` `type CustomValidateResolveType = boolean \| CustomValidateObj` `interface CustomValidateObj { result: boolean; message: string; type?: 'error' \| 'warning' \| 'success' }` `type ValueType = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts) | N whitespace | Boolean | - | \- | N ### FormErrorMessage diff --git a/src/form/form.md b/src/form/form.md index be87cd336..c36fda573 100644 --- a/src/form/form.md +++ b/src/form/form.md @@ -12,10 +12,13 @@ colon | Boolean | false | 是否在表单标签字段右侧显示冒号 | N contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N disabled | Boolean | undefined | 是否禁用整个表单 | N errorMessage | Object | - | 表单错误信息配置,示例:`{ idcard: '请输入正确的身份证号码', max: '字符长度不能超过 ${max}' }`。TS 类型:`FormErrorMessage` | N +form | Object | - | 经 `Form.useForm()` 创建的 form 控制实例。TS 类型:`FormInstanceFunctions` | N id | String | undefined | 表单原生的id属性,支持用于配合非表单内的按钮通过form属性来触发表单事件 | N +initialData | Object | - | 表单初始数据,重置时所需初始数据,优先级小于 FormItem 设置的 initialData | N labelAlign | String | right | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。可选项:left/right/top | N labelWidth | String / Number | '81px' | 可以整体设置label标签宽度,默认为81px | N preventSubmitDefault | Boolean | true | 是否阻止表单提交默认事件(表单提交默认事件会刷新页面),设置为 `true` 可以避免刷新 | N +readonly | Boolean | undefined | 是否整个表单只读 | N requiredMark | Boolean | true | 是否显示必填符号(*),默认显示 | N requiredMarkPosition | String | left | 表单必填符号(*)显示位置。可选项:left/right | N resetType | String | empty | 重置表单的方式,值为 empty 表示重置表单为空,值为 initial 表示重置表单数据为初始值。可选项:empty/initial | N @@ -35,7 +38,14 @@ onValuesChange | Function | | TS 类型:`(changedValues: Record)` | \- | 必需。清空校验结果。可使用 fields 指定清除部分字段的校验结果,fields 值为空则表示清除所有字段校验结果。清除邮箱校验结果示例:`clearValidate(['email'])` +currentElement | \- | `HTMLFormElement` | 获取 form dom 元素 +getCurrentElement | \- | `HTMLFormElement` | 获取 form dom 元素 +getFieldValue | `(field: NamePath) ` | `unknown` | 必需。获取单个字段值 +getFieldsValue | `(nameList: string[] \| boolean)` | `getFieldsValue` | 必需。获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有表单数据。[详细类型定义](https://github.com/Tencent/tdesign-mobile-react/tree/develop/src/form/type.ts)。
`interface getFieldsValue{ (nameList: true): T; (nameList: any[]): Record;}`
+getValidateMessage | `(fields?: Array)` | `Array \| void` | 必需。获取校验结果,当调用 getValidateMessage() 时返回所有校验结果 reset | `(params?: FormResetParams)` | \- | 必需。重置表单,表单里面没有重置按钮`