feat(select): sort exact and startsWith match to first (#18856)

This commit is contained in:
Jesse Yang 2022-03-08 07:08:40 -08:00 committed by GitHub
parent 04a36d5c92
commit c75f233109
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 315 additions and 233 deletions

View File

@ -18,7 +18,7 @@
*/
import React, { ReactNode, useState, useCallback } from 'react';
import ControlHeader from 'src/explore/components/ControlHeader';
import Select, { SelectProps, OptionsTypePage } from './Select';
import Select, { SelectProps, OptionsTypePage, OptionsType } from './Select';
export default {
title: 'Select',
@ -27,7 +27,7 @@ export default {
const DEFAULT_WIDTH = 200;
const options = [
const options: OptionsType = [
{
label: 'Such an incredibly awesome long long label',
value: 'Such an incredibly awesome long long label',
@ -147,13 +147,42 @@ const mountHeader = (type: String) => {
return header;
};
export const InteractiveSelect = (args: SelectProps & { header: string }) => (
const generateOptions = (opts: OptionsType, count: number) => {
let generated = opts.slice();
let iteration = 0;
while (generated.length < count) {
iteration += 1;
generated = generated.concat(
// eslint-disable-next-line no-loop-func
generated.map(({ label, value }) => ({
label: `${label} ${iteration}`,
value: `${value} ${iteration}`,
})),
);
}
return generated.slice(0, count);
};
export const InteractiveSelect = ({
header,
options,
optionsCount,
...args
}: SelectProps & { header: string; optionsCount: number }) => (
<div
style={{
width: DEFAULT_WIDTH,
}}
>
<Select {...args} header={mountHeader(args.header)} />
<Select
{...args}
options={
Array.isArray(options)
? generateOptions(options, optionsCount)
: options
}
header={mountHeader(header)}
/>
</div>
);
@ -170,6 +199,12 @@ InteractiveSelect.args = {
InteractiveSelect.argTypes = {
...ARG_TYPES,
optionsCount: {
defaultValue: options.length,
control: {
type: 'number',
},
},
header: {
defaultValue: 'none',
description: `It adds a header on top of the Select. Can be any ReactNode.`,

View File

@ -46,6 +46,9 @@ const OPTIONS = [
{ label: 'Irfan', value: 18, gender: 'Male' },
{ label: 'George', value: 19, gender: 'Male' },
{ label: 'Ashfaq', value: 20, gender: 'Male' },
{ label: 'Herme', value: 21, gender: 'Male' },
{ label: 'Cher', value: 22, gender: 'Female' },
{ label: 'Her', value: 23, gender: 'Male' },
].sort((option1, option2) => option1.label.localeCompare(option2.label));
const loadOptions = async (search: string, page: number, pageSize: number) => {
@ -111,9 +114,21 @@ test('displays a header', async () => {
});
test('adds a new option if the value is not in the options', async () => {
render(<Select {...defaultProps} options={[]} value={OPTIONS[0]} />);
const { rerender } = render(
<Select {...defaultProps} options={[]} value={OPTIONS[0]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
rerender(
<Select {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
);
await open();
const options = await findAllSelectOptions();
expect(options).toHaveLength(2);
options.forEach((option, i) =>
expect(option).toHaveTextContent(OPTIONS[i].label),
);
});
test('inverts the selection', async () => {
@ -149,9 +164,9 @@ test('sort the options using a custom sort comparator', async () => {
const options = await findAllSelectOptions();
const optionsPage = OPTIONS.slice(0, defaultProps.pageSize);
const sortedOptions = optionsPage.sort(sortComparator);
options.forEach((option, key) =>
expect(option).toHaveTextContent(sortedOptions[key].label),
);
options.forEach((option, key) => {
expect(option).toHaveTextContent(sortedOptions[key].label);
});
});
test('displays the selected values first', async () => {
@ -194,12 +209,44 @@ test('searches for label or value', async () => {
expect(options[0]).toHaveTextContent(option.label);
});
test('search order exact and startWith match first', async () => {
render(<Select {...defaultProps} />);
await type('Her');
const options = await findAllSelectOptions();
expect(options.length).toBe(4);
expect(options[0]?.textContent).toEqual('Her');
expect(options[1]?.textContent).toEqual('Herme');
expect(options[2]?.textContent).toEqual('Cher');
expect(options[3]?.textContent).toEqual('Guilherme');
});
test('ignores case when searching', async () => {
render(<Select {...defaultProps} />);
await type('george');
expect(await findSelectOption('George')).toBeInTheDocument();
});
test('same case should be ranked to the top', async () => {
render(
<Select
{...defaultProps}
options={[
{ value: 'Cac' },
{ value: 'abac' },
{ value: 'acbc' },
{ value: 'CAc' },
]}
/>,
);
await type('Ac');
const options = await findAllSelectOptions();
expect(options.length).toBe(4);
expect(options[0]?.textContent).toEqual('acbc');
expect(options[1]?.textContent).toEqual('CAc');
expect(options[2]?.textContent).toEqual('abac');
expect(options[3]?.textContent).toEqual('Cac');
});
test('ignores special keys when searching', async () => {
render(<Select {...defaultProps} />);
await type('{shift}');
@ -214,12 +261,13 @@ test('searches for custom fields', async () => {
expect(options[0]).toHaveTextContent('Liam');
await type('Female');
options = await findAllSelectOptions();
expect(options.length).toBe(5);
expect(options.length).toBe(6);
expect(options[0]).toHaveTextContent('Ava');
expect(options[1]).toHaveTextContent('Charlotte');
expect(options[2]).toHaveTextContent('Emma');
expect(options[3]).toHaveTextContent('Nikole');
expect(options[4]).toHaveTextContent('Olivia');
expect(options[2]).toHaveTextContent('Cher');
expect(options[3]).toHaveTextContent('Emma');
expect(options[4]).toHaveTextContent('Nikole');
expect(options[5]).toHaveTextContent('Olivia');
await type('1');
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
});

View File

@ -28,7 +28,7 @@ import React, {
useRef,
useCallback,
} from 'react';
import { styled, t } from '@superset-ui/core';
import { ensureIsArray, styled, t } from '@superset-ui/core';
import AntdSelect, {
SelectProps as AntdSelectProps,
SelectValue as AntdSelectValue,
@ -41,7 +41,8 @@ import { Spin } from 'antd';
import Icons from 'src/components/Icons';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { SLOW_DEBOUNCE } from 'src/constants';
import { hasOption, hasOptionIgnoreCase } from './utils';
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
import { getValue, hasOption } from './utils';
const { Option } = AntdSelect;
@ -155,7 +156,7 @@ export interface SelectProps extends PickedSelectProps {
* Works in async mode only (See the options property).
*/
onError?: (error: string) => void;
sortComparator?: (a: AntdLabeledValue, b: AntdLabeledValue) => number;
sortComparator?: typeof DEFAULT_SORT_COMPARATOR;
}
const StyledContainer = styled.div`
@ -227,12 +228,26 @@ const Error = ({ error }: { error: string }) => (
</StyledError>
);
const defaultSortComparator = (a: AntdLabeledValue, b: AntdLabeledValue) => {
export const DEFAULT_SORT_COMPARATOR = (
a: AntdLabeledValue,
b: AntdLabeledValue,
search?: string,
) => {
let aText: string | undefined;
let bText: string | undefined;
if (typeof a.label === 'string' && typeof b.label === 'string') {
return a.label.localeCompare(b.label);
aText = a.label;
bText = b.label;
} else if (typeof a.value === 'string' && typeof b.value === 'string') {
aText = a.value;
bText = b.value;
}
if (typeof a.value === 'string' && typeof b.value === 'string') {
return a.value.localeCompare(b.value);
// sort selected options first
if (typeof aText === 'string' && typeof bText === 'string') {
if (search) {
return rankedSearchCompare(aText, bText, search);
}
return aText.localeCompare(bText);
}
return (a.value as number) - (b.value as number);
};
@ -289,7 +304,7 @@ const Select = (
pageSize = DEFAULT_PAGE_SIZE,
placeholder = t('Select ...'),
showSearch = true,
sortComparator = defaultSortComparator,
sortComparator = DEFAULT_SORT_COMPARATOR,
value,
...props
}: SelectProps,
@ -299,15 +314,9 @@ const Select = (
const isSingleMode = mode === 'single';
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
const initialOptions =
options && Array.isArray(options) ? options : EMPTY_OPTIONS;
const [selectOptions, setSelectOptions] = useState<OptionsType>(
initialOptions.sort(sortComparator),
);
const shouldUseChildrenOptions = !!selectOptions.find(
opt => opt?.customLabel,
);
options && Array.isArray(options) ? options.slice() : EMPTY_OPTIONS;
const [selectValue, setSelectValue] = useState(value);
const [searchedValue, setSearchedValue] = useState('');
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(loading);
const [error, setError] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
@ -321,81 +330,41 @@ const Select = (
: allowNewOptions
? 'tags'
: 'multiple';
const allowFetch = !fetchOnlyOnSearch || searchedValue;
const allowFetch = !fetchOnlyOnSearch || inputValue;
// TODO: Don't assume that isAsync is always labelInValue
const handleTopOptions = useCallback(
(selectedValue: AntdSelectValue | undefined) => {
// bringing selected options to the top of the list
if (selectedValue !== undefined && selectedValue !== null) {
const isLabeledValue = isAsync || labelInValue;
const topOptions: OptionsType = [];
const otherOptions: OptionsType = [];
selectOptions.forEach(opt => {
let found = false;
if (Array.isArray(selectedValue)) {
if (isLabeledValue) {
found =
(selectedValue as AntdLabeledValue[]).find(
element => element.value === opt.value,
) !== undefined;
} else {
found = selectedValue.includes(opt.value);
}
} else {
found = isLabeledValue
? (selectedValue as AntdLabeledValue).value === opt.value
: selectedValue === opt.value;
}
if (found) {
topOptions.push(opt);
} else {
otherOptions.push(opt);
}
});
// fallback for custom options in tags mode as they
// do not appear in the selectOptions state
if (!isSingleMode && Array.isArray(selectedValue)) {
selectedValue.forEach((val: string | number | AntdLabeledValue) => {
if (
!topOptions.find(
tOpt =>
tOpt.value ===
(isLabeledValue ? (val as AntdLabeledValue)?.value : val),
)
) {
if (isLabeledValue) {
const labelValue = val as AntdLabeledValue;
topOptions.push({
label: labelValue.label,
value: labelValue.value,
});
} else {
const value = val as string | number;
topOptions.push({ label: String(value), value });
}
}
});
}
const sortedOptions = [
...topOptions.sort(sortComparator),
...otherOptions.sort(sortComparator),
];
if (!isEqual(sortedOptions, selectOptions)) {
setSelectOptions(sortedOptions);
}
} else {
const sortedOptions = [...selectOptions].sort(sortComparator);
if (!isEqual(sortedOptions, selectOptions)) {
setSelectOptions(sortedOptions);
}
}
},
[isAsync, isSingleMode, labelInValue, selectOptions, sortComparator],
const sortSelectedFirst = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
selectValue && a.value !== undefined && b.value !== undefined
? Number(hasOption(b.value, selectValue)) -
Number(hasOption(a.value, selectValue))
: 0,
[selectValue],
);
const sortComparatorWithSearch = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
[inputValue, sortComparator, sortSelectedFirst],
);
const sortComparatorWithoutSearch = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirst(a, b) || sortComparator(a, b, ''),
[sortComparator, sortSelectedFirst],
);
const [selectOptions, setSelectOptions] =
useState<OptionsType>(initialOptions);
// add selected values to options list if they are not in it
const fullSelectOptions = useMemo(() => {
const missingValues: OptionsType = ensureIsArray(selectValue)
.filter(opt => !hasOption(getValue(opt), selectOptions))
.map(opt =>
typeof opt === 'object' ? opt : { value: opt, label: String(opt) },
);
return missingValues.length > 0
? missingValues.concat(selectOptions)
: selectOptions;
}, [selectOptions, selectValue]);
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
const handleOnSelect = (
selectedValue: string | number | AntdLabeledValue,
@ -423,7 +392,7 @@ const Select = (
]);
}
}
setSearchedValue('');
setInputValue('');
};
const handleOnDeselect = (value: string | number | AntdLabeledValue) => {
@ -436,7 +405,7 @@ const Select = (
setSelectValue(array.filter(element => element.value !== value.value));
}
}
setSearchedValue('');
setInputValue('');
};
const internalOnError = useCallback(
@ -452,42 +421,34 @@ const Select = (
[onError],
);
const handleData = useCallback(
const mergeData = useCallback(
(data: OptionsType) => {
let mergedData: OptionsType = [];
if (data && Array.isArray(data) && data.length) {
const dataValues = new Set();
data.forEach(option =>
dataValues.add(String(option.value).toLocaleLowerCase()),
);
// unique option values should always be case sensitive so don't lowercase
const dataValues = new Set(data.map(opt => opt.value));
// merges with existing and creates unique options
setSelectOptions(prevOptions => {
mergedData = [
...prevOptions.filter(
previousOption =>
!dataValues.has(
String(previousOption.value).toLocaleLowerCase(),
),
),
...data,
];
mergedData.sort(sortComparator);
mergedData = prevOptions
.filter(previousOption => !dataValues.has(previousOption.value))
.concat(data)
.sort(sortComparatorWithoutSearch);
return mergedData;
});
}
return mergedData;
},
[sortComparator],
[sortComparatorWithoutSearch],
);
const handlePaginatedFetch = useMemo(
() => (value: string, page: number) => {
const fetchPage = useMemo(
() => (search: string, page: number) => {
setPage(page);
if (allValuesLoaded) {
setIsLoading(false);
return;
}
const key = getQueryCacheKey(value, page, pageSize);
const key = getQueryCacheKey(search, page, pageSize);
const cachedCount = fetchedQueries.current.get(key);
if (cachedCount !== undefined) {
setTotalCount(cachedCount);
@ -496,9 +457,9 @@ const Select = (
}
setIsLoading(true);
const fetchOptions = options as OptionsPagePromise;
fetchOptions(value, page, pageSize)
fetchOptions(search, page, pageSize)
.then(({ data, totalCount }: OptionsTypePage) => {
const mergedData = handleData(data);
const mergedData = mergeData(data);
fetchedQueries.current.set(key, totalCount);
setTotalCount(totalCount);
if (
@ -517,32 +478,29 @@ const Select = (
[
allValuesLoaded,
fetchOnlyOnSearch,
handleData,
mergeData,
internalOnError,
options,
pageSize,
value,
],
);
const debouncedHandleSearch = useMemo(
() =>
debounce((search: string) => {
// async search will be triggered in handlePaginatedFetch
setSearchedValue(search);
}, SLOW_DEBOUNCE),
[],
const debouncedFetchPage = useMemo(
() => debounce(fetchPage, SLOW_DEBOUNCE),
[fetchPage],
);
const handleOnSearch = (search: string) => {
const searchValue = search.trim();
if (allowNewOptions && isSingleMode) {
const newOption = searchValue &&
!hasOptionIgnoreCase(searchValue, selectOptions) && {
!hasOption(searchValue, fullSelectOptions, true) && {
label: searchValue,
value: searchValue,
isNewOption: true,
};
const cleanSelectOptions = selectOptions.filter(
const cleanSelectOptions = fullSelectOptions.filter(
opt => !opt.isNewOption || hasOption(opt.value, selectValue),
);
const newOptions = newOption
@ -560,7 +518,7 @@ const Select = (
// in loading state
setIsLoading(!(fetchOnlyOnSearch && !searchValue));
}
return debouncedHandleSearch(search);
setInputValue(search);
};
const handlePagination = (e: UIEvent<HTMLElement>) => {
@ -571,8 +529,7 @@ const Select = (
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
const newPage = page + 1;
handlePaginatedFetch(searchedValue, newPage);
setPage(newPage);
fetchPage(inputValue, newPage);
}
};
@ -583,7 +540,6 @@ const Select = (
if (filterOption) {
const searchValue = search.trim().toLowerCase();
if (optionFilterProps && optionFilterProps.length) {
return optionFilterProps.some(prop => {
const optionProp = option?.[prop]
@ -616,11 +572,15 @@ const Select = (
}, 250);
}
}
// multiple or tags mode keep the dropdown visible while selecting options
// this waits for the dropdown to be opened before sorting the top options
if (!isSingleMode && isDropdownVisible) {
handleTopOptions(selectValue);
// if no search input value, force sort options because it won't be sorted by
// `filterSort`.
if (isDropdownVisible && !inputValue && fullSelectOptions.length > 0) {
const sortedOptions = [...fullSelectOptions].sort(
sortComparatorWithSearch,
);
if (!isEqual(sortedOptions, fullSelectOptions)) {
setSelectOptions(sortedOptions);
}
}
if (onDropdownVisibleChange) {
@ -634,7 +594,7 @@ const Select = (
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
if (isLoading && selectOptions.length === 0) {
if (isLoading && fullSelectOptions.length === 0) {
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
}
return error ? <Error error={error} /> : originNode;
@ -660,76 +620,44 @@ const Select = (
};
useEffect(() => {
// when `options` list is updated from component prop, reset states
fetchedQueries.current.clear();
setAllValuesLoaded(false);
setSelectOptions(
options && Array.isArray(options) ? options : EMPTY_OPTIONS,
);
setAllValuesLoaded(false);
}, [options]);
useEffect(() => {
setSelectValue(value);
}, [value]);
useEffect(() => {
if (selectValue) {
setSelectOptions(selectOptions => {
const array = Array.isArray(selectValue)
? (selectValue as AntdLabeledValue[])
: [selectValue as AntdLabeledValue | string | number];
const options: AntdLabeledValue[] = [];
const isLabeledValue = isAsync || labelInValue;
array.forEach(element => {
const found = selectOptions.find(
(option: { value: string | number }) =>
isLabeledValue
? option.value === (element as AntdLabeledValue).value
: option.value === element,
);
if (!found) {
options.push(
isLabeledValue
? (element as AntdLabeledValue)
: ({ value: element, label: element } as AntdLabeledValue),
);
}
});
if (options.length > 0) {
return [...options, ...selectOptions];
}
// return same options won't trigger a re-render
return selectOptions;
});
}
}, [labelInValue, isAsync, selectValue]);
// Stop the invocation of the debounced function after unmounting
useEffect(
() => () => {
debouncedHandleSearch.cancel();
debouncedFetchPage.cancel();
},
[debouncedHandleSearch],
[debouncedFetchPage],
);
useEffect(() => {
if (isAsync && loadingEnabled && allowFetch) {
handlePaginatedFetch(searchedValue, 0);
setPage(0);
// trigger fetch every time inputValue changes
if (inputValue) {
debouncedFetchPage(inputValue, 0);
} else {
fetchPage('', 0);
}
}
}, [
isAsync,
searchedValue,
handlePaginatedFetch,
loadingEnabled,
fetchPage,
allowFetch,
inputValue,
debouncedFetchPage,
]);
useEffect(() => {
if (isSingleMode) {
handleTopOptions(selectValue);
}
}, [handleTopOptions, isSingleMode, selectValue]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {
setIsLoading(loading);
@ -743,6 +671,7 @@ const Select = (
aria-label={ariaLabel || name}
dropdownRender={dropdownRender}
filterOption={handleFilterOption}
filterSort={sortComparatorWithSearch}
getPopupContainer={triggerNode => triggerNode.parentNode}
labelInValue={isAsync || labelInValue}
maxTagCount={MAX_TAG_COUNT}
@ -755,7 +684,7 @@ const Select = (
onSelect={handleOnSelect}
onClear={handleClear}
onChange={onChange}
options={shouldUseChildrenOptions ? undefined : selectOptions}
options={hasCustomLabels ? undefined : fullSelectOptions}
placeholder={placeholder}
showSearch={shouldShowSearch}
showArrow
@ -772,13 +701,12 @@ const Select = (
ref={ref}
{...props}
>
{shouldUseChildrenOptions &&
selectOptions.map(opt => {
{hasCustomLabels &&
fullSelectOptions.map(opt => {
const isOptObject = typeof opt === 'object';
const label = isOptObject ? opt?.label || opt.value : opt;
const value = isOptObject ? opt.value : opt;
const { customLabel, ...optProps } = opt;
return (
<Option {...optProps} key={value} label={label} value={value}>
{isOptObject && customLabel ? customLabel : label}

View File

@ -1,4 +1,3 @@
import { ensureIsArray } from '@superset-ui/core';
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
@ -17,6 +16,8 @@ import { ensureIsArray } from '@superset-ui/core';
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { ensureIsArray } from '@superset-ui/core';
import {
OptionTypeBase,
ValueType,
@ -24,8 +25,6 @@ import {
GroupedOptionsType,
} from 'react-select';
import { OptionsType as AntdOptionsType } from './Select';
/**
* Find Option value that matches a possibly string value.
*
@ -61,27 +60,25 @@ export function findValue<OptionType extends OptionTypeBase>(
return (Array.isArray(value) ? value : [value]).map(find);
}
export function hasOption<VT extends string | number>(
value: VT,
options?: VT | VT[] | { value: VT } | { value: VT }[],
) {
export function getValue(option: string | number | { value: string | number }) {
return typeof option === 'object' ? option.value : option;
}
type LabeledValue<V> = { label?: ReactNode; value?: V };
export function hasOption<V>(
value: V,
options?: V | LabeledValue<V> | (V | LabeledValue<V>)[],
checkLabel = false,
): boolean {
const optionsArray = ensureIsArray(options);
return (
optionsArray.find(x =>
typeof x === 'object' ? x.value === value : x === value,
optionsArray.find(
x =>
x === value ||
(typeof x === 'object' &&
(('value' in x && x.value === value) ||
(checkLabel && 'label' in x && x.label === value))),
) !== undefined
);
}
export function hasOptionIgnoreCase(search: string, options: AntdOptionsType) {
const searchOption = search.trim().toLowerCase();
return options.find(opt => {
const { label, value } = opt;
const labelText = String(label);
const valueText = String(value);
return (
valueText.toLowerCase() === searchOption ||
labelText.toLowerCase() === searchOption
);
});
}

View File

@ -153,11 +153,12 @@ describe('SelectControl', () => {
});
describe('when select has a sortComparator prop', () => {
it('does not add add order key and sorts by sortComparator', () => {
it('does not add add order key', () => {
const sortComparator = (a, b) => a.label.localeCompare(b.label);
const optionsSortedByLabel = options
.map(opt => ({ label: opt.label, value: opt.value }))
.sort(sortComparator);
const optionsSortedByLabel = options.map(opt => ({
label: opt.label,
value: opt.value,
}));
wrapper = mount(
<SelectControl
{...defaultProps}
@ -169,19 +170,6 @@ describe('SelectControl', () => {
expect(wrapper.state().options).toEqual(optionsSortedByLabel);
});
});
describe('when select does not have a sortComparator prop', () => {
it('adds an order key and maintains its intial order', () => {
wrapper = mount(
<SelectControl
{...defaultProps}
value={50}
placeholder="add something"
/>,
);
expect(wrapper.state().options).toEqual(options);
});
});
});
describe('getOptions', () => {

View File

@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { rankedSearchCompare } from './rankedSearchCompare';
const searchSort = (search: string) => (a: string, b: string) =>
rankedSearchCompare(a, b, search);
test('Sort exact match first', async () => {
expect(['abc', 'bc', 'bcd', 'cbc'].sort(searchSort('bc'))).toEqual([
'bc',
'bcd',
'abc',
'cbc',
]);
});
test('Sort starts with first', async () => {
expect(['her', 'Cher', 'Her', 'Hermon'].sort(searchSort('Her'))).toEqual([
'Her',
'Hermon',
'her',
'Cher',
]);
expect(
['abc', 'ab', 'aaabc', 'bbabc', 'BBabc'].sort(searchSort('abc')),
).toEqual(['abc', 'aaabc', 'bbabc', 'BBabc', 'ab']);
});
test('Sort same case first', async () => {
expect(['%f %B', '%F %b'].sort(searchSort('%F'))).toEqual(['%F %b', '%f %B']);
});

View File

@ -0,0 +1,38 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Sort comparator with basic rankings.
*/
export function rankedSearchCompare(a: string, b: string, search: string) {
const aLower = a.toLowerCase() || '';
const bLower = b.toLowerCase() || '';
const searchLower = search.toLowerCase() || '';
if (!search) return a.localeCompare(b);
return (
Number(b === search) - Number(a === search) ||
Number(b.startsWith(search)) - Number(a.startsWith(search)) ||
Number(bLower === searchLower) - Number(aLower === searchLower) ||
Number(bLower.startsWith(searchLower)) -
Number(aLower.startsWith(searchLower)) ||
Number(b.includes(search)) - Number(a.includes(search)) ||
Number(bLower.includes(searchLower)) - Number(a.includes(searchLower)) ||
a.localeCompare(b)
);
}