fix: Form.Item keep render even it's not a Field (#20963)

* Not use Field when Form.Item is pure

* Add test case

* clean up

* add delay update for test
This commit is contained in:
二货机器人 2020-01-17 11:50:06 +08:00 committed by GitHub
parent 55bbe3d113
commit b67a1bee7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 202 additions and 125 deletions

View File

@ -3,6 +3,7 @@ import isEqual from 'lodash/isEqual';
import classNames from 'classnames'; import classNames from 'classnames';
import { Field, FormInstance } from 'rc-field-form'; import { Field, FormInstance } from 'rc-field-form';
import { FieldProps } from 'rc-field-form/lib/Field'; import { FieldProps } from 'rc-field-form/lib/Field';
import { Meta } from 'rc-field-form/lib/interface';
import omit from 'omit.js'; import omit from 'omit.js';
import Row from '../grid/row'; import Row from '../grid/row';
import { ConfigContext } from '../config-provider'; import { ConfigContext } from '../config-provider';
@ -18,6 +19,8 @@ export type ValidateStatus = typeof ValidateStatuses[number];
type RenderChildren = (form: FormInstance) => React.ReactElement; type RenderChildren = (form: FormInstance) => React.ReactElement;
type RcFieldProps = Omit<FieldProps, 'children'>; type RcFieldProps = Omit<FieldProps, 'children'>;
type ChildrenType = React.ReactElement | RenderChildren | React.ReactElement[] | null;
type ChildrenNodeType = Exclude<ChildrenType, RenderChildren>;
export interface FormItemProps export interface FormItemProps
extends FormItemLabelProps, extends FormItemLabelProps,
@ -27,7 +30,7 @@ export interface FormItemProps
noStyle?: boolean; noStyle?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
className?: string; className?: string;
children: React.ReactElement | RenderChildren | React.ReactElement[]; children: ChildrenType;
id?: string; id?: string;
hasFeedback?: boolean; hasFeedback?: boolean;
validateStatus?: ValidateStatus; validateStatus?: ValidateStatus;
@ -37,7 +40,7 @@ export interface FormItemProps
fieldKey?: number; fieldKey?: number;
} }
const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => { function FormItem(props: FormItemProps): React.ReactNode {
const { const {
name, name,
fieldKey, fieldKey,
@ -77,6 +80,119 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
const prefixCls = getPrefixCls('form', customizePrefixCls); const prefixCls = getPrefixCls('form', customizePrefixCls);
// ======================== Errors ========================
// Collect noStyle Field error to the top FormItem
const updateChildItemErrors = noStyle
? updateItemErrors
: (subName: string, subErrors: string[]) => {
if (!isEqual(inlineErrors[subName], subErrors)) {
setInlineErrors({
...inlineErrors,
[subName]: subErrors,
});
}
};
function renderLayout(
baseChildren: ChildrenNodeType,
fieldId?: string,
meta?: Meta,
isRequired?: boolean,
) {
if (noStyle) {
return baseChildren;
}
// ======================== Errors ========================
let mergedErrors: React.ReactNode[];
if (help) {
mergedErrors = toArray(help);
} else {
mergedErrors = meta ? meta.errors : [];
Object.keys(inlineErrors).forEach(subName => {
const subErrors = inlineErrors[subName] || [];
if (subErrors.length) {
mergedErrors = [...mergedErrors, ...subErrors];
}
});
}
// ======================== Status ========================
let mergedValidateStatus: ValidateStatus = '';
if (validateStatus !== undefined) {
mergedValidateStatus = validateStatus;
} else if (meta && meta.validating) {
mergedValidateStatus = 'validating';
} else if (!help && mergedErrors.length) {
mergedValidateStatus = 'error';
} else if (meta && meta.touched) {
mergedValidateStatus = 'success';
}
const itemClassName = {
[`${prefixCls}-item`]: true,
[`${prefixCls}-item-with-help`]: domErrorVisible || help,
[`${className}`]: !!className,
// Status
[`${prefixCls}-item-has-feedback`]: mergedValidateStatus && hasFeedback,
[`${prefixCls}-item-has-success`]: mergedValidateStatus === 'success',
[`${prefixCls}-item-has-warning`]: mergedValidateStatus === 'warning',
[`${prefixCls}-item-has-error`]: mergedValidateStatus === 'error',
[`${prefixCls}-item-has-error-leave`]:
!help && domErrorVisible && mergedValidateStatus !== 'error',
[`${prefixCls}-item-is-validating`]: mergedValidateStatus === 'validating',
};
// ======================= Children =======================
return (
<Row
className={classNames(itemClassName)}
style={style}
key="row"
{...omit(restProps, [
'colon',
'extra',
'getValueFromEvent',
'hasFeedback',
'help',
'htmlFor',
'id', // It is deprecated because `htmlFor` is its replacement.
'label',
'labelAlign',
'labelCol',
'normalize',
'required',
'validateStatus',
'valuePropName',
'wrapperCol',
])}
>
{/* Label */}
<FormItemLabel htmlFor={fieldId} {...props} required={isRequired} prefixCls={prefixCls} />
{/* Input Group */}
<FormItemInput
{...props}
{...meta}
errors={mergedErrors}
prefixCls={prefixCls}
onDomErrorVisibleChange={setDomErrorVisible}
validateStatus={mergedValidateStatus}
>
<FormItemContext.Provider value={{ updateItemErrors: updateChildItemErrors }}>
{baseChildren}
</FormItemContext.Provider>
</FormItemInput>
</Row>
);
}
const isRenderProps = typeof children === 'function';
if (!name && !isRenderProps && !dependencies) {
return renderLayout(children as ChildrenNodeType);
}
return ( return (
<Field <Field
{...props} {...props}
@ -87,21 +203,10 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
}} }}
> >
{(control, meta, context) => { {(control, meta, context) => {
const { errors, name: metaName } = meta; const { errors } = meta;
const mergedName = toArray(name).length ? metaName : [];
// ======================== Errors ======================== const mergedName = toArray(name).length && meta ? meta.name : [];
// Collect noStyle Field error to the top FormItem const fieldId = getFieldId(mergedName, formName);
const updateChildItemErrors = noStyle
? updateItemErrors
: (subName: string, subErrors: string[]) => {
if (!isEqual(inlineErrors[subName], subErrors)) {
setInlineErrors({
...inlineErrors,
[subName]: subErrors,
});
}
};
if (noStyle) { if (noStyle) {
nameRef.current = [...mergedName]; nameRef.current = [...mergedName];
@ -111,47 +216,6 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
updateItemErrors(nameRef.current.join('__SPLIT__'), errors); updateItemErrors(nameRef.current.join('__SPLIT__'), errors);
} }
let mergedErrors: React.ReactNode[];
if (help) {
mergedErrors = toArray(help);
} else {
mergedErrors = errors;
Object.keys(inlineErrors).forEach(subName => {
const subErrors = inlineErrors[subName] || [];
if (subErrors.length) {
mergedErrors = [...mergedErrors, ...subErrors];
}
});
}
// ======================== Status ========================
let mergedValidateStatus: ValidateStatus = '';
if (validateStatus !== undefined) {
mergedValidateStatus = validateStatus;
} else if (meta.validating) {
mergedValidateStatus = 'validating';
} else if (!help && mergedErrors.length) {
mergedValidateStatus = 'error';
} else if (meta.touched) {
mergedValidateStatus = 'success';
}
// ====================== Class Name ======================
const itemClassName = {
[`${prefixCls}-item`]: true,
[`${prefixCls}-item-with-help`]: domErrorVisible || help,
[`${className}`]: !!className,
// Status
[`${prefixCls}-item-has-feedback`]: mergedValidateStatus && hasFeedback,
[`${prefixCls}-item-has-success`]: mergedValidateStatus === 'success',
[`${prefixCls}-item-has-warning`]: mergedValidateStatus === 'warning',
[`${prefixCls}-item-has-error`]: mergedValidateStatus === 'error',
[`${prefixCls}-item-has-error-leave`]:
!help && domErrorVisible && mergedValidateStatus !== 'error',
[`${prefixCls}-item-is-validating`]: mergedValidateStatus === 'validating',
};
const isRequired = const isRequired =
required !== undefined required !== undefined
? required ? required
@ -170,17 +234,16 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
); );
// ======================= Children ======================= // ======================= Children =======================
const fieldId = getFieldId(mergedName, formName);
const mergedControl: typeof control = { const mergedControl: typeof control = {
...control, ...control,
id: fieldId, id: fieldId,
}; };
let childNode; let childNode: ChildrenNodeType = null;
if (Array.isArray(children) && !!name) { if (Array.isArray(children) && !!name) {
warning(false, 'Form.Item', '`children` is array of render props cannot have `name`.'); warning(false, 'Form.Item', '`children` is array of render props cannot have `name`.');
childNode = children; childNode = children;
} else if (typeof children === 'function' && (!shouldUpdate || !!name)) { } else if (isRenderProps && (!shouldUpdate || !!name)) {
warning( warning(
!!shouldUpdate, !!shouldUpdate,
'Form.Item', 'Form.Item',
@ -191,8 +254,12 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
'Form.Item', 'Form.Item',
"Do not use `name` with `children` of render props since it's not a field.", "Do not use `name` with `children` of render props since it's not a field.",
); );
} else if (!mergedName.length && !shouldUpdate && !dependencies) { } else if (dependencies && !isRenderProps && !name) {
childNode = children; warning(
false,
'Form.Item',
'Must set `name` or use render props when `dependencies` is set.',
);
} else if (React.isValidElement(children)) { } else if (React.isValidElement(children)) {
const childProps = { ...children.props, ...mergedControl }; const childProps = { ...children.props, ...mergedControl };
@ -212,69 +279,21 @@ const FormItem: React.FC<FormItemProps> = (props: FormItemProps) => {
}); });
childNode = React.cloneElement(children, childProps); childNode = React.cloneElement(children, childProps);
} else if (typeof children === 'function' && shouldUpdate && !name) { } else if (isRenderProps && shouldUpdate && !name) {
childNode = children(context); childNode = (children as RenderChildren)(context);
} else { } else {
warning( warning(
!mergedName.length, !mergedName.length,
'Form.Item', 'Form.Item',
'`name` is only used for validate React element. If you are using Form.Item as layout display, please remove `name` instead.', '`name` is only used for validate React element. If you are using Form.Item as layout display, please remove `name` instead.',
); );
childNode = children; childNode = children as any;
} }
if (noStyle) { return renderLayout(childNode, fieldId, meta, isRequired);
return childNode;
}
return (
<Row
className={classNames(itemClassName)}
style={style}
key="row"
{...omit(restProps, [
'colon',
'extra',
'getValueFromEvent',
'hasFeedback',
'help',
'htmlFor',
'id', // It is deprecated because `htmlFor` is its replacement.
'label',
'labelAlign',
'labelCol',
'normalize',
'required',
'validateStatus',
'valuePropName',
'wrapperCol',
])}
>
{/* Label */}
<FormItemLabel
htmlFor={fieldId}
{...props}
required={isRequired}
prefixCls={prefixCls}
/>
{/* Input Group */}
<FormItemInput
{...props}
{...meta}
errors={mergedErrors}
prefixCls={prefixCls}
onDomErrorVisibleChange={setDomErrorVisible}
validateStatus={mergedValidateStatus}
>
<FormItemContext.Provider value={{ updateItemErrors: updateChildItemErrors }}>
{childNode}
</FormItemContext.Provider>
</FormItemInput>
</Row>
);
}} }}
</Field> </Field>
); );
}; }
export default FormItem; export default FormItem;

