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();
});