mirror of
https://github.com/ant-design/ant-design.git
synced 2024-11-27 12:39:49 +08:00
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:
parent
0f843cf106
commit
0396899ff6
@ -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>
|
||||
|
90
components/form/FormItem/StatusProvider.tsx
Normal file
90
components/form/FormItem/StatusProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
||||
|
@ -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)\[] | - | |
|
||||
|
@ -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)\[] | - | |
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user