diff --git a/components/form/FormItem/ItemHolder.tsx b/components/form/FormItem/ItemHolder.tsx index d338594325cb..1bd50eda2e6b 100644 --- a/components/form/FormItem/ItemHolder.tsx +++ b/components/form/FormItem/ItemHolder.tsx @@ -1,27 +1,19 @@ -import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; -import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; -import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; -import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; +import * as React from 'react'; import classNames from 'classnames'; import type { Meta } from 'rc-field-form/lib/interface'; -import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import isVisible from 'rc-util/lib/Dom/isVisible'; +import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; import omit from 'rc-util/lib/omit'; -import * as React from 'react'; -import type { FormItemProps, ValidateStatus } from '.'; + +import type { FormItemProps } from '.'; import { Row } from '../../grid'; +import type { ReportMetaChange } from '../context'; +import { FormContext, NoStyleItemContext } from '../context'; import FormItemInput from '../FormItemInput'; import FormItemLabel from '../FormItemLabel'; -import type { FormItemStatusContextProps, ReportMetaChange } from '../context'; -import { FormContext, FormItemInputContext, NoStyleItemContext } from '../context'; import useDebounce from '../hooks/useDebounce'; - -const iconMap = { - success: CheckCircleFilled, - warning: ExclamationCircleFilled, - error: CloseCircleFilled, - validating: LoadingOutlined, -}; +import { getStatus } from '../util'; +import StatusProvider from './StatusProvider'; export interface ItemHolderProps extends FormItemProps { prefixCls: string; @@ -88,52 +80,14 @@ export default function ItemHolder(props: ItemHolderProps) { // ======================== Status ======================== const getValidateState = (isDebounce = false) => { - let status: ValidateStatus = ''; const _errors = isDebounce ? debounceErrors : meta.errors; const _warnings = isDebounce ? debounceWarnings : meta.warnings; - if (validateStatus !== undefined) { - status = validateStatus; - } else if (meta.validating) { - status = 'validating'; - } else if (_errors.length) { - status = 'error'; - } else if (_warnings.length) { - status = 'warning'; - } else if (meta.touched || (hasFeedback && meta.validated)) { - // success feedback should display when pass hasFeedback prop and current value is valid value - status = 'success'; - } - return status; + + return getStatus(_errors, _warnings, meta, '', hasFeedback, validateStatus); }; const mergedValidateStatus = getValidateState(); - const formItemStatusContext = React.useMemo(() => { - let feedbackIcon: React.ReactNode; - if (hasFeedback) { - const IconNode = mergedValidateStatus && iconMap[mergedValidateStatus]; - feedbackIcon = IconNode ? ( - - - - ) : null; - } - - return { - status: mergedValidateStatus, - errors, - warnings, - hasFeedback, - feedbackIcon, - isFormItemInput: true, - }; - }, [mergedValidateStatus, hasFeedback]); - // ======================== Render ======================== const itemClassName = classNames(itemPrefixCls, className, rootClassName, { [`${itemPrefixCls}-with-help`]: hasHelp || debounceErrors.length || debounceWarnings.length, @@ -204,9 +158,17 @@ export default function ItemHolder(props: ItemHolderProps) { onErrorVisibleChanged={onErrorVisibleChanged} > - + {children} - + diff --git a/components/form/FormItem/StatusProvider.tsx b/components/form/FormItem/StatusProvider.tsx new file mode 100644 index 000000000000..d0704ae2d453 --- /dev/null +++ b/components/form/FormItem/StatusProvider.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import CheckCircleFilled from '@ant-design/icons/CheckCircleFilled'; +import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; +import ExclamationCircleFilled from '@ant-design/icons/ExclamationCircleFilled'; +import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; +import classNames from 'classnames'; +import type { Meta } from 'rc-field-form/lib/interface'; + +import type { ValidateStatus } from '.'; +import { FormItemInputContext, type FormItemStatusContextProps } from '../context'; +import { getStatus } from '../util'; + +const iconMap = { + success: CheckCircleFilled, + warning: ExclamationCircleFilled, + error: CloseCircleFilled, + validating: LoadingOutlined, +}; + +export interface StatusProviderProps { + children?: React.ReactNode; + validateStatus?: ValidateStatus; + prefixCls: string; + meta: Meta; + errors: React.ReactNode[]; + warnings: React.ReactNode[]; + hasFeedback?: boolean; + noStyle?: boolean; +} + +export default function StatusProvider({ + children, + errors, + warnings, + hasFeedback, + validateStatus, + prefixCls, + meta, + noStyle, +}: StatusProviderProps) { + const itemPrefixCls = `${prefixCls}-item`; + + const mergedValidateStatus = getStatus(errors, warnings, meta, null, hasFeedback, validateStatus); + + const { isFormItemInput: parentIsFormItemInput, status: parentStatus } = + React.useContext(FormItemInputContext); + + // ====================== Context ======================= + const formItemStatusContext = React.useMemo(() => { + let feedbackIcon: React.ReactNode; + if (hasFeedback) { + const IconNode = mergedValidateStatus && iconMap[mergedValidateStatus]; + feedbackIcon = IconNode ? ( + + + + ) : null; + } + + let isFormItemInput: boolean | undefined = true; + let status: ValidateStatus = mergedValidateStatus || ''; + + // No style will follow parent context + if (noStyle) { + isFormItemInput = parentIsFormItemInput; + status = (mergedValidateStatus ?? parentStatus) || ''; + } + + return { + status, + errors, + warnings, + hasFeedback, + feedbackIcon, + isFormItemInput, + }; + }, [mergedValidateStatus, hasFeedback, noStyle, parentIsFormItemInput, parentStatus]); + + // ======================= Render ======================= + return ( + + {children} + + ); +} diff --git a/components/form/FormItem/index.tsx b/components/form/FormItem/index.tsx index c4f21056c6a6..1ee2872e8118 100644 --- a/components/form/FormItem/index.tsx +++ b/components/form/FormItem/index.tsx @@ -1,24 +1,26 @@ +import * as React from 'react'; import classNames from 'classnames'; import { Field, FieldContext, ListContext } from 'rc-field-form'; import type { FieldProps } from 'rc-field-form/lib/Field'; import type { Meta, NamePath } from 'rc-field-form/lib/interface'; import useState from 'rc-util/lib/hooks/useState'; import { supportRef } from 'rc-util/lib/ref'; -import * as React from 'react'; + import { cloneElement, isValidElement } from '../../_util/reactNode'; import warning from '../../_util/warning'; import { ConfigContext } from '../../config-provider'; +import { FormContext, NoStyleItemContext } from '../context'; +import type { FormInstance } from '../Form'; import type { FormItemInputProps } from '../FormItemInput'; import type { FormItemLabelProps, LabelTooltipType } from '../FormItemLabel'; -import { FormContext, NoStyleItemContext } from '../context'; +import useChildren from '../hooks/useChildren'; import useFormItemStatus from '../hooks/useFormItemStatus'; import useFrameState from '../hooks/useFrameState'; import useItemRef from '../hooks/useItemRef'; +import useStyle from '../style'; import { getFieldId, toArray } from '../util'; import ItemHolder from './ItemHolder'; -import useChildren from '../hooks/useChildren'; -import useStyle from '../style'; -import type { FormInstance } from '../Form'; +import StatusProvider from './StatusProvider'; const NAME_SPLIT = '__SPLIT__'; @@ -213,7 +215,19 @@ function InternalFormItem(props: FormItemProps): React.Rea isRequired?: boolean, ): React.ReactNode { if (noStyle && !hidden) { - return baseChildren; + return ( + + {baseChildren} + + ); } return ( diff --git a/components/form/__tests__/index.test.tsx b/components/form/__tests__/index.test.tsx index c6fb4b6131ae..2186cbdff7b4 100644 --- a/components/form/__tests__/index.test.tsx +++ b/components/form/__tests__/index.test.tsx @@ -1,14 +1,15 @@ -import classNames from 'classnames'; import type { ChangeEventHandler } from 'react'; import React, { version as ReactVersion, useEffect, useRef, useState } from 'react'; -import scrollIntoView from 'scroll-into-view-if-needed'; import type { ColProps } from 'antd/es/grid'; +import classNames from 'classnames'; +import scrollIntoView from 'scroll-into-view-if-needed'; + import type { FormInstance } from '..'; import Form from '..'; +import { resetWarned } from '../../_util/warning'; import mountTest from '../../../tests/shared/mountTest'; import rtlTest from '../../../tests/shared/rtlTest'; import { fireEvent, pureRender, render, screen, waitFakeTimer } from '../../../tests/utils'; -import { resetWarned } from '../../_util/warning'; import Button from '../../button'; import Cascader from '../../cascader'; import Checkbox from '../../checkbox'; @@ -1407,38 +1408,91 @@ describe('Form', () => { expect(subFormInstance).toBe(formInstance); }); - it('noStyle should not affect status', () => { - const Demo: React.FC = () => ( -
- - + + + {/* should follow parent status */} + + + + + + + {/* should follow child status */} - + - - - - + - - - - + + + + - -
- ); - const { container } = render(); - expect(container.querySelector('.custom-select')?.className).not.toContain('status-error'); - expect(container.querySelector('.custom-select')?.className).not.toContain('in-form-item'); - expect(container.querySelector('.custom-select-b')?.className).toContain('status-error'); - expect(container.querySelector('.custom-select-b')?.className).toContain('in-form-item'); - expect(container.querySelector('.custom-select-c')?.className).toContain('status-error'); - expect(container.querySelector('.custom-select-c')?.className).toContain('in-form-item'); - expect(container.querySelector('.custom-select-d')?.className).toContain('status-warning'); - expect(container.querySelector('.custom-select-d')?.className).toContain('in-form-item'); + , + ); + + // Input and set back to empty + await changeValue(0, 'Once'); + await changeValue(0, ''); + + expect(container.querySelector('.ant-form-item-explain-error')?.textContent).toEqual( + "'first' is required", + ); + + expect(container.querySelectorAll('input')[0]).toHaveClass('ant-input-status-error'); + expect(container.querySelectorAll('input')[1]).not.toHaveClass('ant-input-status-error'); + }); }); it('should not affect Popup children style', () => { diff --git a/components/form/context.tsx b/components/form/context.tsx index 7a3f388b58f2..82b97d2bb247 100644 --- a/components/form/context.tsx +++ b/components/form/context.tsx @@ -1,10 +1,11 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import * as React from 'react'; +import { useContext, useMemo } from 'react'; import { FormProvider as RcFormProvider } from 'rc-field-form'; import type { FormProviderProps as RcFormProviderProps } from 'rc-field-form/lib/FormContext'; import type { Meta } from 'rc-field-form/lib/interface'; import omit from 'rc-util/lib/omit'; -import type { PropsWithChildren, ReactNode } from 'react'; -import * as React from 'react'; -import { useContext, useMemo } from 'react'; + import type { ColProps } from '../grid/col'; import type { FormInstance, RequiredMark } from './Form'; import type { ValidateStatus } from './FormItem'; @@ -65,6 +66,10 @@ export interface FormItemStatusContextProps { export const FormItemInputContext = React.createContext({}); +if (process.env.NODE_ENV !== 'production') { + FormItemInputContext.displayName = 'FormItemInputContext'; +} + export type NoFormStyleProps = PropsWithChildren<{ status?: boolean; override?: boolean; diff --git a/components/form/index.en-US.md b/components/form/index.en-US.md index f8dff3bcde46..05923f081d69 100644 --- a/components/form/index.en-US.md +++ b/components/form/index.en-US.md @@ -132,7 +132,7 @@ Form field component for data bidirectional binding, validation, layout, and so | messageVariables | The default validate field info | Record<string, string> | - | 4.7.0 | | name | Field name, support array | [NamePath](#namepath) | - | | | normalize | Normalize value from component value before passing to Form instance. Do not support async | (value, prevValue, prevValues) => any | - | | -| noStyle | No style for `true`, used as a pure field control | boolean | false | | +| noStyle | No style for `true`, used as a pure field control. Will inherit parent Form.Item `validateStatus` if self `validateStatus` not configured | boolean | false | | | preserve | Keep field value even when field removed | boolean | true | 4.4.0 | | required | Display required style. It will be generated by the validation rule | boolean | false | | | rules | Rules for field validation. Click [here](#components-form-demo-basic) to see an example | [Rule](#rule)\[] | - | | diff --git a/components/form/index.zh-CN.md b/components/form/index.zh-CN.md index 17e2b03de542..c64218fe9b5a 100644 --- a/components/form/index.zh-CN.md +++ b/components/form/index.zh-CN.md @@ -133,7 +133,7 @@ const validateMessages = { | messageVariables | 默认验证字段的信息 | Record<string, string> | - | 4.7.0 | | name | 字段名,支持数组 | [NamePath](#namepath) | - | | | normalize | 组件获取值后进行转换,再放入 Form 中。不支持异步 | (value, prevValue, prevValues) => any | - | | -| noStyle | 为 `true` 时不带样式,作为纯字段控件使用 | boolean | false | | +| noStyle | 为 `true` 时不带样式,作为纯字段控件使用。当自身没有 `validateStatus` 而父元素存在有 `validateStatus` 的 Form.Item 会继承父元素的 `validateStatus` | boolean | false | | | preserve | 当字段被删除时保留字段值 | boolean | true | 4.4.0 | | required | 必填样式设置。如不设置,则会根据校验规则自动生成 | boolean | false | | | rules | 校验规则,设置字段的校验逻辑。点击[此处](#components-form-demo-basic)查看示例 | [Rule](#rule)\[] | - | | diff --git a/components/form/util.ts b/components/form/util.ts index 857d5c66dee9..1b18bd552cd9 100644 --- a/components/form/util.ts +++ b/components/form/util.ts @@ -1,3 +1,6 @@ +import type { Meta } from 'rc-field-form/lib/interface'; + +import type { ValidateStatus } from './FormItem'; import type { InternalNamePath } from './interface'; // form item name black list. in form ,you can use form.id get the form item element. @@ -28,3 +31,31 @@ export function getFieldId(namePath: InternalNamePath, formName?: string): strin return isIllegalName ? `${defaultItemNamePrefixCls}_${mergedId}` : mergedId; } + +/** + * Get merged status by meta or passed `validateStatus`. + */ +export function getStatus( + errors: React.ReactNode[], + warnings: React.ReactNode[], + meta: Meta, + defaultValidateStatus: ValidateStatus | DefaultValue, + hasFeedback?: boolean, + validateStatus?: ValidateStatus, +): ValidateStatus | DefaultValue { + let status = defaultValidateStatus; + + if (validateStatus !== undefined) { + status = validateStatus; + } else if (meta.validating) { + status = 'validating'; + } else if (errors.length) { + status = 'error'; + } else if (warnings.length) { + status = 'warning'; + } else if (meta.touched || (hasFeedback && meta.validated)) { + // success feedback should display when pass hasFeedback prop and current value is valid value + status = 'success'; + } + return status; +}