Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(form): fixed dynamic rendering of form default data #3145

Merged
merged 11 commits into from
Oct 31, 2024
2 changes: 1 addition & 1 deletion src/_common
4 changes: 3 additions & 1 deletion src/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ const Form = forwardRefWithStatics(
const [form] = useForm(props.form); // 内部与外部共享 form 实例,外部不传则内部创建
const formRef = useRef<HTMLFormElement>();
const formMapRef = useRef(new Map()); // 收集所有包含 name 属性 formItem 实例
const formInstance = useInstance(props, formRef, formMapRef);
const floatingFormDataRef = useRef({}); // 储存游离值的 formData
const formInstance = useInstance(props, formRef, formMapRef, floatingFormDataRef);

useImperativeHandle(ref, () => formInstance);
Object.assign(form, { ...formInstance });
Expand Down Expand Up @@ -103,6 +104,7 @@ const Form = forwardRefWithStatics(
rules,
disabled,
formMapRef,
floatingFormDataRef,
onFormItemValueChange,
}}
>
Expand Down
4 changes: 3 additions & 1 deletion src/form/FormContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const FormContext = React.createContext<{
rules: TdFormProps['rules'];
errorMessage: TdFormProps['errorMessage'];
formMapRef: React.RefObject<Map<any, React.RefObject<FormItemInstance>>>;
floatingFormDataRef: React.RefObject<Record<any, any>>;
onFormItemValueChange: (changedValue: Record<string, unknown>) => void;
}>({
form: undefined,
Expand All @@ -37,6 +38,7 @@ const FormContext = React.createContext<{
statusIcon: undefined,
onFormItemValueChange: undefined,
formMapRef: undefined,
floatingFormDataRef: undefined,
});

export const useFormContext = () => React.useContext(FormContext);
Expand All @@ -47,7 +49,7 @@ export const FormListContext = React.createContext<{
name: NamePath;
rules: TdFormListProps['rules'];
formListMapRef: React.RefObject<Map<any, React.RefObject<FormItemInstance>>>;
initialData: TdFormProps['initialData'];
initialData: TdFormListProps['initialData'];
}>({
name: undefined,
rules: undefined,
Expand Down
22 changes: 5 additions & 17 deletions src/form/FormItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ import { HOOK_MARK } from './hooks/useForm';
import { validate as validateModal, parseMessage } from './formModel';
import { useFormContext, useFormListContext } from './FormContext';
import useFormItemStyle from './hooks/useFormItemStyle';
import useFormItemInitialData, { ctrlKeyMap } from './hooks/useFormItemInitialData';
import { formItemDefaultProps } from './defaultProps';
import { ctrlKeyMap, getDefaultInitialData } from './useInitialData';
import { ValidateStatus } from './const';
import useDefaultProps from '../hooks/useDefaultProps';
import { useLocaleReceiver } from '../locale/LocalReceiver';

export interface FormItemProps extends TdFormItemProps, StyledProps {
children?: React.ReactNode | ((form: FormInstanceFunctions) => React.ReactElement);
children?: React.ReactNode | React.ReactNode[] | ((form: FormInstanceFunctions) => React.ReactElement);
}

export interface FormItemInstance {
Expand Down Expand Up @@ -62,7 +62,6 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
form,
colon,
layout,
initialData: FromContextInitialData,
requiredMark: requiredMarkFromContext,
labelAlign: labelAlignFromContext,
labelWidth: labelWidthFromContext,
Expand All @@ -76,12 +75,7 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
onFormItemValueChange,
} = useFormContext();

const {
name: formListName,
rules: formListRules,
formListMapRef,
initialData: FormListInitialData,
} = useFormListContext();
const { name: formListName, rules: formListRules, formListMapRef } = useFormListContext();

const props = useDefaultProps<FormItemProps>(originalProps, formItemDefaultProps);

Expand All @@ -105,6 +99,8 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
requiredMark = requiredMarkFromContext,
} = props;

const { getDefaultInitialData } = useFormItemInitialData(name);

const [, forceUpdate] = useState({}); // custom render state
const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined);
const [errorList, setErrorList] = useState([]);
Expand All @@ -114,12 +110,8 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
const [needResetField, setNeedResetField] = useState(false);
const [formValue, setFormValue] = useState(
getDefaultInitialData({
name,
formListName,
children,
initialData,
FromContextInitialData,
FormListInitialData,
}),
);

Expand Down Expand Up @@ -325,12 +317,8 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
function getResetValue(resetType: string): ValueType {
if (resetType === 'initial') {
return getDefaultInitialData({
name,
formListName,
children,
initialData,
FromContextInitialData,
FormListInitialData,
});
}

Expand Down
57 changes: 57 additions & 0 deletions src/form/__tests__/form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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';

const { FormItem } = Form;
Expand Down Expand Up @@ -341,4 +342,60 @@ describe('Form 组件测试', () => {
await mockDelay();
expect(container.querySelector('.t-input__extra').innerHTML).toBe('please input username');
});

test('动态渲染并初始赋值', () => {
const TestForm = () => {
const [form] = Form.useForm();
const setMessage = () => {
form.setFieldsValue({
gender: 'female',
radio2: '3',
});
};

return (
<Form form={form} colon labelWidth={100}>
<FormItem label="性别" name="gender" initialData="male">
<Radio.Group>
<Radio value="male">男性</Radio>
<Radio value="female">女性</Radio>
</Radio.Group>
</FormItem>
<FormItem shouldUpdate={(prev, next) => prev.gender !== next.gender}>
{({ getFieldValue }) => {
if (getFieldValue('gender') === 'female') {
return (
<FormItem label="动态选项2" key="radio2" name="radio2">
<Radio.Group className="radio-group-2">
<Radio value="2">选项三</Radio>
<Radio value="3" className="radio-value-3">
选项四
</Radio>
</Radio.Group>
</FormItem>
);
}
return (
<FormItem label="动态选项1" key="radio1" name="radio1" initialData="0">
<Radio.Group>
<Radio value="0">选项一</Radio>
<Radio value="1">选项二</Radio>
</Radio.Group>
</FormItem>
);
}}
</FormItem>

<FormItem style={{ marginLeft: 100 }}>
<Button onClick={setMessage}>设置信息</Button>
</FormItem>
</Form>
);
};

const { container, getByText } = render(<TestForm />);
fireEvent.click(getByText('设置信息'));

expect(container.querySelector('.radio-value-3')).toHaveClass('t-is-checked');
});
});
47 changes: 47 additions & 0 deletions src/form/_example/form-field-linkage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import Form from '../Form';
import Radio from '../../radio';
import Button from '../../button';

