Skip to content

Commit 6925571

Browse files
committed
fix: shouldUpdate with FormList
1 parent 1632b05 commit 6925571

File tree

5 files changed

+302
-41
lines changed

5 files changed

+302
-41
lines changed

packages/components/form/FormItem.tsx

Lines changed: 20 additions & 19 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, unset } from 'lodash-es';
7+
import { get, isEqual, isFunction, isObject, isString, set } from 'lodash-es';
88

99
import useConfig from '../hooks/useConfig';
1010
import useDefaultProps from '../hooks/useDefaultProps';
@@ -81,7 +81,14 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
8181
onFormItemValueChange,
8282
} = useFormContext();
8383

84-
const { name: formListName, rules: formListRules, formListMapRef, form: formOfFormList } = useFormListContext();
84+
const {
85+
name: formListName,
86+
fullPath: parentFullPath,
87+
rules: formListRules,
88+
formListMapRef,
89+
form: formOfFormList,
90+
} = useFormListContext();
91+
8592
const props = useDefaultProps<FormItemProps>(originalProps, formItemDefaultProps);
8693

8794
const {
@@ -104,10 +111,8 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
104111
requiredMark = requiredMarkFromContext,
105112
} = props;
106113

107-
const { fullPath: parentFullPath } = useFormListContext();
108114
const fullPath = concatName(parentFullPath, name);
109-
110-
const { getDefaultInitialData } = useFormItemInitialData(name, fullPath);
115+
const { defaultInitialData } = useFormItemInitialData(name, fullPath, initialData, children);
111116

112117
const [, forceUpdate] = useState({}); // custom render state
113118
const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined);
@@ -116,12 +121,7 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
116121
const [verifyStatus, setVerifyStatus] = useState('validating');
117122
const [resetValidating, setResetValidating] = useState(false);
118123
const [needResetField, setNeedResetField] = useState(false);
119-
const [formValue, setFormValue] = useState(() =>
120-
getDefaultInitialData({
121-
children,
122-
initialData,
123-
}),
124-
);
124+
const [formValue, setFormValue] = useState(defaultInitialData);
125125

126126
const formItemRef = useRef<FormItemInstance>(null); // 当前 formItem 实例
127127
const innerFormItemsRef = useRef([]);
@@ -325,10 +325,7 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
325325

326326
function getResetValue(resetType: TdFormProps['resetType']): ValueType {
327327
if (resetType === 'initial') {
328-
return getDefaultInitialData({
329-
children,
330-
initialData,
331-
});
328+
return defaultInitialData;
332329
}
333330

334331
let emptyValue: ValueType;
@@ -419,20 +416,24 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
419416
// FormList 下特殊处理
420417
if (formListName && isSameForm) {
421418
formListMapRef.current.set(fullPath, formItemRef);
422-
set(form?.store, fullPath, formValue);
419+
set(form?.store, fullPath, defaultInitialData);
420+
setFormValue(defaultInitialData);
423421
return () => {
424422
// eslint-disable-next-line react-hooks/exhaustive-deps
425423
formListMapRef.current.delete(fullPath);
426-
unset(form?.store, fullPath);
424+
set(form?.store, fullPath, defaultInitialData);
427425
};
428426
}
427+
429428
if (!formMapRef) return;
430429
formMapRef.current.set(fullPath, formItemRef);
431-
set(form?.store, fullPath, formValue);
430+
set(form?.store, fullPath, defaultInitialData);
431+
setFormValue(defaultInitialData);
432+
432433
return () => {
433434
// eslint-disable-next-line react-hooks/exhaustive-deps
434435
formMapRef.current.delete(fullPath);
435-
unset(form?.store, fullPath);
436+
set(form?.store, fullPath, defaultInitialData);
436437
};
437438
// eslint-disable-next-line react-hooks/exhaustive-deps
438439
}, [snakeName, formListName]);

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

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fireEvent, mockTimeout, render, vi } from '@test/utils';
44

55
import Button from '../../button';
66
import Input from '../../input';
7+
import Radio from '../../radio';
78
import FormList from '../FormList';
89
import Form, { type FormProps } from '../index';
910

