Skip to content

Commit bc512da

Browse files
committed
fix(FormList): correct namePath calculation
1 parent c02237c commit bc512da

File tree

4 files changed

+303
-12
lines changed

4 files changed

+303
-12
lines changed

packages/components/form/FormItem.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
CloseCircleFilledIcon as TdCloseCircleFilledIcon,
55
ErrorCircleFilledIcon as TdErrorCircleFilledIcon,
66
} from 'tdesign-icons-react';
7-
import { get, isEqual, isFunction, isObject, isString, set } from 'lodash-es';
7+
import { cloneDeep, get, isEqual, isFunction, isNil, isObject, isString, set } from 'lodash-es';
88

99
import useConfig from '../hooks/useConfig';
1010
import useDefaultProps from '../hooks/useDefaultProps';
@@ -115,7 +115,7 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
115115
const isSameForm = useMemo(() => isEqual(form, formOfFormList), [form, formOfFormList]);
116116

117117
const fullPath = useMemo(() => {
118-
const validParentFullPath = formListName && isSameForm ? parentFullPath : undefined;
118+
const validParentFullPath = !isNil(formListName) && isSameForm ? parentFullPath : undefined;
119119
return concatName(validParentFullPath, name);
120120
}, [formListName, parentFullPath, name, isSameForm]);
121121

@@ -460,7 +460,7 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
460460
value: formValue,
461461
initialData,
462462
isFormList: false,
463-
getValue: () => valueRef.current,
463+
getValue: () => cloneDeep(valueRef.current),
464464
setValue: (newVal: any) => updateFormValue(newVal, true, true),
465465
setField,
466466
validate,

