diff --git a/components/config-provider/__tests__/__snapshots__/components.test.js.snap b/components/config-provider/__tests__/__snapshots__/components.test.js.snap index cf89e5a869..8d3ce09054 100644 --- a/components/config-provider/__tests__/__snapshots__/components.test.js.snap +++ b/components/config-provider/__tests__/__snapshots__/components.test.js.snap @@ -14190,7 +14190,7 @@ exports[`ConfigProvider components Form configProvider 1`] = `
void; +} + +export default function ErrorList({ + errors = EMPTY_LIST, + help, + onDomErrorVisibleChange, +}: ErrorListProps) { + const forceUpdate = useForceUpdate(); + const { prefixCls, status } = React.useContext(FormItemPrefixContext); + + const [visible, cacheErrors] = useCacheErrors( + errors, + changedVisible => { + if (changedVisible) { + /** + * We trigger in sync to avoid dom shaking but this get warning in react 16.13. + * So use Promise to keep in micro async to handle this. + * https://github.com/ant-design/ant-design/issues/21698#issuecomment-593743485 + */ + Promise.resolve().then(() => { + onDomErrorVisibleChange?.(true); + }); + } + forceUpdate(); + }, + !!help, + ); + + const memoErrors = useMemo( + () => cacheErrors, + visible, + (_, nextVisible) => nextVisible, + ); + + // Memo status in same visible + const [innerStatus, setInnerStatus] = React.useState(status); + React.useEffect(() => { + if (visible && status) { + setInnerStatus(status); + } + }, [visible, status]); + + const baseClassName = `${prefixCls}-item-explain`; + + return ( + { + onDomErrorVisibleChange?.(false); + }} + motionAppear + removeOnLeave + > + {({ className: motionClassName }: { className: string }) => { + return ( +
+ {memoErrors.map((error, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {error} +
+ ))} +
+ ); + }} +
+ ); +} diff --git a/components/form/FormItem.tsx b/components/form/FormItem.tsx index 3a7c9ead7e..e899b4d7a3 100644 --- a/components/form/FormItem.tsx +++ b/components/form/FormItem.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useContext, useRef } from 'react'; import isEqual from 'lodash/isEqual'; import classNames from 'classnames'; import { Field, FormInstance } from 'rc-field-form'; @@ -86,15 +87,14 @@ function FormItem(props: FormItemProps): React.ReactElement { hidden, ...restProps } = props; - const destroyRef = React.useRef(false); - const { getPrefixCls } = React.useContext(ConfigContext); - const { name: formName, requiredMark } = React.useContext(FormContext); - const { updateItemErrors } = React.useContext(FormItemContext); + const destroyRef = useRef(false); + const { getPrefixCls } = useContext(ConfigContext); + const { name: formName, requiredMark } = useContext(FormContext); + const { updateItemErrors } = useContext(FormItemContext); const [domErrorVisible, innerSetDomErrorVisible] = React.useState(!!help); - const prevValidateStatusRef = React.useRef(validateStatus); const [inlineErrors, setInlineErrors] = useFrameState>({}); - const { validateTrigger: contextValidateTrigger } = React.useContext(FieldContext); + const { validateTrigger: contextValidateTrigger } = useContext(FieldContext); const mergedValidateTrigger = validateTrigger !== undefined ? validateTrigger : contextValidateTrigger; @@ -107,7 +107,7 @@ function FormItem(props: FormItemProps): React.ReactElement { const hasName = hasValidName(name); // Cache Field NamePath - const nameRef = React.useRef<(string | number)[]>([]); + const nameRef = useRef<(string | number)[]>([]); // Should clean up if Field removed React.useEffect(() => { @@ -176,10 +176,6 @@ function FormItem(props: FormItemProps): React.ReactElement { mergedValidateStatus = 'success'; } - if (domErrorVisible && help) { - prevValidateStatusRef.current = mergedValidateStatus; - } - const itemClassName = { [`${prefixCls}-item`]: true, [`${prefixCls}-item-with-help`]: domErrorVisible || help, @@ -190,8 +186,6 @@ function FormItem(props: FormItemProps): React.ReactElement { [`${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 && prevValidateStatusRef.current === 'error', [`${prefixCls}-item-is-validating`]: mergedValidateStatus === 'validating', [`${prefixCls}-item-hidden`]: hidden, }; @@ -239,6 +233,7 @@ function FormItem(props: FormItemProps): React.ReactElement { {...meta} errors={mergedErrors} prefixCls={prefixCls} + status={mergedValidateStatus} onDomErrorVisibleChange={setDomErrorVisible} validateStatus={mergedValidateStatus} > @@ -253,7 +248,7 @@ function FormItem(props: FormItemProps): React.ReactElement { const isRenderProps = typeof children === 'function'; // Record for real component render - const updateRef = React.useRef(0); + const updateRef = useRef(0); updateRef.current += 1; if (!hasName && !isRenderProps && !dependencies) { diff --git a/components/form/FormItemInput.tsx b/components/form/FormItemInput.tsx index 9ba28eb39a..ff134a93ba 100644 --- a/components/form/FormItemInput.tsx +++ b/components/form/FormItemInput.tsx @@ -4,14 +4,11 @@ import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; -import useMemo from 'rc-util/lib/hooks/useMemo'; -import CSSMotion from 'rc-motion'; import Col, { ColProps } from '../grid/col'; import { ValidateStatus } from './FormItem'; -import { FormContext } from './context'; -import useCacheErrors from './hooks/useCacheErrors'; -import useForceUpdate from '../_util/hooks/useForceUpdate'; +import { FormContext, FormItemPrefixContext } from './context'; +import ErrorList from './ErrorList'; interface FormItemInputMiscProps { prefixCls: string; @@ -26,6 +23,7 @@ export interface FormItemInputProps { wrapperCol?: ColProps; help?: React.ReactNode; extra?: React.ReactNode; + status?: ValidateStatus; } const iconMap: { [key: string]: any } = { @@ -37,6 +35,7 @@ const iconMap: { [key: string]: any } = { const FormItemInput: React.FC = ({ prefixCls, + status, wrapperCol, children, help, @@ -46,8 +45,6 @@ const FormItemInput: React.FC = ({ validateStatus, extra, }) => { - const forceUpdate = useForceUpdate(); - const baseClassName = `${prefixCls}-item`; const formContext = React.useContext(FormContext); @@ -56,24 +53,6 @@ const FormItemInput: React.FC = ({ const className = classNames(`${baseClassName}-control`, mergedWrapperCol.className); - const [visible, cacheErrors] = useCacheErrors( - errors, - changedVisible => { - if (changedVisible) { - /** - * We trigger in sync to avoid dom shaking but this get warning in react 16.13. - * So use Promise to keep in micro async to handle this. - * https://github.com/ant-design/ant-design/issues/21698#issuecomment-593743485 - */ - Promise.resolve().then(() => { - onDomErrorVisibleChange(true); - }); - } - forceUpdate(); - }, - !!help, - ); - React.useEffect( () => () => { onDomErrorVisibleChange(false); @@ -81,12 +60,6 @@ const FormItemInput: React.FC = ({ [], ); - const memoErrors = useMemo( - () => cacheErrors, - visible, - (_, nextVisible) => nextVisible, - ); - // Should provides additional icon if `hasFeedback` const IconNode = validateStatus && iconMap[validateStatus]; const icon = @@ -108,29 +81,13 @@ const FormItemInput: React.FC = ({
{children}
{icon}
- { - onDomErrorVisibleChange(false); - }} - motionAppear - removeOnLeave - > - {({ className: motionClassName }: { className: string }) => { - return ( -
- {memoErrors.map((error, index) => ( - // eslint-disable-next-line react/no-array-index-key -
- {error} -
- ))} -
- ); - }} -
+ + + {extra &&
{extra}
} diff --git a/components/form/FormList.tsx b/components/form/FormList.tsx index bb346470c4..140242edca 100644 --- a/components/form/FormList.tsx +++ b/components/form/FormList.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { List } from 'rc-field-form'; import { StoreValue } from 'rc-field-form/lib/interface'; import devWarning from '../_util/devWarning'; +import { ConfigContext } from '../config-provider'; +import { FormItemPrefixContext } from './context'; export interface FormListFieldData { name: number; @@ -16,19 +18,38 @@ export interface FormListOperation { } export interface FormListProps { + prefixCls?: string; name: string | number | (string | number)[]; - children: (fields: FormListFieldData[], operation: FormListOperation) => React.ReactNode; + children: ( + fields: FormListFieldData[], + operation: FormListOperation, + meta: { errors: React.ReactNode[] }, + ) => React.ReactNode; } -const FormList: React.FC = ({ children, ...props }) => { +const FormList: React.FC = ({ + prefixCls: customizePrefixCls, + children, + ...props +}) => { devWarning(!!props.name, 'Form.List', 'Miss `name` prop.'); + const { getPrefixCls } = React.useContext(ConfigContext); + const prefixCls = getPrefixCls('form', customizePrefixCls); + return ( - {(fields, operation) => { - return children( - fields.map(field => ({ ...field, fieldKey: field.key })), - operation, + {(fields, operation, meta) => { + return ( + + {children( + fields.map(field => ({ ...field, fieldKey: field.key })), + operation, + { + errors: meta.errors, + }, + )} + ); }} diff --git a/components/form/__tests__/__snapshots__/demo.test.js.snap b/components/form/__tests__/__snapshots__/demo.test.js.snap index a388b23d36..d81d9d3925 100644 --- a/components/form/__tests__/__snapshots__/demo.test.js.snap +++ b/components/form/__tests__/__snapshots__/demo.test.js.snap @@ -1078,7 +1078,7 @@ exports[`renders ./components/form/demo/disabled-input-debug.md correctly 1`] =
{ await sleep(); expect(onFinish).toHaveBeenLastCalledWith({ list: ['input2', 'input3'] }); }); + + it('list errors', async () => { + jest.useFakeTimers(); + + let operation; + const wrapper = mount( +
+ { + if (value.length < 2) { + return Promise.reject(new Error('At least 2')); + } + }, + }, + ]} + > + {(_, opt, { errors }) => { + operation = opt; + return ; + }} + +
, + ); + + async function addItem() { + await act(async () => { + operation.add(); + await sleep(); + jest.runAllTimers(); + wrapper.update(); + }); + } + + await addItem(); + expect(wrapper.find('.ant-form-item-explain div').text()).toEqual('At least 2'); + + await addItem(); + expect(wrapper.find('.ant-form-item-explain div')).toHaveLength(0); + + jest.useRealTimers(); + }); + + describe('ErrorList component', () => { + it('should trigger onDomErrorVisibleChange by motion end', async () => { + jest.useFakeTimers(); + + const onDomErrorVisibleChange = jest.fn(); + const wrapper = mount( + , + ); + + await act(async () => { + await sleep(); + jest.runAllTimers(); + wrapper.update(); + }); + + act(() => { + wrapper.find('CSSMotion').props().onLeaveEnd(); + }); + + expect(onDomErrorVisibleChange).toHaveBeenCalledWith(false); + + jest.useRealTimers(); + }); + }); }); diff --git a/components/form/context.tsx b/components/form/context.tsx index 9805412e58..1bec1c02c5 100644 --- a/components/form/context.tsx +++ b/components/form/context.tsx @@ -5,6 +5,7 @@ import { FormProviderProps as RcFormProviderProps } from 'rc-field-form/lib/Form import { ColProps } from '../grid/col'; import { FormLabelAlign } from './interface'; import { RequiredMark } from './Form'; +import { ValidateStatus } from './FormItem'; /** * Form Context @@ -49,3 +50,15 @@ export const FormProvider: React.FC = props => { const providerProps = omit(props, ['prefixCls']); return ; }; + +/** + * Used for ErrorList only + */ +export interface FormItemPrefixContextProps { + prefixCls: string; + status?: ValidateStatus; +} + +export const FormItemPrefixContext = React.createContext({ + prefixCls: '', +}); diff --git a/components/form/demo/dynamic-form-item.md b/components/form/demo/dynamic-form-item.md index a333cb03cf..df4500aa60 100644 --- a/components/form/demo/dynamic-form-item.md +++ b/components/form/demo/dynamic-form-item.md @@ -41,8 +41,19 @@ const DynamicFieldSet = () => { return (
- - {(fields, { add, remove }) => { + { + if (!names || names.length < 2) { + return Promise.reject(new Error('At least 2 passengers')); + } + }, + }, + ]} + > + {(fields, { add, remove }, { errors }) => { return (
{fields.map((field, index) => ( @@ -96,6 +107,8 @@ const DynamicFieldSet = () => { > Add field at head + +
); diff --git a/components/form/index.en-US.md b/components/form/index.en-US.md index 7710f7fe62..6df8e2e082 100644 --- a/components/form/index.en-US.md +++ b/components/form/index.en-US.md @@ -165,10 +165,11 @@ You can modify the default verification information of Form.Item through `messag Provides array management for fields. -| Property | Description | Type | Default | -| --- | --- | --- | --- | -| name | Field name, support array | [NamePath](#NamePath) | - | -| children | Render function | (fields: Field[], operation: { add, remove, move }) => React.ReactNode | - | +| Property | Description | Type | Default | Version | +| --- | --- | --- | --- | --- | +| name | Field name, support array | [NamePath](#NamePath) | - | | +| children | Render function | (fields: Field[], operation: { add, remove, move }) => React.ReactNode | - | | +| rules | Validate rules, only support customize validator. Should work with [ErrorList](#Form.ErrorList) | { validator, message }[] | - | 4.7.0 | ```tsx @@ -194,6 +195,14 @@ Some operator functions in render form of Form.List. | remove | remove form item | (index: number \| number[]) => void | number[]: 4.5.0 | | move | move form item | (from: number, to: number) => void | - | +## Form.ErrorList + +New in 4.7.0. Show error messages, should only work with `rules` of Form.List. + +| Property | Description | Type | Default | +| -------- | ----------- | ----------- | ------- | +| errors | Error list | ReactNode[] | - | + ## Form.Provider Provide linkage between forms. If a sub form with `name` prop update, it will auto trigger Provider related events. See [example](#components-form-demo-form-context). @@ -379,6 +388,10 @@ Validating is also part of the value updating. It pass follow steps: In each `onFieldsChange`, you will get `false` > `true` > `false` with `isFieldValidating`. +### Why Form.List do not support `label` and need ErrorList to show errors? + +Form.List use renderProps which mean internal structure is flexible. Thus `label` and `error` can not have best place. If you want to use antd `label`, you can wrap with Form.Item instead. +