Move Search to hooks and excise PF Dropdown in favor of Select

This commit is contained in:
John Mitchell
2020-07-29 17:05:52 -04:00
parent dc2bf503d1
commit 36585ad74e
2 changed files with 238 additions and 313 deletions

View File

@@ -1,5 +1,5 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { Fragment } from 'react'; import React, { Fragment, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -7,10 +7,6 @@ import { withRouter } from 'react-router-dom';
import { import {
Button, Button,
ButtonVariant, ButtonVariant,
Dropdown,
DropdownPosition,
DropdownToggle,
DropdownItem,
InputGroup, InputGroup,
Select, Select,
SelectOption, SelectOption,
@@ -34,318 +30,256 @@ const NoOptionDropdown = styled.div`
border-bottom-color: var(--pf-global--BorderColor--200); border-bottom-color: var(--pf-global--BorderColor--200);
`; `;
class Search extends React.Component { function Search({
constructor(props) { columns,
super(props); i18n,
onSearch,
onReplaceSearch,
onRemove,
qsConfig,
location,
searchableKeys,
relatedSearchableKeys,
}) {
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
const [searchKey, setSearchKey] = useState(
columns.find(col => col.isDefault).key
);
const [searchValue, setSearchValue] = useState('');
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false);
const { columns } = this.props; const handleDropdownSelect = ({ target }) => {
const { key: actualSearchKey } = columns.find(
this.state = { ({ name }) => name === target.innerText
isSearchDropdownOpen: false,
searchKey: columns.find(col => col.isDefault).key,
searchValue: '',
isFilterDropdownOpen: false,
};
this.handleSearchInputChange = this.handleSearchInputChange.bind(this);
this.handleDropdownToggle = this.handleDropdownToggle.bind(this);
this.handleDropdownSelect = this.handleDropdownSelect.bind(this);
this.handleSearch = this.handleSearch.bind(this);
this.handleTextKeyDown = this.handleTextKeyDown.bind(this);
this.handleFilterDropdownToggle = this.handleFilterDropdownToggle.bind(
this
); );
this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind(
this
);
this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this);
}
handleDropdownToggle(isSearchDropdownOpen) { setIsFilterDropdownOpen(false);
this.setState({ isSearchDropdownOpen }); setSearchKey(actualSearchKey);
} };
handleDropdownSelect({ target }) { const handleSearch = e => {
const { columns } = this.props;
const { innerText } = target;
const { key: searchKey } = columns.find(({ name }) => name === innerText);
this.setState({ isSearchDropdownOpen: false, searchKey });
}
handleSearch(e) {
// keeps page from fully reloading // keeps page from fully reloading
e.preventDefault(); e.preventDefault();
const { searchKey, searchValue } = this.state;
const { onSearch } = this.props;
if (searchValue) { if (searchValue) {
onSearch(searchKey, searchValue); onSearch(searchKey, searchValue);
this.setState({ searchValue: '' }); setSearchValue('');
} }
} };
handleSearchInputChange(searchValue) { const handleTextKeyDown = e => {
this.setState({ searchValue });
}
handleTextKeyDown(e) {
if (e.key && e.key === 'Enter') { if (e.key && e.key === 'Enter') {
this.handleSearch(e); handleSearch(e);
} }
} };
handleFilterDropdownToggle(isFilterDropdownOpen) {
this.setState({ isFilterDropdownOpen });
}
handleFilterDropdownSelect(key, event, actualValue) {
const { onSearch, onRemove } = this.props;
const handleFilterDropdownSelect = (key, event, actualValue) => {
if (event.target.checked) { if (event.target.checked) {
onSearch(key, actualValue); onSearch(key, actualValue);
} else { } else {
onRemove(key, actualValue); onRemove(key, actualValue);
} }
} };
handleFilterBooleanSelect(key, selection) { const filterDefaultParams = (paramsArr, config) => {
const { onReplaceSearch } = this.props; const defaultParamsKeys = Object.keys(config.defaultParams || {});
onReplaceSearch(key, selection); return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1);
} };
render() { const getLabelFromValue = (value, colKey) => {
const { up } = DropdownPosition; const currentSearchColumn = columns.find(({ key }) => key === colKey);
const { if (currentSearchColumn?.options?.length) {
columns, return currentSearchColumn.options.find(
i18n, ([optVal]) => optVal === value
onSearch, )[1];
onRemove, }
qsConfig, return value.toString();
location, };
searchableKeys,
relatedSearchableKeys, const getChipsByKey = () => {
} = this.props; const queryParams = parseQueryString(qsConfig, location.search);
const {
isSearchDropdownOpen, const queryParamsByKey = {};
searchKey, columns.forEach(({ name, key }) => {
searchValue, queryParamsByKey[key] = { key, label: name, chips: [] };
isFilterDropdownOpen, });
} = this.state; const nonDefaultParams = filterDefaultParams(
const { name: searchColumnName } = columns.find( Object.keys(queryParams || {}),
({ key }) => key === searchKey qsConfig
); );
const searchDropdownItems = columns nonDefaultParams.forEach(key => {
.filter(({ key }) => key !== searchKey) const columnKey = key;
.map(({ key, name }) => ( const label = columns.filter(
<DropdownItem key={key} component="button"> ({ key: keyToCheck }) => columnKey === keyToCheck
{name} ).length
</DropdownItem> ? `${
)); columns.find(({ key: keyToCheck }) => columnKey === keyToCheck).name
} (${key})`
: columnKey;
const filterDefaultParams = (paramsArr, config) => { queryParamsByKey[columnKey] = { key, label, chips: [] };
const defaultParamsKeys = Object.keys(config.defaultParams || {});
return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1);
};
const getLabelFromValue = (value, colKey) => { if (Array.isArray(queryParams[key])) {
const currentSearchColumn = columns.find(({ key }) => key === colKey); queryParams[key].forEach(val =>
if (currentSearchColumn?.options?.length) {
return currentSearchColumn.options.find(
([optVal]) => optVal === value
)[1];
}
return value.toString();
};
const getChipsByKey = () => {
const queryParams = parseQueryString(qsConfig, location.search);
const queryParamsByKey = {};
columns.forEach(({ name, key }) => {
queryParamsByKey[key] = { key, label: name, chips: [] };
});
const nonDefaultParams = filterDefaultParams(
Object.keys(queryParams || {}),
qsConfig
);
nonDefaultParams.forEach(key => {
const columnKey = key;
const label = columns.filter(
({ key: keyToCheck }) => columnKey === keyToCheck
).length
? `${
columns.find(({ key: keyToCheck }) => columnKey === keyToCheck)
.name
} (${key})`
: columnKey;
queryParamsByKey[columnKey] = { key, label, chips: [] };
if (Array.isArray(queryParams[key])) {
queryParams[key].forEach(val =>
queryParamsByKey[columnKey].chips.push({
key: `${key}:${val}`,
node: getLabelFromValue(val, columnKey),
})
);
} else {
queryParamsByKey[columnKey].chips.push({ queryParamsByKey[columnKey].chips.push({
key: `${key}:${queryParams[key]}`, key: `${key}:${val}`,
node: getLabelFromValue(queryParams[key], columnKey), node: getLabelFromValue(val, columnKey),
}); })
} );
}); } else {
return queryParamsByKey; queryParamsByKey[columnKey].chips.push({
}; key: `${key}:${queryParams[key]}`,
node: getLabelFromValue(queryParams[key], columnKey),
});
}
});
return queryParamsByKey;
};
const chipsByKey = getChipsByKey(); const chipsByKey = getChipsByKey();
return ( const { name: searchColumnName } = columns.find(
<ToolbarGroup variant="filter-group"> ({ key }) => key === searchKey
<ToolbarItem> );
{searchDropdownItems.length > 0 ? (
<Dropdown const searchOptions = columns
onToggle={this.handleDropdownToggle} .filter(({ key }) => key !== searchKey)
onSelect={this.handleDropdownSelect} .map(({ key, name }) => (
direction={up} <SelectOption key={key} value={name}>
toggle={ {name}
<DropdownToggle </SelectOption>
id="awx-search" ));
onToggle={this.handleDropdownToggle}
style={{ width: '100%' }} return (
> <ToolbarGroup variant="filter-group">
{searchColumnName} <ToolbarItem>
</DropdownToggle> {searchOptions.length > 0 ? (
} <Select
isOpen={isSearchDropdownOpen} variant={SelectVariant.single}
dropdownItems={searchDropdownItems} aria-label={i18n._(t`Simple key select`)}
/> onToggle={setIsSearchDropdownOpen}
) : ( onSelect={handleDropdownSelect}
<NoOptionDropdown>{searchColumnName}</NoOptionDropdown> selections={searchColumnName}
)} isOpen={isSearchDropdownOpen}
</ToolbarItem> >
{columns.map( {searchOptions}
({ key, name, options, isBoolean, booleanLabels = {} }) => ( </Select>
<ToolbarFilter ) : (
chips={chipsByKey[key] ? chipsByKey[key].chips : []} <NoOptionDropdown>{searchColumnName}</NoOptionDropdown>
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
key={key}
showToolbarItem={searchKey === key}
>
{(key === 'advanced' && (
<AdvancedSearch
onSearch={onSearch}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
/>
)) ||
(options && (
<Fragment>
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterBooleanSelect(key, selection)
}
selections={chipsByKey[key].chips[0]}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
<SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)}
</SelectOption>
<SelectOption key="false" value="false">
{booleanLabels.false || i18n._(t`No`)}
</SelectOption>
</Select>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type={
(qsConfig.integerFields.find(
field => field === searchKey
) &&
'number') ||
'search'
}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={this.handleSearchInputChange}
onKeyDown={this.handleTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={this.handleSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
)}
</ToolbarFilter>
)
)} )}
{/* Add a ToolbarFilter for any key that doesn't have it's own </ToolbarItem>
search column so the chips show up */} {columns.map(({ key, name, options, isBoolean, booleanLabels = {} }) => (
{Object.keys(chipsByKey) <ToolbarFilter
.filter(val => chipsByKey[val].chips.length > 0) chips={chipsByKey[key] ? chipsByKey[key].chips : []}
.filter(val => columns.map(val2 => val2.key).indexOf(val) === -1) deleteChip={(unusedKey, chip) => {
.map(leftoverKey => ( const [columnKey, ...value] = chip.key.split(':');
<ToolbarFilter onRemove(columnKey, value.join(':'));
chips={ }}
chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : [] categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
} key={key}
deleteChip={(unusedKey, chip) => { showToolbarItem={searchKey === key}
const [columnKey, ...value] = chip.key.split(':'); >
onRemove(columnKey, value.join(':')); {(key === 'advanced' && (
}} <AdvancedSearch
categoryName={ onSearch={onSearch}
chipsByKey[leftoverKey] searchableKeys={searchableKeys}
? chipsByKey[leftoverKey].label relatedSearchableKeys={relatedSearchableKeys}
: leftoverKey
}
key={leftoverKey}
/> />
))} )) ||
</ToolbarGroup> (options && (
); <Fragment>
} <Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={setIsFilterDropdownOpen}
onSelect={(event, selection) =>
handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
onToggle={setIsFilterDropdownOpen}
onSelect={(event, selection) => onReplaceSearch(key, selection)}
selections={chipsByKey[key].chips[0]}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
<SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)}
</SelectOption>
<SelectOption key="false" value="false">
{booleanLabels.false || i18n._(t`No`)}
</SelectOption>
</Select>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type={
(qsConfig.integerFields.find(
field => field === searchKey
) &&
'number') ||
'search'
}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={setSearchValue}
onKeyDown={handleTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={handleSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
)}
</ToolbarFilter>
))}
{/* Add a ToolbarFilter for any key that doesn't have it's own
search column so the chips show up */}
{Object.keys(chipsByKey)
.filter(val => chipsByKey[val].chips.length > 0)
.filter(val => columns.map(val2 => val2.key).indexOf(val) === -1)
.map(leftoverKey => (
<ToolbarFilter
chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []}
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={
chipsByKey[leftoverKey]
? chipsByKey[leftoverKey].label
: leftoverKey
}
key={leftoverKey}
/>
))}
</ToolbarGroup>
);
} }
Search.propTypes = { Search.propTypes = {

View File

@@ -49,26 +49,9 @@ describe('<Search />', () => {
expect(onSearch).toBeCalledWith('name__icontains', 'test-321'); expect(onSearch).toBeCalledWith('name__icontains', 'test-321');
}); });
test('handleDropdownToggle properly updates state', async () => { test('changing key select updates which key is called for onSearch', () => {
const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const searchButton = 'button[aria-label="Search submit button"]';
const onSearch = jest.fn(); const searchTextInput = 'input[aria-label="Search text input"]';
const wrapper = mountWithContexts(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</ToolbarContent>
</Toolbar>
).find('Search');
expect(wrapper.state('isSearchDropdownOpen')).toEqual(false);
wrapper.instance().handleDropdownToggle(true);
expect(wrapper.state('isSearchDropdownOpen')).toEqual(true);
});
test('handleDropdownSelect properly updates state', async () => {
const columns = [ const columns = [
{ name: 'Name', key: 'name__icontains', isDefault: true }, { name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Description', key: 'description__icontains' }, { name: 'Description', key: 'description__icontains' },
@@ -84,12 +67,20 @@ describe('<Search />', () => {
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> <Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
).find('Search'); );
expect(wrapper.state('searchKey')).toEqual('name__icontains');
wrapper act(() => {
.instance() wrapper
.handleDropdownSelect({ target: { innerText: 'Description' } }); .find('Select[aria-label="Simple key select"]')
expect(wrapper.state('searchKey')).toEqual('description__icontains'); .invoke('onSelect')({ target: { innerText: 'Description' } });
});
wrapper.update();
wrapper.find(searchTextInput).instance().value = 'test-321';
wrapper.find(searchTextInput).simulate('change');
wrapper.find(searchButton).simulate('click');
expect(onSearch).toHaveBeenCalledTimes(1);
expect(onSearch).toBeCalledWith('description__icontains', 'test-321');
}); });
test('attempt to search with empty string', () => { test('attempt to search with empty string', () => {