Merge pull request #7739 from jlmitch5/advSearch

Advanced search

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-08-06 17:57:24 +00:00 committed by GitHub
commit 91df10dbb5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 1742 additions and 571 deletions

View File

@ -11,6 +11,10 @@ class Applications extends Base {
params,
});
}
readTokenOptions(appId) {
return this.http.options(`${this.baseUrl}${appId}/tokens/`);
}
}
export default Applications;

View File

@ -60,6 +60,10 @@ class Users extends Base {
params,
});
}
readTokenOptions(userId) {
return this.http.options(`${this.baseUrl}${userId}/tokens/`);
}
}
export default Users;

View File

@ -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',
},
];

View File

@ -13,7 +13,7 @@ describe('<SelectResourceStep />', () => {
const searchColumns = [
{
name: 'Username',
key: 'username',
key: 'username__icontains',
isDefault: true,
},
];

View File

@ -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={[

View File

@ -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 {
<ToolbarItem>
<Search
qsConfig={qsConfig}
columns={searchColumns}
columns={[
...searchColumns,
{ name: i18n._(t`Advanced`), key: 'advanced' },
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onRemove={onRemove}
@ -106,6 +113,8 @@ DataListToolbar.propTypes = {
clearAllFilters: PropTypes.func,
qsConfig: QSConfig.isRequired,
searchColumns: SearchColumns.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
sortColumns: SortColumns.isRequired,
showSelectAll: PropTypes.bool,
isAllSelected: PropTypes.bool,
@ -121,6 +130,8 @@ DataListToolbar.propTypes = {
DataListToolbar.defaultProps = {
itemCount: 0,
searchableKeys: [],
relatedSearchableKeys: [],
clearAllFilters: null,
showSelectAll: false,
isAllSelected: false,

View File

@ -25,7 +25,9 @@ describe('<DataListToolbar />', () => {
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('<DataListToolbar />', () => {
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('<DataListToolbar />', () => {
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('<DataListToolbar />', () => {
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('<DataListToolbar />', () => {
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(
<DataListToolbar
qsConfig={QS_CONFIG}
searchColumns={searchColumns}
sortColumns={sortColumns}
onSearch={onSearch}
onReplaceSearch={onReplaceSearch}
onSort={onSort}
onSelectAll={onSelectAll}
additionalControls={[
<button key="1" id="test" type="button">
click
</button>,
]}
/>
);
const search = toolbar.find('Search');
expect(
search.prop('columns').filter(col => col.key === 'advanced').length
).toBe(1);
});
});

View File

@ -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 (
<>
<Card>
@ -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 => (
<DatalistToolbar
{...props}

View File

@ -96,6 +96,16 @@ UnifiedJobsAPI.read.mockResolvedValue({
data: { count: 3, results: mockResults },
});
UnifiedJobsAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
function waitForLoaded(wrapper) {
return waitForElement(
wrapper,

View File

@ -52,7 +52,7 @@ function CredentialsStep({ i18n }) {
}, [fetchTypes]);
const {
result: { credentials, count },
result: { credentials, count, actions, relatedSearchFields },
error: credentialsError,
isLoading: isCredentialsLoading,
request: fetchCredentials,
@ -62,16 +62,23 @@ function CredentialsStep({ i18n }) {
return { credentials: [], count: 0 };
}
const params = parseQueryString(QS_CONFIG, history.location.search);
const { data } = await CredentialsAPI.read({
...params,
credential_type: selectedType.id,
});
const [{ data }, actionsResponse] = await Promise.all([
CredentialsAPI.read({
...params,
credential_type: selectedType.id,
}),
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)),
};
}, [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"

View File

@ -31,6 +31,15 @@ describe('CredentialsStep', () => {
count: 5,
},
});
CredentialsAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
});
test('should load credentials', async () => {

View File

@ -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 <ContentLoading />;
}
@ -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}

View File

@ -21,6 +21,16 @@ describe('InventoryStep', () => {
count: 3,
},
});
InventoriesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
});
test('should load inventories', async () => {

View File

@ -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 => <DataListToolbar {...props} />,
searchableKeys: [],
relatedSearchableKeys: [],
};
export default withRouter(ListHeader);

View File

@ -16,7 +16,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={50}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
renderToolbar={renderToolbarFn}
/>
@ -33,7 +35,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -56,7 +60,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -77,7 +83,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }
@ -100,7 +108,9 @@ describe('ListHeader', () => {
<ListHeader
itemCount={7}
qsConfig={qsConfig}
searchColumns={[{ name: 'foo', key: 'foo', isDefault: true }]}
searchColumns={[
{ name: 'foo', key: 'foo__icontains', isDefault: true },
]}
sortColumns={[{ name: 'foo', key: 'foo' }]}
/>,
{ context: { router: { history } } }

View File

@ -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 (
<FormGroup fieldId="application" label={label}>
<Lookup
@ -56,12 +75,12 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Description`),
key: 'description',
key: 'description__icontains',
},
]}
sortColumns={[
@ -82,6 +101,8 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
key: 'description',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
readOnly={!canDelete}
name="application"
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}

View File

@ -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 })}

View File

@ -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 (
<FormGroup
className={className}
@ -74,12 +86,12 @@ function InstanceGroupsLookup(props) {
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Credential Name`),
key: 'credential__name',
key: 'credential__name__icontains',
},
]}
sortColumns={[
@ -88,6 +100,8 @@ function InstanceGroupsLookup(props) {
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={state.multiple}
header={i18n._(t`Instance Groups`)}
name="instanceGroups"

View File

@ -19,26 +19,38 @@ const QS_CONFIG = getQSConfig('inventory', {
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
const {
result: { inventories, count },
result: { inventories, count, actions, relatedSearchFields },
request: fetchInventories,
error,
isLoading,
} = 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]),
{ 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 (
<>
<Lookup
@ -58,16 +70,16 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
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={[
@ -76,6 +88,8 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={state.multiple}
header={i18n._(t`Inventory`)}
name="inventory"

View File

@ -49,7 +49,7 @@ function MultiCredentialsLookup(props) {
}, [fetchTypes]);
const {
result: { credentials, credentialsCount },
result: { credentials, credentialsCount, actions, relatedSearchFields },
request: fetchCredentials,
error: credentialsError,
isLoading: isCredentialsLoading,
@ -62,15 +62,24 @@ function MultiCredentialsLookup(props) {
};
}
const params = parseQueryString(QS_CONFIG, history.location.search);
const { results, count } = await loadCredentials(params, selectedType.id);
const [{ results, count }, actionsResponse] = await Promise.all([
loadCredentials(params, selectedType.id),
CredentialsAPI.readOptions(),
]);
return {
credentials: results,
credentialsCount: count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => 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 (
<Lookup
id="multiCredential"
@ -149,16 +163,16 @@ function MultiCredentialsLookup(props) {
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={[
@ -167,6 +181,8 @@ function MultiCredentialsLookup(props) {
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={isVault}
header={i18n._(t`Credentials`)}
name="credentials"

View File

@ -43,6 +43,15 @@ describe('<MultiCredentialsLookup />', () => {
count: 3,
},
});
CredentialsAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
});
afterEach(() => {

View File

@ -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={[

View File

@ -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 (
<FormGroup
fieldId="project"
@ -83,12 +97,12 @@ function ProjectLookup({
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`)],
@ -99,15 +113,15 @@ function ProjectLookup({
},
{
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={[
@ -116,6 +130,8 @@ function ProjectLookup({
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
options={projects}
optionCount={count}
multiple={state.multiple}

View File

@ -145,12 +145,12 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Type`),
key: 'type',
key: 'or__type',
options: [
['email', i18n._(t`Email`)],
['grafana', i18n._(t`Grafana`)],
@ -165,12 +165,12 @@ function NotificationList({ apiModel, canToggleNotifications, id, i18n }) {
],
},
{
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',
},
]}
toolbarSortColumns={[

View File

@ -30,6 +30,8 @@ function OptionsList({
optionCount,
searchColumns,
sortColumns,
searchableKeys,
relatedSearchableKeys,
multiple,
header,
name,
@ -61,6 +63,8 @@ function OptionsList({
qsConfig={qsConfig}
toolbarSearchColumns={searchColumns}
toolbarSortColumns={sortColumns}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
hasContentLoading={isLoading}
onRowClick={selectItem}
renderItem={item => (

View File

@ -17,7 +17,9 @@ describe('<OptionsList />', () => {
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('<OptionsList />', () => {
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={() => {}}

View File

@ -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,

View File

@ -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',
},
]}

View File

@ -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 => (
<DataListToolbar
{...props}

View File

@ -0,0 +1,276 @@
import 'styled-components/macro';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
ButtonVariant,
InputGroup,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import { SearchIcon } from '@patternfly/react-icons';
import styled from 'styled-components';
const AdvancedGroup = styled.div`
display: flex;
@media (max-width: 991px) {
display: grid;
grid-gap: var(--pf-c-toolbar__expandable-content--m-expanded--GridRowGap);
}
`;
function AdvancedSearch({
i18n,
onSearch,
searchableKeys,
relatedSearchableKeys,
}) {
// TODO: blocked by pf bug, eventually separate these into two groups in the select
// for now, I'm spreading set to get rid of duplicate keys...when they are grouped
// we might want to revisit that.
const allKeys = [
...new Set([...(searchableKeys || []), ...(relatedSearchableKeys || [])]),
];
const [isPrefixDropdownOpen, setIsPrefixDropdownOpen] = useState(false);
const [isLookupDropdownOpen, setIsLookupDropdownOpen] = useState(false);
const [isKeyDropdownOpen, setIsKeyDropdownOpen] = useState(false);
const [prefixSelection, setPrefixSelection] = useState(null);
const [lookupSelection, setLookupSelection] = useState(null);
const [keySelection, setKeySelection] = useState(null);
const [searchValue, setSearchValue] = useState('');
const handleAdvancedSearch = e => {
// keeps page from fully reloading
e.preventDefault();
if (searchValue) {
const actualPrefix = prefixSelection === 'and' ? null : prefixSelection;
let actualSearchKey;
// TODO: once we are able to group options for the key typeahead, we will
// probably want to be able to which group a key was clicked in for duplicates,
// rather than checking to make sure it's not in both for this appending
// __search logic
if (
relatedSearchableKeys.indexOf(keySelection) > -1 &&
searchableKeys.indexOf(keySelection) === -1 &&
keySelection.indexOf('__') === -1
) {
actualSearchKey = `${keySelection}__search`;
} else {
actualSearchKey = [actualPrefix, keySelection, lookupSelection]
.filter(val => !!val)
.join('__');
}
onSearch(actualSearchKey, searchValue);
setSearchValue('');
}
};
const handleAdvancedTextKeyDown = e => {
if (e.key && e.key === 'Enter') {
handleAdvancedSearch(e);
}
};
return (
<AdvancedGroup>
<Select
aria-label={i18n._(t`Set type select`)}
className="setTypeSelect"
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Set type typeahead`)}
onToggle={setIsPrefixDropdownOpen}
onSelect={(event, selection) => setPrefixSelection(selection)}
onClear={() => setPrefixSelection(null)}
selections={prefixSelection}
isOpen={isPrefixDropdownOpen}
placeholderText={i18n._(t`Set type`)}
maxHeight="500px"
>
<SelectOption
key="and"
value="and"
description={i18n._(
t`Returns results that satisfy this one as well as other filters. This is the default set type if nothing is selected.`
)}
/>
<SelectOption
key="or"
value="or"
description={i18n._(
t`Returns results that satisfy this one or any other filters.`
)}
/>
<SelectOption
key="not"
value="not"
description={i18n._(
t`Returns results that have values other than this one as well as other filters.`
)}
/>
</Select>
<Select
aria-label={i18n._(t`Key select`)}
className="keySelect"
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Key typeahead`)}
onToggle={setIsKeyDropdownOpen}
onSelect={(event, selection) => setKeySelection(selection)}
onClear={() => setKeySelection(null)}
selections={keySelection}
isOpen={isKeyDropdownOpen}
placeholderText={i18n._(t`Key`)}
isCreatable
onCreateOption={setKeySelection}
maxHeight="500px"
>
{allKeys.map(optionKey => (
<SelectOption key={optionKey} value={optionKey}>
{optionKey}
</SelectOption>
))}
</Select>
<Select
aria-label={i18n._(t`Lookup select`)}
className="lookupSelect"
variant={SelectVariant.typeahead}
typeAheadAriaLabel={i18n._(t`Lookup typeahead`)}
onToggle={setIsLookupDropdownOpen}
onSelect={(event, selection) => setLookupSelection(selection)}
onClear={() => setLookupSelection(null)}
selections={lookupSelection}
isOpen={isLookupDropdownOpen}
placeholderText={i18n._(t`Lookup type`)}
maxHeight="500px"
>
<SelectOption
key="exact"
value="exact"
description={i18n._(
t`Exact match (default lookup if not specified).`
)}
/>
<SelectOption
key="iexact"
value="iexact"
description={i18n._(t`Case-insensitive version of exact.`)}
/>
<SelectOption
key="contains"
value="contains"
description={i18n._(t`Field contains value.`)}
/>
<SelectOption
key="icontains"
value="icontains"
description={i18n._(t`Case-insensitive version of contains`)}
/>
<SelectOption
key="startswith"
value="startswith"
description={i18n._(t`Field starts with value.`)}
/>
<SelectOption
key="istartswith"
value="istartswith"
description={i18n._(t`Case-insensitive version of startswith.`)}
/>
<SelectOption
key="endswith"
value="endswith"
description={i18n._(t`Field ends with value.`)}
/>
<SelectOption
key="iendswith"
value="iendswith"
description={i18n._(t`Case-insensitive version of endswith.`)}
/>
<SelectOption
key="regex"
value="regex"
description={i18n._(t`Field matches the given regular expression.`)}
/>
<SelectOption
key="iregex"
value="iregex"
description={i18n._(t`Case-insensitive version of regex.`)}
/>
<SelectOption
key="gt"
value="gt"
description={i18n._(t`Greater than comparison.`)}
/>
<SelectOption
key="gte"
value="gte"
description={i18n._(t`Greater than or equal to comparison.`)}
/>
<SelectOption
key="lt"
value="lt"
description={i18n._(t`Less than comparison.`)}
/>
<SelectOption
key="lte"
value="lte"
description={i18n._(t`Less than or equal to comparison.`)}
/>
<SelectOption
key="isnull"
value="isnull"
description={i18n._(
t`Check whether the given field or related object is null; expects a boolean value.`
)}
/>
<SelectOption
key="in"
value="in"
description={i18n._(
t`Check whether the given field's value is present in the list provided; expects a comma-separated list of items.`
)}
/>
</Select>
<InputGroup>
<TextInput
type="search"
aria-label={i18n._(t`Advanced search value input`)}
isDisabled={!keySelection}
value={
(!keySelection && i18n._(t`First, select a key`)) || searchValue
}
onChange={setSearchValue}
onKeyDown={handleAdvancedTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={handleAdvancedSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
</AdvancedGroup>
);
}
AdvancedSearch.propTypes = {
onSearch: PropTypes.func.isRequired,
searchableKeys: PropTypes.arrayOf(PropTypes.string),
relatedSearchableKeys: PropTypes.arrayOf(PropTypes.string),
};
AdvancedSearch.defaultProps = {
searchableKeys: [],
relatedSearchableKeys: [],
};
export default withI18n()(AdvancedSearch);

View File

@ -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('<AdvancedSearch />', () => {
let wrapper;
afterEach(() => {
wrapper.unmount();
jest.clearAllMocks();
});
test('initially renders without crashing', () => {
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
expect(wrapper.length).toBe(1);
});
test('Remove duplicates from searchableKeys/relatedSearchableKeys list', () => {
wrapper = mountWithContexts(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
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(
<AdvancedSearch
onSearch={jest.fn}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={['foo', 'bar']}
relatedSearchableKeys={['bar', 'baz']}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
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(
<AdvancedSearch
onSearch={advancedSearchMock}
searchableKeys={[]}
relatedSearchableKeys={[]}
/>
);
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');
});
});

View File

@ -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 }) => (
<DropdownItem key={key} component="button">
{name}
</DropdownItem>
));
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 (
<ToolbarGroup variant="filter-group">
<ToolbarItem>
{searchDropdownItems.length > 0 ? (
<Dropdown
onToggle={this.handleDropdownToggle}
onSelect={this.handleDropdownSelect}
direction={up}
toggle={
<DropdownToggle
id="awx-search"
onToggle={this.handleDropdownToggle}
style={{ width: '100%' }}
>
{searchColumnName}
</DropdownToggle>
}
isOpen={isSearchDropdownOpen}
dropdownItems={searchDropdownItems}
/>
) : (
<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>
)}
</ToolbarItem>
{columns.map(
({ key, name, options, isBoolean, booleanLabels = {} }) => (
<ToolbarFilter
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
key={key}
showToolbarItem={searchKey === key}
>
{(options && (
<Fragment>
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
onToggle={this.handleFilterDropdownToggle}
onSelect={(event, selection) =>
this.handleFilterBooleanSelect(key, selection)
}
selections={chipsByKey[key].chips[0]}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
<SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)}
</SelectOption>
<SelectOption key="false" value="false">
{booleanLabels.false || i18n._(t`No`)}
</SelectOption>
</Select>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type={
(qsConfig.integerFields.find(
field => field === searchKey
) &&
'number') ||
'search'
}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={this.handleSearchInputChange}
onKeyDown={this.handleTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={this.handleSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
)}
</ToolbarFilter>
)
const searchOptions = columns
.filter(({ key }) => key !== searchKey)
.map(({ key, name }) => (
<SelectOption key={key} value={name}>
{name}
</SelectOption>
));
return (
<ToolbarGroup variant="filter-group">
<ToolbarItem>
{searchOptions.length > 0 ? (
<Select
variant={SelectVariant.single}
className="simpleKeySelect"
aria-label={i18n._(t`Simple key select`)}
onToggle={setIsSearchDropdownOpen}
onSelect={handleDropdownSelect}
selections={searchColumnName}
isOpen={isSearchDropdownOpen}
>
{searchOptions}
</Select>
) : (
<NoOptionDropdown>{searchColumnName}</NoOptionDropdown>
)}
</ToolbarGroup>
);
}
</ToolbarItem>
{columns.map(({ key, name, options, isBoolean, booleanLabels = {} }) => (
<ToolbarFilter
chips={chipsByKey[key] ? chipsByKey[key].chips : []}
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={chipsByKey[key] ? chipsByKey[key].label : key}
key={key}
showToolbarItem={searchKey === key}
>
{(key === 'advanced' && (
<AdvancedSearch
onSearch={onSearch}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
/>
)) ||
(options && (
<Fragment>
<Select
variant={SelectVariant.checkbox}
aria-label={name}
onToggle={setIsFilterDropdownOpen}
onSelect={(event, selection) =>
handleFilterDropdownSelect(key, event, selection)
}
selections={chipsByKey[key].chips.map(chip => {
const [, ...value] = chip.key.split(':');
return value.join(':');
})}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
{options.map(([optionKey, optionLabel]) => (
<SelectOption key={optionKey} value={optionKey}>
{optionLabel}
</SelectOption>
))}
</Select>
</Fragment>
)) ||
(isBoolean && (
<Select
aria-label={name}
onToggle={setIsFilterDropdownOpen}
onSelect={(event, selection) => onReplaceSearch(key, selection)}
selections={chipsByKey[key].chips[0]}
isOpen={isFilterDropdownOpen}
placeholderText={`Filter By ${name}`}
>
<SelectOption key="true" value="true">
{booleanLabels.true || i18n._(t`Yes`)}
</SelectOption>
<SelectOption key="false" value="false">
{booleanLabels.false || i18n._(t`No`)}
</SelectOption>
</Select>
)) || (
<InputGroup>
{/* TODO: add support for dates:
qsConfig.dateFields.filter(field => field === key).length && "date" */}
<TextInput
type={
(qsConfig.integerFields.find(
field => field === searchKey
) &&
'number') ||
'search'
}
aria-label={i18n._(t`Search text input`)}
value={searchValue}
onChange={setSearchValue}
onKeyDown={handleTextKeyDown}
/>
<div css={!searchValue && `cursor:not-allowed`}>
<Button
variant={ButtonVariant.control}
isDisabled={!searchValue}
aria-label={i18n._(t`Search submit button`)}
onClick={handleSearch}
>
<SearchIcon />
</Button>
</div>
</InputGroup>
)}
</ToolbarFilter>
))}
{/* 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 => (
<ToolbarFilter
chips={chipsByKey[leftoverKey] ? chipsByKey[leftoverKey].chips : []}
deleteChip={(unusedKey, chip) => {
const [columnKey, ...value] = chip.key.split(':');
onRemove(columnKey, value.join(':'));
}}
categoryName={
chipsByKey[leftoverKey]
? chipsByKey[leftoverKey].label
: leftoverKey
}
key={leftoverKey}
/>
))}
</ToolbarGroup>
);
}
Search.propTypes = {

View File

@ -22,7 +22,7 @@ describe('<Search />', () => {
});
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('<Search />', () => {
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(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</ToolbarContent>
</Toolbar>
).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('<Search />', () => {
<Search qsConfig={QS_CONFIG} columns={columns} onSearch={onSearch} />
</ToolbarContent>
</Toolbar>
).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(
<Toolbar
@ -119,7 +110,7 @@ describe('<Search />', () => {
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(
<Toolbar
@ -143,12 +134,12 @@ describe('<Search />', () => {
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('<Search />', () => {
{ 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('<Search />', () => {
});
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(
<Toolbar
id={`${QS_CONFIG.namespace}-list-toolbar`}
clearAllFilters={() => {}}
collapseListedFiltersBreakpoint="lg"
>
<ToolbarContent>
<Search qsConfig={QS_CONFIG} columns={columns} />
</ToolbarContent>
</Toolbar>,
{ 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');
});
});

View File

@ -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 <Sort />'
);
}
const sortedColumnName = defaultSortedColumn?.name;
const sortDropdownItems = columns
.filter(({ key }) => key !== sortKey)

View File

@ -82,7 +82,9 @@ describe('<UserAndTeamAccessAdd/>', () => {
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('<UserAndTeamAccessAdd/>', () => {
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('<UserAndTeamAccessAdd/>', () => {
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' }],
})
);

View File

@ -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: [

View File

@ -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 (
<>
<PaginatedDataList
@ -90,7 +109,7 @@ function ApplicationTokenList({ i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'user__username',
key: 'user__username__icontains',
isDefault: true,
},
]}
@ -116,6 +135,8 @@ function ApplicationTokenList({ i18n }) {
key: 'modified',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DatalistToolbar
{...props}

View File

@ -82,6 +82,19 @@ const tokens = {
};
describe('<ApplicationTokenList/>', () => {
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 () => {

View File

@ -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 => (
<DatalistToolbar
{...props}

View File

@ -20,6 +20,16 @@ CredentialsAPI.read.mockResolvedValue({
},
});
CredentialsAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
CredentialTypesAPI.readDetail.mockResolvedValue({
data: {
id: 20,

View File

@ -25,28 +25,38 @@ function CredentialsStep({ i18n }) {
const history = useHistory();
const {
result: { credentials, count },
result: { credentials, count, actions, relatedSearchFields },
error: credentialsError,
isLoading: isCredentialsLoading,
request: fetchCredentials,
} = useRequest(
useCallback(async () => {
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 <ContentError error={credentialsError} />;
}
@ -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}
/>
);
}

View File

@ -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 => (
<HostGroupItem
key={item.id}

View File

@ -29,7 +29,7 @@ function HostList({ i18n }) {
const [selected, setSelected] = useState([]);
const {
result: { hosts, count, actions },
result: { hosts, count, actions, relatedSearchFields },
error: contentError,
isLoading,
request: fetchHosts,
@ -44,12 +44,16 @@ function HostList({ i18n }) {
hosts: results[0].data.results,
count: results[0].data.count,
actions: results[1].data.actions,
relatedSearchFields: (
results[1]?.data?.related_search_fields || []
).map(val => 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 (
<PageSection>
@ -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 => (
<DataListToolbar
{...props}

View File

@ -32,7 +32,7 @@ function InventoryGroupHostList({ i18n }) {
const history = useHistory();
const {
result: { hosts, hostCount, actions },
result: { hosts, hostCount, actions, relatedSearchFields },
error: contentError,
isLoading,
request: fetchHosts,
@ -48,11 +48,16 @@ function InventoryGroupHostList({ i18n }) {
hosts: response.data.results,
hostCount: response.data.count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => 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 => (
<DataListToolbar
{...props}

View File

@ -141,7 +141,7 @@ function InventoryGroupsList({ i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
@ -154,12 +154,12 @@ function InventoryGroupsList({ i18n }) {
},
},
{
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',
},
]}
toolbarSortColumns={[

View File

@ -31,7 +31,7 @@ function InventoryHostGroupsList({ i18n }) {
const { search } = useLocation();
const {
result: { groups, itemCount, actions },
result: { groups, itemCount, actions, relatedSearchFields },
error: contentError,
isLoading,
request: fetchGroups,
@ -53,11 +53,16 @@ function InventoryHostGroupsList({ i18n }) {
groups: results,
itemCount: count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => 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 => (
<InventoryHostGroupItem
key={item.id}

View File

@ -29,7 +29,7 @@ function InventoryList({ i18n }) {
const [selected, setSelected] = useState([]);
const {
result: { results, itemCount, actions },
result: { results, itemCount, actions, relatedSearchFields },
error: contentError,
isLoading,
request: fetchInventories,
@ -44,12 +44,16 @@ function InventoryList({ i18n }) {
results: response.data.results,
itemCount: response.data.count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => 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 => (
<DatalistToolbar
{...props}

View File

@ -31,7 +31,7 @@ function OrganizationsList({ i18n }) {
const addUrl = `${match.url}/add`;
const {
result: { organizations, organizationCount, actions },
result: { organizations, organizationCount, actions, relatedSearchFields },
error: contentError,
isLoading: isOrgsLoading,
request: fetchOrganizations,
@ -46,12 +46,16 @@ function OrganizationsList({ i18n }) {
organizations: orgs.data.results,
organizationCount: orgs.data.count,
actions: orgActions.data.actions,
relatedSearchFields: (
orgActions?.data?.related_search_fields || []
).map(val => 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 => (
<DataListToolbar
{...props}

View File

@ -53,16 +53,16 @@ function OrganizationTeamList({ id, 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={[

View File

@ -104,20 +104,16 @@ function ProjectJobTemplatesList({ i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Playbook name`),
key: 'job_template__playbook',
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
toolbarSortColumns={[

View File

@ -30,7 +30,7 @@ function ProjectList({ i18n }) {
const [selected, setSelected] = useState([]);
const {
result: { results, itemCount, actions },
result: { results, itemCount, actions, relatedSearchFields },
error: contentError,
isLoading,
request: fetchProjects,
@ -45,12 +45,16 @@ function ProjectList({ i18n }) {
results: response.data.results,
itemCount: response.data.count,
actions: actionsResponse.data.actions,
relatedSearchFields: (
actionsResponse?.data?.related_search_fields || []
).map(val => 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`),

View File

@ -78,6 +78,7 @@ describe('<ProjectList />', () => {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
});

View File

@ -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 => (
<DataListToolbar
{...props}

View File

@ -37,7 +37,7 @@ function TeamRolesList({ i18n, me, team }) {
isLoading,
request: fetchRoles,
contentError,
result: { roleCount, roles, isAdminOfOrg },
result: { roleCount, roles, isAdminOfOrg, actions, relatedSearchFields },
} = useRequest(
useCallback(async () => {
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 => (
<DataListToolbar
{...props}

View File

@ -164,6 +164,13 @@ describe('<TeamRolesList />', () => {
},
],
});
TeamsAPI.readRoleOptions.mockResolvedValue({
data: {
actions: { GET: {} },
related_search_fields: [],
},
});
});
afterEach(() => {

View File

@ -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 => (
<DatalistToolbar
{...props}

View File

@ -68,12 +68,12 @@ function InventorySourcesList({ i18n, nodeResource, onUpdateNodeResource }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Source`),
key: 'source',
key: 'or__source',
options: [
[`file`, i18n._(t`File, directory or script`)],
[`scm`, i18n._(t`Sourced from a project`)],

View File

@ -70,20 +70,20 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) {
toolbarSearchColumns={[
{
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',
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',
},
]}
toolbarSortColumns={[

View File

@ -68,12 +68,12 @@ function ProjectsList({ i18n, nodeResource, onUpdateNodeResource }) {
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`)],
@ -83,16 +83,16 @@ function ProjectsList({ i18n, nodeResource, onUpdateNodeResource }) {
],
},
{
name: i18n._(t`Source control URL`),
key: 'scm_url',
name: i18n._(t`Source Control URL`),
key: 'scm_url__icontains',
},
{
name: i18n._(t`Modified by (username)`),
key: 'modified_by__username',
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
{
name: i18n._(t`Created by (username)`),
key: 'created_by__username',
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
]}
toolbarSortColumns={[

View File

@ -74,24 +74,24 @@ function WorkflowJobTemplatesList({
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Organization (name)`),
key: 'organization__name',
name: i18n._(t`Organization (Name)`),
key: 'organization__name__icontains',
},
{
name: i18n._(t`Inventory (name)`),
key: 'inventory__name',
name: i18n._(t`Inventory (Name)`),
key: 'inventory__name__icontains',
},
{
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',
},
]}
toolbarSortColumns={[

View File

@ -32,13 +32,13 @@ const QS_CONFIG = getQSConfig('roles', {
function UserAccessList({ i18n, user }) {
const { search } = useLocation();
const [isWizardOpen, setIsWizardOpen] = useState(false);
const [roleToDisassociate, setRoleToDisassociate] = useState(null);
const {
isLoading,
request: fetchRoles,
error,
result: { roleCount, roles, options },
result: { roleCount, roles, actions, relatedSearchFields },
} = useRequest(
useCallback(async () => {
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 (
<UserAccessListItem

View File

@ -12,9 +12,8 @@ jest.mock('../../../api/models/Roles');
UsersAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
},
actions: { GET: {} },
related_search_fields: [],
},
});
@ -144,6 +143,15 @@ describe('<UserAccessList />', () => {
);
});
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: [

View File

@ -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',
},
]}

View File

@ -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 (
<PaginatedDataList
items={teams}
@ -66,14 +83,16 @@ function UserTeamList({ i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'name',
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Organization`),
key: 'organization__name',
key: 'organization__name__icontains',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
/>
);
}

View File

@ -58,13 +58,14 @@ describe('<UserTeamList />', () => {
data: mockAPIUserTeamList.data,
})
);
UsersAPI.readOptions = jest.fn(() =>
UsersAPI.readTeamsOptions = jest.fn(() =>
Promise.resolve({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
})
);

View File

@ -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 (
<>
<PaginatedDataList
@ -93,12 +110,12 @@ function UserTokenList({ i18n }) {
toolbarSearchColumns={[
{
name: i18n._(t`Name`),
key: 'application__name',
key: 'application__name__icontains',
isDefault: true,
},
{
name: i18n._(t`Description`),
key: 'description',
key: 'description__icontains',
},
]}
toolbarSortColumns={[
@ -123,6 +140,8 @@ function UserTokenList({ i18n }) {
key: 'modified',
},
]}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
renderToolbar={props => (
<DataListToolbar
{...props}

View File

@ -123,8 +123,15 @@ const tokens = {
describe('<UserTokenList />', () => {
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(<UserTokenList />);
});
@ -137,7 +144,6 @@ describe('<UserTokenList />', () => {
});
test('edit button should be disabled', async () => {
UsersAPI.readTokens.mockResolvedValue(tokens);
await act(async () => {
wrapper = mountWithContexts(<UserTokenList />);
});