From b46a87209a8d2c78697d82f067f1d18022459857 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 28 Jul 2020 14:55:52 -0400 Subject: [PATCH 01/14] Add advanced search to UI --- .../DataListToolbar/DataListToolbar.jsx | 13 +- .../DataListToolbar/DataListToolbar.test.jsx | 35 +- .../src/components/ListHeader/ListHeader.jsx | 8 + .../components/ListHeader/ListHeader.test.jsx | 20 +- .../Lookup/InstanceGroupsLookup.jsx | 24 +- .../PaginatedDataList/PaginatedDataList.jsx | 8 + .../src/components/Search/AdvancedSearch.jsx | 270 ++++++++++++++ .../components/Search/AdvancedSearch.test.jsx | 342 ++++++++++++++++++ awx/ui_next/src/components/Search/Search.jsx | 113 +++--- .../src/components/Search/Search.test.jsx | 65 +++- 10 files changed, 828 insertions(+), 70 deletions(-) create mode 100644 awx/ui_next/src/components/Search/AdvancedSearch.jsx create mode 100644 awx/ui_next/src/components/Search/AdvancedSearch.test.jsx diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index cc4b44dc72b1..9e02017fdf04 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx @@ -23,6 +23,8 @@ class DataListToolbar extends React.Component { itemCount, clearAllFilters, searchColumns, + searchableKeys, + relatedSearchableKeys, sortColumns, showSelectAll, isAllSelected, @@ -64,7 +66,12 @@ class DataListToolbar extends React.Component { ', () => { const onSelectAll = jest.fn(); test('it triggers the expected callbacks', () => { - const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const searchColumns = [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ]; const sortColumns = [{ name: 'Name', key: 'name' }]; const search = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; @@ -108,7 +110,7 @@ describe('', () => { searchDropdownToggle.simulate('click'); toolbar.update(); let searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(1); + expect(searchDropdownItems.length).toBe(2); const mockedSortEvent = { target: { innerText: 'Bar' } }; searchDropdownItems.at(0).simulate('click', mockedSortEvent); toolbar = mountWithContexts( @@ -144,7 +146,7 @@ describe('', () => { toolbar.update(); searchDropdownItems = toolbar.find(searchDropdownMenuItems).children(); - expect(searchDropdownItems.length).toBe(1); + expect(searchDropdownItems.length).toBe(2); const mockedSearchEvent = { target: { innerText: 'Bar' } }; searchDropdownItems.at(0).simulate('click', mockedSearchEvent); @@ -283,4 +285,31 @@ describe('', () => { const checkbox = toolbar.find('Checkbox'); expect(checkbox.prop('isChecked')).toBe(true); }); + + test('always adds advanced item to search column array', () => { + const searchColumns = [{ name: 'Name', key: 'name', isDefault: true }]; + const sortColumns = [{ name: 'Name', key: 'name' }]; + + toolbar = mountWithContexts( + + click + , + ]} + /> + ); + + const search = toolbar.find('Search'); + expect( + search.prop('columns').filter(col => col.key === 'advanced').length + ).toBe(1); + }); }); diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.jsx index 305c741a478e..f383c24130e3 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.jsx @@ -94,6 +94,8 @@ class ListHeader extends React.Component { emptyStateControls, itemCount, searchColumns, + searchableKeys, + relatedSearchableKeys, sortColumns, renderToolbar, qsConfig, @@ -122,6 +124,8 @@ class ListHeader extends React.Component { itemCount, searchColumns, sortColumns, + searchableKeys, + relatedSearchableKeys, onSearch: this.handleSearch, onReplaceSearch: this.handleReplaceSearch, onSort: this.handleSort, @@ -141,12 +145,16 @@ ListHeader.propTypes = { itemCount: PropTypes.number.isRequired, qsConfig: QSConfig.isRequired, searchColumns: SearchColumns.isRequired, + searchableKeys: PropTypes.arrayOf(PropTypes.string), + relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), sortColumns: SortColumns.isRequired, renderToolbar: PropTypes.func, }; ListHeader.defaultProps = { renderToolbar: props => , + searchableKeys: [], + relatedSearchableKeys: [], }; export default withRouter(ListHeader); diff --git a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx index 52263d2ec7f0..d501418c4481 100644 --- a/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx +++ b/awx/ui_next/src/components/ListHeader/ListHeader.test.jsx @@ -16,7 +16,9 @@ describe('ListHeader', () => { @@ -33,7 +35,9 @@ describe('ListHeader', () => { , { context: { router: { history } } } @@ -56,7 +60,9 @@ describe('ListHeader', () => { , { context: { router: { history } } } @@ -77,7 +83,9 @@ describe('ListHeader', () => { , { context: { router: { history } } } @@ -100,7 +108,9 @@ describe('ListHeader', () => { , { context: { router: { history } } } diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index cd7d35f7ba23..c44f17cd1475 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -30,26 +30,38 @@ function InstanceGroupsLookup(props) { } = props; const { - result: { instanceGroups, count }, + result: { instanceGroups, count, actions, relatedSearchFields }, request: fetchInstanceGroups, error, isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InstanceGroupsAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InstanceGroupsAPI.read(params), + InstanceGroupsAPI.readOptions(), + ]); return { instanceGroups: data.results, count: data.count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [history.location]), - { instanceGroups: [], count: 0 } + { instanceGroups: [], count: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { fetchInstanceGroups(); }, [fetchInstanceGroups]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( @@ -193,6 +197,8 @@ PaginatedDataList.propTypes = { qsConfig: QSConfig.isRequired, renderItem: PropTypes.func, toolbarSearchColumns: SearchColumns, + toolbarSearchableKeys: PropTypes.arrayOf(PropTypes.string), + toolbarRelatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), toolbarSortColumns: SortColumns, showPageSizeOptions: PropTypes.bool, renderToolbar: PropTypes.func, @@ -205,6 +211,8 @@ PaginatedDataList.defaultProps = { hasContentLoading: false, contentError: null, toolbarSearchColumns: [], + toolbarSearchableKeys: [], + toolbarRelatedSearchableKeys: [], toolbarSortColumns: [], pluralizedItemName: 'Items', showPageSizeOptions: true, diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.jsx new file mode 100644 index 000000000000..43ef7ce09f49 --- /dev/null +++ b/awx/ui_next/src/components/Search/AdvancedSearch.jsx @@ -0,0 +1,270 @@ +import 'styled-components/macro'; +import React, { useState } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + Button, + ButtonVariant, + InputGroup, + Select, + SelectOption, + SelectVariant, + TextInput, +} from '@patternfly/react-core'; +import { SearchIcon } from '@patternfly/react-icons'; +import styled from 'styled-components'; + +const AdvancedGroup = styled.div` + display: flex; + + @media (max-width: 991px) { + display: grid; + grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap); + } +`; + +function AdvancedSearch({ + i18n, + onSearch, + searchableKeys, + relatedSearchableKeys, +}) { + // TODO: blocked by pf bug, eventually separate these into two groups in the select + // for now, I'm spreading set to get rid of duplicate keys...when they are grouped + // we might want to revisit that. + const allKeys = [ + ...new Set([...(searchableKeys || []), ...(relatedSearchableKeys || [])]), + ]; + + const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false); + const [isLookupDropdownOpen, setIsLookupDropdownOpen] = useState(false); + const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false); + const [prefixSelection, setPrefixSelection] = useState(null); + const [lookupSelection, setLookupSelection] = useState(null); + const [keySelection, setKeySelection] = useState(null); + const [searchValue, setSearchValue] = useState(''); + + const handleAdvancedSearch = e => { + // keeps page from fully reloading + e.preventDefault(); + + if (searchValue) { + const actualPrefix = prefixSelection === 'and' ? null : prefixSelection; + let actualSearchKey; + // TODO: once we are able to group options for the key typeahead, we will + // probably want to be able to which group a key was clicked in for duplicates, + // rather than checking to make sure it's not in both for this appending + // __search logic + if ( + relatedSearchableKeys.indexOf(keySelection) > -1 && + searchableKeys.indexOf(keySelection) === -1 && + keySelection.indexOf('__') === -1 + ) { + actualSearchKey = `${keySelection}__search`; + } else { + actualSearchKey = [actualPrefix, keySelection, lookupSelection] + .filter(val => !!val) + .join('__'); + } + onSearch(actualSearchKey, searchValue); + setSearchValue(''); + } + }; + + const handleAdvancedTextKeyDown = e => { + if (e.key && e.key === 'Enter') { + handleAdvancedSearch(e); + } + }; + + return ( + + + + + + +
+ +
+
+
+ ); +} + +// TODO: prop types + +export default withI18n()(AdvancedSearch); diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx new file mode 100644 index 000000000000..32c784a01f45 --- /dev/null +++ b/awx/ui_next/src/components/Search/AdvancedSearch.test.jsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import AdvancedSearch from './AdvancedSearch'; + +describe('', () => { + let wrapper; + + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + + test('initially renders without crashing', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.length).toBe(1); + }); + + test('Remove duplicates from searchableKeys/relatedSearchableKeys list', () => { + wrapper = mountWithContexts( + + ); + wrapper + .find('Select[aria-label="Key select"] SelectToggle') + .simulate('click'); + expect( + wrapper.find('Select[aria-label="Key select"] SelectOption') + ).toHaveLength(3); + }); + + test("Don't call onSearch unless a search value is set", () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + wrapper + .find('Select[aria-label="Key select"] SelectToggle') + .simulate('click'); + wrapper + .find('Select[aria-label="Key select"] SelectOption') + .at(1) + .simulate('click'); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + expect(advancedSearchMock).toBeCalledTimes(0); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('foo'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledTimes(1); + }); + + test('Disable searchValue input until a key is set', () => { + wrapper = mountWithContexts( + + ); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('isDisabled') + ).toBe(true); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + }); + wrapper.update(); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('isDisabled') + ).toBe(false); + }); + + test('Strip and__ set type from key', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'and' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('foo', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo', 'bar'); + }); + + test('Add __search lookup to key when applicable', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('foo', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'bar' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('bar', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'baz' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('baz__search', 'bar'); + }); + + test('Key should be properly constructed from three typeaheads', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onSelect')( + {}, + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + }); + + test('searchValue should clear after onSearch is called', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + expect( + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('value') + ).toBe(''); + }); + + test('typeahead onClear should remove key components', () => { + const advancedSearchMock = jest.fn(); + wrapper = mountWithContexts( + + ); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onSelect')( + {}, + 'or' + ); + wrapper.find('Select[aria-label="Key select"]').invoke('onCreateOption')( + 'foo' + ); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onSelect')( + {}, + 'exact' + ); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('bar'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('or__foo__exact', 'bar'); + jest.clearAllMocks(); + act(() => { + wrapper.find('Select[aria-label="Set type select"]').invoke('onClear')(); + wrapper.find('Select[aria-label="Key select"]').invoke('onClear')(); + wrapper.find('Select[aria-label="Lookup select"]').invoke('onClear')(); + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .invoke('onChange')('baz'); + }); + wrapper.update(); + act(() => { + wrapper + .find('TextInputBase[aria-label="Advanced search value input"]') + .prop('onKeyDown')({ key: 'Enter', preventDefault: jest.fn }); + }); + wrapper.update(); + expect(advancedSearchMock).toBeCalledWith('', 'baz'); + }); +}); diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index 0fb59e080643..029c2a39a5bb 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -24,6 +24,7 @@ import { SearchIcon } from '@patternfly/react-icons'; import styled from 'styled-components'; import { parseQueryString } from '../../util/qs'; import { QSConfig, SearchColumns } from '../../types'; +import AdvancedSearch from './AdvancedSearch'; const NoOptionDropdown = styled.div` align-self: stretch; @@ -77,19 +78,10 @@ class Search extends React.Component { e.preventDefault(); const { searchKey, searchValue } = this.state; - const { onSearch, qsConfig } = this.props; + const { onSearch } = this.props; if (searchValue) { - const isNonStringField = - qsConfig.integerFields.find(field => field === searchKey) || - qsConfig.dateFields.find(field => field === searchKey); - - const actualSearchKey = isNonStringField - ? searchKey - : `${searchKey}__icontains`; - - onSearch(actualSearchKey, searchValue); - + onSearch(searchKey, searchValue); this.setState({ searchValue: '' }); } } @@ -112,9 +104,9 @@ class Search extends React.Component { const { onSearch, onRemove } = this.props; if (event.target.checked) { - onSearch(`or__${key}`, actualValue); + onSearch(key, actualValue); } else { - onRemove(`or__${key}`, actualValue); + onRemove(key, actualValue); } } @@ -125,7 +117,16 @@ class Search extends React.Component { render() { const { up } = DropdownPosition; - const { columns, i18n, onRemove, qsConfig, location } = this.props; + const { + columns, + i18n, + onSearch, + onRemove, + qsConfig, + location, + searchableKeys, + relatedSearchableKeys, + } = this.props; const { isSearchDropdownOpen, searchKey, @@ -172,12 +173,14 @@ class Search extends React.Component { ); nonDefaultParams.forEach(key => { - const columnKey = key.replace('__icontains', '').replace('or__', ''); + const columnKey = key; const label = columns.filter( ({ key: keyToCheck }) => columnKey === keyToCheck ).length - ? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0] - .name + ? `${ + columns.find(({ key: keyToCheck }) => columnKey === keyToCheck) + .name + } (${key})` : columnKey; queryParamsByKey[columnKey] = { key, label, chips: [] }; @@ -196,7 +199,6 @@ class Search extends React.Component { }); } }); - return queryParamsByKey; }; @@ -238,30 +240,37 @@ class Search extends React.Component { key={key} showToolbarItem={searchKey === key} > - {(options && ( - - - + {(key === 'advanced' && ( + )) || + (options && ( + + + + )) || (isBoolean && ( + {searchOptions} + + ) : ( + {searchColumnName} + )} +
+ {columns.map(({ key, name, options, isBoolean, booleanLabels = {} }) => ( + { + const [columnKey, ...value] = chip.key.split(':'); + onRemove(columnKey, value.join(':')); + }} + categoryName={chipsByKey[key] ? chipsByKey[key].label : key} + key={key} + showToolbarItem={searchKey === key} + > + {(key === 'advanced' && ( + - ) : ( - {searchColumnName} - )} - - {columns.map( - ({ key, name, options, isBoolean, booleanLabels = {} }) => ( - { - const [columnKey, ...value] = chip.key.split(':'); - onRemove(columnKey, value.join(':')); - }} - categoryName={chipsByKey[key] ? chipsByKey[key].label : key} - key={key} - showToolbarItem={searchKey === key} - > - {(key === 'advanced' && ( - + + + )) || + (isBoolean && ( + + )) || ( + + {/* TODO: add support for dates: + qsConfig.dateFields.filter(field => field === key).length && "date" */} + field === searchKey + ) && + 'number') || + 'search' + } + aria-label={i18n._(t`Search text input`)} + value={searchValue} + onChange={setSearchValue} + onKeyDown={handleTextKeyDown} /> - )) || - (options && ( - - - - )) || - (isBoolean && ( - - )) || ( - - {/* TODO: add support for dates: - qsConfig.dateFields.filter(field => field === key).length && "date" */} - field === searchKey - ) && - 'number') || - 'search' - } - aria-label={i18n._(t`Search text input`)} - value={searchValue} - onChange={this.handleSearchInputChange} - onKeyDown={this.handleTextKeyDown} - /> -
- -
-
- )} -
- ) - )} - {/* 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 => ( - { - const [columnKey, ...value] = chip.key.split(':'); - onRemove(columnKey, value.join(':')); - }} - categoryName={ - chipsByKey[leftoverKey] - ? chipsByKey[leftoverKey].label - : leftoverKey - } - key={leftoverKey} - /> - ))} - - ); - } + + + + + )} + + ))} + {/* 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 => ( + { + const [columnKey, ...value] = chip.key.split(':'); + onRemove(columnKey, value.join(':')); + }} + categoryName={ + chipsByKey[leftoverKey] + ? chipsByKey[leftoverKey].label + : leftoverKey + } + key={leftoverKey} + /> + ))} + + ); } Search.propTypes = { diff --git a/awx/ui_next/src/components/Search/Search.test.jsx b/awx/ui_next/src/components/Search/Search.test.jsx index 50e2178e07b2..a34b6bcc0931 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -49,26 +49,9 @@ describe('', () => { expect(onSearch).toBeCalledWith('name__icontains', 'test-321'); }); - test('handleDropdownToggle properly updates state', async () => { - const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; - const onSearch = jest.fn(); - const wrapper = mountWithContexts( - {}} - collapseListedFiltersBreakpoint="lg" - > - - - - - ).find('Search'); - expect(wrapper.state('isSearchDropdownOpen')).toEqual(false); - wrapper.instance().handleDropdownToggle(true); - expect(wrapper.state('isSearchDropdownOpen')).toEqual(true); - }); - - test('handleDropdownSelect properly updates state', async () => { + test('changing key select updates which key is called for onSearch', () => { + const searchButton = 'button[aria-label="Search submit button"]'; + const searchTextInput = 'input[aria-label="Search text input"]'; const columns = [ { name: 'Name', key: 'name__icontains', isDefault: true }, { name: 'Description', key: 'description__icontains' }, @@ -84,12 +67,20 @@ describe('', () => { - ).find('Search'); - expect(wrapper.state('searchKey')).toEqual('name__icontains'); - wrapper - .instance() - .handleDropdownSelect({ target: { innerText: 'Description' } }); - expect(wrapper.state('searchKey')).toEqual('description__icontains'); + ); + + act(() => { + wrapper + .find('Select[aria-label="Simple key select"]') + .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', () => { From ede1260675fb72e96180862ac550646f8bf2eb27 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 31 Jul 2020 15:50:32 -0400 Subject: [PATCH 06/14] fix merge conflicts and failing test --- .../src/components/DataListToolbar/DataListToolbar.test.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx index b30019f4232a..6f90cc5d8999 100644 --- a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx +++ b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.test.jsx @@ -68,11 +68,12 @@ describe('', () => { test('dropdown items sortable/searchable columns work', () => { const sortDropdownToggleSelector = 'button[id="awx-sort"]'; - const searchDropdownToggleSelector = 'button[id="awx-search"]'; + const searchDropdownToggleSelector = + 'Select[aria-label="Simple key select"] SelectToggle'; const sortDropdownMenuItems = 'DropdownMenu > ul[aria-labelledby="awx-sort"]'; const searchDropdownMenuItems = - 'DropdownMenu > ul[aria-labelledby="awx-search"]'; + 'Select[aria-label="Simple key select"] SelectOption'; const NEW_QS_CONFIG = { namespace: 'organization', From 0876b944ed37037e4c5e17b5898558981d06bd59 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 4 Aug 2020 15:01:54 -0400 Subject: [PATCH 07/14] fix AddRersourceRole sort column --- awx/ui_next/src/components/AddRole/AddResourceRole.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 2a8db9b40ba5..2da3b02e9d83 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -172,15 +172,15 @@ class AddResourceRole extends React.Component { const userSortColumns = [ { name: i18n._(t`Username`), - key: 'username__icontains', + key: 'username', }, { name: i18n._(t`First Name`), - key: 'first_name__icontains', + key: 'first_name', }, { name: i18n._(t`Last Name`), - key: 'last_name__icontains', + key: 'last_name', }, ]; From 5b362ef162d0c2699214948b744e7aae69dad3aa Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 4 Aug 2020 17:21:23 -0400 Subject: [PATCH 08/14] add selectors for cypress tests --- awx/ui_next/src/components/Search/AdvancedSearch.jsx | 3 +++ awx/ui_next/src/components/Search/Search.jsx | 1 + 2 files changed, 4 insertions(+) diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.jsx index 3aab1a5d0383..04b43614c094 100644 --- a/awx/ui_next/src/components/Search/AdvancedSearch.jsx +++ b/awx/ui_next/src/components/Search/AdvancedSearch.jsx @@ -82,6 +82,7 @@ function AdvancedSearch({ Date: Wed, 5 Aug 2020 10:54:39 -0400 Subject: [PATCH 09/14] add back in searchable keys props to user token list --- awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx index 1236cbe04cd0..e30f2c6fa2e8 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx @@ -140,6 +140,8 @@ function UserTokenList({ i18n }) { key: 'modified', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( Date: Wed, 5 Aug 2020 11:15:19 -0400 Subject: [PATCH 10/14] make sortColumnKey error message more clear --- awx/ui_next/src/components/Sort/Sort.jsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c90d1200554a..c0a513cd48b6 100644 --- a/awx/ui_next/src/components/Sort/Sort.jsx +++ b/awx/ui_next/src/components/Sort/Sort.jsx @@ -102,9 +102,16 @@ class Sort extends React.Component { const { up } = DropdownPosition; const { columns, i18n } = this.props; const { isSortDropdownOpen, sortKey, sortOrder, isNumeric } = this.state; - const [{ name: sortedColumnName }] = columns.filter( - ({ key }) => key === sortKey - ); + + const defaultSortedColumn = columns.find(({ key }) => key === sortKey); + + if (!defaultSortedColumn) { + throw new Error( + 'sortKey must match one of the column keys, check the sortColumns prop passed to ' + ); + } + + const sortedColumnName = defaultSortedColumn?.name; const sortDropdownItems = columns .filter(({ key }) => key !== sortKey) From f24654fb262bba2894eb0555f870f64128feddde Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 5 Aug 2020 11:23:44 -0400 Subject: [PATCH 11/14] update searchablekeys prop names for project lookup --- awx/ui_next/src/components/Lookup/ProjectLookup.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index e8a0a501224d..23e8b32f9e50 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -130,8 +130,8 @@ function ProjectLookup({ key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} options={projects} optionCount={count} multiple={state.multiple} From f8bd8abc8299b62dd4253a79da5034a2f6826bbe Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 5 Aug 2020 11:48:22 -0400 Subject: [PATCH 12/14] make name default searchColumn for ProjectJobTemplatesList. also add helpful error message to tell you this is the issue --- awx/ui_next/src/components/Search/Search.jsx | 12 +++++++++++- .../ProjectJobTemplatesList.jsx | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/Search/Search.jsx b/awx/ui_next/src/components/Search/Search.jsx index f0c3583b529a..916629c691af 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -43,7 +43,17 @@ function Search({ }) { const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); const [searchKey, setSearchKey] = useState( - columns.find(col => col.isDefault).key + (() => { + const defaultColumn = columns.filter(col => col.isDefault); + + if (defaultColumn.length !== 1) { + throw new Error( + 'One (and only one) searchColumn must be marked isDefault: true' + ); + } + + return defaultColumn[0]?.key; + })() ); const [searchValue, setSearchValue] = useState(''); const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); diff --git a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx index 92bb583907e9..d804a6ea8334 100644 --- a/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectJobTemplatesList/ProjectJobTemplatesList.jsx @@ -102,6 +102,11 @@ function ProjectJobTemplatesList({ i18n }) { qsConfig={QS_CONFIG} onRowClick={handleSelect} toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name__icontains', + isDefault: true, + }, { name: i18n._(t`Created By (Username)`), key: 'created_by__username__icontains', From cbea77a90c117b0303dc09c3d5d991ae4715b1e3 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 5 Aug 2020 13:40:41 -0400 Subject: [PATCH 13/14] update rest of lookups to use correct searchableKeys props --- .../components/LaunchPrompt/steps/InventoryStep.jsx | 4 ++-- .../src/components/Lookup/ApplicationLookup.jsx | 4 ++-- .../src/components/Lookup/CredentialLookup.jsx | 4 ++-- .../src/components/Lookup/InstanceGroupsLookup.jsx | 4 ++-- .../src/components/Lookup/InventoryLookup.jsx | 4 ++-- .../src/components/Lookup/InventoryScriptLookup.jsx | 4 ++-- .../components/Lookup/MultiCredentialsLookup.jsx | 4 ++-- .../src/components/Search/AdvancedSearch.jsx | 13 +++---------- 8 files changed, 17 insertions(+), 24 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index 70e87ee6aa3b..0c23c0c2b279 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -95,8 +95,8 @@ function InventoryStep({ i18n }) { key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} header={i18n._(t`Inventory`)} name="inventory" qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx index bbcc1eabe600..8056114046df 100644 --- a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx @@ -101,8 +101,8 @@ function ApplicationLookup({ i18n, onChange, value, label }) { key: 'description', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} readOnly={!canDelete} name="application" selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index aed9f15d73ba..2d7d109d8349 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -139,8 +139,8 @@ function CredentialLookup({ key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} readOnly={!canDelete} name="credential" selectItem={item => dispatch({ type: 'SELECT_ITEM', item })} diff --git a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx index c44f17cd1475..21fe8cfa8f51 100644 --- a/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InstanceGroupsLookup.jsx @@ -100,8 +100,8 @@ function InstanceGroupsLookup(props) { key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={state.multiple} header={i18n._(t`Instance Groups`)} name="instanceGroups" diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 46e4d4f0c5d8..117e0cebb85a 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -88,8 +88,8 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={state.multiple} header={i18n._(t`Inventory`)} name="inventory" diff --git a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx index fb29a7a63e3f..d5638fc831dc 100644 --- a/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryScriptLookup.jsx @@ -125,8 +125,8 @@ function InventoryScriptLookup({ key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} /> )} /> diff --git a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx index 116996174061..723bdebc5a03 100644 --- a/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx +++ b/awx/ui_next/src/components/Lookup/MultiCredentialsLookup.jsx @@ -181,8 +181,8 @@ function MultiCredentialsLookup(props) { key: 'name', }, ]} - toolbarSearchableKeys={searchableKeys} - toolbarRelatedSearchableKeys={relatedSearchableKeys} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={isVault} header={i18n._(t`Credentials`)} name="credentials" diff --git a/awx/ui_next/src/components/Search/AdvancedSearch.jsx b/awx/ui_next/src/components/Search/AdvancedSearch.jsx index 04b43614c094..cb1d8b72bdba 100644 --- a/awx/ui_next/src/components/Search/AdvancedSearch.jsx +++ b/awx/ui_next/src/components/Search/AdvancedSearch.jsx @@ -95,32 +95,25 @@ function AdvancedSearch({ > -