Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): added errorMessage, errorPlacement and validationType props #1291

Merged
merged 7 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/components/Select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -974,6 +974,25 @@ const MyComponent = () => {

<!--/GITHUB_BLOCK-->

### Error

The state of the `Select` in which you want to indicate incorrect user input. To change `Select` appearance, use the `validationState` property with the `"invalid"` value. An optional message text can be added via the `errorMessage` property. By default, message text is rendered outside the component.
This behaviour can be changed with the `errorPlacement` property.

<!--LANDING_BLOCK
<ExampleBlock
code={`
<Select placeholder="Placeholder" errorMessage="Error message" validationState="invalid" />
<Select placeholder="Placeholder" errorPlacement="inside" errorMessage="Error message" validationState="invalid" />
`}
>
<UIKit.Select placeholder="Placeholder" errorMessage="Error message" validationState="invalid" />
<UIKit.Select placeholder="Placeholder" errorPlacement="inside" errorMessage="Error message" validationState="invalid" />
</ExampleBlock>
LANDING_BLOCK-->

<!--GITHUB_BLOCK-->

## Properties

| Name | Description | Type | Default |
Expand Down Expand Up @@ -1016,3 +1035,6 @@ const MyComponent = () => {
| view | Control view | `string` | `'normal'` |
| [virtualizationThreshold](#virtualized-list) | The threshold of the options count after which virtualization is enabled | `number` | `50` |
| [width](#control-width) | Control width | `string \| number` | `undefined` |
| errorMessage | Error text | `string` | |
| errorPlacement | Error placement | `outside` `inside` | `outside` |
| validationState | Validation state | `"invalid"` | |
25 changes: 24 additions & 1 deletion src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import React from 'react';
import {KeyCode} from '../../constants';
import {useFocusWithin, useForkRef, useSelect, useUniqId} from '../../hooks';
import type {List} from '../List';
import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent';
import {errorPropsMapper} from '../controls/utils';
import {useMobile} from '../mobile';
import type {CnMods} from '../utils/cn';

Expand Down Expand Up @@ -122,6 +124,20 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
);
const virtualized = filteredFlattenOptions.length >= virtualizationThreshold;

const {errorMessage, errorPlacement, validationState} = errorPropsMapper({
error,
errorMessage: props.errorMessage,
errorPlacement: props.errorPlacement || 'outside',
validationState: props.validationState,
});
const errorMessageId = useUniqId();

const isErrorMsgVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'outside';
const isErrorIconVisible =
validationState === 'invalid' && Boolean(errorMessage) && errorPlacement === 'inside';
const isErrorStateVisible = isErrorMsgVisible || isErrorIconVisible;

const handleOptionClick = React.useCallback(
(option?: FlattenOption) => {
if (!option || option?.disabled || 'label' in option) {
Expand Down Expand Up @@ -250,7 +266,8 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
label={label}
placeholder={placeholder}
selectedOptionsContent={selectedOptionsContent}
error={error}
isErrorVisible={isErrorStateVisible}
errorMessage={isErrorIconVisible ? errorMessage : undefined}
open={open}
disabled={disabled}
onKeyDown={handleControlKeyDown}
Expand All @@ -260,6 +277,7 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
selectId={`select-${selectId}`}
activeIndex={activeIndex}
/>

<SelectPopup
ref={controlWrapRef}
className={popupClassName}
Expand Down Expand Up @@ -307,6 +325,11 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(function
<EmptyOptions filter={filter} renderEmptyOptions={renderEmptyOptions} />
)}
</SelectPopup>

<OuterAdditionalContent
errorMessage={isErrorMsgVisible ? errorMessage : null}
errorMessageId={errorMessageId}
/>
</div>
);
}) as unknown as SelectComponent;
Expand Down
34 changes: 34 additions & 0 deletions src/components/Select/__stories__/SelectShowcase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,40 @@ export const SelectShowcase = (props: SelectProps) => {
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>

<div>
<h2>Select (with text error)</h2>

<ExampleItem
title="Select with outside error"
selectProps={{
...props,
errorPlacement: 'outside',
errorMessage: 'A validation error has occurred',
validationState: 'invalid',
}}
>
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>

<ExampleItem
title="Select with inside error"
selectProps={{
...props,
errorPlacement: 'inside',
errorMessage: 'A validation error has occurred',
validationState: 'invalid',
}}
>
<Select.Option value="val1" content="Value1" />
<Select.Option value="val2" content="Value2" />
<Select.Option value="val3" content="Value3" />
<Select.Option value="val4" content="Value4" />
</ExampleItem>
</div>
</div>
);
};
66 changes: 66 additions & 0 deletions src/components/Select/__tests__/Select.error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from 'react';

import {render, screen} from '@testing-library/react';

import {CONTROL_ERROR_MESSAGE_QA} from '../../controls/utils';
import {Select} from '../Select';

describe('Select error', () => {
test('render error message with error prop (if it is not an empty string)', () => {
render(<Select error="Some Error" />);

expect(screen.getByText('Some Error')).toBeVisible();
});

test('render error message with errorMessage prop (if it is not an empty string)', () => {
render(<Select errorMessage="Some Error with errorMessage prop" />);

expect(screen.queryByText('Some Error with errorMessage prop')).not.toBeInTheDocument();
});

test('render error message with errorMessage prop and invalid state (if it is not an empty string)', () => {
render(
<Select errorMessage="Some Error with errorMessage prop" validationState="invalid" />,
);

expect(screen.getByText('Some Error with errorMessage prop')).toBeVisible();
});

test('render error icon if tooltip option is selected for errorPlacement prop', () => {
render(
<Select errorMessage="Some Error" validationState="invalid" errorPlacement="inside" />,
);

expect(screen.getByLabelText('Show popup with error info')).toBeInTheDocument();
});

test('do not show error message without error/errorMessage prop', () => {
render(<Select />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});

test('do not show error message if error prop value is an empty string', () => {
render(<Select error={''} />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});

test('do not show error message if errorMessage prop value is an empty string', () => {
render(<Select errorMessage={''} />);

expect(screen.queryByTestId(CONTROL_ERROR_MESSAGE_QA)).not.toBeInTheDocument();
});

test('do not show error icon if error prop is an empty string', () => {
render(<Select error={''} errorPlacement="inside" />);

expect(screen.queryByLabelText('Show popup with error info')).not.toBeInTheDocument();
});

test('do not show error icon if errorMessage prop is an empty string', () => {
render(<Select errorMessage={''} errorPlacement="inside" />);

expect(screen.queryByLabelText('Show popup with error info')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -239,10 +239,9 @@ $blockButton: '.#{variables.$ns-new}select-control__button';
margin-left: 0;
}

&__error {
@include mixins.text-body-1();

&__error-icon {
box-sizing: content-box;
color: var(--g-color-text-danger);
margin-top: 2px;
padding: var(--_--text-input-error-icon-padding);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import React from 'react';

import {ChevronDown} from '@gravity-ui/icons';
import {ChevronDown, TriangleExclamation} from '@gravity-ui/icons';
import isEmpty from 'lodash/isEmpty';

import {Icon} from '../../../Icon';
import {Popover} from '../../../Popover';
import type {CnMods} from '../../../utils/cn';
import {selectControlBlock, selectControlButtonBlock} from '../../constants';
import i18n from '../../i18n';
import type {
SelectProps,
SelectRenderClearArgs,
Expand All @@ -28,7 +30,8 @@ type ControlProps = {
qa?: string;
label?: string;
placeholder?: SelectProps['placeholder'];
error?: SelectProps['error'];
isErrorVisible?: boolean;
errorMessage?: SelectProps['errorMessage'];
disabled?: boolean;
value: SelectProps['value'];
clearValue: () => void;
Expand All @@ -50,7 +53,8 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
name,
label,
placeholder,
error,
isErrorVisible,
errorMessage,
open,
disabled,
value,
Expand All @@ -70,7 +74,7 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
size,
pin,
disabled,
error: Boolean(error),
error: isErrorVisible,
'has-clear': hasClear,
'no-active': isDisabledButtonAnimation,
'has-value': hasValue,
Expand All @@ -82,7 +86,7 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
view,
pin,
disabled,
error: Boolean(error),
error: isErrorVisible,
};

const disableButtonAnimation = React.useCallback(() => {
Expand Down Expand Up @@ -163,15 +167,25 @@ export const SelectControl = React.forwardRef<HTMLButtonElement, ControlProps>((
)}
</button>
{renderClearIcon({})}

{errorMessage && (
<Popover content={errorMessage}>
<span aria-label={i18n('label_show-error-info')}>
<Icon
data={TriangleExclamation}
className={selectControlBlock('error-icon')}
size={size === 's' ? 12 : 16}
/>
</span>
</Popover>
)}

<Icon
className={selectControlBlock('chevron-icon', {disabled})}
data={ChevronDown}
aria-hidden="true"
/>
</div>
{typeof error === 'string' && (
<div className={selectControlBlock('error')}>{error}</div>
)}
</React.Fragment>
);
});
Expand Down
3 changes: 2 additions & 1 deletion src/components/Select/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"label_clear": "Clear"
"label_clear": "Clear",
"label_show-error-info": "Show popup with error info"
}
3 changes: 2 additions & 1 deletion src/components/Select/i18n/ru.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"label_clear": "Очистить"
"label_clear": "Очистить",
"label_show-error-info": "Показать попап с информацей об ошибке"
}
9 changes: 9 additions & 0 deletions src/components/Select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,16 @@ export type SelectProps<T = any> = QAProps &
value?: string[];
defaultValue?: string[];
options?: (SelectOption<T> | SelectOptionGroup<T>)[];
/**
* @deprecated Prop `error` has a lower priority than `errorMessage`. Use `errorMessage` instead
*/
error?: string | boolean;
/** Determines content of the error message */
errorMessage?: React.ReactNode;
/** Determines whether the error message will be placed under the input field as text or in the tooltip */
errorPlacement?: 'outside' | 'inside';
/** Describes the validation state */
validationState?: 'invalid';
multiple?: boolean;
filterable?: boolean;
disablePortal?: boolean;
Expand Down
Loading