diff --git a/packages/components/form/FormItem.tsx b/packages/components/form/FormItem.tsx index a1a56ee549..c6485514e1 100644 --- a/packages/components/form/FormItem.tsx +++ b/packages/components/form/FormItem.tsx @@ -4,7 +4,7 @@ import { CloseCircleFilledIcon as TdCloseCircleFilledIcon, ErrorCircleFilledIcon as TdErrorCircleFilledIcon, } from 'tdesign-icons-react'; -import { get, isEqual, isFunction, isObject, isString, set, unset } from 'lodash-es'; +import { get, isEqual, isFunction, isObject, isString, set } from 'lodash-es'; import useConfig from '../hooks/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -81,7 +81,14 @@ const FormItem = forwardRef((originalProps, ref onFormItemValueChange, } = useFormContext(); - const { name: formListName, rules: formListRules, formListMapRef, form: formOfFormList } = useFormListContext(); + const { + name: formListName, + fullPath: parentFullPath, + rules: formListRules, + formListMapRef, + form: formOfFormList, + } = useFormListContext(); + const props = useDefaultProps(originalProps, formItemDefaultProps); const { @@ -104,10 +111,15 @@ const FormItem = forwardRef((originalProps, ref requiredMark = requiredMarkFromContext, } = props; - const { fullPath: parentFullPath } = useFormListContext(); - const fullPath = concatName(parentFullPath, name); + /* 用于处理嵌套 Form 的情况 (例如 FormList 内有一个 Dialog + Form) */ + const isSameForm = useMemo(() => isEqual(form, formOfFormList), [form, formOfFormList]); + + const fullPath = useMemo(() => { + const validParentFullPath = formListName && isSameForm ? parentFullPath : undefined; + return concatName(validParentFullPath, name); + }, [formListName, parentFullPath, name, isSameForm]); - const { getDefaultInitialData } = useFormItemInitialData(name, fullPath); + const { defaultInitialData } = useFormItemInitialData(name, fullPath, initialData, children); const [, forceUpdate] = useState({}); // custom render state const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined); @@ -116,12 +128,7 @@ const FormItem = forwardRef((originalProps, ref const [verifyStatus, setVerifyStatus] = useState('validating'); const [resetValidating, setResetValidating] = useState(false); const [needResetField, setNeedResetField] = useState(false); - const [formValue, setFormValue] = useState(() => - getDefaultInitialData({ - children, - initialData, - }), - ); + const [formValue, setFormValue] = useState(defaultInitialData); const formItemRef = useRef(null); // 当前 formItem 实例 const innerFormItemsRef = useRef([]); @@ -130,7 +137,6 @@ const FormItem = forwardRef((originalProps, ref const valueRef = useRef(formValue); // 当前最新值 const errorListMapRef = useRef(new Map()); - const isSameForm = useMemo(() => isEqual(form, formOfFormList), [form, formOfFormList]); // 用于处理 Form 嵌套的情况 const snakeName = [] .concat(isSameForm ? formListName : undefined, name) .filter((item) => item !== undefined) @@ -325,10 +331,7 @@ const FormItem = forwardRef((originalProps, ref function getResetValue(resetType: TdFormProps['resetType']): ValueType { if (resetType === 'initial') { - return getDefaultInitialData({ - children, - initialData, - }); + return defaultInitialData; } let emptyValue: ValueType; @@ -413,26 +416,21 @@ const FormItem = forwardRef((originalProps, ref }, [shouldUpdate, form]); useEffect(() => { - // 记录填写 name 属性 formItem if (typeof name === 'undefined') return; - // FormList 下特殊处理 - if (formListName && isSameForm) { - formListMapRef.current.set(fullPath, formItemRef); - set(form?.store, fullPath, formValue); - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - formListMapRef.current.delete(fullPath); - unset(form?.store, fullPath); - }; - } - if (!formMapRef) return; - formMapRef.current.set(fullPath, formItemRef); - set(form?.store, fullPath, formValue); + const isFormList = formListName && isSameForm; + const mapRef = isFormList ? formListMapRef : formMapRef; + if (!mapRef.current) return; + + // 注册实例 + mapRef.current.set(fullPath, formItemRef); + + // 初始化 + set(form?.store, fullPath, defaultInitialData); + setFormValue(defaultInitialData); + return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - formMapRef.current.delete(fullPath); - unset(form?.store, fullPath); + mapRef.current.delete(fullPath); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [snakeName, formListName]); diff --git a/packages/components/form/FormList.tsx b/packages/components/form/FormList.tsx index c49c3314de..394068b092 100644 --- a/packages/components/form/FormList.tsx +++ b/packages/components/form/FormList.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { castArray, get, isEqual, merge, set, unset } from 'lodash-es'; +import { castArray, cloneDeep, get, isEqual, merge, set, unset } from 'lodash-es'; + import log from '@tdesign/common-js/log/index'; import { FormListContext, useFormContext, useFormListContext } from './FormContext'; import { HOOK_MARK } from './hooks/useForm'; @@ -34,10 +35,11 @@ const FormList: React.FC = (props) => { } else { propsInitialData = get(initialDataFromForm, fullPath); } - return propsInitialData; - }, [fullPath, parentFullPath, initialDataFromForm, parentInitialData, props.initialData]); + return cloneDeep(propsInitialData || []); + }, [props.initialData, fullPath, parentFullPath, parentInitialData, initialDataFromForm]); + + const [formListValue, setFormListValue] = useState(() => get(form?.store, fullPath) || initialData); - const [formListValue, setFormListValue] = useState(() => get(form?.store, fullPath) || initialData || []); const [fields, setFields] = useState(() => formListValue.map((data, index) => ({ data: { ...data }, @@ -57,20 +59,6 @@ const FormList: React.FC = (props) => { .filter((item) => item !== undefined) .toString(); // 转化 name - const buildDefaultFieldMap = () => { - if (formListMapRef.current.size <= 0) return {}; - const defaultValues: Record = {}; - formListMapRef.current.forEach((item, itemPath) => { - const itemPathArray = convertNameToArray(itemPath); - const isChildField = itemPathArray.length === convertNameToArray(fullPath).length + 2; - if (!isChildField) return; - const fieldName = itemPathArray[itemPathArray.length - 1]; - // add 没有传参时,构建一个包含所有子字段的对象用于占位,确保回调给用户的数据结构完整 - defaultValues[fieldName] = item.current.initialData; - }); - return defaultValues; - }; - const updateFormList = (newFields: any, newFormListValue: any) => { setFields(newFields); setFormListValue(newFormListValue); @@ -89,11 +77,7 @@ const FormList: React.FC = (props) => { isListField: true, }); const newFormListValue = [...formListValue]; - if (defaultValue !== undefined) { - newFormListValue.splice(index, 0, defaultValue); - } else { - newFormListValue.splice(index, 0, buildDefaultFieldMap()); - } + newFormListValue.splice(index, 0, cloneDeep(defaultValue)); updateFormList(newFields, newFormListValue); }, remove(index: number | number[]) { @@ -141,7 +125,9 @@ const FormList: React.FC = (props) => { useEffect(() => { if (!name || !formMapRef) return; + // 初始化 formMapRef.current.set(fullPath, formListRef); + set(form?.store, fullPath, formListValue); return () => { // eslint-disable-next-line react-hooks/exhaustive-deps formMapRef.current.delete(fullPath); @@ -173,10 +159,14 @@ const FormList: React.FC = (props) => { return new Promise((resolve) => { Promise.all(validates).then((validateResult) => { validateResult.forEach((result) => { + if (typeof result !== 'object') return; const errorValue = Object.values(result)[0]; merge(resultList, errorValue); }); - const errorItems = validateResult.filter((item) => Object.values(item)[0] !== true); + const errorItems = validateResult.filter((item) => { + if (typeof item !== 'object') return; + return Object.values(item)[0] !== true; + }); if (errorItems.length) { resolve({ [snakeName]: resultList }); } else { @@ -203,17 +193,16 @@ const FormList: React.FC = (props) => { const resetType = type || resetTypeFromContext; if (resetType === 'initial') { const currentData = get(form?.store, fullPath); - const data = initialData || []; if (isEqual(currentData, initialData)) return; - setFormListValue(data); - const newFields = data?.map((data, index) => ({ + setFormListValue(initialData); + const newFields = initialData?.map((data, index) => ({ data: { ...data }, key: (globalKey += 1), name: index, isListField: true, })); setFields(newFields); - set(form?.store, fullPath, data); + set(form?.store, fullPath, initialData); } else { // 重置为空 setFormListValue([]); diff --git a/packages/components/form/__tests__/form-list.test.tsx b/packages/components/form/__tests__/form-list.test.tsx index f0f1e7b9ab..1058aedd5e 100644 --- a/packages/components/form/__tests__/form-list.test.tsx +++ b/packages/components/form/__tests__/form-list.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, mockTimeout, render, vi } from '@test/utils'; import Button from '../../button'; import Input from '../../input'; +import Radio from '../../radio'; import FormList from '../FormList'; import Form, { type FormProps } from '../index'; @@ -52,8 +53,11 @@ const BasicForm = (props: FormProps & { operation }) => { ); }; -describe('Form List 组件测试', () => { - test('form list 测试', async () => { +describe('FormList 组件测试', () => { + test('FormList basic API', async () => { + const onValuesChangeFn = vi.fn(); + let latestFormValues = {}; + const TestView = () => { const [form] = Form.useForm(); @@ -83,9 +87,18 @@ describe('Form List 组件测试', () => { form.clearValidate(); } + function getFormValues() { + const values = form.getFieldsValue(true); + document.getElementById('form-values-result')?.setAttribute('data-result', JSON.stringify(values)); + } + return ( { + onValuesChangeFn(changedValues, allValues); + latestFormValues = allValues; + }} operation={() => ( <> @@ -96,6 +109,8 @@ describe('Form List 组件测试', () => { + +
)} /> @@ -104,33 +119,134 @@ describe('Form List 组件测试', () => { const { container, queryByDisplayValue, queryByText } = render(); const addBtn = container.querySelector('#test-add-with-data'); - const submitBtn = queryByText('submit'); const resetBtn = queryByText('reset'); + // Reset onValuesChange call count + onValuesChangeFn.mockClear(); + + // Test 1: Add field with data and verify onValuesChange is called fireEvent.click(addBtn); + await mockTimeout(() => true); expect(queryByDisplayValue('guangdong')).toBeTruthy(); expect(queryByDisplayValue('shenzhen')).toBeTruthy(); + + // Verify onValuesChange was called with correct data + expect(onValuesChangeFn).toHaveBeenCalled(); + const lastCall = onValuesChangeFn.mock.calls[onValuesChangeFn.mock.calls.length - 1]; + expect(lastCall[1]).toEqual({ + address: [{ province: 'guangdong', area: 'shenzhen' }], + }); + + // Verify UI matches the data in onValuesChange + expect(latestFormValues).toEqual({ + address: [{ province: 'guangdong', area: 'shenzhen' }], + }); + + const initialCallCount = onValuesChangeFn.mock.calls.length; + fireEvent.click(resetBtn); - fireEvent.click(submitBtn); await mockTimeout(() => true); - expect(queryByText('guangdong')).not.toBeTruthy(); - expect(queryByText('shenzhen')).not.toBeTruthy(); + + // Check if UI is cleared after reset + expect(queryByDisplayValue('guangdong')).not.toBeTruthy(); + expect(queryByDisplayValue('shenzhen')).not.toBeTruthy(); + + // Verify form values are cleared (either through onValuesChange or direct form state) + const currentFormValues = container.querySelector('#form-values-result'); + if (currentFormValues) { + fireEvent.click(queryByText('getFormValues')); + await mockTimeout(() => true); + const formValuesResult = currentFormValues.getAttribute('data-result'); + const formValues = JSON.parse(formValuesResult || '{}'); + expect(formValues).toEqual({}); + } + + // If onValuesChange was called during reset, verify the data + if (onValuesChangeFn.mock.calls.length > initialCallCount) { + const resetCall = onValuesChangeFn.mock.calls[onValuesChangeFn.mock.calls.length - 1]; + expect(resetCall[1]).toEqual({}); + expect(latestFormValues).toEqual({}); + } else { + latestFormValues = {}; + } + + const beforeAddCallCount = onValuesChangeFn.mock.calls.length; fireEvent.click(addBtn); + await mockTimeout(() => true); expect(queryByDisplayValue('guangdong')).toBeTruthy(); expect(queryByDisplayValue('shenzhen')).toBeTruthy(); + const removeBtn = container.querySelector('.test-remove-0'); fireEvent.click(removeBtn); + await mockTimeout(() => true); expect(queryByDisplayValue('guangdong')).not.toBeTruthy(); expect(queryByDisplayValue('shenzhen')).not.toBeTruthy(); + const afterRemoveCallCount = onValuesChangeFn.mock.calls.length; + + // Verify onValuesChange was called for both add and remove operations + expect(afterRemoveCallCount).toBeGreaterThan(beforeAddCallCount); + if (afterRemoveCallCount > beforeAddCallCount) { + const removeCall = onValuesChangeFn.mock.calls[afterRemoveCallCount - 1]; + expect(removeCall[1]).toEqual({ address: [] }); + } + + // Test 4: setFields and verify onValuesChange + const beforeSetFieldsCallCount = onValuesChangeFn.mock.calls.length; + fireEvent.click(queryByText('setFields')); await mockTimeout(() => true); expect(queryByDisplayValue('setFields')).toBeTruthy(); + + const afterSetFieldsCallCount = onValuesChangeFn.mock.calls.length; + + if (afterSetFieldsCallCount > beforeSetFieldsCallCount) { + const setFieldsCall = onValuesChangeFn.mock.calls[afterSetFieldsCallCount - 1]; + expect(setFieldsCall[1]).toEqual({ + address: [{ province: 'setFields' }], + }); + } + + // Test 5: setFieldsValue and verify onValuesChange + const beforeSetFieldsValueCallCount = onValuesChangeFn.mock.calls.length; fireEvent.click(queryByText('setFieldsValue')); await mockTimeout(() => true); expect(queryByDisplayValue('setFieldsValue')).toBeTruthy(); + const afterSetFieldsValueCallCount = onValuesChangeFn.mock.calls.length; + if (afterSetFieldsValueCallCount > beforeSetFieldsValueCallCount) { + const setFieldsValueCall = onValuesChangeFn.mock.calls[afterSetFieldsValueCallCount - 1]; + expect(setFieldsValueCall[1]).toEqual({ + address: [{ province: 'setFieldsValue' }], + }); + } + + fireEvent.click(queryByText('getFormValues')); + await mockTimeout(() => true); + const formValuesResult = container.querySelector('#form-values-result')?.getAttribute('data-result'); + const formValues = JSON.parse(formValuesResult || '{}'); + expect(formValues).toEqual(latestFormValues); + expect(formValues).toEqual({ + address: [{ province: 'setFieldsValue' }], + }); + + const beforeManualInputCallCount = onValuesChangeFn.mock.calls.length; + const provinceInput = container.querySelector('input[value="setFieldsValue"]') as HTMLInputElement; + fireEvent.change(provinceInput, { target: { value: 'manual-input' } }); + await mockTimeout(() => true); + + const afterManualInputCallCount = onValuesChangeFn.mock.calls.length; + if (afterManualInputCallCount > beforeManualInputCallCount) { + const manualInputCall = onValuesChangeFn.mock.calls[afterManualInputCallCount - 1]; + expect(manualInputCall[1]).toEqual({ + address: [{ province: 'manual-input' }], + }); + expect(latestFormValues).toEqual({ + address: [{ province: 'manual-input' }], + }); + } + // validate validateOnly test fireEvent.click(queryByText('validateOnly')); await mockTimeout(() => true); @@ -142,7 +258,7 @@ describe('Form List 组件测试', () => { expect(queryByText('地区必填')).not.toBeTruthy(); }); - test('reset to initial data', async () => { + test('FormList reset to initial data', async () => { const TestView = () => { const [form] = Form.useForm(); @@ -164,14 +280,40 @@ describe('Form List 组件测试', () => { }; const { container, queryByText, getByPlaceholderText } = render(); - const resetBtn = queryByText('reset'); - const removeBtn = container.querySelector('.test-remove-0'); - fireEvent.click(removeBtn); + // 验证初始数据渲染正确 + expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe('shenzhen'); + expect((getByPlaceholderText('area-input-1') as HTMLInputElement).value).toBe('beijing'); + + // 删除 beijing + const removeBtn1 = container.querySelector('.test-remove-1'); + fireEvent.click(removeBtn1); + await mockTimeout(() => true); + // 只剩 shenzhen + expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe('shenzhen'); + expect(container.querySelector('[placeholder="area-input-1"]')).toBeFalsy(); + + // 添加空数据 + const addBtn = container.querySelector('#test-add'); + fireEvent.click(addBtn); await mockTimeout(() => true); - expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe('beijing'); + expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe('shenzhen'); + expect(container.querySelector('[placeholder="area-input-1"]')).toBeTruthy(); + expect((getByPlaceholderText('area-input-1') as HTMLInputElement).value).toBe(''); + + // 再删除 shenzhen + const removeBtn0 = container.querySelector('.test-remove-0'); + fireEvent.click(removeBtn0); + await mockTimeout(() => true); + expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe(''); + expect(container.querySelector('[placeholder="area-input-1"]')).toBeFalsy(); + + // 点击 reset 重置 + const resetBtn = queryByText('reset'); fireEvent.click(resetBtn); await mockTimeout(() => true); + + // 恢复到初始化数据 expect((getByPlaceholderText('area-input-0') as HTMLInputElement).value).toBe('shenzhen'); expect((getByPlaceholderText('area-input-1') as HTMLInputElement).value).toBe('beijing'); }); @@ -219,7 +361,7 @@ describe('Form List 组件测试', () => { expect(fn).toHaveBeenCalledTimes(1); }); - test('Multiple nested FormList', async () => { + test('FormList with nested structures', async () => { const TestView = () => { const [form] = Form.useForm(); @@ -307,7 +449,6 @@ describe('Form List 组件测试', () => { function getFieldsValueAll() { const allValues = form.getFieldsValue(true); - console.log('getFieldsValue(true):', allValues); document.getElementById('all-values-result')?.setAttribute('data-result', JSON.stringify(allValues)); } @@ -342,88 +483,74 @@ describe('Form List 组件测试', () => { {(userFields, { add: addUser, remove: removeUser }) => ( <> - {userFields.map(({ key: userKey, name: userName, ...userRestField }, userIndex) => ( + {userFields.map(({ key: userKey, name: userName }, userIndex) => ( - + {(projectFields, { add: addProject, remove: removeProject }) => ( <> - {projectFields.map( - ({ key: projectKey, name: projectName, ...projectRestField }, projectIndex) => ( - - - - - - - {(taskFields, { add: addTask, remove: removeTask }) => ( - <> - {taskFields.map( - ({ key: taskKey, name: taskName, ...taskRestField }, taskIndex) => ( - - - - - - - - - removeTask(taskName)} - /> - - - ), - )} - - + + + + + + + removeTask(taskName)} + /> + - - )} - - - - removeProject(projectName)} - /> - + ))} + + + + + )} + + + + removeProject(projectName)} + /> - ), - )} + + ))} + +
+ )} + + + ))} + + )} + + + ); + }; + + const { container, getByPlaceholderText } = render(); + + // Test initial data - first route (type: weight) + const weightRadio0 = container.querySelector('[data-route-index="0"] input[value="weight"]') as HTMLInputElement; + expect(weightRadio0.checked).toBe(true); + expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('50'); + expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy(); + + // Test initial data - second route (type: abtest) + const abtestRadio1 = container.querySelector('[data-route-index="1"] input[value="abtest"]') as HTMLInputElement; + expect(abtestRadio1.checked).toBe(true); + expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('uid'); + expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy(); + + // Test switching first route from weight to abtest + const abtestRadio0 = container.querySelector('[data-route-index="0"] input[value="abtest"]') as HTMLInputElement; + fireEvent.click(abtestRadio0); + await mockTimeout(); + expect((getByPlaceholderText('route-abtest-0-0') as HTMLInputElement).value).toBe('cid'); + expect(container.querySelector('[placeholder="route-weight-0-0"]')).toBeFalsy(); + + // Test switching first route back to weight + fireEvent.click(weightRadio0); + await mockTimeout(); + expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('50'); + expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy(); + + // Test manual modification persistence - modify weight value manually + const weightInput0 = getByPlaceholderText('route-weight-0-0') as HTMLInputElement; + fireEvent.change(weightInput0, { target: { value: '200' } }); + await mockTimeout(); + expect(weightInput0.value).toBe('200'); + + // Switch to abtest + fireEvent.click(abtestRadio0); + await mockTimeout(); + expect(container.querySelector('[placeholder="route-weight-0-0"]')).toBeFalsy(); + expect((getByPlaceholderText('route-abtest-0-0') as HTMLInputElement).value).toBe('cid'); + + // Switch back to weight - should show manually modified value (200) + fireEvent.click(weightRadio0); + await mockTimeout(); + expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('200'); + expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy(); + + // Test switching second route from abtest to weight + const weightRadio1 = container.querySelector('[data-route-index="1"] input[value="weight"]') as HTMLInputElement; + fireEvent.click(weightRadio1); + await mockTimeout(); + expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('30'); + expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy(); + + // Test manual modification persistence - modify weight value manually after switching + const weightInput1 = getByPlaceholderText('route-weight-0-1') as HTMLInputElement; + fireEvent.change(weightInput1, { target: { value: '100' } }); + await mockTimeout(); + expect(weightInput1.value).toBe('100'); + + // Switch back to abtest - should show initial abtest value (uid) + fireEvent.click(abtestRadio1); + await mockTimeout(); + expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('uid'); + expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy(); + + // Switch to weight again - should show manually modified value (100) + fireEvent.click(weightRadio1); + await mockTimeout(); + expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('100'); + expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy(); + + // Modify abtest value manually + fireEvent.click(abtestRadio1); + await mockTimeout(); + const abtestInput1 = getByPlaceholderText('route-abtest-0-1') as HTMLInputElement; + expect(abtestInput1.value).toBe('uid'); + fireEvent.change(abtestInput1, { target: { value: 'custom-key' } }); + await mockTimeout(); + expect(abtestInput1.value).toBe('custom-key'); + + // Switch to weight + fireEvent.click(weightRadio1); + await mockTimeout(); + expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy(); + expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('100'); + + // Switch back to abtest - should show manually modified value + fireEvent.click(abtestRadio1); + await mockTimeout(); + expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('custom-key'); + expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy(); + + // Test adding default route (empty data) + const addDefaultBtn = container.querySelector('#test-add-route-0-default'); + fireEvent.click(addDefaultBtn); + await mockTimeout(); + const newRouteRadios = container.querySelectorAll('[data-route-index="2"] input[type="radio"]'); + expect(newRouteRadios.length).toBe(2); + // No radio should be checked initially + const checkedRadio = container.querySelector('[data-route-index="2"] input[type="radio"]:checked'); + expect(checkedRadio).toBeFalsy(); + // No conditional field should be rendered when type is empty + expect(container.querySelector('[placeholder="route-weight-0-2"]')).toBeFalsy(); + expect(container.querySelector('[placeholder="route-abtest-0-2"]')).toBeFalsy(); + + // Test setting type to weight for new route + const newWeightRadio = container.querySelector('[data-route-index="2"] input[value="weight"]') as HTMLInputElement; + fireEvent.click(newWeightRadio); + await mockTimeout(); + const newWeightInput = getByPlaceholderText('route-weight-0-2') as HTMLInputElement; + expect(newWeightInput).toBeTruthy(); + expect(newWeightInput.value).toBe(''); + + // Test setting weight value + fireEvent.change(newWeightInput, { target: { value: '100' } }); + await mockTimeout(); + expect(newWeightInput.value).toBe('100'); + + // Test switching new route to abtest + const newAbtestRadio = container.querySelector('[data-route-index="2"] input[value="abtest"]') as HTMLInputElement; + fireEvent.click(newAbtestRadio); + await mockTimeout(); + expect(container.querySelector('[placeholder="route-weight-0-2"]')).toBeFalsy(); + const newAbtestInput = getByPlaceholderText('route-abtest-0-2') as HTMLInputElement; + expect(newAbtestInput).toBeTruthy(); + expect(newAbtestInput.value).toBe(''); + + // Test setting abtest value + fireEvent.change(newAbtestInput, { target: { value: 'new-key' } }); + await mockTimeout(); + expect(newAbtestInput.value).toBe('new-key'); + + // Test switching back to weight - should show manually modified value (100) + fireEvent.click(newWeightRadio); + await mockTimeout(); + expect(container.querySelector('[placeholder="route-abtest-0-2"]')).toBeFalsy(); + const weightInputAgain = getByPlaceholderText('route-weight-0-2') as HTMLInputElement; + expect(weightInputAgain.value).toBe('100'); + + // Test adding specified route (with initial data) + const addSpecifiedBtn = container.querySelector('#test-add-route-0-specified'); + fireEvent.click(addSpecifiedBtn); + await mockTimeout(); + const specifiedWeightRadio = container.querySelector( + '[data-route-index="3"] input[value="weight"]', + ) as HTMLInputElement; + expect(specifiedWeightRadio.checked).toBe(true); + const specifiedWeightInput = getByPlaceholderText('route-weight-0-3') as HTMLInputElement; + expect(specifiedWeightInput.value).toBe('50'); + expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy(); + + // Test switching specified route to abtest + const specifiedAbtestRadio = container.querySelector( + '[data-route-index="3"] input[value="abtest"]', + ) as HTMLInputElement; + fireEvent.click(specifiedAbtestRadio); + await mockTimeout(); + const specifiedAbtestInput = getByPlaceholderText('route-abtest-0-3') as HTMLInputElement; + expect(specifiedAbtestInput.value).toBe('cid'); + expect(container.querySelector('[placeholder="route-weight-0-3"]')).toBeFalsy(); + + // Test switching specified route back to weight - should show initial weight value + fireEvent.click(specifiedWeightRadio); + await mockTimeout(); + const specifiedWeightInputAgain = getByPlaceholderText('route-weight-0-3') as HTMLInputElement; + expect(specifiedWeightInputAgain.value).toBe('50'); + expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy(); + }); }); diff --git a/packages/components/form/_example/form-field-linkage.tsx b/packages/components/form/_example/form-field-linkage.tsx index 5eee192845..8b20e4e6e4 100644 --- a/packages/components/form/_example/form-field-linkage.tsx +++ b/packages/components/form/_example/form-field-linkage.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { Form, Radio, Button } from 'tdesign-react'; +import { Button, Form, Radio } from 'tdesign-react'; const { FormItem } = Form; export default function FormExample() { const [form] = Form.useForm(); - const setMessage = () => { + + const applyColdPreset = () => { form.setFieldsValue({ type: 'cold', ice: '1', @@ -24,7 +25,7 @@ export default function FormExample() { {({ getFieldValue }) => { if (getFieldValue('type') === 'cold') { return ( - + 正常冰 少冰 @@ -38,7 +39,7 @@ export default function FormExample() { - + ); diff --git a/packages/components/form/_example/form-list.tsx b/packages/components/form/_example/form-list.tsx index f81d87996d..cea4300425 100644 --- a/packages/components/form/_example/form-list.tsx +++ b/packages/components/form/_example/form-list.tsx @@ -25,21 +25,21 @@ export default function BaseForm() { onSubmit={onSubmit} initialData={{ task: [{ type: 'review' }, { type: 'ui' }] }} onValuesChange={(change, all) => { - console.log('change:', change, JSON.stringify(change)); - console.log('all:', all, JSON.stringify(all)); + console.log('change:', change, '\n', JSON.stringify(change)); + console.log('all:', all, '\n', JSON.stringify(all)); }} resetType="initial" > {(fields, { add, remove, move }) => ( <> - {fields.map(({ key, name, ...restField }, index) => ( + {fields.map(({ key, name }, index) => ( - + @@ -68,7 +68,7 @@ export default function BaseForm() { - + diff --git a/packages/components/form/_example/nested-data.tsx b/packages/components/form/_example/nested-data.tsx index 9b3f5762fd..1cfb67d0fd 100644 --- a/packages/components/form/_example/nested-data.tsx +++ b/packages/components/form/_example/nested-data.tsx @@ -78,17 +78,16 @@ export default function BaseForm() { {(fields, { add, remove }) => ( <> - {fields.map(({ key, name, ...restField }) => ( + {fields.map(({ key, name }) => ( - + diff --git a/packages/components/form/hooks/useFormItemInitialData.ts b/packages/components/form/hooks/useFormItemInitialData.ts index c0d9d6e847..fa22c0c5ad 100644 --- a/packages/components/form/hooks/useFormItemInitialData.ts +++ b/packages/components/form/hooks/useFormItemInitialData.ts @@ -7,7 +7,12 @@ import { TD_DEFAULT_VALUE_MAP } from '../const'; import type { NamePath } from '../type'; -export default function useFormItemInitialData(name: NamePath, fullPath: NamePath) { +export default function useFormItemInitialData( + name: NamePath, + fullPath: NamePath, + initialData: FormItemProps['initialData'], + children: FormItemProps['children'], +) { let hadReadFloatingFormData = false; const { form, floatingFormDataRef, initialData: formContextInitialData } = useFormContext(); @@ -21,14 +26,10 @@ export default function useFormItemInitialData(name: NamePath, fullPath: NamePat } }, [hadReadFloatingFormData, floatingFormDataRef, formListName, name]); - // 整理初始值 优先级:Form.initialData < FormList.initialData < FormItem.initialData < floatFormData - function getDefaultInitialData({ - children, - initialData, - }: { - children: FormItemProps['children']; - initialData: FormItemProps['initialData']; - }) { + const defaultInitialData = getDefaultInitialData(children, initialData); + + // 优先级:floatFormData > FormItem.initialData > FormList.initialData > Form.initialData + function getDefaultInitialData(children: FormItemProps['children'], initialData: FormItemProps['initialData']) { if (name && floatingFormDataRef?.current && !isEmpty(floatingFormDataRef.current)) { const nameList = formListName ? [formListName, name].flat() : name; const defaultInitialData = get(floatingFormDataRef.current, nameList); @@ -39,6 +40,10 @@ export default function useFormItemInitialData(name: NamePath, fullPath: NamePat } } + if (typeof initialData !== 'undefined') { + return initialData; + } + if (formListName && Array.isArray(fullPath)) { const pathPrefix = fullPath.slice(0, -1); const pathExisted = has(form.store, pathPrefix); @@ -50,12 +55,12 @@ export default function useFormItemInitialData(name: NamePath, fullPath: NamePat } } - if (typeof initialData !== 'undefined') { - return initialData; - } - - if (name && formListInitialData.length) { - const defaultInitialData = get(formListInitialData, name); + if (Array.isArray(name) && formListInitialData?.length) { + let defaultInitialData; + const [index, ...relativePath] = name; + if (formListInitialData[index]) { + defaultInitialData = get(formListInitialData[index], relativePath); + } if (typeof defaultInitialData !== 'undefined') return defaultInitialData; } @@ -77,6 +82,6 @@ export default function useFormItemInitialData(name: NamePath, fullPath: NamePat } return { - getDefaultInitialData, + defaultInitialData, }; } diff --git a/packages/components/form/type.ts b/packages/components/form/type.ts index 97f42f7e5e..117ae88ace 100644 --- a/packages/components/form/type.ts +++ b/packages/components/form/type.ts @@ -138,7 +138,7 @@ export interface FormInstanceFunctions { /** * 获取单个字段值 */ - getFieldValue: (field: NamePath) => unknown; + getFieldValue: (field: NamePath) => any; /** * 获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有表单数据 */ diff --git a/packages/tdesign-react/.changelog/pr-4005.md b/packages/tdesign-react/.changelog/pr-4005.md new file mode 100644 index 0000000000..01488e9a65 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4005.md @@ -0,0 +1,10 @@ +--- +pr_number: 4005 +contributor: RylanBot +--- + +- fix(Form): 修复使用 `shouldUpdate` 时,必须给 `FormItem` 加 `key` 才能生效的问题 @RylanBot ([#4005](https://github.com/Tencent/tdesign-react/pull/4005)) +- fix(FormList): 修复子节点存在另一个 Form 时,部分 API 异常的问题 @RylanBot ([#4005](https://github.com/Tencent/tdesign-react/pull/4005)) +- fix(FormList): 修复结合 `shouldUpdate` 使用时,`initialData` 不生效的问题 @RylanBot ([#4005](https://github.com/Tencent/tdesign-react/pull/4005)) +- fix(FormList): 修复 `add` 过程中缺乏拷贝从而污染用户原始数据的问题 @RylanBot ([#4005](https://github.com/Tencent/tdesign-react/pull/4005)) +- undefined @RylanBot ([#4005](https://github.com/Tencent/tdesign-react/pull/4005))