From da3a716ce9fffd21819f546ad31d940e74bff53a Mon Sep 17 00:00:00 2001 From: Rylan Date: Fri, 31 Oct 2025 16:20:39 +0800 Subject: [PATCH 1/3] fix(FormList): `setFields` failure in nested components --- packages/components/form/FormItem.tsx | 44 ++++++----- packages/components/form/FormList.tsx | 11 +-- .../components/form/hooks/useInstance.tsx | 75 +++++++++++-------- packages/components/form/type.ts | 2 +- packages/components/form/utils/index.ts | 37 ++++++++- 5 files changed, 106 insertions(+), 63 deletions(-) diff --git a/packages/components/form/FormItem.tsx b/packages/components/form/FormItem.tsx index e3d2fb861b..1e07b8145c 100644 --- a/packages/components/form/FormItem.tsx +++ b/packages/components/form/FormItem.tsx @@ -1,11 +1,10 @@ +import { flattenDeep, get, isEqual, isFunction, isObject, isString, merge, set, unset } from 'lodash-es'; import React, { forwardRef, ReactNode, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { CheckCircleFilledIcon as TdCheckCircleFilledIcon, CloseCircleFilledIcon as TdCloseCircleFilledIcon, ErrorCircleFilledIcon as TdErrorCircleFilledIcon, } from 'tdesign-icons-react'; -import { flattenDeep, get, isEqual, isFunction, isObject, isString, merge, set, unset } from 'lodash-es'; -import { StyledProps } from '../common'; import useConfig from '../hooks/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; import useGlobalIcon from '../hooks/useGlobalIcon'; @@ -17,15 +16,20 @@ import { parseMessage, validate as validateModal } from './formModel'; import { HOOK_MARK } from './hooks/useForm'; import useFormItemInitialData, { ctrlKeyMap } from './hooks/useFormItemInitialData'; import useFormItemStyle from './hooks/useFormItemStyle'; +import { calcFieldValue } from './utils'; + +import type { StyledProps } from '../common'; import type { + FieldData, FormInstanceFunctions, FormItemValidateMessage, FormRule, NamePath, TdFormItemProps, + TdFormProps, + ValidateTriggerType, ValueType, } from './type'; -import { calcFieldValue } from './utils'; export interface FormItemProps extends TdFormItemProps, StyledProps { children?: React.ReactNode | React.ReactNode[] | ((form: FormInstanceFunctions) => React.ReactElement); @@ -33,18 +37,19 @@ export interface FormItemProps extends TdFormItemProps, StyledProps { export interface FormItemInstance { name?: NamePath; - isUpdated?: boolean; value?: any; - getValue?: Function; - setValue?: Function; - setField?: Function; - validate?: Function; - resetField?: Function; - setValidateMessage?: Function; - getValidateMessage?: Function; - resetValidate?: Function; - validateOnly?: Function; + isUpdated?: boolean; isFormList?: boolean; + formListMapRef?: React.MutableRefObject>; + getValue?: () => any; + setValue?: (newVal: any, originalData?: any) => void; + setField?: (field: Omit, originalData?: any) => 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; } const FormItem = forwardRef((originalProps, ref) => { @@ -108,7 +113,6 @@ const FormItem = forwardRef((originalProps, ref const [formValue, setFormValue] = useState(() => { const fieldName = flattenDeep([formListName, name]); const storeValue = get(form?.store, fieldName); - // if (!storeValue && formListName) return; // TODO 针对新增空的动态表单情况,避免回填默认值 return ( storeValue ?? getDefaultInitialData({ @@ -225,7 +229,7 @@ const FormItem = forwardRef((originalProps, ref return null; }; - async function analysisValidateResult(trigger) { + async function analysisValidateResult(trigger: ValidateTriggerType) { const result = { successList: [], errorList: [], @@ -262,7 +266,7 @@ const FormItem = forwardRef((originalProps, ref return result; } - async function validate(trigger = 'all', showErrorMessage?: boolean) { + async function validate(trigger: ValidateTriggerType = 'all', showErrorMessage?: boolean) { if (innerFormItemsRef.current.length) { return innerFormItemsRef.current.map((innerFormItem) => innerFormItem?.validate(trigger, showErrorMessage)); } @@ -316,7 +320,7 @@ const FormItem = forwardRef((originalProps, ref }; } - async function validateOnly(trigger = 'all') { + async function validateOnly(trigger: ValidateTriggerType = 'all') { const { errorList: innerErrorList, resultList } = await analysisValidateResult(trigger); return { @@ -331,7 +335,7 @@ const FormItem = forwardRef((originalProps, ref filterRules.length && validate('blur'); } - function getResetValue(resetType: string): ValueType { + function getResetValue(resetType: TdFormProps['resetType']): ValueType { if (resetType === 'initial') { return getDefaultInitialData({ children, @@ -351,7 +355,7 @@ const FormItem = forwardRef((originalProps, ref return emptyValue; } - function resetField(type: string) { + function resetField(type: TdFormProps['resetType']) { if (typeof name === 'undefined') return; const resetType = type || resetTypeFromContext; @@ -373,7 +377,7 @@ const FormItem = forwardRef((originalProps, ref setVerifyStatus(ValidateStatus.VALIDATING); } - function setField(field: { value?: string; status?: ValidateStatus; validateMessage?: FormItemValidateMessage }) { + function setField(field: Omit) { const { value, status, validateMessage } = field; if (typeof status !== 'undefined') { setErrorList(validateMessage ? [validateMessage] : []); diff --git a/packages/components/form/FormList.tsx b/packages/components/form/FormList.tsx index e6c529f381..9d8831cc66 100644 --- a/packages/components/form/FormList.tsx +++ b/packages/components/form/FormList.tsx @@ -182,6 +182,7 @@ const FormList: React.FC = (props) => { (): FormItemInstance => ({ name, isFormList: true, + formListMapRef, getValue() { const formListValue = []; [...formListMapRef.current.values()].forEach((formItemRef) => { @@ -214,26 +215,26 @@ const FormList: React.FC = (props) => { }); }, // TODO 支持局部更新数据 - setValue: (fieldData: any[], originData) => { + setValue: (fieldData, originalData) => { setListFields( fieldData, (formItemRef, data) => { formItemRef?.current?.setValue?.(data); }, - originData, + originalData, ); }, - setField: (fieldData: { value?: any[]; status?: string }, originData) => { + setField: (fieldData, originalData) => { const { value, status } = fieldData; setListFields( value, (formItemRef, data) => { formItemRef?.current?.setField?.({ value: data, status }); }, - originData, + originalData, ); }, - resetField: (type: string) => { + resetField: (type) => { const resetType = type || resetTypeFromContext; if (resetType === 'initial') { diff --git a/packages/components/form/hooks/useInstance.tsx b/packages/components/form/hooks/useInstance.tsx index 8528bb1782..7da3ce8c4f 100644 --- a/packages/components/form/hooks/useInstance.tsx +++ b/packages/components/form/hooks/useInstance.tsx @@ -1,15 +1,17 @@ -import { isEmpty, isFunction, isEqual, merge, get, set } from 'lodash-es'; +import { get, isEmpty, isEqual, isFunction, merge, set } from 'lodash-es'; import log from '@tdesign/common-js/log/index'; +import useConfig from '../../hooks/useConfig'; +import { calcFieldValue, findFormItem, findFormItemDeep, objectToArray, travelMapFromObject } from '../utils'; + +import type { FormItemInstance } from '../FormItem'; import type { - TdFormProps, - FormValidateResult, + AllValidateResult, FormResetParams, FormValidateMessage, - AllValidateResult, + FormValidateResult, NamePath, + TdFormProps, } from '../type'; -import useConfig from '../../hooks/useConfig'; -import { getMapValue, objectToArray, travelMapFromObject, calcFieldValue } from '../utils'; // 检测是否需要校验 默认全量校验 function needValidate(name: NamePath, fields: string[]) { @@ -40,7 +42,7 @@ function formatValidateResult(validateResultList) { export default function useInstance( props: TdFormProps, - formRef, + formRef: React.RefObject, formMapRef: React.MutableRefObject>, floatingFormDataRef: React.RefObject>, ) { @@ -109,7 +111,7 @@ export default function useInstance( function getFieldValue(name: NamePath) { if (!name) return null; - const formItemRef = getMapValue(name, formMapRef); + const formItemRef = findFormItem(name, formMapRef); return formItemRef?.current?.getValue?.(); } @@ -130,12 +132,12 @@ export default function useInstance( } } else { if (!Array.isArray(nameList)) { - log.error('Form', '`getFieldsValue` 参数需要 Array 类型'); + log.error('Form', 'The parameter of "getFieldsValue" must be an array'); return {}; } nameList.forEach((name) => { - const formItemRef = getMapValue(name, formMapRef); + const formItemRef = findFormItem(name, formMapRef); if (!formItemRef) return; const fieldValue = calcFieldValue(name, formItemRef?.current.getValue?.()); @@ -175,13 +177,15 @@ export default function useInstance( // 对外方法,设置对应 formItem 的数据 function setFields(fields = []) { - if (!Array.isArray(fields)) throw new Error('setFields 参数需要 Array 类型'); + if (!Array.isArray(fields)) throw new TypeError('The parameter of "setFields" must be an array'); fields.forEach((field) => { const { name, ...restFields } = field; - const formItemRef = getMapValue(name, formMapRef); - - formItemRef?.current?.setField(restFields, field); + let formItemRef = findFormItem(name, formMapRef); + if (!formItemRef) { + formItemRef = findFormItemDeep(name, formMapRef); + } + formItemRef?.current?.setField(restFields); }); } @@ -196,7 +200,7 @@ export default function useInstance( const { type = 'initial', fields = [] } = params; fields.forEach((name) => { - const formItemRef = getMapValue(name, formMapRef); + const formItemRef = findFormItem(name, formMapRef); formItemRef?.current?.resetField(type); }); } @@ -211,10 +215,10 @@ export default function useInstance( formItemRef?.current?.resetValidate(); }); } else { - if (!Array.isArray(fields)) throw new Error('clearValidate 参数需要 Array 类型'); + if (!Array.isArray(fields)) throw new TypeError('The parameter of "clearValidate" must be an array'); fields.forEach((name) => { - const formItemRef = getMapValue(name, formMapRef); + const formItemRef = findFormItem(name, formMapRef); formItemRef?.current?.resetValidate(); }); } @@ -229,25 +233,30 @@ export default function useInstance( // 对外方法,获取 formItem 的错误信息 function getValidateMessage(fields?: Array) { - const message = {}; + const formItemRefs = + typeof fields === 'undefined' + ? [...formMapRef.current.values()] + : fields.map((name) => findFormItem(name, formMapRef)).filter(Boolean); - if (typeof fields === 'undefined') { - [...formMapRef.current.values()].forEach((formItemRef) => { - const item = formItemRef?.current?.getValidateMessage?.(); - if (isEmpty(item)) return; - message[formItemRef?.current?.name] = item; - }); - } else { - if (!Array.isArray(fields)) throw new Error('getValidateMessage 参数需要 Array 类型'); - - fields.forEach((name) => { - const formItemRef = getMapValue(name, formMapRef); - const item = formItemRef?.current?.getValidateMessage?.(); - if (isEmpty(item)) return; - message[formItemRef?.current?.name] = item; - }); + if (typeof fields !== 'undefined' && !Array.isArray(fields)) { + throw new TypeError('The parameter of "getValidateMessage" must be an array'); } + const extractValidateMessage = (formItemRef: React.RefObject) => { + const item = formItemRef?.current?.getValidateMessage?.(); + if (isEmpty(item)) return null; + const nameKey = Array.isArray(formItemRef?.current?.name) + ? formItemRef?.current?.name.join('.') + : String(formItemRef?.current?.name); + return { nameKey, item }; + }; + + const message = {}; + formItemRefs.forEach((formItemRef) => { + const result = extractValidateMessage(formItemRef); + if (result) message[result.nameKey] = result.item; + }); + if (isEmpty(message)) return; return message; diff --git a/packages/components/form/type.ts b/packages/components/form/type.ts index de151da894..5999021861 100644 --- a/packages/components/form/type.ts +++ b/packages/components/form/type.ts @@ -447,7 +447,7 @@ export interface FormResetParams { export interface FieldData { name: NamePath; - value?: unknown; + value?: any; status?: string; validateMessage?: { type?: string; message?: string }; } diff --git a/packages/components/form/utils/index.ts b/packages/components/form/utils/index.ts index 4c28172bef..fd9067954b 100644 --- a/packages/components/form/utils/index.ts +++ b/packages/components/form/utils/index.ts @@ -1,10 +1,16 @@ -import { has, get, isObject, isArray, isEmpty } from 'lodash-es'; +import { get, has, isArray, isEmpty, isObject } from 'lodash-es'; +import type { FormItemInstance } from '../FormItem'; import type { NamePath } from '../type'; -// 获取 formMap 管理的数据 -export function getMapValue(name: NamePath, formMapRef: React.MutableRefObject>) { +/** + * 在 `formMap` 中查找指定的 `FormItem` + * (仅查找当前层级) + */ +export function findFormItem( + name: NamePath, + formMapRef: React.MutableRefObject>, +): React.RefObject | undefined { if (!formMapRef.current) return; - // 提取所有 map key const mapKeys = [...formMapRef.current.keys()]; // 转译为字符串后比对 key 兼容数组格式 @@ -13,6 +19,29 @@ export function getMapValue(name: NamePath, formMapRef: React.MutableRefObject>, +): React.RefObject | undefined { + if (!formMapRef?.current) return; + const targetPath = Array.isArray(name) ? name : [name]; + for (const [key, ref] of formMapRef.current.entries()) { + const formItem = ref?.current; + if (!formItem?.isFormList || !formItem.formListMapRef) continue; + const { formListMapRef } = formItem; + for (const [itemKey, itemRef] of formListMapRef.current.entries()) { + const fullPath = [key, itemKey].flat(); + if (String(fullPath) === String(targetPath)) return itemRef; + } + const found = findFormItemDeep(name, formListMapRef); + if (found) return found; + } +} + // { user: { name: '' } } => [['user', 'name']] // 不处理数组类型 // { user: [{ name: '' }]} => [['user']] From 69c2c62e9e1d307d86e1e5c08fce1a66aa20d470 Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 4 Nov 2025 13:11:23 +0800 Subject: [PATCH 2/3] chore(useInstance): improve message extraction logic --- .../components/form/hooks/useInstance.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/components/form/hooks/useInstance.tsx b/packages/components/form/hooks/useInstance.tsx index 7da3ce8c4f..f741af80a2 100644 --- a/packages/components/form/hooks/useInstance.tsx +++ b/packages/components/form/hooks/useInstance.tsx @@ -233,28 +233,31 @@ export default function useInstance( // 对外方法,获取 formItem 的错误信息 function getValidateMessage(fields?: Array) { + if (typeof fields !== 'undefined' && !Array.isArray(fields)) { + throw new TypeError('The parameter of "getValidateMessage" must be an array'); + } + const formItemRefs = typeof fields === 'undefined' ? [...formMapRef.current.values()] : fields.map((name) => findFormItem(name, formMapRef)).filter(Boolean); - if (typeof fields !== 'undefined' && !Array.isArray(fields)) { - throw new TypeError('The parameter of "getValidateMessage" must be an array'); - } - const extractValidateMessage = (formItemRef: React.RefObject) => { const item = formItemRef?.current?.getValidateMessage?.(); if (isEmpty(item)) return null; - const nameKey = Array.isArray(formItemRef?.current?.name) - ? formItemRef?.current?.name.join('.') - : String(formItemRef?.current?.name); + const nameKey = formItemRef?.current?.name; return { nameKey, item }; }; - const message = {}; + const message: Record = {}; + formItemRefs.forEach((formItemRef) => { const result = extractValidateMessage(formItemRef); - if (result) message[result.nameKey] = result.item; + if (!result) return; + const key = Array.isArray(result.nameKey) + ? result.nameKey.toString() // 将 数组 [a,b] 转为 a,b 作为 key + : String(result.nameKey); + message[key] = result.item; }); if (isEmpty(message)) return; From 39bc5bf7979168cedc8e9249acd0ec0b3b95de29 Mon Sep 17 00:00:00 2001 From: Rylan Date: Tue, 4 Nov 2025 13:11:56 +0800 Subject: [PATCH 3/3] test(Form): add tests for `getValidateMessage` functionality --- .../components/form/__tests__/form.test.tsx | 115 ++++++++++++++++-- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/components/form/__tests__/form.test.tsx b/packages/components/form/__tests__/form.test.tsx index 5b3dd17e15..359c518019 100644 --- a/packages/components/form/__tests__/form.test.tsx +++ b/packages/components/form/__tests__/form.test.tsx @@ -1,14 +1,13 @@ /* eslint-disable */ -import { render, fireEvent, mockDelay, mockTimeout, vi } from '@test/utils'; import React, { useEffect, useState } from 'react'; - -import Form, { TdFormProps } from '../index'; -import Input from '../../input'; -import Button from '../../button'; -import Radio from '../../radio'; import { HelpCircleIcon } from 'tdesign-icons-react'; +import { fireEvent, mockDelay, mockTimeout, render, vi } from '@test/utils'; +import Button from '../../button'; +import Checkbox from '../../checkbox'; +import Input from '../../input'; import InputNumber from '../../input-number'; -import { Checkbox } from 'tdesign-react'; +import Radio from '../../radio'; +import Form, { type TdFormProps } from '../index'; const { FormItem, FormList } = Form; @@ -286,6 +285,108 @@ describe('Form 组件测试', () => { expect(container.querySelector('.password .t-input__extra').innerHTML).toBe('custom warning message'); }); + test('Form.getValidateMessage works fine', async () => { + const TestForm = () => { + const [form] = Form.useForm(); + const [message, setMessage] = useState(null); + + const handleGetAll = () => { + const msg = form.getValidateMessage(); + setMessage(msg); + }; + + const handleGetSpecific = () => { + const msg = form.getValidateMessage(['username', 'email']); + setMessage(msg); + }; + + const handleValidate = async () => { + await form.validate(); + const msg = form.getValidateMessage(); + setMessage(msg); + }; + + return ( +
+
+ + + + + + + + + + + + + + + + + +
+ {message && ( +
+
{JSON.stringify(message, null, 2)}
+
+ )} +
+ ); + }; + + const { getByText, queryByTestId, getByTestId } = render(); + + // getValidateMessage returns undefined when no validation errors + fireEvent.click(getByText('getAllMessages')); + await mockDelay(); + expect(queryByTestId('message-display')).toBeNull(); + + // getValidateMessage returns all validation messages after validate + fireEvent.click(getByText('validate')); + await mockDelay(); + fireEvent.click(getByText('getAllMessages')); + await mockDelay(); + const allMessagesDisplay = getByTestId('message-display'); + expect(allMessagesDisplay.textContent).toContain('username is required'); + expect(allMessagesDisplay.textContent).toContain('email is required'); + expect(allMessagesDisplay.textContent).toContain('phone is required'); + expect(allMessagesDisplay.textContent).toContain('address is required'); + // array field name is converted to string key + expect(allMessagesDisplay.textContent).toContain('user,address'); + + // getValidateMessage returns specific field messages + fireEvent.click(getByText('getSpecificMessages')); + await mockDelay(); + const specificMessagesDisplay = getByTestId('message-display'); + const messageContent = specificMessagesDisplay.textContent; + expect(messageContent).toContain('username is required'); + expect(messageContent).toContain('email is required'); + expect(messageContent).not.toContain('phone is required'); + expect(messageContent).not.toContain('address is required'); + }); + test('Form disabled `input` keydown enter submit form', async () => { const TestForm = () => { return (