packages/components/form/FormList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ const FormList: React.FC<TdFormListProps> = (props) => {
150150
initialData,
151151
isFormList: true,
152152
formListMapRef,
153-
getValue: () => get(form?.store, fullPath),
153+
getValue: () => cloneDeep(get(form?.store, fullPath)),
154154
validate: (trigger = 'all') => {
155155
const resultList = [];
156156
const validates = [...formListMapRef.current.values()].map((formItemRef) =>

packages/components/form/__tests__/form-list.test.tsx

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from 'react';
22
import { MinusCircleIcon } from 'tdesign-icons-react';
3-
import { fireEvent, mockTimeout, render, vi } from '@test/utils';
3+
import { fireEvent, mockTimeout, render, vi, waitFor, within } from '@test/utils';
44

55
import Button from '../../button';
6+
import Dialog from '../../dialog';
67
import Input from '../../input';
78
import Radio from '../../radio';
89
import FormList from '../FormList';
@@ -361,7 +362,7 @@ describe('FormList 组件测试', () => {
361362
expect(fn).toHaveBeenCalledTimes(1);
362363
});
363364

364-
test('FormList with nested structures', async () => {
365+
test('FormList with nested objects', async () => {
365366
const TestView = () => {
366367
const [form] = Form.useForm();
367368

@@ -733,6 +734,167 @@ describe('FormList 组件测试', () => {
733734
expect(queryByText('用户名必填')).not.toBeTruthy();
734735
});
735736

737+
test('FormList with nested arrays', async () => {
738+
const onValuesChangeFn = vi.fn();
739+
let latestFormValues: any = {};
740+
let latestChangedValues: any = {};
741+
742+
const TestView = () => {
743+
const [form] = Form.useForm();
744+
745+
const INIT_DATA = {
746+
vector: ['v1', 'v2', 'v3'],
747+
matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']],
748+
};
749+
750+
return (
751+
<Form
752+
form={form}
753+
initialData={INIT_DATA}
754+
onValuesChange={(changedValues, allValues) => {
755+
onValuesChangeFn(changedValues, allValues);
756+
latestChangedValues = changedValues;
757+
latestFormValues = allValues;
758+
}}
759+
>
760+
<div data-testid="vector">
761+
<FormList name="vector">
762+
{(fields, { add, remove }) => (
763+
<>
764+
{fields.map(({ key, name }) => (
765+
<div key={key}>
766+
<FormItem name={name}>
767+
<Input />
768+
</FormItem>
769+
<Button onClick={() => remove(name)}>Remove</Button>
770+
</div>
771+
))}
772+
<Button onClick={() => add('v-new')}>Add Vector</Button>
773+
</>
774+
)}
775+
</FormList>
776+
</div>
777+
778+
<div data-testid="matrix">
779+
<FormList name="matrix">
780+
{(rowFields, { add: addRow, remove: removeRow }) => (
781+
<>
782+
{rowFields.map(({ key, name: rowName }) => (
783+
<div key={key}>
784+
<div>Row {rowName}</div>
785+
<FormList name={rowName}>
786+
{(colFields, { add: addCol, remove: removeCol }) => (
787+
<>
788+
{colFields.map(({ key, name: colName }) => (
789+
<div key={key}>
790+
<FormItem name={colName}>
791+
<Input />
792+
</FormItem>
793+
<Button onClick={() => removeCol(colName)}>Remove Col</Button>
794+
</div>
795+
))}
796+
<Button onClick={() => addCol('m-new')}>Add Col</Button>
797+
</>
798+
)}
799+
</FormList>
800+
<Button onClick={() => removeRow(rowName)}>Remove Row</Button>
801+
</div>
802+
))}
803+
<Button onClick={() => addRow(['m-row'])}>Add Row</Button>
804+
</>
805+
)}
806+
</FormList>
807+
</div>
808+
</Form>
809+
);
810+
};
811+
812+
const { getByText, getByTestId, getAllByText } = render(<TestView />);
813+
814+
const getInputValues = (testId: string) =>
815+
within(getByTestId(testId))
816+
.getAllByRole('textbox')
817+
.map((el) => (el as HTMLInputElement).value);
818+
819+
// ===== initial =====
820+
expect(getInputValues('vector')).toEqual(['v1', 'v2', 'v3']);
821+
expect(getInputValues('matrix')).toEqual(['m11', 'm12', 'm21', 'm22', 'm23', 'm31']);
822+
823+
// ===== vector: add =====
824+
fireEvent.click(getByText('Add Vector'));
825+
await mockTimeout(() => true);
826+
expect(getInputValues('vector')).toEqual(['v1', 'v2', 'v3', 'v-new']);
827+
expect(onValuesChangeFn).toHaveBeenCalled();
828+
expect(latestChangedValues).toEqual({
829+
vector: ['v1', 'v2', 'v3', 'v-new'],
830+
});
831+
expect(latestFormValues).toEqual({
832+
vector: ['v1', 'v2', 'v3', 'v-new'],
833+
matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']],
834+
});
835+
836+
// ===== vector: remove =====
837+
fireEvent.click(within(getByTestId('vector')).getAllByText('Remove')[0]);
838+
await mockTimeout(() => true);
839+
expect(latestChangedValues).toEqual({
840+
vector: ['v2', 'v3', 'v-new'],
841+
});
842+
expect(latestFormValues).toEqual({
843+
vector: ['v2', 'v3', 'v-new'],
844+
matrix: [['m11', 'm12'], ['m21', 'm22', 'm23'], ['m31']],
845+
});
846+
847+
// ===== matrix: add col (row 0) =====
848+
const matrix = getByTestId('matrix');
849+
fireEvent.click(within(matrix).getAllByText('Add Col')[0]);
850+
await mockTimeout(() => true);
851+
const expectedChangedMatrix1 = [];
852+
expectedChangedMatrix1[0] = ['m11', 'm12', 'm-new'];
853+
expect(latestChangedValues).toEqual({
854+
matrix: expectedChangedMatrix1,
855+
});
856+
expect(latestFormValues).toEqual({
857+
vector: ['v2', 'v3', 'v-new'],
858+
matrix: [['m11', 'm12', 'm-new'], ['m21', 'm22', 'm23'], ['m31']],
859+
});
860+
861+
// ===== matrix: remove col =====
862+
const row0 = getAllByText(/^Row /)[0].parentElement;
863+
fireEvent.click(within(row0).getAllByText('Remove Col')[0]);
864+
await mockTimeout(() => true);
865+
const expectedChangedMatrix2 = [];
866+
expectedChangedMatrix2[0] = ['m12', 'm-new'];
867+
expect(latestChangedValues).toEqual({
868+
matrix: expectedChangedMatrix2,
869+
});
870+
expect(latestFormValues).toEqual({
871+
vector: ['v2', 'v3', 'v-new'],
872+
matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31']],
873+
});
874+
875+
// ===== matrix: add row =====
876+
fireEvent.click(getByText('Add Row'));
877+
await mockTimeout(() => true);
878+
expect(latestChangedValues).toEqual({
879+
matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31'], ['m-row']],
880+
});
881+
expect(latestFormValues).toEqual({
882+
vector: ['v2', 'v3', 'v-new'],
883+
matrix: [['m12', 'm-new'], ['m21', 'm22', 'm23'], ['m31'], ['m-row']],
884+
});
885+
886+
// ===== matrix: remove row =====
887+
fireEvent.click(within(getAllByText(/^Row /)[0].parentElement).getByText('Remove Row'));
888+
await mockTimeout(() => true);
889+
expect(latestChangedValues).toEqual({
890+
matrix: [['m21', 'm22', 'm23'], ['m31'], ['m-row']],
891+
});
892+
expect(latestFormValues).toEqual({
893+
vector: ['v2', 'v3', 'v-new'],
894+
matrix: [['m21', 'm22', 'm23'], ['m31'], ['m-row']],
895+
});
896+
});
897+
736898
test('FormList with shouldUpdate', async () => {
737899
const TestView = () => {
738900
const [form] = Form.useForm();
@@ -985,4 +1147,114 @@ describe('FormList 组件测试', () => {
9851147
expect(specifiedWeightInputAgain.value).toBe('50');
9861148
expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy();
9871149
});
1150+
1151+
test('FormList with Form in Dialog', async () => {
1152+
const TestView = () => {
1153+
const [mainForm] = Form.useForm();
1154+
const [dialogForm] = Form.useForm();
1155+
1156+
const [dialogVisible, setDialogVisible] = React.useState(false);
1157+
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
1158+
1159+
const openDialog = (index: number) => {
1160+
setEditingIndex(index);
1161+
const currentAmount = mainForm.getFieldValue(['main', index, 'userAmount']) || '';
1162+
dialogForm.setFieldsValue({ amount: currentAmount });
1163+
setDialogVisible(true);
1164+
};
1165+
1166+
const handleConfirm = () => {
1167+
const amount = dialogForm.getFieldValue('amount');
1168+
mainForm.setFieldsValue({
1169+
main: mainForm
1170+
.getFieldValue('main')
1171+
.map((item: any, idx: number) => (idx === editingIndex ? { ...item, userAmount: amount } : item)),
1172+
});
1173+
setDialogVisible(false);
1174+
};
1175+
1176+
return (
1177+
<Form form={mainForm} initialData={{ main: [{ userAmount: '' }] }}>
1178+
<FormList name="main">
1179+
{(fields, { add }) => (
1180+
<>
1181+
{fields.map(({ key, name }) => (
1182+
<div key={key}>
1183+
<FormItem name={[name, 'userAmount']} label={`用户金额 ${name + 1}`}>
1184+
<Input disabled data-testid={`amount-${name}`} />
1185+
</FormItem>
1186+
<Button onClick={() => openDialog(name)}>设置金额</Button>
1187+
</div>
1188+
))}
1189+
1190+
<Button onClick={() => add({ userAmount: '' })}>新增一项</Button>
1191+
1192+
<Dialog visible={dialogVisible} onConfirm={handleConfirm} onCancel={() => setDialogVisible(false)}>
1193+
<Form form={dialogForm}>
1194+
<FormItem name="amount">
1195+
<Input data-testid="dialog-input" />
1196+
</FormItem>
1197+
</Form>
1198+
</Dialog>
1199+
</>
1200+
)}
1201+
</FormList>
1202+
</Form>
1203+
);
1204+
};
1205+
1206+
const { getByText, getAllByText, getByTestId, findByTestId } = render(<TestView />);
1207+
1208+
// 初始只有一项
1209+
expect(getByText('用户金额 1')).toBeInTheDocument();
1210+
1211+
// 新增一项
1212+
fireEvent.click(getByText('新增一项'));
1213+
expect(getByText('用户金额 2')).toBeInTheDocument();
1214+
1215+
// ===== 设置第一项金额 =====
1216+
fireEvent.click(getAllByText('设置金额')[0]);
1217+
1218+
const dialogInputWrapper1 = await findByTestId('dialog-input');
1219+
const dialogInput1 = within(dialogInputWrapper1).getByRole('textbox');
1220+
fireEvent.change(dialogInput1, { target: { value: '100' } });
1221+
1222+
const confirmButton1 = document.querySelector('.t-dialog__confirm') as HTMLButtonElement;
1223+
fireEvent.click(confirmButton1);
1224+
1225+
await waitFor(() => {
1226+
const amountInput1 = within(getByTestId('amount-0')).getByRole('textbox');
1227+
expect(amountInput1).toHaveValue('100');
1228+
});
1229+
1230+
// ===== 设置第二项金额 =====
1231+
fireEvent.click(getAllByText('设置金额')[1]);
1232+
1233+
const dialogInputWrapper2 = await findByTestId('dialog-input');
1234+
const dialogInput2 = within(dialogInputWrapper2).getByRole('textbox');
1235+
fireEvent.change(dialogInput2, { target: { value: '200' } });
1236+
1237+
const confirmButton2 = document.querySelector('.t-dialog__confirm') as HTMLButtonElement;
1238+
fireEvent.click(confirmButton2);
1239+
1240+
await waitFor(() => {
1241+
const amountInput2 = within(getByTestId('amount-1')).getByRole('textbox');
1242+
expect(amountInput2).toHaveValue('200');
1243+
});
1244+
1245+
// ===== 取消不生效 =====
1246+
fireEvent.click(getAllByText('设置金额')[0]);
1247+
1248+
const dialogInputWrapper3 = await findByTestId('dialog-input');
1249+
const dialogInput3 = within(dialogInputWrapper3).getByRole('textbox');
1250+
fireEvent.change(dialogInput3, { target: { value: '999' } });
1251+
1252+
const cancelButton = document.querySelector('.t-dialog__cancel') as HTMLButtonElement;
1253+
fireEvent.click(cancelButton);
1254+
1255+
await waitFor(() => {
1256+
const firstAmountInputAfterCancel = within(getByTestId('amount-0')).getByRole('textbox');
1257+
expect(firstAmountInputAfterCancel).toHaveValue('100');
1258+
});
1259+
});
9881260
});

packages/components/form/hooks/useFormItemInitialData.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,20 +46,39 @@ export default function useFormItemInitialData(
4646

4747
if (formListName && Array.isArray(fullPath)) {
4848
const pathPrefix = fullPath.slice(0, -1);
49-
const pathExisted = has(form.store, pathPrefix);
50-
if (pathExisted) {
49+
const parentPathExisted = has(form.store, pathPrefix);
50+
if (parentPathExisted) {
5151
// 只要路径存在,哪怕值为 undefined 也取 store 里的值
5252
// 兼容 add() 或者 add({}) 导致的空对象场景
5353
// https://github.com/Tencent/tdesign-react/issues/2329
5454
return get(form.store, fullPath);
5555
}
5656
}
5757

58-
if (Array.isArray(name) && formListInitialData?.length) {
58+
if (formListInitialData?.length && (typeof name === 'number' || Array.isArray(name))) {
59+
const fullPathExisted = has(form.store, fullPath);
60+
if (fullPathExisted) {
61+
return get(form.store, fullPath);
62+
}
63+
5964
let defaultInitialData;
60-
const [index, ...relativePath] = name;
61-
if (formListInitialData[index]) {
62-
defaultInitialData = get(formListInitialData[index], relativePath);
65+
let index: number;
66+
let relativePath: NamePath = [];
67+
68+
if (typeof name === 'number') {
69+
index = name;
70+
} else {
71+
// @ts-ignore
72+
[index, ...relativePath] = name;
73+
}
74+
75+
const itemData = formListInitialData[index];
76+
if (itemData) {
77+
if (Array.isArray(relativePath) && relativePath.length === 0) {
78+
defaultInitialData = itemData;
79+
} else if (typeof itemData === 'object') {
80+
defaultInitialData = get(itemData, relativePath);
81+
}
6382
}
6483
if (typeof defaultInitialData !== 'undefined') return defaultInitialData;
6584
}

0 commit comments

Comments
 (0)