feat: Select all for synchronous select (#22084)

Co-authored-by: GITHUB_USERNAME <EMAIL>
This commit is contained in:
cccs-RyanK 2023-01-18 07:41:58 -05:00 committed by GitHub
parent ad758c0802
commit 02c9242d68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 429 additions and 50 deletions

View File

@ -404,9 +404,9 @@ describe('Horizontal FilterBar', () => {
saveNativeFilterSettings([SAMPLE_CHART]);
cy.getBySel('filter-bar').within(() => {
cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible');
cy.get(nativeFilters.filterItem).contains('+1').should('be.visible');
cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible');
cy.get('.ant-select-selection-search-input').click();
cy.get(nativeFilters.filterItem).contains('+2').should('be.visible');
cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible');
});
});
});

View File

@ -71,7 +71,7 @@ import {
TOKEN_SEPARATORS,
DEFAULT_SORT_COMPARATOR,
} from './constants';
import { oneLineTagRender } from './CustomTag';
import { customTagRender } from './CustomTag';
const Error = ({ error }: { error: string }) => (
<StyledError>
@ -517,7 +517,7 @@ const AsyncSelect = forwardRef(
)
}
oneLine={oneLine}
tagRender={oneLine ? oneLineTagRender : undefined}
tagRender={customTagRender}
{...props}
ref={ref}
>

View File

