Skip to content

Commit

Permalink
fix: Form.Item noStyle support validation status (ant-design#44576)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zombieJ authored Sep 4, 2023
1 parent 0f843cf commit 0396899
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 100 deletions.
78 changes: 20 additions & 58 deletions components/form/FormItem/ItemHolder.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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>
Expand Down
90 changes: 90 additions & 0 deletions components/form/FormItem/StatusProvider.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
26 changes: 20 additions & 6 deletions components/form/FormItem/index.tsx
Original file line number Diff line number Diff line change
@@ -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__';

Expand Down Expand Up @@ -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 (
Expand Down
116 changes: 85 additions & 31 deletions components/form/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading

0 comments on commit 0396899

Please sign in to comment.