Merge pull request #7880 from jlmitch5/kebabifyAdvancedSearch

kebabify additional controls when advanced search is displayed

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-08-12 13:53:52 +00:00
committed by GitHub
7 changed files with 308 additions and 146 deletions

View File

@@ -1,25 +1,46 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect, Fragment } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Dropdown, DropdownPosition } from '@patternfly/react-core'; import {
Dropdown,
DropdownPosition,
DropdownItem,
} from '@patternfly/react-core';
import { ToolbarAddButton } from '../PaginatedDataList'; import { ToolbarAddButton } from '../PaginatedDataList';
import { toTitleCase } from '../../util/strings';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
function AddDropDownButton({ dropdownItems }) { function AddDropDownButton({ dropdownItems, i18n }) {
const { isKebabified } = useKebabifiedMenu();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const element = useRef(null); const element = useRef(null);
const toggle = e => {
if (!element || !element.current.contains(e.target)) {
setIsOpen(false);
}
};
useEffect(() => { useEffect(() => {
const toggle = e => {
if (!isKebabified && (!element || !element.current.contains(e.target))) {
setIsOpen(false);
}
};
document.addEventListener('click', toggle, false); document.addEventListener('click', toggle, false);
return () => { return () => {
document.removeEventListener('click', toggle); document.removeEventListener('click', toggle);
}; };
}, []); }, [isKebabified]);
if (isKebabified) {
return (
<Fragment>
{dropdownItems.map(item => (
<DropdownItem key={item.url} component={Link} to={item.url}>
{toTitleCase(`${i18n._(t`Add`)} ${item.label}`)}
</DropdownItem>
))}
</Fragment>
);
}
return ( return (
<div ref={element} key="add"> <div ref={element} key="add">
@@ -52,4 +73,4 @@ AddDropDownButton.propTypes = {
}; };
export { AddDropDownButton as _AddDropDownButton }; export { AddDropDownButton as _AddDropDownButton };
export default AddDropDownButton; export default withI18n()(AddDropDownButton);

View File

@@ -1,4 +1,4 @@
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';
@@ -9,103 +9,128 @@ import {
ToolbarGroup, ToolbarGroup,
ToolbarItem, ToolbarItem,
ToolbarToggleGroup, ToolbarToggleGroup,
Dropdown,
KebabToggle,
} from '@patternfly/react-core'; } from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons'; import { SearchIcon } from '@patternfly/react-icons';
import ExpandCollapse from '../ExpandCollapse'; import ExpandCollapse from '../ExpandCollapse';
import Search from '../Search'; import Search from '../Search';
import Sort from '../Sort'; import Sort from '../Sort';
import { SearchColumns, SortColumns, QSConfig } from '../../types'; import { SearchColumns, SortColumns, QSConfig } from '../../types';
import { KebabifiedProvider } from '../../contexts/Kebabified';
class DataListToolbar extends React.Component { function DataListToolbar({
render() { itemCount,
const { clearAllFilters,
itemCount, searchColumns,
clearAllFilters, searchableKeys,
searchColumns, relatedSearchableKeys,
searchableKeys, sortColumns,
relatedSearchableKeys, showSelectAll,
sortColumns, isAllSelected,
showSelectAll, isCompact,
isAllSelected, onSort,
isCompact, onSearch,
onSort, onReplaceSearch,
onSearch, onRemove,
onReplaceSearch, onCompact,
onRemove, onExpand,
onCompact, onSelectAll,
onExpand, additionalControls,
onSelectAll, i18n,
additionalControls, qsConfig,
i18n, pagination,
qsConfig, }) {
pagination, const showExpandCollapse = onCompact && onExpand;
} = this.props; const [kebabIsOpen, setKebabIsOpen] = useState(false);
const [advancedSearchShown, setAdvancedSearchShown] = useState(false);
const showExpandCollapse = onCompact && onExpand; const onShowAdvancedSearch = shown => {
return ( setAdvancedSearchShown(shown);
<Toolbar setKebabIsOpen(false);
id={`${qsConfig.namespace}-list-toolbar`} };
clearAllFilters={clearAllFilters}
collapseListedFiltersBreakpoint="lg" return (
> <Toolbar
<ToolbarContent> id={`${qsConfig.namespace}-list-toolbar`}
{showSelectAll && ( clearAllFilters={clearAllFilters}
<ToolbarGroup> collapseListedFiltersBreakpoint="lg"
<ToolbarItem> >
<Checkbox <ToolbarContent>
isChecked={isAllSelected} {showSelectAll && (
onChange={onSelectAll} <ToolbarGroup>
aria-label={i18n._(t`Select all`)}
id="select-all"
/>
</ToolbarItem>
</ToolbarGroup>
)}
<ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
<ToolbarItem> <ToolbarItem>
<Search <Checkbox
qsConfig={qsConfig} isChecked={isAllSelected}
columns={[ onChange={onSelectAll}
...searchColumns, aria-label={i18n._(t`Select all`)}
{ name: i18n._(t`Advanced`), key: 'advanced' }, id="select-all"
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onRemove={onRemove}
/> />
</ToolbarItem> </ToolbarItem>
<ToolbarItem> </ToolbarGroup>
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} /> )}
</ToolbarItem> <ToolbarToggleGroup toggleIcon={<SearchIcon />} breakpoint="lg">
</ToolbarToggleGroup> <ToolbarItem>
{showExpandCollapse && ( <Search
<ToolbarGroup> qsConfig={qsConfig}
<Fragment> columns={[
<ToolbarItem> ...searchColumns,
<ExpandCollapse { name: i18n._(t`Advanced`), key: 'advanced' },
isCompact={isCompact} ]}
onCompact={onCompact} searchableKeys={searchableKeys}
onExpand={onExpand} relatedSearchableKeys={relatedSearchableKeys}
/> onSearch={onSearch}
</ToolbarItem> onReplaceSearch={onReplaceSearch}
</Fragment> onShowAdvancedSearch={onShowAdvancedSearch}
</ToolbarGroup> onRemove={onRemove}
)} />
</ToolbarItem>
<ToolbarItem>
<Sort qsConfig={qsConfig} columns={sortColumns} onSort={onSort} />
</ToolbarItem>
</ToolbarToggleGroup>
{showExpandCollapse && (
<ToolbarGroup>
<Fragment>
<ToolbarItem>
<ExpandCollapse
isCompact={isCompact}
onCompact={onCompact}
onExpand={onExpand}
/>
</ToolbarItem>
</Fragment>
</ToolbarGroup>
)}
{advancedSearchShown && (
<ToolbarItem>
<Dropdown
toggle={<KebabToggle onToggle={setKebabIsOpen} />}
isOpen={kebabIsOpen}
isPlain
dropdownItems={additionalControls.map(control => {
return (
<KebabifiedProvider value={{ isKebabified: true }}>
{control}
</KebabifiedProvider>
);
})}
/>
</ToolbarItem>
)}
{!advancedSearchShown && (
<ToolbarGroup> <ToolbarGroup>
{additionalControls.map(control => ( {additionalControls.map(control => (
<ToolbarItem key={control.key}>{control}</ToolbarItem> <ToolbarItem key={control.key}>{control}</ToolbarItem>
))} ))}
</ToolbarGroup> </ToolbarGroup>
{pagination && itemCount > 0 && ( )}
<ToolbarItem variant="pagination">{pagination}</ToolbarItem> {!advancedSearchShown && pagination && itemCount > 0 && (
)} <ToolbarItem variant="pagination">{pagination}</ToolbarItem>
</ToolbarContent> )}
</Toolbar> </ToolbarContent>
); </Toolbar>
} );
} }
DataListToolbar.propTypes = { DataListToolbar.propTypes = {

View File

@@ -1,16 +1,32 @@
import React from 'react'; import React from 'react';
import { string, func } from 'prop-types'; import { string, func } from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button, Tooltip } from '@patternfly/react-core'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useKebabifiedMenu } from '../../contexts/Kebabified';
function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) { function ToolbarAddButton({ linkTo, onClick, i18n, isDisabled }) {
const { isKebabified } = useKebabifiedMenu();
if (!linkTo && !onClick) { if (!linkTo && !onClick) {
throw new Error( throw new Error(
'ToolbarAddButton requires either `linkTo` or `onClick` prop' 'ToolbarAddButton requires either `linkTo` or `onClick` prop'
); );
} }
if (isKebabified) {
return (
<DropdownItem
key="add"
isDisabled={isDisabled}
component={linkTo ? Link : Button}
to={linkTo}
onClick={!onClick ? undefined : onClick}
>
{i18n._(t`Add`)}
</DropdownItem>
);
}
if (linkTo) { if (linkTo) {
return ( return (
<Tooltip content={i18n._(t`Add`)} position="top"> <Tooltip content={i18n._(t`Add`)} position="top">

View File

@@ -8,10 +8,11 @@ import {
shape, shape,
checkPropTypes, checkPropTypes,
} from 'prop-types'; } from 'prop-types';
import { Button, Tooltip } from '@patternfly/react-core'; import { Button, DropdownItem, Tooltip } from '@patternfly/react-core';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import AlertModal from '../AlertModal'; import AlertModal from '../AlertModal';
import { Kebabified } from '../../contexts/Kebabified';
const requireNameOrUsername = props => { const requireNameOrUsername = props => {
const { name, username } = props; const { name, username } = props;
@@ -138,54 +139,69 @@ class ToolbarDeleteButton extends React.Component {
// we can delete the extra <div> around the <DeleteButton> below. // we can delete the extra <div> around the <DeleteButton> below.
// See: https://github.com/patternfly/patternfly-react/issues/1894 // See: https://github.com/patternfly/patternfly-react/issues/1894
return ( return (
<Fragment> <Kebabified>
<Tooltip content={this.renderTooltip()} position="top"> {({ isKebabified }) => (
<div> <Fragment>
<Button {isKebabified ? (
variant="secondary" <DropdownItem
aria-label={i18n._(t`Delete`)} key="add"
onClick={this.handleConfirmDelete} isDisabled={isDisabled}
isDisabled={isDisabled} component="Button"
> onClick={this.handleConfirmDelete}
{i18n._(t`Delete`)}
</Button>
</div>
</Tooltip>
{isModalOpen && (
<AlertModal
variant="danger"
title={modalTitle}
isOpen={isModalOpen}
onClose={this.handleCancelDelete}
actions={[
<Button
key="delete"
variant="danger"
aria-label={i18n._(t`confirm delete`)}
onClick={this.handleDelete}
> >
{i18n._(t`Delete`)} {i18n._(t`Delete`)}
</Button>, </DropdownItem>
<Button ) : (
key="cancel" <Tooltip content={this.renderTooltip()} position="top">
variant="secondary" <div>
aria-label={i18n._(t`cancel delete`)} <Button
onClick={this.handleCancelDelete} variant="secondary"
aria-label={i18n._(t`Delete`)}
onClick={this.handleConfirmDelete}
isDisabled={isDisabled}
>
{i18n._(t`Delete`)}
</Button>
</div>
</Tooltip>
)}
{isModalOpen && (
<AlertModal
variant="danger"
title={modalTitle}
isOpen={isModalOpen}
onClose={this.handleCancelDelete}
actions={[
<Button
key="delete"
variant="danger"
aria-label={i18n._(t`confirm delete`)}
onClick={this.handleDelete}
>
{i18n._(t`Delete`)}
</Button>,
<Button
key="cancel"
variant="secondary"
aria-label={i18n._(t`cancel delete`)}
onClick={this.handleCancelDelete}
>
{i18n._(t`Cancel`)}
</Button>,
]}
> >
{i18n._(t`Cancel`)} <div>{i18n._(t`This action will delete the following:`)}</div>
</Button>, {itemsToDelete.map(item => (
]} <span key={item.id}>
> <strong>{item.name || item.username}</strong>
<div>{i18n._(t`This action will delete the following:`)}</div> <br />
{itemsToDelete.map(item => ( </span>
<span key={item.id}> ))}
<strong>{item.name || item.username}</strong> </AlertModal>
<br /> )}
</span> </Fragment>
))}
</AlertModal>
)} )}
</Fragment> </Kebabified>
); );
} }
} }

View File

@@ -40,6 +40,7 @@ function Search({
location, location,
searchableKeys, searchableKeys,
relatedSearchableKeys, relatedSearchableKeys,
onShowAdvancedSearch,
}) { }) {
const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false);
const [searchKey, setSearchKey] = useState( const [searchKey, setSearchKey] = useState(
@@ -62,7 +63,7 @@ function Search({
const { key: actualSearchKey } = columns.find( const { key: actualSearchKey } = columns.find(
({ name }) => name === target.innerText ({ name }) => name === target.innerText
); );
onShowAdvancedSearch(actualSearchKey === 'advanced');
setIsFilterDropdownOpen(false); setIsFilterDropdownOpen(false);
setSearchKey(actualSearchKey); setSearchKey(actualSearchKey);
}; };
@@ -301,6 +302,7 @@ Search.propTypes = {
columns: SearchColumns.isRequired, columns: SearchColumns.isRequired,
onSearch: PropTypes.func, onSearch: PropTypes.func,
onRemove: PropTypes.func, onRemove: PropTypes.func,
onShowAdvancedSearch: PropTypes.func.isRequired,
}; };
Search.defaultProps = { Search.defaultProps = {

View File

@@ -36,7 +36,12 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onSearch={onSearch}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
); );
@@ -64,7 +69,12 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onSearch={onSearch}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
); );
@@ -83,6 +93,50 @@ describe('<Search />', () => {
expect(onSearch).toBeCalledWith('description__icontains', 'test-321'); expect(onSearch).toBeCalledWith('description__icontains', 'test-321');
}); });
test('changing key select to and from advanced causes onShowAdvancedSearch callback to be invoked', () => {
const columns = [
{ name: 'Name', key: 'name__icontains', isDefault: true },
{ name: 'Description', key: 'description__icontains' },
{ name: 'Advanced', key: 'advanced' },
];
const onSearch = jest.fn();
const onShowAdvancedSearch = jest.fn();
const wrapper = mountWithContexts(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search
qsConfig={QS_CONFIG}
columns={columns}
onSearch={onSearch}
onShowAdvancedSearch={onShowAdvancedSearch}
/>
</ToolbarContent>
</Toolbar>
);
act(() => {
wrapper
.find('Select[aria-label="Simple key select"]')
.invoke('onSelect')({ target: { innerText: 'Advanced' } });
});
wrapper.update();
expect(onShowAdvancedSearch).toHaveBeenCalledTimes(1);
expect(onShowAdvancedSearch).toBeCalledWith(true);
jest.clearAllMocks();
act(() => {
wrapper
.find('Select[aria-label="Simple key select"]')
.invoke('onSelect')({ target: { innerText: 'Description' } });
});
wrapper.update();
expect(onShowAdvancedSearch).toHaveBeenCalledTimes(1);
expect(onShowAdvancedSearch).toBeCalledWith(false);
});
test('attempt to search with empty string', () => { test('attempt to search with empty string', () => {
const searchButton = 'button[aria-label="Search submit button"]'; const searchButton = 'button[aria-label="Search submit button"]';
const searchTextInput = 'input[aria-label="Search text input"]'; const searchTextInput = 'input[aria-label="Search text input"]';
@@ -95,7 +149,12 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onSearch={onSearch}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
); );
@@ -119,7 +178,12 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onSearch={onSearch}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar> </Toolbar>
); );
@@ -150,7 +214,11 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar>, </Toolbar>,
{ context: { router: { history } } } { context: { router: { history } } }
@@ -197,6 +265,7 @@ describe('<Search />', () => {
qsConfig={qsConfigNew} qsConfig={qsConfigNew}
columns={columns} columns={columns}
onRemove={onRemove} onRemove={onRemove}
onShowAdvancedSearch={jest.fn}
/> />
</ToolbarContent> </ToolbarContent>
</Toolbar>, </Toolbar>,
@@ -243,6 +312,7 @@ describe('<Search />', () => {
qsConfig={qsConfigNew} qsConfig={qsConfigNew}
columns={columns} columns={columns}
onRemove={onRemove} onRemove={onRemove}
onShowAdvancedSearch={jest.fn}
/> />
</ToolbarContent> </ToolbarContent>
</Toolbar>, </Toolbar>,
@@ -277,7 +347,11 @@ describe('<Search />', () => {
collapseListedFiltersBreakpoint="lg" collapseListedFiltersBreakpoint="lg"
> >
<ToolbarContent> <ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} /> <Search
qsConfig={QS_CONFIG}
columns={columns}
onShowAdvancedSearch={jest.fn}
/>
</ToolbarContent> </ToolbarContent>
</Toolbar>, </Toolbar>,
{ context: { router: { history } } } { context: { router: { history } } }

View File

@@ -0,0 +1,8 @@
import React, { useContext } from 'react';
// eslint-disable-next-line import/prefer-default-export
export const KebabifiedContext = React.createContext({});
export const KebabifiedProvider = KebabifiedContext.Provider;
export const Kebabified = KebabifiedContext.Consumer;
export const useKebabifiedMenu = () => useContext(KebabifiedContext);