@ -22,6 +22,8 @@ import { styled } from '@superset-ui/core';
import { useCSSTextTruncation } from 'src/hooks/useTruncation';
import { Tooltip } from '../Tooltip';
import { CustomTagProps } from './types';
import { SELECT_ALL_VALUE } from './utils';
import { NoElement } from './styles';
const StyledTag = styled(AntdTag)`
& .ant-tag-close-icon {
@ -51,10 +53,10 @@ const Tag = (props: any) => {
};
/**
* Custom tag renderer dedicated for oneLine mode
* Custom tag renderer
*/
export const oneLineTagRender = (props: CustomTagProps) => {
const { label } = props;
export const customTagRender = (props: CustomTagProps) => {
const { label, value } = props;
const onPreventMouseDown = (event: React.MouseEvent<HTMLElement>) => {
// if close icon is clicked, stop propagation to avoid opening the dropdown
@ -69,9 +71,12 @@ export const oneLineTagRender = (props: CustomTagProps) => {
}
};
return (
<Tag onMouseDown={onPreventMouseDown} {...props}>
{label}
</Tag>
);
if (value !== SELECT_ALL_VALUE) {
return (
<Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
{label}
</Tag>
);
}
return <NoElement />;
};

View File

@ -141,6 +141,13 @@ const ARG_TYPES = {
Requires '"mode=multiple"'.
`,
},
maxTagCount: {
defaultValue: 4,
description: `Sets maxTagCount attribute. The overflow tag is displayed in
place of the remaining items.
Requires '"mode=multiple"'.
`,
},
};
const mountHeader = (type: String) => {
@ -207,6 +214,7 @@ InteractiveSelect.args = {
placeholder: 'Select ...',
optionFilterProps: ['value', 'label', 'custom'],
oneLine: false,
maxTagCount: 4,
};
InteractiveSelect.argTypes = {

View File

@ -19,7 +19,8 @@
import React from 'react';
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { Select } from 'src/components';
import Select from 'src/components/Select/Select';
import { SELECT_ALL_VALUE } from './utils';
const ARIA_LABEL = 'Test';
const NEW_OPTION = 'Kyle';
@ -64,6 +65,9 @@ const defaultProps = {
showSearch: true,
};
const selectAllOptionLabel = (numOptions: number) =>
`${String(SELECT_ALL_VALUE)} (${numOptions})`;
const getElementByClassName = (className: string) =>
document.querySelector(className)! as HTMLElement;
@ -89,7 +93,12 @@ const findSelectValue = () =>
waitFor(() => getElementByClassName('.ant-select-selection-item'));
const findAllSelectValues = () =>
waitFor(() => getElementsByClassName('.ant-select-selection-item'));
waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]);
const findAllCheckedValues = () =>
waitFor(() => [
...getElementsByClassName('.ant-select-item-option-selected'),
]);
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
@ -209,26 +218,37 @@ test('should sort selected to the top when in multi mode', async () => {
let labels = originalLabels.slice();
await open();
userEvent.click(await findSelectOption(labels[1]));
expect(await matchOrder(labels)).toBe(true);
userEvent.click(await findSelectOption(labels[2]));
expect(
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
).toBe(true);
await type('{esc}');
await open();
labels = labels.splice(1, 1).concat(labels);
expect(await matchOrder(labels)).toBe(true);
labels = labels.splice(2, 1).concat(labels);
expect(
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
).toBe(true);
await open();
userEvent.click(await findSelectOption(labels[5]));
await type('{esc}');
await open();
labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels);
expect(await matchOrder(labels)).toBe(true);
expect(
await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
).toBe(true);
// should revert to original order
clearAll();
await type('{esc}');
await open();
expect(await matchOrder(originalLabels)).toBe(true);
expect(
await matchOrder([
selectAllOptionLabel(originalLabels.length),
...originalLabels,
]),
).toBe(true);
});
test('searches for label or value', async () => {
@ -440,7 +460,7 @@ test('changes the selected item in single mode', async () => {
label: firstOption.label,
value: firstOption.value,
}),
firstOption,
expect.objectContaining(firstOption),
);
userEvent.click(await findSelectOption(secondOption.label));
expect(onChange).toHaveBeenCalledWith(
@ -448,7 +468,7 @@ test('changes the selected item in single mode', async () => {
label: secondOption.label,
value: secondOption.value,
}),
secondOption,
expect.objectContaining(secondOption),
);
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
});
@ -566,6 +586,136 @@ test('finds an element with a numeric value and does not duplicate the options',
expect(await querySelectOption('11')).not.toBeInTheDocument();
});
test('render "Select all" for multi select', async () => {
render(<Select {...defaultProps} mode="multiple" options={OPTIONS} />);
await open();
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
});
test('does not render "Select all" for single select', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="single" />);
await open();
expect(
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
).not.toBeInTheDocument();
});
test('does not render "Select all" for an empty multiple select', async () => {
render(<Select {...defaultProps} options={[]} mode="multiple" />);
await open();
expect(
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
).not.toBeInTheDocument();
});
test('does not render "Select all" when searching', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
await open();
await type('Select');
expect(
screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
).not.toBeInTheDocument();
});
test('does not render "Select all" as one of the tags after selection', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
const values = await findAllSelectValues();
expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
});
test('keeps "Select all" at the top after a selection', async () => {
const selected = OPTIONS[2];
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
value={[selected]}
/>,
);
await open();
const options = await findAllSelectOptions();
expect(options[0]).toHaveTextContent(selectAllOptionLabel(10));
expect(options[1]).toHaveTextContent(selected.label);
});
test('selects all values', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
const values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
});
test('unselects all values', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
let values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
values = await findAllSelectValues();
expect(values.length).toBe(0);
});
test('deselecting a value also deselects "Select all"', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
let values = await findAllCheckedValues();
expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
userEvent.click(await findSelectOption(OPTIONS[0].label));
values = await findAllCheckedValues();
expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(10));
});
test('selecting all values also selects "Select all"', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
const options = await findAllSelectOptions();
options.forEach((option, index) => {
// skip select all
if (index > 0) {
userEvent.click(option);
}
});
const values = await findAllSelectValues();
expect(values[0]).toHaveTextContent(`+ 10 ...`);
});
test('Renders only 1 tag and an overflow tag in oneLine mode', () => {
render(
<Select
@ -614,6 +764,61 @@ test('Renders only an overflow tag if dropdown is open in oneLine mode', async (
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
test('+N tag does not count the "Select All" option', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
const values = await findAllSelectValues();
// maxTagCount is 0 so the +N tag should be + 10 ...
expect(values[0]).toHaveTextContent('+ 10 ...');
});
test('"Select All" is checked when unchecking a newly added option and all the other options are still selected', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
allowNewOptions
/>,
);
await open();
userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
// add a new option
await type(`${NEW_OPTION}{enter}`);
expect(await findSelectOption(selectAllOptionLabel(11))).toBeInTheDocument();
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
// select all should be selected
let values = await findAllCheckedValues();
expect(values[0]).toHaveTextContent(selectAllOptionLabel(11));
// remove new option
userEvent.click(await findSelectOption(NEW_OPTION));
// select all should still be selected
values = await findAllCheckedValues();
expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
});
test('does not render "Select All" when there are 0 or 1 options', async () => {
render(
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
);
await open();
expect(screen.queryByText(selectAllOptionLabel(0))).not.toBeInTheDocument();
await type(`${NEW_OPTION}{enter}`);
expect(screen.queryByText(selectAllOptionLabel(1))).not.toBeInTheDocument();
await type(`Kyle2{enter}`);
expect(screen.queryByText(selectAllOptionLabel(2))).toBeInTheDocument();
});
/*
TODO: Add tests that require scroll interaction. Needs further investigation.
- Fetches more data when scrolling and more data is available

View File

@ -25,20 +25,26 @@ import React, {
useState,
useCallback,
} from 'react';
import { ensureIsArray, t } from '@superset-ui/core';
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
import {
ensureIsArray,
formatNumber,
NumberFormats,
t,
} from '@superset-ui/core';
import AntdSelect, { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
import { isEqual } from 'lodash';
import {
getValue,
hasOption,
isLabeledValue,
renderSelectOptions,
hasCustomLabels,
sortSelectedFirstHelper,
sortComparatorWithSearchHelper,
handleFilterOptionHelper,
dropDownRenderHelper,
getSuffixIcon,
SELECT_ALL_VALUE,
selectAllOption,
} from './utils';
import { SelectOptionsType, SelectProps } from './types';
import {
@ -54,7 +60,7 @@ import {
TOKEN_SEPARATORS,
DEFAULT_SORT_COMPARATOR,
} from './constants';
import { oneLineTagRender } from './CustomTag';
import { customTagRender } from './CustomTag';
/**
* This component is a customized version of the Antdesign 4.X Select component
@ -125,6 +131,8 @@ const Select = forwardRef(
? 'tags'
: 'multiple';
const { Option } = AntdSelect;
const sortSelectedFirst = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirstHelper(a, b, selectValue),
@ -162,11 +170,23 @@ const Select = forwardRef(
.map(opt =>
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
);
return missingValues.length > 0
? missingValues.concat(selectOptions)
: selectOptions;
const result =
missingValues.length > 0
? missingValues.concat(selectOptions)
: selectOptions;
return result.filter(opt => opt.value !== SELECT_ALL_VALUE);
}, [selectOptions, selectValue]);
const selectAllEnabled = useMemo(
() => !isSingleMode && fullSelectOptions.length > 1 && !inputValue,
[fullSelectOptions, isSingleMode, inputValue],
);
const selectAllMode = useMemo(
() => ensureIsArray(selectValue).length === fullSelectOptions.length + 1,
[selectValue, fullSelectOptions],
);
const handleOnSelect = (
selectedItem: string | number | AntdLabeledValue | undefined,
) => {
@ -177,11 +197,29 @@ const Select = forwardRef(
const array = ensureIsArray(previousState);
const value = getValue(selectedItem);
// Tokenized values can contain duplicated values
if (value === getValue(SELECT_ALL_VALUE)) {
if (isLabeledValue(selectedItem)) {
return [
...fullSelectOptions,
selectAllOption,
] as AntdLabeledValue[];
}
return [
SELECT_ALL_VALUE,
...fullSelectOptions.map(opt => opt.value),
] as AntdLabeledValue[];
}
if (!hasOption(value, array)) {
const result = [...array, selectedItem];
return isLabeledValue(selectedItem)
? (result as AntdLabeledValue[])
: (result as (string | number)[]);
if (
result.length === fullSelectOptions.length &&
selectAllEnabled
) {
return isLabeledValue(selectedItem)
? ([...result, selectAllOption] as AntdLabeledValue[])
: ([...result, SELECT_ALL_VALUE] as (string | number)[]);
}
return result as AntdLabeledValue[];
}
return previousState;
});
@ -193,14 +231,23 @@ const Select = forwardRef(
value: string | number | AntdLabeledValue | undefined,
) => {
if (Array.isArray(selectValue)) {
if (isLabeledValue(value)) {
const array = selectValue as AntdLabeledValue[];
setSelectValue(
array.filter(element => element.value !== value.value),
);
if (getValue(value) === getValue(SELECT_ALL_VALUE)) {
setSelectValue(undefined);
} else {
const array = selectValue as (string | number)[];
setSelectValue(array.filter(element => element !== value));
let array = selectValue as AntdLabeledValue[];
array = array.filter(
element => getValue(element) !== getValue(value),
);
// if this was not a new item, deselect select all option
if (
selectAllMode &&
selectOptions.some(opt => opt.value === getValue(value))
) {
array = array.filter(
element => getValue(element) !== SELECT_ALL_VALUE,
);
}
setSelectValue(array);
}
}
setInputValue('');
@ -215,7 +262,7 @@ const Select = forwardRef(
value: searchValue,
isNewOption: true,
};
const cleanSelectOptions = fullSelectOptions.filter(
const cleanSelectOptions = ensureIsArray(fullSelectOptions).filter(
opt => !opt.isNewOption || hasOption(opt.value, selectValue),
);
const newOptions = newOption
@ -277,6 +324,97 @@ const Select = forwardRef(
setSelectValue(value);
}, [value]);
useEffect(() => {
// if all values are selected, add select all to value
if (
!isSingleMode &&
ensureIsArray(value).length === fullSelectOptions.length &&
fullSelectOptions.length > 0
) {
setSelectValue(
labelInValue
? ([...ensureIsArray(value), selectAllOption] as AntdLabeledValue[])
: ([
...ensureIsArray(value),
SELECT_ALL_VALUE,
] as AntdLabeledValue[]),
);
}
}, [value, isSingleMode, labelInValue, fullSelectOptions.length]);
useEffect(() => {
const checkSelectAll = ensureIsArray(selectValue).some(
v => getValue(v) === SELECT_ALL_VALUE,
);
if (checkSelectAll && !selectAllMode) {
setSelectValue(
labelInValue
? ([...fullSelectOptions, selectAllOption] as AntdLabeledValue[])
: ([...fullSelectOptions, SELECT_ALL_VALUE] as AntdLabeledValue[]),
);
}
}, [selectValue, selectAllMode, labelInValue, fullSelectOptions]);
const selectAllLabel = useMemo(
() => () =>
`${SELECT_ALL_VALUE} (${formatNumber(
NumberFormats.INTEGER,
fullSelectOptions.length,
)})`,
[fullSelectOptions.length],
);
const handleOnChange = (values: any, options: any) => {
// intercept onChange call to handle the select all case
// if the "select all" option is selected, we want to send all options to the onChange,
// otherwise we want to remove
let newValues = values;
let newOptions = options;
if (!isSingleMode) {
if (
ensureIsArray(newValues).some(
val => getValue(val) === SELECT_ALL_VALUE,
)
) {
// send all options to onchange if all are not currently there
if (!selectAllMode) {
newValues = labelInValue
? fullSelectOptions.map(opt => ({
key: opt.value,
value: opt.value,
label: opt.label,
}))
: fullSelectOptions.map(opt => opt.value);
newOptions = fullSelectOptions.map(opt => ({
children: opt.label,
key: opt.value,
value: opt.value,
label: opt.label,
}));
} else {
newValues = ensureIsArray(values).filter(
(val: any) => getValue(val) !== SELECT_ALL_VALUE,
);
}
} else if (
ensureIsArray(values).length === fullSelectOptions.length &&
selectAllMode
) {
newValues = [];
newValues = [];
}
}
onChange?.(newValues, newOptions);
};
const customMaxTagPlaceholder = () => {
const num_selected = ensureIsArray(selectValue).length;
const num_shown = maxTagCount as number;
return selectAllMode
? `+ ${num_selected - num_shown - 1} ...`
: `+ ${num_selected - num_shown} ...`;
};
return (
<StyledContainer headerPosition={headerPosition}>
{header && (
@ -294,6 +432,7 @@ const Select = forwardRef(
headerPosition={headerPosition}
labelInValue={labelInValue}
maxTagCount={maxTagCount}
maxTagPlaceholder={customMaxTagPlaceholder}
mode={mappedMode}
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
onDeselect={handleOnDeselect}
@ -302,8 +441,7 @@ const Select = forwardRef(
onSearch={shouldShowSearch ? handleOnSearch : undefined}
onSelect={handleOnSelect}
onClear={handleClear}
onChange={onChange}
options={hasCustomLabels(options) ? undefined : fullSelectOptions}
onChange={handleOnChange}
placeholder={placeholder}
showSearch={shouldShowSearch}
showArrow
@ -322,11 +460,20 @@ const Select = forwardRef(
)
}
oneLine={oneLine}
tagRender={oneLine ? oneLineTagRender : undefined}
tagRender={customTagRender}
{...props}
ref={ref}
>
{hasCustomLabels(options) && renderSelectOptions(fullSelectOptions)}
{selectAllEnabled && (
<Option
id="select-all"
key={SELECT_ALL_VALUE}
value={SELECT_ALL_VALUE}
>
{selectAllLabel()}
</Option>
)}
{renderSelectOptions(fullSelectOptions)}
</StyledSelect>
</StyledContainer>
);

View File

@ -18,7 +18,7 @@
*/
import { styled } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { Spin } from 'antd';
import { Spin, Tag } from 'antd';
import AntdSelect from 'antd/lib/select';
export const StyledHeader = styled.span<{ headerPosition: string }>`
@ -74,6 +74,18 @@ export const StyledSelect = styled(AntdSelect, {
`}
`;
export const NoElement = styled.span`
display: none;
`;
export const StyledTag = styled(Tag)`
${({ theme }) => `
background: ${theme.colors.grayscale.light3};
font-size: ${theme.typography.sizes.m}px;
border: none;
`}
`;
export const StyledStopOutlined = styled(Icons.StopOutlined)`
vertical-align: 0;
`;

View File

@ -158,8 +158,6 @@ export interface SelectProps extends BaseSelectProps {
/**
* It defines the options of the Select.
* The options can be static, an array of options.
* The options can also be async, a promise that returns
* an array of options.
*/
options: SelectOptionsType;
}
@ -215,4 +213,5 @@ export interface AsyncSelectProps extends BaseSelectProps {
export type CustomTagProps = HTMLSpanElement &
TagProps & {
label: ReactNode;
value: string;
};

View File

@ -25,6 +25,12 @@ import { LabeledValue, RawValue, SelectOptionsType, V } from './types';
const { Option } = AntdSelect;
export const SELECT_ALL_VALUE: RawValue = 'Select All';
export const selectAllOption = {
value: SELECT_ALL_VALUE,
label: String(SELECT_ALL_VALUE),
};
export function isObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&

View File

@ -333,9 +333,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
// @ts-ignore
options={options}
sortComparator={sortComparator}
maxTagPlaceholder={(val: AntdLabeledValue[]) => (
<span>+{val.length}</span>
)}
onDropdownVisibleChange={setFilterActive}
/>
</StyledFormItem>