diff --git a/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx b/awx/ui_next/src/components/DataListToolbar/DataListToolbar.jsx index cc4b44dc72..9e02017fdf 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 305c741a47..f383c24130 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 52263d2ec7..d501418c44 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 cd7d35f7ba..c44f17cd14 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 0000000000..43ef7ce09f --- /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 0000000000..32c784a01f --- /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 0fb59e0806..029c2a39a5 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 && (