const { FormItem } = Form;

export default function FormExample() {
const [form] = Form.useForm();
const setMessage = () => {
form.setFieldsValue({
type: 'cold',
ice: '1',
});
};

return (
<Form form={form} colon labelWidth={100}>
<FormItem label="类型" name="type" initialData="hot">
<Radio.Group>
<Radio value="hot">热饮</Radio>
<Radio value="cold">冷饮</Radio>
</Radio.Group>
</FormItem>
<FormItem shouldUpdate={(prev, next) => prev.type !== next.type}>
{({ getFieldValue }) => {
if (getFieldValue('type') === 'cold') {
return (
<FormItem label="冰量" key="ice" name="ice">
<Radio.Group>
<Radio value="0">正常冰</Radio>
<Radio value="1">少冰</Radio>
<Radio value="2">去冰</Radio>
</Radio.Group>
</FormItem>
);
}
return null;
}}
</FormItem>

<FormItem style={{ marginLeft: 100 }}>
<Button onClick={setMessage}>选择冷饮-少冰</Button>
</FormItem>
</Form>
);
}
26 changes: 6 additions & 20 deletions src/form/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@

{{ form-list }}

### 字段联动的表单

在某些特定场景,例如修改某个字段值后出现新的字段选项、或者纯粹希望表单任意变化都对某一个区域进行渲染。你可以通过 `shouldUpdate` 修改 `FormItem` 的更新逻辑。

{{ form-field-linkage }}

### 自定义表单控件

可以使用 `Form.FormItem` 包裹自定义组件并在组件中接受 `value` 和 `onChange` 的入参,实现自定义表单控件。
Expand Down Expand Up @@ -76,26 +82,6 @@ Form 组件设计的初衷是为了解放开发者配置大量的 `value`、`onC
</Form.FormItem>
```