@@ -605,4 +606,257 @@ describe('Form List 组件测试', () => {
605606
await mockTimeout();
606607
expect(queryByText('用户名必填')).not.toBeTruthy();
607608
});
609+
610+
test('FormList with shouldUpdate', async () => {
611+
const TestView = () => {
612+
const [form] = Form.useForm();
613+
614+
const INIT_DATA = {
615+
services: [
616+
{
617+
modelName: 'modelA',
618+
routes: [
619+
{ type: 'weight', weight: 50, abtest: 'cid' },
620+
{ type: 'abtest', weight: 30, abtest: 'uid' },
621+
],
622+
},
623+
],
624+
};
625+
626+
return (
627+
<Form form={form} initialData={INIT_DATA}>
628+
<FormList name="services">
629+
{(fields) => (
630+
<>
631+
{fields.map(({ key, name: serviceName }) => (
632+
<div key={key}>
633+
<FormList name={[serviceName, 'routes']}>
634+
{(routeFields, { add: addRoute }) => (
635+
<div>
636+
{routeFields.map((f) => (
637+
<div key={f.key} data-route-index={f.name}>
638+
<FormItem name={[f.name, 'type']} label="类型">
639+
<Radio.Group
640+
options={[
641+
{ label: '权重', value: 'weight' },
642+
{ label: 'ABTest', value: 'abtest' },
643+
]}
644+
/>
645+
</FormItem>
646+
647+
<FormItem
648+
shouldUpdate={(p, n) =>
649+
p.services?.[serviceName]?.routes?.[f.name]?.type !==
650+
n.services?.[serviceName]?.routes?.[f.name]?.type
651+
}
652+
>
653+
{({ getFieldValue }) => {
654+
const type = getFieldValue(['services', serviceName, 'routes', f.name, 'type']);
655+
if (type === 'weight') {
656+
return (
657+
<FormItem name={[f.name, 'weight']} label="权重">
658+
<Input placeholder={`route-weight-${serviceName}-${f.name}`} />
659+
</FormItem>
660+
);
661+
}
662+
if (type === 'abtest') {
663+
return (
664+
<FormItem name={[f.name, 'abtest']} label="分流Key">
665+
<Input placeholder={`route-abtest-${serviceName}-${f.name}`} />
666+
</FormItem>
667+
);
668+
}
669+
return null;
670+
}}
671+
</FormItem>
672+
</div>
673+
))}
674+
<Button id={`test-add-route-${serviceName}-default`} onClick={() => addRoute()}>
675+
新增默认路由
676+
</Button>
677+
<Button
678+
id={`test-add-route-${serviceName}-specified`}
679+
onClick={() => addRoute(INIT_DATA.services[0].routes[0])}
680+
>
681+
新增指定路由
682+
</Button>
683+
</div>
684+
)}
685+
</FormList>
686+
</div>
687+
))}
688+
</>
689+
)}
690+
</FormList>
691+
</Form>
692+
);
693+
};
694+
695+
const { container, getByPlaceholderText } = render(<TestView />);
696+
697+
// Test initial data - first route (type: weight)
698+
const weightRadio0 = container.querySelector('[data-route-index="0"] input[value="weight"]') as HTMLInputElement;
699+
expect(weightRadio0.checked).toBe(true);
700+
expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('50');
701+
expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy();
702+
703+
// Test initial data - second route (type: abtest)
704+
const abtestRadio1 = container.querySelector('[data-route-index="1"] input[value="abtest"]') as HTMLInputElement;
705+
expect(abtestRadio1.checked).toBe(true);
706+
expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('uid');
707+
expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy();
708+
709+
// Test switching first route from weight to abtest
710+
const abtestRadio0 = container.querySelector('[data-route-index="0"] input[value="abtest"]') as HTMLInputElement;
711+
fireEvent.click(abtestRadio0);
712+
await mockTimeout();
713+
expect((getByPlaceholderText('route-abtest-0-0') as HTMLInputElement).value).toBe('cid');
714+
expect(container.querySelector('[placeholder="route-weight-0-0"]')).toBeFalsy();
715+
716+
// Test switching first route back to weight
717+
fireEvent.click(weightRadio0);
718+
await mockTimeout();
719+
expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('50');
720+
expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy();
721+
722+
// Test manual modification persistence - modify weight value manually
723+
const weightInput0 = getByPlaceholderText('route-weight-0-0') as HTMLInputElement;
724+
fireEvent.change(weightInput0, { target: { value: '200' } });
725+
await mockTimeout();
726+
expect(weightInput0.value).toBe('200');
727+
728+
// Switch to abtest
729+
fireEvent.click(abtestRadio0);
730+
await mockTimeout();
731+
expect(container.querySelector('[placeholder="route-weight-0-0"]')).toBeFalsy();
732+
expect((getByPlaceholderText('route-abtest-0-0') as HTMLInputElement).value).toBe('cid');
733+
734+
// Switch back to weight - should show initial value (50), not manually modified value (200)
735+
fireEvent.click(weightRadio0);
736+
await mockTimeout();
737+
expect((getByPlaceholderText('route-weight-0-0') as HTMLInputElement).value).toBe('50');
738+
expect(container.querySelector('[placeholder="route-abtest-0-0"]')).toBeFalsy();
739+
740+
// Test switching second route from abtest to weight
741+
const weightRadio1 = container.querySelector('[data-route-index="1"] input[value="weight"]') as HTMLInputElement;
742+
fireEvent.click(weightRadio1);
743+
await mockTimeout();
744+
expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('30');
745+
expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy();
746+
747+
// Test manual modification persistence - modify weight value manually after switching
748+
const weightInput1 = getByPlaceholderText('route-weight-0-1') as HTMLInputElement;
749+
fireEvent.change(weightInput1, { target: { value: '100' } });
750+
await mockTimeout();
751+
expect(weightInput1.value).toBe('100');
752+
753+
// Switch back to abtest - should show initial abtest value (uid)
754+
fireEvent.click(abtestRadio1);
755+
await mockTimeout();
756+
expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('uid');
757+
expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy();
758+
759+
// Switch to weight again - should show initial weight value (30), not manually modified value (100)
760+
fireEvent.click(weightRadio1);
761+
await mockTimeout();
762+
expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('30');
763+
expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy();
764+
765+
// Modify abtest value manually
766+
fireEvent.click(abtestRadio1);
767+
await mockTimeout();
768+
const abtestInput1 = getByPlaceholderText('route-abtest-0-1') as HTMLInputElement;
769+
expect(abtestInput1.value).toBe('uid');
770+
fireEvent.change(abtestInput1, { target: { value: 'custom-key' } });
771+
await mockTimeout();
772+
expect(abtestInput1.value).toBe('custom-key');
773+
774+
// Switch to weight
775+
fireEvent.click(weightRadio1);
776+
await mockTimeout();
777+
expect(container.querySelector('[placeholder="route-abtest-0-1"]')).toBeFalsy();
778+
expect((getByPlaceholderText('route-weight-0-1') as HTMLInputElement).value).toBe('30');
779+
780+
// Switch back to abtest - should show initial value (uid), not manually modified value (custom-key)
781+
fireEvent.click(abtestRadio1);
782+
await mockTimeout();
783+
expect((getByPlaceholderText('route-abtest-0-1') as HTMLInputElement).value).toBe('uid');
784+
expect(container.querySelector('[placeholder="route-weight-0-1"]')).toBeFalsy();
785+
786+
// Test adding default route (empty data)
787+
const addDefaultBtn = container.querySelector('#test-add-route-0-default');
788+
fireEvent.click(addDefaultBtn);
789+
await mockTimeout();
790+
const newRouteRadios = container.querySelectorAll('[data-route-index="2"] input[type="radio"]');
791+
expect(newRouteRadios.length).toBe(2);
792+
// No radio should be checked initially
793+
const checkedRadio = container.querySelector('[data-route-index="2"] input[type="radio"]:checked');
794+
expect(checkedRadio).toBeFalsy();
795+
// No conditional field should be rendered when type is empty
796+
expect(container.querySelector('[placeholder="route-weight-0-2"]')).toBeFalsy();
797+
expect(container.querySelector('[placeholder="route-abtest-0-2"]')).toBeFalsy();
798+
799+
// Test setting type to weight for new route
800+
const newWeightRadio = container.querySelector('[data-route-index="2"] input[value="weight"]') as HTMLInputElement;
801+
fireEvent.click(newWeightRadio);
802+
await mockTimeout();
803+
const newWeightInput = getByPlaceholderText('route-weight-0-2') as HTMLInputElement;
804+
expect(newWeightInput).toBeTruthy();
805+
expect(newWeightInput.value).toBe('');
806+
807+
// Test setting weight value
808+
fireEvent.change(newWeightInput, { target: { value: '100' } });
809+
await mockTimeout();
810+
expect(newWeightInput.value).toBe('100');
811+
812+
// Test switching new route to abtest
813+
const newAbtestRadio = container.querySelector('[data-route-index="2"] input[value="abtest"]') as HTMLInputElement;
814+
fireEvent.click(newAbtestRadio);
815+
await mockTimeout();
816+
expect(container.querySelector('[placeholder="route-weight-0-2"]')).toBeFalsy();
817+
const newAbtestInput = getByPlaceholderText('route-abtest-0-2') as HTMLInputElement;
818+
expect(newAbtestInput).toBeTruthy();
819+
expect(newAbtestInput.value).toBe('');
820+
821+
// Test setting abtest value
822+
fireEvent.change(newAbtestInput, { target: { value: 'new-key' } });
823+
await mockTimeout();
824+
expect(newAbtestInput.value).toBe('new-key');
825+
826+
// Test switching back to weight - should show empty value, not previous manually modified value
827+
fireEvent.click(newWeightRadio);
828+
await mockTimeout();
829+
expect(container.querySelector('[placeholder="route-abtest-0-2"]')).toBeFalsy();
830+
const weightInputAgain = getByPlaceholderText('route-weight-0-2') as HTMLInputElement;
831+
expect(weightInputAgain.value).toBe('');
832+
833+
// Test adding specified route (with initial data)
834+
const addSpecifiedBtn = container.querySelector('#test-add-route-0-specified');
835+
fireEvent.click(addSpecifiedBtn);
836+
await mockTimeout();
837+
const specifiedWeightRadio = container.querySelector(
838+
'[data-route-index="3"] input[value="weight"]',
839+
) as HTMLInputElement;
840+
expect(specifiedWeightRadio.checked).toBe(true);
841+
const specifiedWeightInput = getByPlaceholderText('route-weight-0-3') as HTMLInputElement;
842+
expect(specifiedWeightInput.value).toBe('50');
843+
expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy();
844+
845+
// Test switching specified route to abtest
846+
const specifiedAbtestRadio = container.querySelector(
847+
'[data-route-index="3"] input[value="abtest"]',
848+
) as HTMLInputElement;
849+
fireEvent.click(specifiedAbtestRadio);
850+
await mockTimeout();
851+
const specifiedAbtestInput = getByPlaceholderText('route-abtest-0-3') as HTMLInputElement;
852+
expect(specifiedAbtestInput.value).toBe('cid');
853+
expect(container.querySelector('[placeholder="route-weight-0-3"]')).toBeFalsy();
854+
855+
// Test switching specified route back to weight - should show initial weight value
856+
fireEvent.click(specifiedWeightRadio);
857+
await mockTimeout();
858+
const specifiedWeightInputAgain = getByPlaceholderText('route-weight-0-3') as HTMLInputElement;
859+
expect(specifiedWeightInputAgain.value).toBe('50');
860+
expect(container.querySelector('[placeholder="route-abtest-0-3"]')).toBeFalsy();
861+
});
608862
});

