diff --git a/packages/components/form/FormItem.tsx b/packages/components/form/FormItem.tsx index c6485514e1..325997c57b 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 } from 'lodash-es'; +import { cloneDeep, get, isEqual, isFunction, isNil, isObject, isString, set } from 'lodash-es'; import useConfig from '../hooks/useConfig'; import useDefaultProps from '../hooks/useDefaultProps'; @@ -115,7 +115,7 @@ const FormItem = forwardRef((originalProps, ref const isSameForm = useMemo(() => isEqual(form, formOfFormList), [form, formOfFormList]); const fullPath = useMemo(() => { - const validParentFullPath = formListName && isSameForm ? parentFullPath : undefined; + const validParentFullPath = !isNil(formListName) && isSameForm ? parentFullPath : undefined; return concatName(validParentFullPath, name); }, [formListName, parentFullPath, name, isSameForm]); @@ -460,7 +460,7 @@ const FormItem = forwardRef((originalProps, ref value: formValue, initialData, isFormList: false, - getValue: () => valueRef.current, + getValue: () => cloneDeep(valueRef.current), setValue: (newVal: any) => updateFormValue(newVal, true, true), setField, validate, diff --git a/packages/components/form/FormList.tsx b/packages/components/form/FormList.tsx index 394068b092..1d4bd756d3 100644 --- a/packages/components/form/FormList.tsx +++ b/packages/components/form/FormList.tsx @@ -150,7 +150,7 @@ const FormList: React.FC = (props) => { initialData, isFormList: true, formListMapRef, - getValue: () => get(form?.store, fullPath), + getValue: () => cloneDeep(get(form?.store, fullPath)), validate: (trigger = 'all') => { const resultList = []; const validates = [...formListMapRef.current.values()].map((formItemRef) => diff --git a/packages/components/form/__tests__/form-list.test.tsx b/packages/components/form/__tests__/form-list.test.tsx index 1058aedd5e..55f5ea5e1e 100644 --- a/packages/components/form/__tests__/form-list.test.tsx +++ b/packages/components/form/__tests__/form-list.test.tsx @@ -1,8 +1,9 @@ -import React from 'react'; +import React, { useState } from 'react'; import { MinusCircleIcon } from 'tdesign-icons-react'; -import { fireEvent, mockTimeout, render, vi } from '@test/utils'; +import { fireEvent, mockTimeout, render, vi, waitFor, within } from '@test/utils'; import Button from '../../button'; +import Dialog from '../../dialog'; import Input from '../../input'; import Radio from '../../radio'; import FormList from '../FormList'; @@ -361,7 +362,7 @@ describe('FormList 组件测试', () => { expect(fn).toHaveBeenCalledTimes(1); }); - test('FormList with nested structures', async () => { + test('FormList with nested objects', async () => { const TestView = () => { const [form] = Form.useForm(); @@ -733,6 +734,167 @@ describe('FormList 组件测试', () => { expect(queryByText('用户名必填')).not.toBeTruthy(); }); + test('FormList with nested arrays', async () => { + const onValuesChangeFn = vi.fn(); + let latestChangedValues = {}; + let latestFormValues = {}; + + const TestView = () => { + const [form] = Form.useForm(); + + const INIT_DATA = { + vector: ['v1', 'v2', 'v3'], + matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']], + }; + + return ( +
{ + onValuesChangeFn(changedValues, allValues); + latestChangedValues = changedValues; + latestFormValues = allValues; + }} + > +
+ + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name }) => ( +
+ + + + +
+ ))} + + + )} +
+
+ +
+ + {(rowFields, { add: addRow, remove: removeRow }) => ( + <> + {rowFields.map(({ key, name: rowName }) => ( +
+
Row {rowName}
+ + {(colFields, { add: addCol, remove: removeCol }) => ( + <> + {colFields.map(({ key, name: colName }) => ( +
+ + + + +
+ ))} + + + )} +
+ +
+ ))} + + + )} +
+
+
+ ); + }; + + const { getByText, getByTestId, getAllByText } = render(); + + const getInputValues = (testId: string) => + within(getByTestId(testId)) + .getAllByRole('textbox') + .map((el) => (el as HTMLInputElement).value); + + // ===== initial ===== + expect(getInputValues('vector')).toEqual(['v1', 'v2', 'v3']); + expect(getInputValues('matrix')).toEqual(['m11', 'm12', 'm21', 'm22', 'm23', 'm31']); + + // ===== vector: add ===== + fireEvent.click(getByText('Add Vector')); + await mockTimeout(() => true); + expect(getInputValues('vector')).toEqual(['v1', 'v2', 'v3', 'v-new']); + expect(onValuesChangeFn).toHaveBeenCalled(); + expect(latestChangedValues).toEqual({ + vector: ['v1', 'v2', 'v3', 'v-new'], + }); + expect(latestFormValues).toEqual({ + vector: ['v1', 'v2', 'v3', 'v-new'], + matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']], + }); + + // ===== vector: remove ===== + fireEvent.click(within(getByTestId('vector')).getAllByText('Remove')[0]); + await mockTimeout(() => true); + expect(latestChangedValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + }); + expect(latestFormValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']], + }); + + // ===== matrix: add col (row 0) ===== + const matrix = getByTestId('matrix'); + fireEvent.click(within(matrix).getAllByText('Add Col')[0]); + await mockTimeout(() => true); + const expectedChangedMatrix1 = []; + expectedChangedMatrix1[0] = ['m11', 'm12', 'm-new']; + expect(latestChangedValues).toEqual({ + matrix: expectedChangedMatrix1, + }); + expect(latestFormValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + matrix: [['m11', 'm12', 'm-new'], ['m21', 'm22', 'm23'], ['m31']], + }); + + // ===== matrix: remove col ===== + const row0 = getAllByText(/^Row /)[0].parentElement; + fireEvent.click(within(row0).getAllByText('Remove Col')[0]); + await mockTimeout(() => true); + const expectedChangedMatrix2 = []; + expectedChangedMatrix2[0] = ['m12', 'm-new']; + expect(latestChangedValues).toEqual({ + matrix: expectedChangedMatrix2, + }); + expect(latestFormValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31']], + }); + + // ===== matrix: add row ===== + fireEvent.click(getByText('Add Row')); + await mockTimeout(() => true); + expect(latestChangedValues).toEqual({ + matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31'], ['m-row']], + }); + expect(latestFormValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31'], ['m-row']], + }); + + // ===== matrix: remove row ===== + fireEvent.click(within(getAllByText(/^Row /)[0].parentElement).getByText('Remove Row')); + await mockTimeout(() => true); + expect(latestChangedValues).toEqual({ + matrix: [['m21', 'm22', 'm23'], ['m31'], ['m-row']], + }); + expect(latestFormValues).toEqual({ + vector: ['v2', 'v3', 'v-new'], + matrix: [['m21', 'm22', 'm23'], ['m31'], ['m-row']], + }); + }); + test('FormList with shouldUpdate', async () => { const TestView = () => { const [form] = Form.useForm(); @@ -985,4 +1147,105 @@ describe('FormList 组件测试', () => { expect(specifiedWeightInputAgain.value).toBe('50'); expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy(); }); + + test('FormList with Form in Dialog', async () => { + const TestView = () => { + const [mainForm] = Form.useForm(); + const [dialogForm] = Form.useForm(); + + const [dialogVisible, setDialogVisible] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + + const openDialog = (index: number) => { + setEditingIndex(index); + const currentAmount = mainForm.getFieldValue(['main', index, 'userAmount']) || ''; + dialogForm.setFieldsValue({ amount: currentAmount }); + setDialogVisible(true); + }; + + const handleConfirm = () => { + const amount = dialogForm.getFieldValue('amount'); + mainForm.setFieldsValue({ + main: mainForm + .getFieldValue('main') + .map((item: any, idx: number) => (idx === editingIndex ? { ...item, userAmount: amount } : item)), + }); + setDialogVisible(false); + }; + + return ( +
+ + {(fields, { add }) => ( + <> + {fields.map(({ key, name }) => ( +
+ + + + +
+ ))} + + + + setDialogVisible(false)}> + + + + + + + + )} +
+ + ); + }; + + const { getByText, getAllByText, getByTestId, findByTestId } = render(); + + // ===== 初始值 ===== + expect(getByText('用户金额 1')).toBeInTheDocument(); + + // ===== 新增一项 ===== + fireEvent.click(getByText('新增一项')); + expect(getByText('用户金额 2')).toBeInTheDocument(); + + // ===== 设置第一项金额 ===== + fireEvent.click(getAllByText('设置金额')[0]); + const dialogInputWrapper1 = await findByTestId('dialog-input'); + const dialogInput1 = within(dialogInputWrapper1).getByRole('textbox'); + fireEvent.change(dialogInput1, { target: { value: '100' } }); + const confirmButton1 = document.querySelector('.t-dialog__confirm') as HTMLButtonElement; + fireEvent.click(confirmButton1); + await waitFor(() => { + const amountInput1 = within(getByTestId('amount-0')).getByRole('textbox'); + expect(amountInput1).toHaveValue('100'); + }); + + // ===== 设置第二项金额 ===== + fireEvent.click(getAllByText('设置金额')[1]); + const dialogInputWrapper2 = await findByTestId('dialog-input'); + const dialogInput2 = within(dialogInputWrapper2).getByRole('textbox'); + fireEvent.change(dialogInput2, { target: { value: '200' } }); + const confirmButton2 = document.querySelector('.t-dialog__confirm') as HTMLButtonElement; + fireEvent.click(confirmButton2); + await waitFor(() => { + const amountInput2 = within(getByTestId('amount-1')).getByRole('textbox'); + expect(amountInput2).toHaveValue('200'); + }); + + // ===== 取消不生效 ===== + fireEvent.click(getAllByText('设置金额')[0]); + const dialogInputWrapper3 = await findByTestId('dialog-input'); + const dialogInput3 = within(dialogInputWrapper3).getByRole('textbox'); + fireEvent.change(dialogInput3, { target: { value: '999' } }); + const cancelButton = document.querySelector('.t-dialog__cancel') as HTMLButtonElement; + fireEvent.click(cancelButton); + await waitFor(() => { + const firstAmountInputAfterCancel = within(getByTestId('amount-0')).getByRole('textbox'); + expect(firstAmountInputAfterCancel).toHaveValue('100'); + }); + }); }); diff --git a/packages/components/form/hooks/useFormItemInitialData.ts b/packages/components/form/hooks/useFormItemInitialData.ts index fa22c0c5ad..0c1e94bf5d 100644 --- a/packages/components/form/hooks/useFormItemInitialData.ts +++ b/packages/components/form/hooks/useFormItemInitialData.ts @@ -46,8 +46,8 @@ export default function useFormItemInitialData( if (formListName && Array.isArray(fullPath)) { const pathPrefix = fullPath.slice(0, -1); - const pathExisted = has(form.store, pathPrefix); - if (pathExisted) { + const parentPathExisted = has(form.store, pathPrefix); + if (parentPathExisted) { // 只要路径存在,哪怕值为 undefined 也取 store 里的值 // 兼容 add() 或者 add({}) 导致的空对象场景 // https://github.com/Tencent/tdesign-react/issues/2329 @@ -55,12 +55,27 @@ export default function useFormItemInitialData( } } - if (Array.isArray(name) && formListInitialData?.length) { + if (formListInitialData?.length && (typeof name === 'number' || Array.isArray(name))) { + const fullPathExisted = has(form.store, fullPath); + if (fullPathExisted) { + return get(form.store, fullPath); + } + let defaultInitialData; - const [index, ...relativePath] = name; - if (formListInitialData[index]) { - defaultInitialData = get(formListInitialData[index], relativePath); + let index; + let relativePath = []; + + if (typeof name === 'number') { + index = name; + } else { + [index, ...relativePath] = name; } + + const itemData = formListInitialData[index]; + if (itemData) { + defaultInitialData = relativePath.length ? get(itemData, relativePath) : itemData; + } + if (typeof defaultInitialData !== 'undefined') return defaultInitialData; }