### 如何根据某个字段变化动态展示数据

而在某些特定场景,例如修改某个字段值后出现新的字段选项、或者纯粹希望表单任意变化都对某一个区域进行渲染。你可以通过 `shouldUpdate` 修改 `FormItem` 的更新逻辑。

```js
<Form.FormItem shouldUpdate={(prev, next) => prev.additional !== next.additional}>
{({ getFieldValue }) => {
if (getFieldValue('additional') === 'test') {
return (
<Form.FormItem name="test">
<Input />
</Form.FormItem>
);
}
return null;
}}
<Input />
</Form.FormItem>
```

## API

### Form Props
Expand Down
96 changes: 96 additions & 0 deletions src/form/hooks/useFormItemInitialData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import unset from 'lodash/unset';
import isEmpty from 'lodash/isEmpty';

// 兼容特殊数据结构和受控 key
import Tree from '../../tree/Tree';
import Upload from '../../upload/upload';
import CheckTag from '../../tag/CheckTag';
import Checkbox from '../../checkbox/Checkbox';
import TagInput from '../../tag-input/TagInput';
import RangeInput from '../../range-input/RangeInput';
import Transfer from '../../transfer/Transfer';
import CheckboxGroup from '../../checkbox/CheckboxGroup';
import DateRangePicker from '../../date-picker/DateRangePicker';
import TimeRangePicker from '../../time-picker/TimeRangePicker';

import { useFormContext, useFormListContext } from '../FormContext';
import { FormItemProps } from '../FormItem';

// FormItem 子组件受控 key
export const ctrlKeyMap = new Map();
ctrlKeyMap.set(Checkbox, 'checked');
ctrlKeyMap.set(CheckTag, 'checked');
ctrlKeyMap.set(Upload, 'files');

// FormItem 默认数据类型
export const initialDataMap = new Map();
[Tree, Upload, Transfer, TagInput, RangeInput, CheckboxGroup, DateRangePicker, TimeRangePicker].forEach((component) => {
initialDataMap.set(component, []);
});
[Checkbox].forEach((component) => {
initialDataMap.set(component, false);
});

export default function useFormItemInitialData(name: FormItemProps['name']) {
let hadReadFloatingFormData = false;

const { floatingFormDataRef, initialData: formContextInitialData } = useFormContext();

const { name: formListName, initialData: formListInitialData } = useFormListContext();

// 组件渲染后删除对应游离值
useEffect(() => {
if (hadReadFloatingFormData) {
const nameList = formListName ? [formListName, name].flat() : name;
unset(floatingFormDataRef.current, nameList);
}
}, [hadReadFloatingFormData, floatingFormDataRef, formListName, name]);

// 整理初始值 优先级:Form.initialData < FormList.initialData < FormItem.initialData < floatFormData
function getDefaultInitialData({
children,
initialData,
}: {
children: FormItemProps['children'];
initialData: FormItemProps['initialData'];
}) {
if (name && !isEmpty(floatingFormDataRef.current)) {
const nameList = formListName ? [formListName, name].flat() : name;
const defaultInitialData = get(floatingFormDataRef.current, nameList);
if (typeof defaultInitialData !== 'undefined') {
hadReadFloatingFormData = true;
return defaultInitialData;
}
}

if (typeof initialData !== 'undefined') {
return initialData;
}

if (name && formListInitialData.length) {
const defaultInitialData = get(formListInitialData, name);
if (typeof defaultInitialData !== 'undefined') return defaultInitialData;
}

if (name && formContextInitialData) {
const defaultInitialData = get(formContextInitialData, name);
if (typeof defaultInitialData !== 'undefined') return defaultInitialData;
}

if (typeof children !== 'function') {
const childList = React.Children.toArray(children);
const lastChild = childList[childList.length - 1];
if (lastChild && React.isValidElement(lastChild)) {
// @ts-ignore
const isMultiple = lastChild?.props?.multiple;
return isMultiple ? [] : initialDataMap.get(lastChild.type);
}
}
}

return {
getDefaultInitialData,
};
}
Loading
Loading