packages/components/form/_example/form-field-linkage.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from 'react';
2-
import { Form, Radio, Button } from 'tdesign-react';
2+
import { Button, Form, Radio } from 'tdesign-react';
33

44
const { FormItem } = Form;
55

66
export default function FormExample() {
77
const [form] = Form.useForm();
8-
const setMessage = () => {
8+
9+
const applyColdPreset = () => {
910
form.setFieldsValue({
1011
type: 'cold',
1112
ice: '1',
@@ -24,7 +25,7 @@ export default function FormExample() {
2425
{({ getFieldValue }) => {
2526
if (getFieldValue('type') === 'cold') {
2627
return (
27-
<FormItem label="冰量" key="ice" name="ice">
28+
<FormItem label="冰量" name="ice">
2829
<Radio.Group>
2930
<Radio value="0">正常冰</Radio>
3031
<Radio value="1">少冰</Radio>
@@ -38,7 +39,7 @@ export default function FormExample() {
3839
</FormItem>
3940

4041
<FormItem style={{ marginLeft: 100 }}>
41-
<Button onClick={setMessage}>选择冷饮-少冰</Button>
42+
<Button onClick={applyColdPreset}>选择冷饮-少冰</Button>
4243
</FormItem>
4344
</Form>
4445
);

0 commit comments

Comments
 (0)