View File

@ -18,8 +18,6 @@ interface FormItemInputMiscProps {
prefixCls: string; prefixCls: string;
children: React.ReactNode; children: React.ReactNode;
errors: React.ReactNode[]; errors: React.ReactNode[];
touched: boolean;
validating: boolean;
hasFeedback?: boolean; hasFeedback?: boolean;
validateStatus?: ValidateStatus; validateStatus?: ValidateStatus;
onDomErrorVisibleChange: (visible: boolean) => void; onDomErrorVisibleChange: (visible: boolean) => void;
@ -59,12 +57,16 @@ const FormItemInput: React.FC<FormItemInputProps & FormItemInputMiscProps> = ({
const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className); const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className);
const [visible, cacheErrors] = useCacheErrors(errors, changedVisible => { const [visible, cacheErrors] = useCacheErrors(
if (changedVisible) { errors,
onDomErrorVisibleChange(true); changedVisible => {
} if (changedVisible) {
forceUpdate({}); onDomErrorVisibleChange(true);
}, !!help); }
forceUpdate({});
},
!!help,
);
const memoErrors = useMemo( const memoErrors = useMemo(
() => cacheErrors, () => cacheErrors,

View File

@ -12,7 +12,7 @@ export interface FormItemLabelProps {
labelCol?: ColProps; labelCol?: ColProps;
} }
const FormItemLabel: React.FC<FormItemLabelProps & { required: boolean; prefixCls: string }> = ({ const FormItemLabel: React.FC<FormItemLabelProps & { required?: boolean; prefixCls: string }> = ({
prefixCls, prefixCls,
label, label,
htmlFor, htmlFor,

View File

@ -89,9 +89,11 @@ describe('Form', () => {
expect(wrapper.find(Input).length).toBe(2); expect(wrapper.find(Input).length).toBe(2);
await change(wrapper, 1, ''); await change(wrapper, 1, '');
wrapper.update();
expect(wrapper.find('.ant-form-item-explain').length).toBe(1); expect(wrapper.find('.ant-form-item-explain').length).toBe(1);
await operate('.remove'); await operate('.remove');
wrapper.update();
expect(wrapper.find(Input).length).toBe(1); expect(wrapper.find(Input).length).toBe(1);
expect(wrapper.find('.ant-form-item-explain').length).toBe(0); expect(wrapper.find('.ant-form-item-explain').length).toBe(0);
}); });
@ -341,7 +343,61 @@ describe('Form', () => {
const wrapper = mount(<App />); const wrapper = mount(<App />);
wrapper.find('button').simulate('click'); wrapper.find('button').simulate('click');
expect(wrapper.find('.ant-form-item').first().hasClass('ant-form-item-with-help')).toBeTruthy(); expect(
wrapper
.find('.ant-form-item')
.first()
.hasClass('ant-form-item-with-help'),
).toBeTruthy();
expect(wrapper.find('.ant-form-item-explain').text()).toEqual('bamboo'); expect(wrapper.find('.ant-form-item-explain').text()).toEqual('bamboo');
}); });
it('warning when use `dependencies` but `name` is empty & children is not a render props', () => {
mount(
<Form>
<Form.Item dependencies={[]}>text</Form.Item>
</Form>,
);
expect(errorSpy).toHaveBeenCalledWith(
'Warning: [antd: Form.Item] Must set `name` or use render props when `dependencies` is set.',
);
});
// https://github.com/ant-design/ant-design/issues/20948
it('not repeat render when Form.Item is not a real Field', async () => {
const shouldNotRender = jest.fn();
const StaticInput = () => {
shouldNotRender();
return <Input />;
};
const shouldRender = jest.fn();
const DynamicInput = () => {
shouldRender();
return <Input />;
};
const formRef = React.createRef();
mount(
<div>
<Form ref={formRef}>
<Form.Item>
<StaticInput />
</Form.Item>
<Form.Item name="light">
<DynamicInput />
</Form.Item>
</Form>
</div>,
);
expect(shouldNotRender).toHaveBeenCalledTimes(1);
expect(shouldRender).toHaveBeenCalledTimes(1);
formRef.current.setFieldsValue({ light: 'bamboo' });
await Promise.resolve();
expect(shouldNotRender).toHaveBeenCalledTimes(1);
expect(shouldRender).toHaveBeenCalledTimes(2);
});
}); });