fix: Form.Item noStyle support validation status (#44576)

* fix: FormItem.useStatus can not get status

* fix: noStyle not patch style

* fix: noStyle inhreit logic

* docs: update docs

* test: add test case

* refactor: nostyle block status

* fix: coverage
This commit is contained in:
二货爱吃白萝卜 2023-09-04 10:03:12 +08:00 committed by GitHub
parent 0f843cf106
commit 0396899ff6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 256 additions and 100 deletions

View File

@ -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<FormItemStatusContextProps>(() => {
let feedbackIcon: React.ReactNode;
if (hasFeedback) {
const IconNode = mergedValidateStatus && iconMap[mergedValidateStatus];
feedbackIcon = IconNode ? (
<span
className={classNames(
`${itemPrefixCls}-feedback-icon`,
`${itemPrefixCls}-feedback-icon-${mergedValidateStatus}`,
)}
>
<IconNode />
</span>
) : 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}
>
<NoStyleItemContext.Provider value={onSubItemMetaChange}>
<FormItemInputContext.Provider value={formItemStatusContext}>
<StatusProvider
prefixCls={prefixCls}
meta={meta}
errors={meta.errors}
warnings={meta.warnings}
hasFeedback={hasFeedback}
// Already calculated
validateStatus={mergedValidateStatus}
>
{children}
</FormItemInputContext.Provider>
</StatusProvider>
</NoStyleItemContext.Provider>
</FormItemInput>
</Row>

View File

@ -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<FormItemStatusContextProps>(() => {
let feedbackIcon: React.ReactNode;
if (hasFeedback) {
const IconNode = mergedValidateStatus && iconMap[mergedValidateStatus];
feedbackIcon = IconNode ? (
<span
className={classNames(
`${itemPrefixCls}-feedback-icon`,
`${itemPrefixCls}-feedback-icon-${mergedValidateStatus}`,
)}
>
<IconNode />
</span>
) : 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 (
<FormItemInputContext.Provider value={formItemStatusContext}>
{children}
</FormItemInputContext.Provider>
);
}

View File

@ -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<Values = any>(props: FormItemProps<Values>): React.Rea
isRequired?: boolean,
): React.ReactNode {
if (noStyle && !hidden) {
return baseChildren;
return (
<StatusProvider
prefixCls={prefixCls}
hasFeedback={props.hasFeedback}
validateStatus={props.validateStatus}
meta={meta}
errors={mergedErrors}
warnings={mergedWarnings}
noStyle
>
{baseChildren}
</StatusProvider>
);
}
return (

View File

@ -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 = () => (
<Form>
<Form.Item validateStatus="error" noStyle>
<Select className="custom-select" />
</Form.Item>
<Form.Item validateStatus="error">
describe('noStyle with status', () => {
it('noStyle should not affect status', async () => {
const Demo: React.FC = () => (
<Form>
{/* should change status */}
<Form.Item validateStatus="error" noStyle>
<Select className="custom-select" />
</Form.Item>
{/* should follow parent status */}
<Form.Item validateStatus="error">
<Form.Item noStyle>
<Select className="custom-select-b" />
</Form.Item>
</Form.Item>
{/* should follow child status */}
<Form.Item validateStatus="error">
<Form.Item noStyle validateStatus="warning">
<Select className="custom-select-c" />
</Form.Item>
</Form.Item>
{/* should follow child status */}
<Form.Item noStyle>
<Select className="custom-select-b" />
<Form.Item validateStatus="warning">
<Select className="custom-select-d" />
</Form.Item>
</Form.Item>
</Form.Item>
<Form.Item validateStatus="error">
<Form.Item noStyle validateStatus="warning">
<Select className="custom-select-c" />
{/* should follow child status */}
<Form.Item validateStatus="error">
<Form.Item noStyle validateStatus="">
<Select className="custom-select-e" />
</Form.Item>
</Form.Item>
</Form.Item>
<Form.Item noStyle>
<Form.Item validateStatus="warning">
<Select className="custom-select-d" />
</Form>
);
const { container } = render(<Demo />);
await waitFakeTimer();
expect(container.querySelector('.custom-select')).toHaveClass('ant-select-status-error');
expect(container.querySelector('.custom-select')).not.toHaveClass('ant-select-in-form-item');
expect(container.querySelector('.custom-select-b')).toHaveClass('ant-select-status-error');
expect(container.querySelector('.custom-select-b')).toHaveClass('ant-select-in-form-item');
expect(container.querySelector('.custom-select-c')).toHaveClass('ant-select-status-warning');
expect(container.querySelector('.custom-select-c')).toHaveClass('ant-select-in-form-item');
expect(container.querySelector('.custom-select-d')).toHaveClass('ant-select-status-warning');
expect(container.querySelector('.custom-select-d')).toHaveClass('ant-select-in-form-item');
expect(container.querySelector('.custom-select-e')).not.toHaveClass(
'ant-select-status-error',
);
expect(container.querySelector('.custom-select-e')).toHaveClass('ant-select-in-form-item');
});
it('parent pass status', async () => {
const { container } = render(
<Form>
<Form.Item label="name">
<Form.Item name="first" noStyle rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="last" noStyle>
<Input />
</Form.Item>
</Form.Item>
</Form.Item>
</Form>
);
const { container } = render(<Demo />);
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');
</Form>,
);
// 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', () => {

View File

@ -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<FormItemStatusContextProps>({});
if (process.env.NODE_ENV !== 'production') {
FormItemInputContext.displayName = 'FormItemInputContext';
}
export type NoFormStyleProps = PropsWithChildren<{
status?: boolean;
override?: boolean;

View File

@ -132,7 +132,7 @@ Form field component for data bidirectional binding, validation, layout, and so
| messageVariables | The default validate field info | Record&lt;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)\[] | - | |

View File

@ -133,7 +133,7 @@ const validateMessages = {
| messageVariables | 默认验证字段的信息 | Record&lt;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)\[] | - | |

View File

@ -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<DefaultValue>(
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;
}