mirror of
https://github.com/apache/superset.git
synced 2024-09-16 10:39:55 -04:00
feat: Adds a helper text option to the Select component (#21269)
Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
This commit is contained in:
parent
60db0bb47e
commit
fbe980779e
@ -206,13 +206,7 @@ test('sort the options using a custom sort comparator', async () => {
|
|||||||
option1: typeof OPTIONS[0],
|
option1: typeof OPTIONS[0],
|
||||||
option2: typeof OPTIONS[0],
|
option2: typeof OPTIONS[0],
|
||||||
) => option1.gender.localeCompare(option2.gender);
|
) => option1.gender.localeCompare(option2.gender);
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} sortComparator={sortComparator} />);
|
||||||
<AsyncSelect
|
|
||||||
{...defaultProps}
|
|
||||||
options={loadOptions}
|
|
||||||
sortComparator={sortComparator}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
const optionsPage = OPTIONS.slice(0, defaultProps.pageSize);
|
const optionsPage = OPTIONS.slice(0, defaultProps.pageSize);
|
||||||
@ -294,7 +288,7 @@ test('searches for label or value', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('search order exact and startWith match first', async () => {
|
test('search order exact and startWith match first', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
await open();
|
await open();
|
||||||
await type('Her');
|
await type('Her');
|
||||||
expect(await findSelectOption('Guilherme')).toBeInTheDocument();
|
expect(await findSelectOption('Guilherme')).toBeInTheDocument();
|
||||||
@ -333,7 +327,7 @@ test('same case should be ranked to the top', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ignores special keys when searching', async () => {
|
test('ignores special keys when searching', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
await type('{shift}');
|
await type('{shift}');
|
||||||
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -434,7 +428,7 @@ test('clear all the values', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('does not add a new option if allowNewOptions is false', async () => {
|
test('does not add a new option if allowNewOptions is false', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
await open();
|
await open();
|
||||||
await type(NEW_OPTION);
|
await type(NEW_OPTION);
|
||||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||||
@ -469,7 +463,7 @@ test('adds the null option when selected in multiple mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renders the select with default props', () => {
|
test('renders the select with default props', () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
expect(getSelect()).toBeInTheDocument();
|
expect(getSelect()).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -485,7 +479,7 @@ test('opens the select without any data', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('displays the loading indicator when opening', async () => {
|
test('displays the loading indicator when opening', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
userEvent.click(getSelect());
|
userEvent.click(getSelect());
|
||||||
expect(screen.getByText(LOADING)).toBeInTheDocument();
|
expect(screen.getByText(LOADING)).toBeInTheDocument();
|
||||||
@ -494,7 +488,7 @@ test('displays the loading indicator when opening', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('makes a selection in single mode', async () => {
|
test('makes a selection in single mode', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
const optionText = 'Emma';
|
const optionText = 'Emma';
|
||||||
await open();
|
await open();
|
||||||
userEvent.click(await findSelectOption(optionText));
|
userEvent.click(await findSelectOption(optionText));
|
||||||
@ -502,9 +496,7 @@ test('makes a selection in single mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('multiple selections in multiple mode', async () => {
|
test('multiple selections in multiple mode', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} mode="multiple" />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
const [firstOption, secondOption] = OPTIONS;
|
const [firstOption, secondOption] = OPTIONS;
|
||||||
userEvent.click(await findSelectOption(firstOption.label));
|
userEvent.click(await findSelectOption(firstOption.label));
|
||||||
@ -516,9 +508,7 @@ test('multiple selections in multiple mode', async () => {
|
|||||||
|
|
||||||
test('changes the selected item in single mode', async () => {
|
test('changes the selected item in single mode', async () => {
|
||||||
const onChange = jest.fn();
|
const onChange = jest.fn();
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} onChange={onChange} />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} onChange={onChange} />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
const [firstOption, secondOption] = OPTIONS;
|
const [firstOption, secondOption] = OPTIONS;
|
||||||
userEvent.click(await findSelectOption(firstOption.label));
|
userEvent.click(await findSelectOption(firstOption.label));
|
||||||
@ -542,9 +532,7 @@ test('changes the selected item in single mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('deselects an item in multiple mode', async () => {
|
test('deselects an item in multiple mode', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} mode="multiple" />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
const option3 = OPTIONS[2];
|
const option3 = OPTIONS[2];
|
||||||
const option8 = OPTIONS[7];
|
const option8 = OPTIONS[7];
|
||||||
@ -578,18 +566,14 @@ test('deselects an item in multiple mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds a new option if none is available and allowNewOptions is true', async () => {
|
test('adds a new option if none is available and allowNewOptions is true', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} allowNewOptions />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
await type(NEW_OPTION);
|
await type(NEW_OPTION);
|
||||||
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
|
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not add a new option if the option already exists', async () => {
|
test('does not add a new option if the option already exists', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} allowNewOptions />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
|
||||||
);
|
|
||||||
const option = OPTIONS[0].label;
|
const option = OPTIONS[0].label;
|
||||||
await open();
|
await open();
|
||||||
await type(option);
|
await type(option);
|
||||||
@ -602,32 +586,21 @@ test('does not add a new option if the option already exists', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('shows "No data" when allowNewOptions is false and a new option is entered', async () => {
|
test('shows "No data" when allowNewOptions is false and a new option is entered', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} allowNewOptions={false} showSearch />);
|
||||||
<AsyncSelect
|
|
||||||
{...defaultProps}
|
|
||||||
options={loadOptions}
|
|
||||||
allowNewOptions={false}
|
|
||||||
showSearch
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
await type(NEW_OPTION);
|
await type(NEW_OPTION);
|
||||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
|
test('does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} allowNewOptions />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
await type(NEW_OPTION);
|
await type(NEW_OPTION);
|
||||||
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument();
|
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sets a initial value in single mode', async () => {
|
test('sets a initial value in single mode', async () => {
|
||||||
render(
|
render(<AsyncSelect {...defaultProps} value={OPTIONS[0]} />);
|
||||||
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
|
|
||||||
);
|
|
||||||
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
|
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -636,7 +609,6 @@ test('sets a initial value in multiple mode', async () => {
|
|||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
options={loadOptions}
|
|
||||||
value={[OPTIONS[0], OPTIONS[1]]}
|
value={[OPTIONS[0], OPTIONS[1]]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@ -646,7 +618,7 @@ test('sets a initial value in multiple mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('searches for matches in both loaded and unloaded pages', async () => {
|
test('searches for matches in both loaded and unloaded pages', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
await open();
|
await open();
|
||||||
await type('and');
|
await type('and');
|
||||||
|
|
||||||
@ -750,6 +722,20 @@ test('fires a new request if all values have not been fetched', async () => {
|
|||||||
expect(mock).toHaveBeenCalledTimes(2);
|
expect(mock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not render a helper text by default', async () => {
|
||||||
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
|
await open();
|
||||||
|
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a helper text when one is provided', async () => {
|
||||||
|
const helperText = 'Helper text';
|
||||||
|
render(<AsyncSelect {...defaultProps} helperText={helperText} />);
|
||||||
|
await open();
|
||||||
|
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(helperText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
TODO: Add tests that require scroll interaction. Needs further investigation.
|
TODO: Add tests that require scroll interaction. Needs further investigation.
|
||||||
- Fetches more data when scrolling and more data is available
|
- Fetches more data when scrolling and more data is available
|
||||||
|
@ -102,6 +102,11 @@ export interface AsyncSelectProps extends PickedSelectProps {
|
|||||||
* Can be any ReactNode.
|
* Can be any ReactNode.
|
||||||
*/
|
*/
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
|
/**
|
||||||
|
* It adds a helper text on top of the Select options
|
||||||
|
* with additional context to help with the interaction.
|
||||||
|
*/
|
||||||
|
helperText?: string;
|
||||||
/**
|
/**
|
||||||
* It fires a request against the server after
|
* It fires a request against the server after
|
||||||
* the first interaction and not on render.
|
* the first interaction and not on render.
|
||||||
@ -182,6 +187,9 @@ const StyledSelect = styled(AntdSelect)`
|
|||||||
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.ant-select-dropdown {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -224,6 +232,16 @@ const StyledLoadingText = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledHelperText = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
padding: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px;
|
||||||
|
color: ${theme.colors.grayscale.base};
|
||||||
|
font-size: ${theme.typography.sizes.s}px;
|
||||||
|
cursor: default;
|
||||||
|
border-bottom: 1px solid ${theme.colors.grayscale.light2};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
const MAX_TAG_COUNT = 4;
|
const MAX_TAG_COUNT = 4;
|
||||||
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
const DEFAULT_PAGE_SIZE = 100;
|
||||||
@ -297,6 +315,7 @@ const AsyncSelect = forwardRef(
|
|||||||
fetchOnlyOnSearch,
|
fetchOnlyOnSearch,
|
||||||
filterOption = true,
|
filterOption = true,
|
||||||
header = null,
|
header = null,
|
||||||
|
helperText,
|
||||||
invertSelection = false,
|
invertSelection = false,
|
||||||
lazyLoading = true,
|
lazyLoading = true,
|
||||||
loading,
|
loading,
|
||||||
@ -612,7 +631,16 @@ const AsyncSelect = forwardRef(
|
|||||||
if (isLoading && fullSelectOptions.length === 0) {
|
if (isLoading && fullSelectOptions.length === 0) {
|
||||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
||||||
}
|
}
|
||||||
return error ? <Error error={error} /> : originNode;
|
return error ? (
|
||||||
|
<Error error={error} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{helperText && (
|
||||||
|
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
||||||
|
)}
|
||||||
|
{originNode}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// use a function instead of component since every rerender of the
|
// use a function instead of component since every rerender of the
|
||||||
|
@ -535,6 +535,20 @@ test('triggers getPopupContainer if passed', async () => {
|
|||||||
expect(getPopupContainer).toHaveBeenCalled();
|
expect(getPopupContainer).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not render a helper text by default', async () => {
|
||||||
|
render(<Select {...defaultProps} />);
|
||||||
|
await open();
|
||||||
|
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders a helper text when one is provided', async () => {
|
||||||
|
const helperText = 'Helper text';
|
||||||
|
render(<Select {...defaultProps} helperText={helperText} />);
|
||||||
|
await open();
|
||||||
|
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(helperText)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
TODO: Add tests that require scroll interaction. Needs further investigation.
|
TODO: Add tests that require scroll interaction. Needs further investigation.
|
||||||
- Fetches more data when scrolling and more data is available
|
- Fetches more data when scrolling and more data is available
|
||||||
|
@ -84,6 +84,11 @@ export interface SelectProps extends PickedSelectProps {
|
|||||||
* Can be any ReactNode.
|
* Can be any ReactNode.
|
||||||
*/
|
*/
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
|
/**
|
||||||
|
* It adds a helper text on top of the Select options
|
||||||
|
* with additional context to help with the interaction.
|
||||||
|
*/
|
||||||
|
helperText?: string;
|
||||||
/**
|
/**
|
||||||
* It defines whether the Select should allow for the
|
* It defines whether the Select should allow for the
|
||||||
* selection of multiple options or single.
|
* selection of multiple options or single.
|
||||||
@ -139,6 +144,9 @@ const StyledSelect = styled(AntdSelect)`
|
|||||||
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
.ant-select-dropdown {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@ -162,6 +170,16 @@ const StyledLoadingText = styled.div`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledHelperText = styled.div`
|
||||||
|
${({ theme }) => `
|
||||||
|
padding: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px;
|
||||||
|
color: ${theme.colors.grayscale.base};
|
||||||
|
font-size: ${theme.typography.sizes.s}px;
|
||||||
|
cursor: default;
|
||||||
|
border-bottom: 1px solid ${theme.colors.grayscale.light2};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
const MAX_TAG_COUNT = 4;
|
const MAX_TAG_COUNT = 4;
|
||||||
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
||||||
const EMPTY_OPTIONS: OptionsType = [];
|
const EMPTY_OPTIONS: OptionsType = [];
|
||||||
@ -224,6 +242,7 @@ const Select = forwardRef(
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
filterOption = true,
|
filterOption = true,
|
||||||
header = null,
|
header = null,
|
||||||
|
helperText,
|
||||||
invertSelection = false,
|
invertSelection = false,
|
||||||
labelInValue = false,
|
labelInValue = false,
|
||||||
loading,
|
loading,
|
||||||
@ -401,7 +420,14 @@ const Select = forwardRef(
|
|||||||
if (isLoading && fullSelectOptions.length === 0) {
|
if (isLoading && fullSelectOptions.length === 0) {
|
||||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
||||||
}
|
}
|
||||||
return originNode;
|
return (
|
||||||
|
<>
|
||||||
|
{helperText && (
|
||||||
|
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
||||||
|
)}
|
||||||
|
{originNode}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// use a function instead of component since every rerender of the
|
// use a function instead of component since every rerender of the
|
||||||
|
Loading…
Reference in New Issue
Block a user