diff --git a/awx/ui_next/src/api/models/Applications.js b/awx/ui_next/src/api/models/Applications.js index 51aaeaa2a1..a8fe15f694 100644 --- a/awx/ui_next/src/api/models/Applications.js +++ b/awx/ui_next/src/api/models/Applications.js @@ -11,6 +11,10 @@ class Applications extends Base { params, }); } + + readTokenOptions(appId) { + return this.http.options(`${this.baseUrl}${appId}/tokens/`); + } } export default Applications; diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index 02099c2b67..c9d47826e2 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -60,6 +60,10 @@ class Users extends Base { params, }); } + + readTokenOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/tokens/`); + } } export default Users; diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 5727a78c74..2da3b02e9d 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -156,16 +156,16 @@ class AddResourceRole extends React.Component { const userSearchColumns = [ { name: i18n._(t`Username`), - key: 'username', + key: 'username__icontains', isDefault: true, }, { name: i18n._(t`First Name`), - key: 'first_name', + key: 'first_name__icontains', }, { name: i18n._(t`Last Name`), - key: 'last_name', + key: 'last_name__icontains', }, ]; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index d309ea706f..c4c83f9d3b 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -13,7 +13,7 @@ describe('', () => { const searchColumns = [ { name: 'Username', - key: 'username', + key: 'username__icontains', isDefault: true, }, ]; diff --git a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx index e813e1f70b..339b5ed744 100644 --- a/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx +++ b/awx/ui_next/src/components/AssociateModal/AssociateModal.jsx @@ -114,16 +114,16 @@ function AssociateModal({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ 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"]'; @@ -66,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', @@ -108,7 +111,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 +147,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 +286,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/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 5a18e10233..b1108b8cb9 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -38,7 +38,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { const [selected, setSelected] = useState([]); const location = useLocation(); const { - result: { results, count }, + result: { results, count, actions, relatedSearchFields }, error: contentError, isLoading, request: fetchJobs, @@ -46,12 +46,27 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { useCallback( async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { data } = await UnifiedJobsAPI.read({ ...params }); - return data; + const [response, actionsResponse] = await Promise.all([ + UnifiedJobsAPI.read({ ...params }), + UnifiedJobsAPI.readOptions(), + ]); + return { + results: response.data.results, + count: response.data.count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + }; }, [location] // eslint-disable-line react-hooks/exhaustive-deps ), - { results: [], count: 0 } + { + results: [], + count: 0, + actions: {}, + relatedSearchFields: [], + } ); useEffect(() => { fetchJobs(); @@ -123,6 +138,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { } }; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( <> @@ -137,7 +157,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { @@ -146,11 +166,11 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { }, { name: i18n._(t`Label Name`), - key: 'labels__name', + key: 'labels__name__icontains', }, { name: i18n._(t`Job Type`), - key: `type`, + key: `or__type`, options: [ [`project_update`, i18n._(t`Source Control Update`)], [`inventory_update`, i18n._(t`Inventory Sync`)], @@ -162,7 +182,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { }, { name: i18n._(t`Launched By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Status`), @@ -209,6 +229,8 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { key: 'started', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( val.slice(0, -8)), }; }, [selectedType, history.location.search]), - { credentials: [], count: 0 } + { credentials: [], count: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { @@ -97,6 +104,11 @@ function CredentialsStep({ i18n }) { /> ); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( <> {types && types.length > 0 && ( @@ -129,16 +141,16 @@ function CredentialsStep({ i18n }) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -147,6 +159,8 @@ function CredentialsStep({ i18n }) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} multiple={isVault} header={i18n._(t`Credentials`)} name="credentials" diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx index 543ac0baad..aec480547b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialsStep.test.jsx @@ -31,6 +31,15 @@ describe('CredentialsStep', () => { count: 5, }, }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); test('should load credentials', async () => { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index 7360a53f07..0c23c0c2b2 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -27,20 +27,29 @@ function InventoryStep({ i18n }) { const { isLoading, error, - result: { inventories, count }, + result: { inventories, count, actions, relatedSearchFields }, request: fetchInventories, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InventoriesAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InventoriesAPI.read(params), + InventoriesAPI.readOptions(), + ]); return { inventories: data.results, count: data.count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [history.location]), { count: 0, inventories: [], + actions: {}, + relatedSearchFields: [], } ); @@ -48,6 +57,11 @@ function InventoryStep({ i18n }) { fetchInventories(); }, [fetchInventories]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + if (isLoading) { return ; } @@ -63,16 +77,16 @@ function InventoryStep({ i18n }) { searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -81,6 +95,8 @@ function InventoryStep({ i18n }) { key: 'name', }, ]} + searchableKeys={searchableKeys} + relatedSearchableKeys={relatedSearchableKeys} header={i18n._(t`Inventory`)} name="inventory" qsConfig={QS_CONFIG} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx index f2bf29d90a..21380b07a9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.test.jsx @@ -21,6 +21,16 @@ describe('InventoryStep', () => { count: 3, }, }); + + InventoriesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); test('should load inventories', async () => { 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/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx index 2d43c0491b..8056114046 100644 --- a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx @@ -22,22 +22,41 @@ function ApplicationLookup({ i18n, onChange, value, label }) { const location = useLocation(); const { error, - result: { applications, itemCount }, + result: { applications, itemCount, actions, relatedSearchFields }, request: fetchApplications, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count }, - } = await ApplicationsAPI.read(params); - return { applications: results, itemCount: count }; + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + ApplicationsAPI.read(params), + ApplicationsAPI.readOptions, + ]); + return { + applications: results, + itemCount: count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + }; }, [location]), - { applications: [], itemCount: 0 } + { applications: [], itemCount: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { fetchApplications(); }, [fetchApplications]); + + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( 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 27b63f585c..2d7d109d83 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -35,7 +35,7 @@ function CredentialLookup({ tooltip, }) { const { - result: { count, credentials }, + result: { count, credentials, actions, relatedSearchFields }, error, request: fetchCredentials, } = useRequest( @@ -51,16 +51,23 @@ function CredentialLookup({ ? { credential_type__namespace: credentialTypeNamespace } : {}; - const { data } = await CredentialsAPI.read( - mergeParams(params, { - ...typeIdParams, - ...typeKindParams, - ...typeNamespaceParams, - }) - ); + const [{ data }, actionsResponse] = await Promise.all([ + CredentialsAPI.read( + mergeParams(params, { + ...typeIdParams, + ...typeKindParams, + ...typeNamespaceParams, + }) + ), + CredentialsAPI.readOptions, + ]); return { count: data.count, credentials: data.results, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [ credentialTypeId, @@ -71,6 +78,8 @@ function CredentialLookup({ { count: 0, credentials: [], + actions: {}, + relatedSearchFields: [], } ); @@ -78,6 +87,11 @@ function CredentialLookup({ fetchCredentials(); }, [fetchCredentials]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + // TODO: replace credential type search with REST-based grabbing of cred types return ( @@ -107,16 +121,16 @@ function CredentialLookup({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} sortColumns={[ @@ -125,6 +139,8 @@ function CredentialLookup({ key: 'name', }, ]} + 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 cd7d35f7ba..21fe8cfa8f 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 ( { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await InventoriesAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + InventoriesAPI.read(params), + InventoriesAPI.readOptions(), + ]); return { inventories: data.results, count: data.count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [history.location]), - { inventories: [], count: 0 } + { inventories: [], count: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { fetchInventories(); }, [fetchInventories]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( <> val.slice(0, -8)), }; }, [selectedType, history.location]), { credentials: [], credentialsCount: 0, + actions: {}, + relatedSearchFields: [], } ); @@ -95,6 +104,11 @@ function MultiCredentialsLookup(props) { const isVault = selectedType?.kind === 'vault'; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( ', () => { count: 3, }, }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); }); afterEach(() => { diff --git a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx index 57d4323450..82274cd7fc 100644 --- a/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx +++ b/awx/ui_next/src/components/Lookup/OrganizationLookup.jsx @@ -80,16 +80,16 @@ function OrganizationLookup({ searchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { - name: i18n._(t`Created by (username)`), - key: 'created_by__username', + name: i18n._(t`Created By (Username)`), + key: 'created_by__username__icontains', }, { - name: i18n._(t`Modified by (username)`), - key: 'modified_by__username', + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username__icontains', }, ]} sortColumns={[ diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index e0246c574e..23e8b32f9e 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -32,25 +32,34 @@ function ProjectLookup({ history, }) { const { - result: { projects, count }, + result: { projects, count, actions, relatedSearchFields }, request: fetchProjects, error, isLoading, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await ProjectsAPI.read(params); + const [{ data }, actionsResponse] = await Promise.all([ + ProjectsAPI.read(params), + ProjectsAPI.readOptions(), + ]); if (data.count === 1 && autocomplete) { autocomplete(data.results[0]); } return { count: data.count, projects: data.results, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [history.location.search, autocomplete]), { count: 0, projects: [], + actions: {}, + relatedSearchFields: [], } ); @@ -58,6 +67,11 @@ function ProjectLookup({ fetchProjects(); }, [fetchProjects]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( ( diff --git a/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx b/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx index 34bfed560b..5c4498a748 100644 --- a/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx +++ b/awx/ui_next/src/components/OptionsList/OptionsList.test.jsx @@ -17,7 +17,9 @@ describe('', () => { value={[]} options={options} optionCount={3} - searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'Foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} @@ -40,7 +42,9 @@ describe('', () => { value={[options[1]]} options={options} optionCount={3} - searchColumns={[{ name: 'Foo', key: 'foo', isDefault: true }]} + searchColumns={[ + { name: 'Foo', key: 'foo__icontains', isDefault: true }, + ]} sortColumns={[{ name: 'Foo', key: 'foo' }]} qsConfig={qsConfig} selectItem={() => {}} diff --git a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx index d25ad79e6b..8bd449e851 100644 --- a/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx +++ b/awx/ui_next/src/components/PaginatedDataList/PaginatedDataList.jsx @@ -69,6 +69,8 @@ class PaginatedDataList extends React.Component { qsConfig, renderItem, toolbarSearchColumns, + toolbarSearchableKeys, + toolbarRelatedSearchableKeys, toolbarSortColumns, pluralizedItemName, showPageSizeOptions, @@ -151,6 +153,8 @@ class PaginatedDataList extends React.Component { emptyStateControls={emptyStateControls} searchColumns={searchColumns} sortColumns={sortColumns} + searchableKeys={toolbarSearchableKeys} + relatedSearchableKeys={toolbarRelatedSearchableKeys} qsConfig={qsConfig} pagination={ToolbarPagination} /> @@ -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/ResourceAccessList/ResourceAccessList.jsx b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx index 6d8a1bf280..c74d6b5865 100644 --- a/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx +++ b/awx/ui_next/src/components/ResourceAccessList/ResourceAccessList.jsx @@ -80,16 +80,16 @@ function ResourceAccessList({ i18n, apiModel, resource }) { toolbarSearchColumns={[ { name: i18n._(t`Username`), - key: 'username', + key: 'username__icontains', isDefault: true, }, { - name: i18n._(t`First name`), - key: 'first_name', + name: i18n._(t`First Name`), + key: 'first_name__icontains', }, { - name: i18n._(t`Last name`), - key: 'last_name', + name: i18n._(t`Last Name`), + key: 'last_name__icontains', }, ]} toolbarSortColumns={[ @@ -98,11 +98,11 @@ function ResourceAccessList({ i18n, apiModel, resource }) { key: 'username', }, { - name: i18n._(t`First name`), + name: i18n._(t`First Name`), key: 'first_name', }, { - name: i18n._(t`Last name`), + name: i18n._(t`Last Name`), key: 'last_name', }, ]} diff --git a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx index 16ce15c4b4..281e8ac8ff 100644 --- a/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx +++ b/awx/ui_next/src/components/Schedule/ScheduleList/ScheduleList.jsx @@ -32,7 +32,7 @@ function ScheduleList({ const location = useLocation(); const { - result: { schedules, itemCount, actions }, + result: { schedules, itemCount, actions, relatedSearchFields }, error: contentError, isLoading, request: fetchSchedules, @@ -49,12 +49,16 @@ function ScheduleList({ schedules: results, itemCount: count, actions: scheduleActions.data.actions, + relatedSearchFields: ( + scheduleActions?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [location, loadSchedules, loadScheduleOptions]), { schedules: [], itemCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -102,6 +106,10 @@ function ScheduleList({ actions && Object.prototype.hasOwnProperty.call(actions, 'POST') && !hideAddButton; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> @@ -123,7 +131,7 @@ function ScheduleList({ toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, ]} @@ -141,6 +149,8 @@ function ScheduleList({ key: 'unified_job_template__polymorphic_ctype__model', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { + // 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 ( + + + + + + +
+ +
+
+
+ ); +} + +AdvancedSearch.propTypes = { + onSearch: PropTypes.func.isRequired, + searchableKeys: PropTypes.arrayOf(PropTypes.string), + relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string), +}; + +AdvancedSearch.defaultProps = { + searchableKeys: [], + relatedSearchableKeys: [], +}; + +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..916629c691 100644 --- a/awx/ui_next/src/components/Search/Search.jsx +++ b/awx/ui_next/src/components/Search/Search.jsx @@ -1,5 +1,5 @@ import 'styled-components/macro'; -import React, { Fragment } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -7,10 +7,6 @@ import { withRouter } from 'react-router-dom'; import { Button, ButtonVariant, - Dropdown, - DropdownPosition, - DropdownToggle, - DropdownItem, InputGroup, Select, SelectOption, @@ -24,6 +20,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; @@ -33,288 +30,267 @@ const NoOptionDropdown = styled.div` border-bottom-color: var(--pf-global--BorderColor--200); `; -class Search extends React.Component { - constructor(props) { - super(props); +function Search({ + columns, + i18n, + onSearch, + onReplaceSearch, + onRemove, + qsConfig, + location, + searchableKeys, + relatedSearchableKeys, +}) { + const [isSearchDropdownOpen, setIsSearchDropdownOpen] = useState(false); + const [searchKey, setSearchKey] = useState( + (() => { + const defaultColumn = columns.filter(col => col.isDefault); - const { columns } = this.props; + if (defaultColumn.length !== 1) { + throw new Error( + 'One (and only one) searchColumn must be marked isDefault: true' + ); + } - this.state = { - isSearchDropdownOpen: false, - searchKey: columns.find(col => col.isDefault).key, - searchValue: '', - isFilterDropdownOpen: false, - }; + return defaultColumn[0]?.key; + })() + ); + const [searchValue, setSearchValue] = useState(''); + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(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 + const handleDropdownSelect = ({ target }) => { + const { key: actualSearchKey } = columns.find( + ({ name }) => name === target.innerText ); - this.handleFilterDropdownSelect = this.handleFilterDropdownSelect.bind( - this - ); - this.handleFilterBooleanSelect = this.handleFilterBooleanSelect.bind(this); - } - handleDropdownToggle(isSearchDropdownOpen) { - this.setState({ isSearchDropdownOpen }); - } + setIsFilterDropdownOpen(false); + setSearchKey(actualSearchKey); + }; - handleDropdownSelect({ target }) { - const { columns } = this.props; - const { innerText } = target; - - const { key: searchKey } = columns.find(({ name }) => name === innerText); - this.setState({ isSearchDropdownOpen: false, searchKey }); - } - - handleSearch(e) { + const handleSearch = e => { // keeps page from fully reloading e.preventDefault(); - const { searchKey, searchValue } = this.state; - const { onSearch, qsConfig } = 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); - - this.setState({ searchValue: '' }); + onSearch(searchKey, searchValue); + setSearchValue(''); } - } + }; - handleSearchInputChange(searchValue) { - this.setState({ searchValue }); - } - - handleTextKeyDown(e) { + const handleTextKeyDown = e => { 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) { - onSearch(`or__${key}`, actualValue); + onSearch(key, actualValue); } else { - onRemove(`or__${key}`, actualValue); + onRemove(key, actualValue); } - } + }; - handleFilterBooleanSelect(key, selection) { - const { onReplaceSearch } = this.props; - onReplaceSearch(key, selection); - } + const filterDefaultParams = (paramsArr, config) => { + const defaultParamsKeys = Object.keys(config.defaultParams || {}); + return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); + }; - render() { - const { up } = DropdownPosition; - const { columns, i18n, onRemove, qsConfig, location } = this.props; - const { - isSearchDropdownOpen, - searchKey, - searchValue, - isFilterDropdownOpen, - } = this.state; - const { name: searchColumnName } = columns.find( - ({ key }) => key === searchKey + const getLabelFromValue = (value, colKey) => { + const currentSearchColumn = columns.find(({ key }) => key === colKey); + 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 ); - const searchDropdownItems = columns - .filter(({ key }) => key !== searchKey) - .map(({ key, name }) => ( - - {name} - - )); + nonDefaultParams.forEach(key => { + const columnKey = key; + const label = columns.filter( + ({ key: keyToCheck }) => columnKey === keyToCheck + ).length + ? `${ + columns.find(({ key: keyToCheck }) => columnKey === keyToCheck).name + } (${key})` + : columnKey; - const filterDefaultParams = (paramsArr, config) => { - const defaultParamsKeys = Object.keys(config.defaultParams || {}); - return paramsArr.filter(key => defaultParamsKeys.indexOf(key) === -1); - }; + queryParamsByKey[columnKey] = { key, label, chips: [] }; - const getLabelFromValue = (value, colKey) => { - const currentSearchColumn = columns.find(({ key }) => key === colKey); - 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.replace('__icontains', '').replace('or__', ''); - const label = columns.filter( - ({ key: keyToCheck }) => columnKey === keyToCheck - ).length - ? columns.filter(({ key: keyToCheck }) => columnKey === keyToCheck)[0] - .name - : 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 { + if (Array.isArray(queryParams[key])) { + queryParams[key].forEach(val => queryParamsByKey[columnKey].chips.push({ - key: `${key}:${queryParams[key]}`, - node: getLabelFromValue(queryParams[key], columnKey), - }); - } - }); + key: `${key}:${val}`, + node: getLabelFromValue(val, columnKey), + }) + ); + } else { + queryParamsByKey[columnKey].chips.push({ + key: `${key}:${queryParams[key]}`, + node: getLabelFromValue(queryParams[key], columnKey), + }); + } + }); + return queryParamsByKey; + }; - return queryParamsByKey; - }; + const chipsByKey = getChipsByKey(); - const chipsByKey = getChipsByKey(); + const { name: searchColumnName } = columns.find( + ({ key }) => key === searchKey + ); - return ( - - - {searchDropdownItems.length > 0 ? ( - - {searchColumnName} - - } - isOpen={isSearchDropdownOpen} - dropdownItems={searchDropdownItems} - /> - ) : ( - {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} - > - {(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} - /> -
- -
-
- )} -
- ) + const searchOptions = columns + .filter(({ key }) => key !== searchKey) + .map(({ key, name }) => ( + + {name} + + )); + + return ( + + + {searchOptions.length > 0 ? ( + + ) : ( + {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' && ( + + )) || + (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={setSearchValue} + onKeyDown={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} + /> + ))} + + ); } 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 5eac1c532b..a34b6bcc09 100644 --- a/awx/ui_next/src/components/Search/Search.test.jsx +++ b/awx/ui_next/src/components/Search/Search.test.jsx @@ -22,7 +22,7 @@ describe('', () => { }); test('it triggers the expected callbacks', () => { - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const searchBtn = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; @@ -49,29 +49,12 @@ describe('', () => { expect(onSearch).toBeCalledWith('name__icontains', 'test-321'); }); - test('handleDropdownToggle properly updates state', async () => { - const columns = [{ name: 'Name', key: 'name', 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', isDefault: true }, - { name: 'Description', key: 'description' }, + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Description', key: 'description__icontains' }, ]; const onSearch = jest.fn(); const wrapper = mountWithContexts( @@ -84,18 +67,26 @@ describe('', () => { - ).find('Search'); - expect(wrapper.state('searchKey')).toEqual('name'); - wrapper - .instance() - .handleDropdownSelect({ target: { innerText: 'Description' } }); - expect(wrapper.state('searchKey')).toEqual('description'); + ); + + 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', () => { const searchButton = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( ', () => { test('search with a valid string', () => { const searchButton = 'button[aria-label="Search submit button"]'; const searchTextInput = 'input[aria-label="Search text input"]'; - const columns = [{ name: 'Name', key: 'name', isDefault: true }]; + const columns = [{ name: 'Name', key: 'name__icontains', isDefault: true }]; const onSearch = jest.fn(); const wrapper = mountWithContexts( ', () => { test('filter keys are properly labeled', () => { const columns = [ - { name: 'Name', key: 'name', isDefault: true }, - { name: 'Type', key: 'type', options: [['foo', 'Foo Bar!']] }, + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] }, { name: 'Description', key: 'description' }, ]; const query = - '?organization.or__type=foo&organization.name=bar&item.page_size=10'; + '?organization.or__scm_type=foo&organization.name__icontains=bar&item.page_size=10'; const history = createMemoryHistory({ initialEntries: [`/organizations/${query}`], }); @@ -165,13 +156,15 @@ describe('', () => { { context: { router: { history } } } ); const typeFilterWrapper = wrapper.find( - 'ToolbarFilter[categoryName="Type"]' + 'ToolbarFilter[categoryName="Type (or__scm_type)"]' ); - expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__type:foo'); + expect(typeFilterWrapper.prop('chips')[0].key).toEqual('or__scm_type:foo'); const nameFilterWrapper = wrapper.find( - 'ToolbarFilter[categoryName="Name"]' + 'ToolbarFilter[categoryName="Name (name__icontains)"]' + ); + expect(nameFilterWrapper.prop('chips')[0].key).toEqual( + 'name__icontains:bar' ); - expect(nameFilterWrapper.prop('chips')[0].key).toEqual('name:bar'); }); test('should test handle remove of option-based key', async () => { @@ -265,4 +258,37 @@ describe('', () => { }); expect(onRemove).toBeCalledWith('or__type', ''); }); + + test("ToolbarFilter added for any key that doesn't have search column", () => { + const columns = [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + { name: 'Type', key: 'or__scm_type', options: [['foo', 'Foo Bar!']] }, + { name: 'Description', key: 'description' }, + ]; + const query = + '?organization.or__scm_type=foo&organization.name__icontains=bar&organization.name__exact=baz&item.page_size=10&organization.foo=bar'; + const history = createMemoryHistory({ + initialEntries: [`/organizations/${query}`], + }); + const wrapper = mountWithContexts( + {}} + collapseListedFiltersBreakpoint="lg" + > + + + + , + { context: { router: { history } } } + ); + const nameExactFilterWrapper = wrapper.find( + 'ToolbarFilter[categoryName="name__exact"]' + ); + expect(nameExactFilterWrapper.prop('chips')[0].key).toEqual( + 'name__exact:baz' + ); + const fooFilterWrapper = wrapper.find('ToolbarFilter[categoryName="foo"]'); + expect(fooFilterWrapper.prop('chips')[0].key).toEqual('foo:bar'); + }); }); diff --git a/awx/ui_next/src/components/Sort/Sort.jsx b/awx/ui_next/src/components/Sort/Sort.jsx index c90d120055..c0a513cd48 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) diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx index b5f986c5db..a46d5d87a3 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -82,7 +82,9 @@ describe('', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); @@ -116,7 +118,9 @@ describe('', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); @@ -190,7 +194,9 @@ describe('', () => { fetchItems: JobTemplatesAPI.read, label: 'Job template', selectedResource: 'jobTemplate', - searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + searchColumns: [ + { name: 'Name', key: 'name__icontains', isDefault: true }, + ], sortColumns: [{ name: 'Name', key: 'name' }], }) ); diff --git a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js index 718476e70e..cd922c23aa 100644 --- a/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -16,20 +16,20 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Playbook name`), - key: 'playbook', + key: 'playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -46,20 +46,20 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Playbook name`), - key: 'playbook', + key: 'playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -76,12 +76,12 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -92,15 +92,15 @@ export default function getResourceAccessConfig(i18n) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ], sortColumns: [ @@ -117,16 +117,16 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ @@ -143,12 +143,12 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -159,15 +159,15 @@ export default function getResourceAccessConfig(i18n) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ], sortColumns: [ @@ -184,16 +184,16 @@ export default function getResourceAccessConfig(i18n) { searchColumns: [ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ], sortColumns: [ diff --git a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx index b0b29d6264..7d123385ef 100644 --- a/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationTokens/ApplicationTokenList.jsx @@ -26,14 +26,20 @@ function ApplicationTokenList({ i18n }) { const { error, isLoading, - result: { tokens, itemCount }, + result: { tokens, itemCount, actions, relatedSearchFields }, request: fetchTokens, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count }, - } = await ApplicationsAPI.readTokens(id, params); + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + ApplicationsAPI.readTokens(id, params), + ApplicationsAPI.readTokenOptions(id), + ]); const modifiedResults = results.map(result => { result.summary_fields = { user: result.summary_fields.user, @@ -43,9 +49,16 @@ function ApplicationTokenList({ i18n }) { result.name = result.summary_fields.user?.username; return result; }); - return { tokens: modifiedResults, itemCount: count }; + return { + tokens: modifiedResults, + itemCount: count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + }; }, [id, location.search]), - { tokens: [], itemCount: 0 } + { tokens: [], itemCount: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { @@ -77,6 +90,12 @@ function ApplicationTokenList({ i18n }) { await handleDeleteApplications(); setSelected([]); }; + + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( <> ( ', () => { let wrapper; + + beforeEach(() => { + ApplicationsAPI.readTokenOptions.mockResolvedValue({ + data: { + actions: { + GET: {}, + POST: {}, + }, + related_search_fields: [], + }, + }); + }); + test('should mount properly', async () => { ApplicationsAPI.readTokens.mockResolvedValue(tokens); await act(async () => { diff --git a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx index f4f2571863..598f4234c7 100644 --- a/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx +++ b/awx/ui_next/src/screens/Application/ApplicationsList/ApplicationsList.jsx @@ -32,7 +32,7 @@ function ApplicationsList({ i18n }) { isLoading, error, request: fetchApplications, - result: { applications, itemCount, actions }, + result: { applications, itemCount, actions, relatedSearchFields }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); @@ -46,12 +46,16 @@ function ApplicationsList({ i18n }) { applications: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [location]), { applications: [], itemCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -85,6 +89,10 @@ function ApplicationsList({ i18n }) { }; const canAdd = actions && actions.POST; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> @@ -101,12 +109,12 @@ function ApplicationsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Description`), - key: 'description', + key: 'description__icontains', }, ]} toolbarSortColumns={[ @@ -127,6 +135,8 @@ function ApplicationsList({ i18n }) { key: 'description', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { const params = parseQueryString(QS_CONFIG, history.location.search); - const { data } = await CredentialsAPI.read({ - ...params, - }); + const [{ data }, actionsResponse] = await Promise.all([ + CredentialsAPI.read({ ...params }), + CredentialsAPI.readOptions(), + ]); return { credentials: data.results, count: data.count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [history.location.search]), - { credentials: [], count: 0 } + { credentials: [], count: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { fetchCredentials(); }, [fetchCredentials]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + if (credentialsError) { return ; } @@ -76,16 +86,16 @@ function CredentialsStep({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -94,6 +104,8 @@ function CredentialsStep({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} /> ); } diff --git a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx index 9e8737912d..bfcde651a4 100644 --- a/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx +++ b/awx/ui_next/src/screens/Host/HostGroups/HostGroupsList.jsx @@ -33,7 +33,7 @@ function HostGroupsList({ i18n, host }) { const invId = host.summary_fields.inventory.id; const { - result: { groups, itemCount, actions }, + result: { groups, itemCount, actions, relatedSearchFields }, error: contentError, isLoading, request: fetchGroups, @@ -55,11 +55,16 @@ function HostGroupsList({ i18n, host }) { groups: results, itemCount: count, actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [hostId, search]), { groups: [], itemCount: 0, + actions: {}, + relatedSearchFields: [], } ); @@ -123,6 +128,10 @@ function HostGroupsList({ i18n, host }) { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> @@ -136,16 +145,16 @@ function HostGroupsList({ i18n, host }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -154,6 +163,8 @@ function HostGroupsList({ i18n, host }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={item => ( val.slice(0, -8)), }; }, [location]), { hosts: [], count: 0, actions: {}, + relatedSearchFields: [], } ); @@ -93,6 +97,10 @@ function HostList({ i18n }) { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( @@ -108,16 +116,16 @@ function HostList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -126,6 +134,8 @@ function HostList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( val.slice(0, -8)), }; }, [groupId, inventoryId, location.search]), { hosts: [], hostCount: 0, + actions: {}, + relatedSearchFields: [], } ); @@ -122,6 +127,10 @@ function InventoryGroupHostList({ i18n }) { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); const addFormUrl = `/inventories/inventory/${inventoryId}/groups/${groupId}/nested_hosts/add`; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> @@ -136,16 +145,16 @@ function InventoryGroupHostList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -154,6 +163,8 @@ function InventoryGroupHostList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( val.slice(0, -8)), }; }, [hostId, search]), // eslint-disable-line react-hooks/exhaustive-deps { groups: [], itemCount: 0, + actions: {}, + relatedSearchFields: [], } ); @@ -121,6 +126,10 @@ function InventoryHostGroupsList({ i18n }) { const canAdd = actions && Object.prototype.hasOwnProperty.call(actions, 'POST'); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> @@ -134,16 +143,16 @@ function InventoryHostGroupsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -152,6 +161,8 @@ function InventoryHostGroupsList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={item => ( val.slice(0, -8)), }; }, [location]), { results: [], itemCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -93,6 +97,10 @@ function InventoryList({ i18n }) { const hasContentLoading = isDeleteLoading || isLoading; const canAdd = actions && actions.POST; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const handleSelectAll = isSelected => { setSelected(isSelected ? [...inventories] : []); @@ -135,16 +143,16 @@ function InventoryList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -153,6 +161,8 @@ function InventoryList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( val.slice(0, -8)), }; }, [location]), { organizations: [], organizationCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -86,6 +90,10 @@ function OrganizationsList({ i18n }) { const hasContentLoading = isDeleteLoading || isOrgsLoading; const canAdd = actions && actions.POST; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const handleSelectAll = isSelected => { setSelected(isSelected ? [...organizations] : []); @@ -114,16 +122,16 @@ function OrganizationsList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -132,6 +140,8 @@ function OrganizationsList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( val.slice(0, -8)), }; }, [location]), { results: [], itemCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -85,6 +89,10 @@ function ProjectList({ i18n }) { const hasContentLoading = isDeleteLoading || isLoading; const canAdd = actions && actions.POST; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const handleSelectAll = isSelected => { setSelected(isSelected ? [...projects] : []); @@ -113,12 +121,12 @@ function ProjectList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Type`), - key: 'scm_type', + key: 'or__scm_type', options: [ [``, i18n._(t`Manual`)], [`git`, i18n._(t`Git`)], @@ -129,17 +137,19 @@ function ProjectList({ i18n }) { }, { name: i18n._(t`Source Control URL`), - key: 'scm_url', + key: 'scm_url__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} toolbarSortColumns={[ { name: i18n._(t`Name`), diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx index 945fbfc8e4..b50e569f18 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.test.jsx @@ -78,6 +78,7 @@ describe('', () => { GET: {}, POST: {}, }, + related_search_fields: [], }, }); }); diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx index 5dc6580274..f660064266 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamList.jsx @@ -29,7 +29,7 @@ function TeamList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { teams, itemCount, actions }, + result: { teams, itemCount, actions, relatedSearchFields }, error: contentError, isLoading, request: fetchTeams, @@ -44,12 +44,16 @@ function TeamList({ i18n }) { teams: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [location]), { teams: [], itemCount: 0, actions: {}, + relatedSearchFields: [], } ); @@ -81,6 +85,10 @@ function TeamList({ i18n }) { const hasContentLoading = isDeleteLoading || isLoading; const canAdd = actions && actions.POST; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const handleSelectAll = isSelected => { setSelected(isSelected ? [...teams] : []); @@ -109,20 +117,20 @@ function TeamList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Organization Name`), - key: 'organization__name', + key: 'organization__name__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -131,6 +139,8 @@ function TeamList({ i18n }) { key: 'name', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { const params = parseQueryString(QS_CONFIG, search); @@ -46,22 +46,30 @@ function TeamRolesList({ i18n, me, team }) { data: { results, count }, }, { count: orgAdminCount }, + actionsResponse, ] = await Promise.all([ TeamsAPI.readRoles(team.id, params), UsersAPI.readAdminOfOrganizations(me.id, { id: team.organization, }), + TeamsAPI.readRoleOptions(team.id), ]); return { roleCount: count, roles: results, isAdminOfOrg: orgAdminCount > 0, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [me.id, team.id, team.organization, search]), { roles: [], roleCount: 0, isAdminOfOrg: false, + actions: {}, + relatedSearchFields: [], } ); @@ -90,6 +98,10 @@ function TeamRolesList({ i18n, me, team }) { ); const canAdd = team?.summary_fields?.user_capabilities?.edit || isAdminOfOrg; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; @@ -136,16 +148,18 @@ function TeamRolesList({ i18n, me, team }) { toolbarSearchColumns={[ { name: i18n._(t`Role`), - key: 'role_field', + key: 'role_field__icontains', isDefault: true, }, ]} toolbarSortColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`ID`), key: 'id', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( ', () => { }, ], }); + + TeamsAPI.readRoleOptions.mockResolvedValue({ + data: { + actions: { GET: {} }, + related_search_fields: [], + }, + }); }); afterEach(() => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 909db5d29c..31c04263ad 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -36,7 +36,7 @@ function TemplateList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { results, count, jtActions, wfjtActions }, + result: { results, count, jtActions, wfjtActions, relatedSearchFields }, error: contentError, isLoading, request: fetchTemplates, @@ -53,6 +53,14 @@ function TemplateList({ i18n }) { count: responses[0].data.count, jtActions: responses[1].data.actions, wfjtActions: responses[2].data.actions, + relatedSearchFields: [ + ...(responses[1]?.data?.related_search_fields || []).map(val => + val.slice(0, -8) + ), + ...(responses[2]?.data?.related_search_fields || []).map(val => + val.slice(0, -8) + ), + ], }; }, [location]), { @@ -60,6 +68,7 @@ function TemplateList({ i18n }) { count: 0, jtActions: {}, wfjtActions: {}, + relatedSearchFields: [], } ); @@ -118,6 +127,18 @@ function TemplateList({ i18n }) { jtActions && Object.prototype.hasOwnProperty.call(jtActions, 'POST'); const canAddWFJT = wfjtActions && Object.prototype.hasOwnProperty.call(wfjtActions, 'POST'); + // spreading Set() returns only unique keys + const relatedSearchableKeys = [...new Set(relatedSearchFields)] || []; + const searchableKeys = [ + ...new Set([ + ...Object.keys(jtActions?.GET || {}).filter( + key => jtActions.GET[key].filterable + ), + ...Object.keys(wfjtActions?.GET || {}).filter( + key => wfjtActions.GET[key].filterable + ), + ]), + ]; const addButtonOptions = []; if (canAddJT) { @@ -152,16 +173,16 @@ function TemplateList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Name`), - key: 'name', + key: 'name__icontains', isDefault: true, }, { name: i18n._(t`Description`), - key: 'description', + key: 'description__icontains', }, { name: i18n._(t`Type`), - key: 'type', + key: 'or__type', options: [ [`job_template`, i18n._(t`Job Template`)], [`workflow_job_template`, i18n._(t`Workflow Template`)], @@ -169,15 +190,15 @@ function TemplateList({ i18n }) { }, { name: i18n._(t`Playbook name`), - key: 'job_template__playbook', + key: 'job_template__playbook__icontains', }, { name: i18n._(t`Created By (Username)`), - key: 'created_by__username', + key: 'created_by__username__icontains', }, { name: i18n._(t`Modified By (Username)`), - key: 'modified_by__username', + key: 'modified_by__username__icontains', }, ]} toolbarSortColumns={[ @@ -206,6 +227,8 @@ function TemplateList({ i18n }) { key: 'type', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderToolbar={props => ( { const params = parseQueryString(QS_CONFIG, search); @@ -46,20 +46,28 @@ function UserAccessList({ i18n, user }) { { data: { results, count }, }, - { - data: { actions }, - }, + actionsResponse, ] = await Promise.all([ UsersAPI.readRoles(user.id, params), UsersAPI.readOptions(), ]); - return { roleCount: count, roles: results, options: actions }; + return { + roleCount: count, + roles: results, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + }; }, [user.id, search]), { roles: [], roleCount: 0, + actions: {}, + relatedSearchFields: [], } ); + useEffect(() => { fetchRoles(); }, [fetchRoles]); @@ -82,7 +90,12 @@ function UserAccessList({ i18n, user }) { const canAdd = user?.summary_fields?.user_capabilities?.edit || - (options && Object.prototype.hasOwnProperty.call(options, 'POST')); + (actions && Object.prototype.hasOwnProperty.call(actions, 'POST')); + + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); const saveRoles = () => { setIsWizardOpen(false); @@ -132,16 +145,18 @@ function UserAccessList({ i18n, user }) { toolbarSearchColumns={[ { name: i18n._(t`Role`), - key: 'role_field', + key: 'role_field__icontains', isDefault: true, }, ]} toolbarSortColumns={[ { - name: i18n._(t`Name`), + name: i18n._(t`ID`), key: 'id', }, ]} + toolbarSearchableKeys={searchableKeys} + toolbarRelatedSearchableKeys={relatedSearchableKeys} renderItem={role => { return ( ', () => { ); }); test('should not render add button when user cannot create other users and user cannot edit this user', async () => { + UsersAPI.readRoleOptions.mockResolvedValueOnce({ + data: { + actions: { + GET: {}, + }, + related_search_fields: [], + }, + }); + UsersAPI.readRoles.mockResolvedValue({ data: { results: [ diff --git a/awx/ui_next/src/screens/User/UserList/UserList.jsx b/awx/ui_next/src/screens/User/UserList/UserList.jsx index 20f5570aa6..d34706a5d6 100644 --- a/awx/ui_next/src/screens/User/UserList/UserList.jsx +++ b/awx/ui_next/src/screens/User/UserList/UserList.jsx @@ -98,16 +98,16 @@ function UserList({ i18n }) { toolbarSearchColumns={[ { name: i18n._(t`Username`), - key: 'username', + key: 'username__icontains', isDefault: true, }, { - name: i18n._(t`First name`), - key: 'first_name', + name: i18n._(t`First Name`), + key: 'first_name__icontains', }, { - name: i18n._(t`Last name`), - key: 'last_name', + name: i18n._(t`Last Name`), + key: 'last_name__icontains', }, ]} toolbarSortColumns={[ @@ -116,11 +116,11 @@ function UserList({ i18n }) { key: 'username', }, { - name: i18n._(t`First name`), + name: i18n._(t`First Name`), key: 'first_name', }, { - name: i18n._(t`Last name`), + name: i18n._(t`Last Name`), key: 'last_name', }, ]} diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx index d7a902b7e3..25d245cffd 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx @@ -20,24 +20,36 @@ function UserTeamList({ i18n }) { const { id: userId } = useParams(); const { - result: { teams, count }, + result: { teams, count, actions, relatedSearchFields }, error: contentError, isLoading, request: fetchOrgs, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count: teamCount }, - } = await UsersAPI.readTeams(userId, params); + const [ + { + data: { results, count: teamCount }, + }, + actionsResponse, + ] = await Promise.all([ + UsersAPI.readTeams(userId, params), + UsersAPI.readTeamsOptions(userId), + ]); return { teams: results, count: teamCount, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), }; }, [userId, location.search]), { teams: [], count: 0, + actions: {}, + relatedSearchFields: [], } ); @@ -45,6 +57,11 @@ function UserTeamList({ i18n }) { fetchOrgs(); }, [fetchOrgs]); + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); + return ( ); } diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx index caac6b0c5f..b7fd0a9abf 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx @@ -58,13 +58,14 @@ describe('', () => { data: mockAPIUserTeamList.data, }) ); - UsersAPI.readOptions = jest.fn(() => + UsersAPI.readTeamsOptions = jest.fn(() => Promise.resolve({ data: { actions: { GET: {}, POST: {}, }, + related_search_fields: [], }, }) ); diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx index 8ae62d85e1..e30f2c6fa2 100644 --- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx +++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenList.jsx @@ -28,13 +28,19 @@ function UserTokenList({ i18n }) { error, isLoading, request: fetchTokens, - result: { tokens, itemCount }, + result: { tokens, itemCount, actions, relatedSearchFields }, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const { - data: { results, count }, - } = await UsersAPI.readTokens(id, params); + const [ + { + data: { results, count }, + }, + actionsResponse, + ] = await Promise.all([ + UsersAPI.readTokens(id, params), + UsersAPI.readTokenOptions(id), + ]); const modifiedResults = results.map(result => { result.summary_fields = { user: result.summary_fields.user, @@ -44,9 +50,16 @@ function UserTokenList({ i18n }) { result.name = result.summary_fields.application?.name; return result; }); - return { tokens: modifiedResults, itemCount: count }; + return { + tokens: modifiedResults, + itemCount: count, + actions: actionsResponse.data.actions, + relatedSearchFields: ( + actionsResponse?.data?.related_search_fields || [] + ).map(val => val.slice(0, -8)), + }; }, [id, location.search]), - { tokens: [], itemCount: 0 } + { tokens: [], itemCount: 0, actions: {}, relatedSearchFields: [] } ); useEffect(() => { @@ -80,6 +93,10 @@ function UserTokenList({ i18n }) { }; const canAdd = true; + const relatedSearchableKeys = relatedSearchFields || []; + const searchableKeys = Object.keys(actions?.GET || {}).filter( + key => actions.GET[key].filterable + ); return ( <> ( ', () => { let wrapper; - test('should mount properly, and fetch tokens', async () => { + + beforeEach(() => { UsersAPI.readTokens.mockResolvedValue(tokens); + UsersAPI.readTokenOptions.mockResolvedValue({ + data: { related_search_fields: [] }, + }); + }); + + test('should mount properly, and fetch tokens', async () => { await act(async () => { wrapper = mountWithContexts(); }); @@ -137,7 +144,6 @@ describe('', () => { }); test('edit button should be disabled', async () => { - UsersAPI.readTokens.mockResolvedValue(tokens); await act(async () => { wrapper = mountWithContexts(); });