mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #7644 from marshmalien/smart-inventory-forms
Add smart inventory add/edit forms and host filter lookup Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
commit
ddb6c5d0cc
342
awx/ui_next/src/components/Lookup/HostFilterLookup.jsx
Normal file
342
awx/ui_next/src/components/Lookup/HostFilterLookup.jsx
Normal file
@ -0,0 +1,342 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { withRouter, useHistory, useLocation } from 'react-router-dom';
|
||||
import { number, func, bool, string } from 'prop-types';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import { SearchIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
Button,
|
||||
ButtonVariant,
|
||||
Chip,
|
||||
FormGroup,
|
||||
InputGroup,
|
||||
Modal,
|
||||
} from '@patternfly/react-core';
|
||||
import ChipGroup from '../ChipGroup';
|
||||
import DataListToolbar from '../DataListToolbar';
|
||||
import LookupErrorMessage from './shared/LookupErrorMessage';
|
||||
import PaginatedDataList, { PaginatedDataListItem } from '../PaginatedDataList';
|
||||
import { HostsAPI } from '../../api';
|
||||
import { getQSConfig, mergeParams, parseQueryString } from '../../util/qs';
|
||||
import useRequest, { useDismissableError } from '../../util/useRequest';
|
||||
import {
|
||||
removeDefaultParams,
|
||||
removeNamespacedKeys,
|
||||
toHostFilter,
|
||||
toQueryString,
|
||||
toSearchParams,
|
||||
} from './shared/HostFilterUtils';
|
||||
|
||||
const ChipHolder = styled.div`
|
||||
--pf-c-form-control--Height: auto;
|
||||
.pf-c-chip-group {
|
||||
margin-right: 8px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ModalList = styled.div`
|
||||
.pf-c-toolbar__content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const useModal = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
function toggleModal() {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
|
||||
return {
|
||||
isModalOpen,
|
||||
toggleModal,
|
||||
closeModal,
|
||||
};
|
||||
};
|
||||
|
||||
const QS_CONFIG = getQSConfig(
|
||||
'smart_hosts',
|
||||
{
|
||||
page: 1,
|
||||
page_size: 5,
|
||||
order_by: 'name',
|
||||
},
|
||||
['id', 'page', 'page_size', 'inventory']
|
||||
);
|
||||
|
||||
const buildSearchColumns = i18n => [
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`ID`),
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Group`),
|
||||
key: 'groups__name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Inventory ID`),
|
||||
key: 'inventory',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Enabled`),
|
||||
key: 'enabled',
|
||||
isBoolean: true,
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Instance ID`),
|
||||
key: 'instance_id',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Last job`),
|
||||
key: 'last_job',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Insights system ID`),
|
||||
key: 'insights_system_id',
|
||||
},
|
||||
];
|
||||
|
||||
function HostFilterLookup({
|
||||
helperTextInvalid,
|
||||
i18n,
|
||||
isValid,
|
||||
isDisabled,
|
||||
onBlur,
|
||||
onChange,
|
||||
organizationId,
|
||||
value,
|
||||
}) {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const [chips, setChips] = useState({});
|
||||
const [queryString, setQueryString] = useState('');
|
||||
const { isModalOpen, toggleModal, closeModal } = useModal();
|
||||
const searchColumns = buildSearchColumns(i18n);
|
||||
|
||||
const {
|
||||
result: { count, hosts },
|
||||
error: contentError,
|
||||
request: fetchHosts,
|
||||
isLoading,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async orgId => {
|
||||
const params = parseQueryString(QS_CONFIG, location.search);
|
||||
const { data } = await HostsAPI.read(
|
||||
mergeParams(params, { inventory__organization: orgId })
|
||||
);
|
||||
return {
|
||||
count: data.count,
|
||||
hosts: data.results,
|
||||
};
|
||||
},
|
||||
[location.search]
|
||||
),
|
||||
{
|
||||
count: 0,
|
||||
hosts: [],
|
||||
}
|
||||
);
|
||||
|
||||
const { error, dismissError } = useDismissableError(contentError);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && organizationId) {
|
||||
dismissError();
|
||||
fetchHosts(organizationId);
|
||||
}
|
||||
}, [fetchHosts, organizationId, isModalOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const filters = toSearchParams(value);
|
||||
setQueryString(toQueryString(QS_CONFIG, filters));
|
||||
setChips(buildChips(filters));
|
||||
}, [value]);
|
||||
|
||||
function qsToHostFilter(qs) {
|
||||
const searchParams = toSearchParams(qs);
|
||||
const withoutNamespace = removeNamespacedKeys(QS_CONFIG, searchParams);
|
||||
const withoutDefaultParams = removeDefaultParams(
|
||||
QS_CONFIG,
|
||||
withoutNamespace
|
||||
);
|
||||
return toHostFilter(withoutDefaultParams);
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
const hostFilterString = qsToHostFilter(location.search);
|
||||
onChange(hostFilterString);
|
||||
closeModal();
|
||||
history.replace({
|
||||
pathname: `${location.pathname}`,
|
||||
search: '',
|
||||
});
|
||||
};
|
||||
|
||||
function buildChips(filter = {}) {
|
||||
const inputGroupChips = Object.keys(filter).reduce((obj, param) => {
|
||||
const parsedKey = param.replace('__icontains', '').replace('or__', '');
|
||||
const chipsArray = [];
|
||||
|
||||
if (Array.isArray(filter[param])) {
|
||||
filter[param].forEach(val =>
|
||||
chipsArray.push({
|
||||
key: `${param}:${val}`,
|
||||
node: `${val}`,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
chipsArray.push({
|
||||
key: `${param}:${filter[param]}`,
|
||||
node: `${filter[param]}`,
|
||||
});
|
||||
}
|
||||
|
||||
obj[parsedKey] = {
|
||||
key: parsedKey,
|
||||
label: filter[param],
|
||||
chips: [...chipsArray],
|
||||
};
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
return inputGroupChips;
|
||||
}
|
||||
|
||||
const handleOpenModal = () => {
|
||||
history.replace({
|
||||
pathname: `${location.pathname}`,
|
||||
search: queryString,
|
||||
});
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeModal();
|
||||
history.replace({
|
||||
pathname: `${location.pathname}`,
|
||||
search: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormGroup
|
||||
fieldId="host-filter"
|
||||
helperTextInvalid={helperTextInvalid}
|
||||
isRequired
|
||||
label={i18n._(t`Host filter`)}
|
||||
validated={isValid ? 'default' : 'error'}
|
||||
>
|
||||
<InputGroup onBlur={onBlur}>
|
||||
<Button
|
||||
aria-label="Search"
|
||||
id="host-filter"
|
||||
isDisabled={isDisabled}
|
||||
onClick={handleOpenModal}
|
||||
variant={ButtonVariant.control}
|
||||
>
|
||||
<SearchIcon />
|
||||
</Button>
|
||||
<ChipHolder className="pf-c-form-control">
|
||||
{searchColumns.map(({ name, key }) => (
|
||||
<ChipGroup
|
||||
categoryName={name}
|
||||
key={name}
|
||||
numChips={5}
|
||||
totalChips={chips[key]?.chips?.length || 0}
|
||||
>
|
||||
{chips[key]?.chips?.map((chip, index) => (
|
||||
<Chip key={index} isReadOnly>
|
||||
{chip.node}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipGroup>
|
||||
))}
|
||||
</ChipHolder>
|
||||
</InputGroup>
|
||||
<Modal
|
||||
aria-label={i18n._(t`Lookup modal`)}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleClose}
|
||||
title={i18n._(t`Perform a search to define a host filter`)}
|
||||
variant="large"
|
||||
actions={[
|
||||
<Button
|
||||
isDisabled={!location.search}
|
||||
key="select"
|
||||
onClick={save}
|
||||
variant="primary"
|
||||
>
|
||||
{i18n._(t`Select`)}
|
||||
</Button>,
|
||||
<Button key="cancel" variant="link" onClick={handleClose}>
|
||||
{i18n._(t`Cancel`)}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<ModalList>
|
||||
<PaginatedDataList
|
||||
contentError={error}
|
||||
hasContentLoading={isLoading}
|
||||
itemCount={count}
|
||||
items={hosts}
|
||||
onRowClick={() => {}}
|
||||
pluralizedItemName={i18n._(t`hosts`)}
|
||||
qsConfig={QS_CONFIG}
|
||||
renderItem={item => (
|
||||
<PaginatedDataListItem
|
||||
key={item.id}
|
||||
item={{ ...item, url: `/hosts/${item.id}/details` }}
|
||||
/>
|
||||
)}
|
||||
renderToolbar={props => <DataListToolbar {...props} fillWidth />}
|
||||
toolbarSearchColumns={searchColumns}
|
||||
toolbarSortColumns={[
|
||||
{
|
||||
name: i18n._(t`Name`),
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Created`),
|
||||
key: 'created',
|
||||
},
|
||||
{
|
||||
name: i18n._(t`Modified`),
|
||||
key: 'modified',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalList>
|
||||
</Modal>
|
||||
<LookupErrorMessage error={error} />
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
HostFilterLookup.propTypes = {
|
||||
isValid: bool,
|
||||
onBlur: func,
|
||||
onChange: func,
|
||||
organizationId: number,
|
||||
value: string,
|
||||
};
|
||||
HostFilterLookup.defaultProps = {
|
||||
isValid: true,
|
||||
onBlur: () => {},
|
||||
onChange: () => {},
|
||||
organizationId: null,
|
||||
value: '',
|
||||
};
|
||||
|
||||
export default withI18n()(withRouter(HostFilterLookup));
|
||||
@ -40,7 +40,7 @@ function OrganizationLookup({
|
||||
organizations: data.results,
|
||||
itemCount: data.count,
|
||||
};
|
||||
}, [history.location]),
|
||||
}, [history.location.search]),
|
||||
{
|
||||
organizations: [],
|
||||
itemCount: 0,
|
||||
|
||||
@ -5,3 +5,4 @@ export { default as ProjectLookup } from './ProjectLookup';
|
||||
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
|
||||
export { default as CredentialLookup } from './CredentialLookup';
|
||||
export { default as ApplicationLookup } from './ApplicationLookup';
|
||||
export { default as HostFilterLookup } from './HostFilterLookup';
|
||||
|
||||
104
awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
Normal file
104
awx/ui_next/src/components/Lookup/shared/HostFilterUtils.jsx
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Convert host filter string to params object
|
||||
* @param {string} string host filter string
|
||||
* @return {object} A string or array of strings keyed by query param key
|
||||
*/
|
||||
export function toSearchParams(string = '') {
|
||||
if (string === '') {
|
||||
return {};
|
||||
}
|
||||
return string
|
||||
.replace(/^\?/, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.split(/ and | or /)
|
||||
.map(s => s.split('='))
|
||||
.reduce((searchParams, [k, v]) => {
|
||||
const key = decodeURIComponent(k);
|
||||
const value = decodeURIComponent(v);
|
||||
if (searchParams[key] === undefined) {
|
||||
searchParams[key] = value;
|
||||
} else if (Array.isArray(searchParams[key])) {
|
||||
searchParams[key] = [...searchParams[key], value];
|
||||
} else {
|
||||
searchParams[key] = [searchParams[key], value];
|
||||
}
|
||||
return searchParams;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert params object to an encoded namespaced url query string
|
||||
* Used to put into url bar when modal opens
|
||||
* @param {object} config Config object for namespacing params
|
||||
* @param {object} searchParams A string or array of strings keyed by query param key
|
||||
* @return {string} URL query string
|
||||
*/
|
||||
export function toQueryString(config, searchParams = {}) {
|
||||
if (Object.keys(searchParams).length === 0) return '';
|
||||
|
||||
return Object.keys(searchParams)
|
||||
.flatMap(key => {
|
||||
if (Array.isArray(searchParams[key])) {
|
||||
return searchParams[key].map(
|
||||
val =>
|
||||
`${config.namespace}.${encodeURIComponent(
|
||||
key
|
||||
)}=${encodeURIComponent(val)}`
|
||||
);
|
||||
}
|
||||
return `${config.namespace}.${encodeURIComponent(
|
||||
key
|
||||
)}=${encodeURIComponent(searchParams[key])}`;
|
||||
})
|
||||
.join('&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert params object to host filter string
|
||||
* @param {object} searchParams A string or array of strings keyed by query param key
|
||||
* @return {string} Host filter string
|
||||
*/
|
||||
export function toHostFilter(searchParams = {}) {
|
||||
return Object.keys(searchParams)
|
||||
.flatMap(key => {
|
||||
if (Array.isArray(searchParams[key])) {
|
||||
return searchParams[key].map(val => `${key}=${val}`);
|
||||
}
|
||||
return `${key}=${searchParams[key]}`;
|
||||
})
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remove namespace from params object
|
||||
* @param {object} config Config object with namespace param
|
||||
* @param {object} obj A string or array of strings keyed by query param key
|
||||
* @return {object} Params object without namespaced keys
|
||||
*/
|
||||
export function removeNamespacedKeys(config, obj = {}) {
|
||||
const clonedObj = Object.assign({}, obj);
|
||||
const newObj = {};
|
||||
Object.keys(clonedObj).forEach(nsKey => {
|
||||
let key = nsKey;
|
||||
if (nsKey.startsWith(config.namespace)) {
|
||||
key = nsKey.substr(config.namespace.length + 1);
|
||||
}
|
||||
newObj[key] = clonedObj[nsKey];
|
||||
});
|
||||
return newObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remove default params from params object
|
||||
* @param {object} config Config object with default params
|
||||
* @param {object} obj A string or array of strings keyed by query param key
|
||||
* @return {string} Params object without default params
|
||||
*/
|
||||
export function removeDefaultParams(config, obj = {}) {
|
||||
const clonedObj = Object.assign({}, obj);
|
||||
const defaultKeys = Object.keys(config.defaultParams);
|
||||
defaultKeys.forEach(keyToOmit => {
|
||||
delete clonedObj[keyToOmit];
|
||||
});
|
||||
return clonedObj;
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import {
|
||||
removeDefaultParams,
|
||||
removeNamespacedKeys,
|
||||
toHostFilter,
|
||||
toQueryString,
|
||||
toSearchParams,
|
||||
} from './HostFilterUtils';
|
||||
|
||||
const QS_CONFIG = {
|
||||
namespace: 'mock',
|
||||
defaultParams: { page: 1, page_size: 5, order_by: 'name' },
|
||||
integerFields: ['page', 'page_size', 'id', 'inventory'],
|
||||
};
|
||||
|
||||
describe('toSearchParams', () => {
|
||||
let string;
|
||||
let paramsObject;
|
||||
|
||||
test('should return an empty object', () => {
|
||||
expect(toSearchParams(undefined)).toEqual({});
|
||||
expect(toSearchParams('')).toEqual({});
|
||||
});
|
||||
test('should take a query string and return search params object', () => {
|
||||
string = '?foo=bar';
|
||||
paramsObject = { foo: 'bar' };
|
||||
expect(toSearchParams(string)).toEqual(paramsObject);
|
||||
});
|
||||
test('should take a host filter string and return search params object', () => {
|
||||
string = 'foo=bar and foo=baz and foo=qux and isa=sampu';
|
||||
paramsObject = {
|
||||
foo: ['bar', 'baz', 'qux'],
|
||||
isa: 'sampu',
|
||||
};
|
||||
expect(toSearchParams(string)).toEqual(paramsObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toQueryString', () => {
|
||||
test('should return an empty string', () => {
|
||||
expect(toQueryString(QS_CONFIG, undefined)).toEqual('');
|
||||
});
|
||||
test('should return namespaced query string with a single key-value pair', () => {
|
||||
const object = {
|
||||
foo: 'bar',
|
||||
};
|
||||
expect(toQueryString(QS_CONFIG, object)).toEqual('mock.foo=bar');
|
||||
});
|
||||
test('should return namespaced query string with multiple values per key', () => {
|
||||
const object = {
|
||||
foo: ['bar', 'baz'],
|
||||
};
|
||||
expect(toQueryString(QS_CONFIG, object)).toEqual(
|
||||
'mock.foo=bar&mock.foo=baz'
|
||||
);
|
||||
});
|
||||
test('should return namespaced query string with multiple key-value pairs', () => {
|
||||
const object = {
|
||||
foo: ['bar', 'baz', 'qux'],
|
||||
isa: 'sampu',
|
||||
};
|
||||
expect(toQueryString(QS_CONFIG, object)).toEqual(
|
||||
'mock.foo=bar&mock.foo=baz&mock.foo=qux&mock.isa=sampu'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toHostFilter', () => {
|
||||
test('should return an empty string', () => {
|
||||
expect(toHostFilter(undefined)).toEqual('');
|
||||
});
|
||||
test('should return a host filter string', () => {
|
||||
const object = {
|
||||
isa: '2',
|
||||
tatlo: ['foo', 'bar', 'baz'],
|
||||
};
|
||||
expect(toHostFilter(object)).toEqual(
|
||||
'isa=2 and tatlo=foo and tatlo=bar and tatlo=baz'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeNamespacedKeys', () => {
|
||||
test('should return an empty object', () => {
|
||||
expect(removeNamespacedKeys(QS_CONFIG, undefined)).toEqual({});
|
||||
});
|
||||
test('should remove namespace from keys', () => {
|
||||
expect(removeNamespacedKeys(QS_CONFIG, { 'mock.foo': 'bar' })).toEqual({
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeDefaultParams', () => {
|
||||
test('should return an empty object', () => {
|
||||
expect(removeDefaultParams(QS_CONFIG, undefined)).toEqual({});
|
||||
});
|
||||
test('should remove default params', () => {
|
||||
const object = {
|
||||
foo: ['bar', 'baz', 'qux'],
|
||||
apat: 'lima',
|
||||
page: 10,
|
||||
order_by: '-name',
|
||||
};
|
||||
expect(removeDefaultParams(QS_CONFIG, object)).toEqual({
|
||||
foo: ['bar', 'baz', 'qux'],
|
||||
apat: 'lima',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -97,13 +97,16 @@ function Search({
|
||||
};
|
||||
|
||||
const getLabelFromValue = (value, colKey) => {
|
||||
let label = value;
|
||||
const currentSearchColumn = columns.find(({ key }) => key === colKey);
|
||||
if (currentSearchColumn?.options?.length) {
|
||||
return currentSearchColumn.options.find(
|
||||
[, label] = currentSearchColumn.options.find(
|
||||
([optVal]) => optVal === value
|
||||
)[1];
|
||||
);
|
||||
} else if (currentSearchColumn?.booleanLabels) {
|
||||
label = currentSearchColumn.booleanLabels[value];
|
||||
}
|
||||
return value.toString();
|
||||
return label.toString();
|
||||
};
|
||||
|
||||
const getChipsByKey = () => {
|
||||
@ -227,7 +230,7 @@ function Search({
|
||||
aria-label={name}
|
||||
onToggle={setIsFilterDropdownOpen}
|
||||
onSelect={(event, selection) => onReplaceSearch(key, selection)}
|
||||
selections={chipsByKey[key].chips[0]}
|
||||
selections={chipsByKey[key].chips[0]?.label}
|
||||
isOpen={isFilterDropdownOpen}
|
||||
placeholderText={`Filter By ${name}`}
|
||||
>
|
||||
|
||||
@ -117,7 +117,7 @@ describe('<InventorySourceAdd />', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should navigate to inventory sources list when cancel is clicked', async () => {
|
||||
test('should navigate to inventory source detail when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
|
||||
@ -1,10 +1,74 @@
|
||||
import React, { Component } from 'react';
|
||||
import { PageSection } from '@patternfly/react-core';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Card, PageSection } from '@patternfly/react-core';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
|
||||
class SmartInventoryAdd extends Component {
|
||||
render() {
|
||||
return <PageSection>Coming soon :)</PageSection>;
|
||||
}
|
||||
function SmartInventoryAdd() {
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
error: submitError,
|
||||
request: submitRequest,
|
||||
result: inventoryId,
|
||||
} = useRequest(
|
||||
useCallback(async (values, groupsToAssociate) => {
|
||||
const {
|
||||
data: { id: invId },
|
||||
} = await InventoriesAPI.create(values);
|
||||
|
||||
await Promise.all(
|
||||
groupsToAssociate.map(({ id }) =>
|
||||
InventoriesAPI.associateInstanceGroup(invId, id)
|
||||
)
|
||||
);
|
||||
return invId;
|
||||
}, [])
|
||||
);
|
||||
|
||||
const handleSubmit = async form => {
|
||||
const { instance_groups, organization, ...remainingForm } = form;
|
||||
|
||||
await submitRequest(
|
||||
{
|
||||
organization: organization?.id,
|
||||
...remainingForm,
|
||||
},
|
||||
instance_groups
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push({
|
||||
pathname: '/inventories',
|
||||
search: '',
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inventoryId) {
|
||||
history.push({
|
||||
pathname: `/inventories/smart_inventory/${inventoryId}/details`,
|
||||
search: '',
|
||||
});
|
||||
}
|
||||
}, [inventoryId, history]);
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<SmartInventoryForm
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default SmartInventoryAdd;
|
||||
|
||||
@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import SmartInventoryAdd from './SmartInventoryAdd';
|
||||
import {
|
||||
InventoriesAPI,
|
||||
OrganizationsAPI,
|
||||
InstanceGroupsAPI,
|
||||
} from '../../../api';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/InstanceGroups');
|
||||
OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
|
||||
const formData = {
|
||||
name: 'Mock',
|
||||
description: 'Foo',
|
||||
organization: { id: 1 },
|
||||
kind: 'smart',
|
||||
host_filter: 'name__icontains=mock',
|
||||
variables: '---',
|
||||
instance_groups: [{ id: 2 }],
|
||||
};
|
||||
|
||||
describe('<SmartInventoryAdd />', () => {
|
||||
describe('when initialized by users with POST capability', () => {
|
||||
let history;
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
InventoriesAPI.create.mockResolvedValueOnce({ data: { id: 1 } });
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: { actions: { POST: true } },
|
||||
});
|
||||
history = createMemoryHistory({
|
||||
initialEntries: [`/inventories/smart_inventory/add`],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SmartInventoryAdd />, {
|
||||
context: { router: { history } },
|
||||
});
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should enable save button', () => {
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('should post to the api when submit is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')(formData);
|
||||
});
|
||||
const { instance_groups, ...formRequest } = formData;
|
||||
expect(InventoriesAPI.create).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.create).toHaveBeenCalledWith({
|
||||
...formRequest,
|
||||
organization: formRequest.organization.id,
|
||||
});
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledWith(1, 2);
|
||||
});
|
||||
|
||||
test('successful form submission should trigger redirect to details', async () => {
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/smart_inventory/1/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('should navigate to inventory list when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual('/inventories');
|
||||
});
|
||||
|
||||
test('unsuccessful form submission should show an error message', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
InventoriesAPI.create.mockImplementationOnce(() => Promise.reject(error));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SmartInventoryAdd />);
|
||||
});
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')({});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when initialized by users without POST capability', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
InventoriesAPI.readOptions.mockResolvedValueOnce({
|
||||
data: { actions: { POST: false } },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(<SmartInventoryAdd />);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should disable save button', () => {
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -58,7 +58,7 @@ describe('<SmartInventoryDetail />', () => {
|
||||
assertDetail('Description', 'smart inv description');
|
||||
assertDetail('Type', 'Smart inventory');
|
||||
assertDetail('Organization', 'Default');
|
||||
assertDetail('Smart host filter', 'search=local');
|
||||
assertDetail('Smart host filter', 'name__icontains=local');
|
||||
assertDetail('Instance groups', 'mock instance group');
|
||||
expect(wrapper.find(`Detail[label="Activity"] Sparkline`)).toHaveLength(
|
||||
1
|
||||
|
||||
@ -1,10 +1,120 @@
|
||||
import React, { Component } from 'react';
|
||||
import { PageSection } from '@patternfly/react-core';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Inventory } from '../../../types';
|
||||
import { getAddedAndRemoved } from '../../../util/lists';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
import { CardBody } from '../../../components/Card';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import SmartInventoryForm from '../shared/SmartInventoryForm';
|
||||
|
||||
class SmartInventoryEdit extends Component {
|
||||
render() {
|
||||
return <PageSection>Coming soon :)</PageSection>;
|
||||
function SmartInventoryEdit({ inventory }) {
|
||||
const history = useHistory();
|
||||
const detailsUrl = `/inventories/smart_inventory/${inventory.id}/details`;
|
||||
|
||||
const {
|
||||
error: contentError,
|
||||
isLoading: hasContentLoading,
|
||||
request: fetchInstanceGroups,
|
||||
result: instanceGroups,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const {
|
||||
data: { results },
|
||||
} = await InventoriesAPI.readInstanceGroups(inventory.id);
|
||||
return results;
|
||||
}, [inventory.id]),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchInstanceGroups();
|
||||
}, [fetchInstanceGroups]);
|
||||
|
||||
const {
|
||||
error: submitError,
|
||||
request: submitRequest,
|
||||
result: submitResult,
|
||||
} = useRequest(
|
||||
useCallback(
|
||||
async (values, groupsToAssociate, groupsToDisassociate) => {
|
||||
const { data } = await InventoriesAPI.update(inventory.id, values);
|
||||
await Promise.all(
|
||||
groupsToAssociate.map(id =>
|
||||
InventoriesAPI.associateInstanceGroup(inventory.id, id)
|
||||
)
|
||||
);
|
||||
await Promise.all(
|
||||
groupsToDisassociate.map(id =>
|
||||
InventoriesAPI.disassociateInstanceGroup(inventory.id, id)
|
||||
)
|
||||
);
|
||||
return data;
|
||||
},
|
||||
[inventory.id]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (submitResult) {
|
||||
history.push({
|
||||
pathname: detailsUrl,
|
||||
search: '',
|
||||
});
|
||||
}
|
||||
}, [submitResult, detailsUrl, history]);
|
||||
|
||||
const handleSubmit = async form => {
|
||||
const { instance_groups, organization, ...remainingForm } = form;
|
||||
|
||||
const { added, removed } = getAddedAndRemoved(
|
||||
instanceGroups,
|
||||
instance_groups
|
||||
);
|
||||
const addedIds = added.map(({ id }) => id);
|
||||
const removedIds = removed.map(({ id }) => id);
|
||||
|
||||
await submitRequest(
|
||||
{
|
||||
organization: organization?.id,
|
||||
...remainingForm,
|
||||
},
|
||||
addedIds,
|
||||
removedIds
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
history.push({
|
||||
pathname: detailsUrl,
|
||||
search: '',
|
||||
});
|
||||
};
|
||||
|
||||
if (hasContentLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (contentError) {
|
||||
return <ContentError error={contentError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CardBody>
|
||||
<SmartInventoryForm
|
||||
inventory={inventory}
|
||||
instanceGroups={instanceGroups}
|
||||
onCancel={handleCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitError={submitError}
|
||||
/>
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
SmartInventoryEdit.propTypes = {
|
||||
inventory: Inventory.isRequired,
|
||||
};
|
||||
|
||||
export default SmartInventoryEdit;
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import SmartInventoryEdit from './SmartInventoryEdit';
|
||||
import mockSmartInventory from '../shared/data.smart_inventory.json';
|
||||
import {
|
||||
InventoriesAPI,
|
||||
OrganizationsAPI,
|
||||
InstanceGroupsAPI,
|
||||
} from '../../../api';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: () => ({
|
||||
id: 2,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/InstanceGroups');
|
||||
OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
|
||||
const mockSmartInv = Object.assign(
|
||||
{},
|
||||
{
|
||||
...mockSmartInventory,
|
||||
organization: {
|
||||
id: mockSmartInventory.organization,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
describe('<SmartInventoryEdit />', () => {
|
||||
let history;
|
||||
let wrapper;
|
||||
|
||||
beforeAll(async () => {
|
||||
InventoriesAPI.associateInstanceGroup.mockResolvedValue();
|
||||
InventoriesAPI.disassociateInstanceGroup.mockResolvedValue();
|
||||
InventoriesAPI.update.mockResolvedValue({ data: mockSmartInv });
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: { actions: { POST: true } },
|
||||
});
|
||||
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
||||
data: { count: 0, results: [{ id: 10 }, { id: 20 }] },
|
||||
});
|
||||
history = createMemoryHistory({
|
||||
initialEntries: [`/inventories/smart_inventory/${mockSmartInv.id}/edit`],
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryEdit inventory={{ ...mockSmartInv }} />,
|
||||
{
|
||||
context: { router: { history } },
|
||||
}
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should fetch related instance groups on initial render', async () => {
|
||||
expect(InventoriesAPI.readInstanceGroups).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('save button should be enabled for users with POST capability', () => {
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('should post to the api when submit is clicked', async () => {
|
||||
expect(InventoriesAPI.update).toHaveBeenCalledTimes(0);
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(0);
|
||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(0);
|
||||
await act(async () => {
|
||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')({
|
||||
...mockSmartInv,
|
||||
instance_groups: [{ id: 10 }, { id: 30 }],
|
||||
});
|
||||
});
|
||||
expect(InventoriesAPI.update).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.associateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||
expect(InventoriesAPI.disassociateInstanceGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('successful form submission should trigger redirect to details', async () => {
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/smart_inventory/2/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('should navigate to inventory details when cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
|
||||
});
|
||||
expect(history.location.pathname).toEqual(
|
||||
'/inventories/smart_inventory/2/details'
|
||||
);
|
||||
});
|
||||
|
||||
test('unsuccessful form submission should show an error message', async () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
InventoriesAPI.update.mockImplementationOnce(() => Promise.reject(error));
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryEdit inventory={{ ...mockSmartInv }} />
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(0);
|
||||
await act(async () => {
|
||||
wrapper.find('SmartInventoryForm').invoke('onSubmit')({});
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should throw content error', async () => {
|
||||
expect(wrapper.find('ContentError').length).toBe(0);
|
||||
InventoriesAPI.readInstanceGroups.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryEdit inventory={{ ...mockSmartInv }} />
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
});
|
||||
|
||||
test('save button should be disabled for users without POST capability', async () => {
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: { actions: { POST: false } },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryEdit inventory={{ ...mockSmartInv }} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
175
awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx
Normal file
175
awx/ui_next/src/screens/Inventory/shared/SmartInventoryForm.jsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { Formik, useField } from 'formik';
|
||||
import { withI18n } from '@lingui/react';
|
||||
import { t } from '@lingui/macro';
|
||||
import { func, shape, object, arrayOf } from 'prop-types';
|
||||
import { Form } from '@patternfly/react-core';
|
||||
import { VariablesField } from '../../../components/CodeMirrorInput';
|
||||
import ContentError from '../../../components/ContentError';
|
||||
import ContentLoading from '../../../components/ContentLoading';
|
||||
import FormActionGroup from '../../../components/FormActionGroup';
|
||||
import FormField, { FormSubmitError } from '../../../components/FormField';
|
||||
import {
|
||||
FormColumnLayout,
|
||||
FormFullWidthLayout,
|
||||
} from '../../../components/FormLayout';
|
||||
import HostFilterLookup from '../../../components/Lookup/HostFilterLookup';
|
||||
import InstanceGroupsLookup from '../../../components/Lookup/InstanceGroupsLookup';
|
||||
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
|
||||
import useRequest from '../../../util/useRequest';
|
||||
import { required } from '../../../util/validators';
|
||||
import { InventoriesAPI } from '../../../api';
|
||||
|
||||
const SmartInventoryFormFields = withI18n()(({ i18n }) => {
|
||||
const [organizationField, organizationMeta, organizationHelpers] = useField({
|
||||
name: 'organization',
|
||||
validate: required(i18n._(t`Select a value for this field`), i18n),
|
||||
});
|
||||
const [instanceGroupsField, , instanceGroupsHelpers] = useField({
|
||||
name: 'instance_groups',
|
||||
});
|
||||
const [hostFilterField, hostFilterMeta, hostFilterHelpers] = useField({
|
||||
name: 'host_filter',
|
||||
validate: required(null, i18n),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
id="name"
|
||||
label={i18n._(t`Name`)}
|
||||
name="name"
|
||||
type="text"
|
||||
validate={required(null, i18n)}
|
||||
isRequired
|
||||
/>
|
||||
<FormField
|
||||
id="description"
|
||||
label={i18n._(t`Description`)}
|
||||
name="description"
|
||||
type="text"
|
||||
/>
|
||||
<OrganizationLookup
|
||||
helperTextInvalid={organizationMeta.error}
|
||||
isValid={!organizationMeta.touched || !organizationMeta.error}
|
||||
onBlur={() => organizationHelpers.setTouched()}
|
||||
onChange={value => {
|
||||
organizationHelpers.setValue(value);
|
||||
}}
|
||||
value={organizationField.value}
|
||||
required
|
||||
/>
|
||||
<HostFilterLookup
|
||||
value={hostFilterField.value}
|
||||
organizationId={organizationField.value?.id}
|
||||
helperTextInvalid={hostFilterMeta.error}
|
||||
onChange={value => {
|
||||
hostFilterHelpers.setValue(value);
|
||||
}}
|
||||
onBlur={() => hostFilterHelpers.setTouched()}
|
||||
isValid={!hostFilterMeta.touched || !hostFilterMeta.error}
|
||||
isDisabled={!organizationField.value}
|
||||
/>
|
||||
<InstanceGroupsLookup
|
||||
value={instanceGroupsField.value}
|
||||
onChange={value => {
|
||||
instanceGroupsHelpers.setValue(value);
|
||||
}}
|
||||
/>
|
||||
<FormFullWidthLayout>
|
||||
<VariablesField
|
||||
id="variables"
|
||||
name="variables"
|
||||
label={i18n._(t`Variables`)}
|
||||
tooltip={i18n._(
|
||||
t`Enter inventory variables using either JSON or YAML syntax.
|
||||
Use the radio button to toggle between the two. Refer to the
|
||||
Ansible Tower documentation for example syntax.`
|
||||
)}
|
||||
/>
|
||||
</FormFullWidthLayout>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
function SmartInventoryForm({
|
||||
inventory,
|
||||
instanceGroups,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitError,
|
||||
}) {
|
||||
const initialValues = {
|
||||
description: inventory.description || '',
|
||||
host_filter: inventory.host_filter || '',
|
||||
instance_groups: instanceGroups || [],
|
||||
kind: 'smart',
|
||||
name: inventory.name || '',
|
||||
organization: inventory.summary_fields?.organization || null,
|
||||
variables: inventory.variables || '---',
|
||||
};
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
error: optionsError,
|
||||
request: fetchOptions,
|
||||
result: options,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await InventoriesAPI.readOptions();
|
||||
return data;
|
||||
}, []),
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ContentLoading />;
|
||||
}
|
||||
|
||||
if (optionsError) {
|
||||
return <ContentError error={optionsError} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={values => {
|
||||
onSubmit(values);
|
||||
}}
|
||||
>
|
||||
{formik => (
|
||||
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
|
||||
<FormColumnLayout>
|
||||
<SmartInventoryFormFields />
|
||||
{submitError && <FormSubmitError error={submitError} />}
|
||||
<FormActionGroup
|
||||
onCancel={onCancel}
|
||||
onSubmit={formik.handleSubmit}
|
||||
submitDisabled={!options?.actions?.POST}
|
||||
/>
|
||||
</FormColumnLayout>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
SmartInventoryForm.propTypes = {
|
||||
instanceGroups: arrayOf(object),
|
||||
inventory: shape({}),
|
||||
onCancel: func.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
submitError: shape({}),
|
||||
};
|
||||
|
||||
SmartInventoryForm.defaultProps = {
|
||||
instanceGroups: [],
|
||||
inventory: {},
|
||||
submitError: null,
|
||||
};
|
||||
|
||||
export default withI18n()(SmartInventoryForm);
|
||||
@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import {
|
||||
mountWithContexts,
|
||||
waitForElement,
|
||||
} from '../../../../testUtils/enzymeHelpers';
|
||||
import SmartInventoryForm from './SmartInventoryForm';
|
||||
import {
|
||||
InventoriesAPI,
|
||||
OrganizationsAPI,
|
||||
InstanceGroupsAPI,
|
||||
} from '../../../api';
|
||||
|
||||
jest.mock('../../../api/models/Inventories');
|
||||
jest.mock('../../../api/models/Organizations');
|
||||
jest.mock('../../../api/models/InstanceGroups');
|
||||
OrganizationsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
InstanceGroupsAPI.read.mockResolvedValue({ data: { results: [], count: 0 } });
|
||||
InventoriesAPI.readOptions.mockResolvedValue({
|
||||
data: { actions: { POST: true } },
|
||||
});
|
||||
|
||||
const mockFormValues = {
|
||||
kind: 'smart',
|
||||
name: 'new smart inventory',
|
||||
description: '',
|
||||
organization: { id: 1, name: 'mock organization' },
|
||||
host_filter:
|
||||
'name__icontains=mock and name__icontains=foo and groups__name=mock group',
|
||||
instance_groups: [{ id: 123 }],
|
||||
variables: '---',
|
||||
};
|
||||
|
||||
describe('<SmartInventoryForm />', () => {
|
||||
describe('when initialized by users with POST capability', () => {
|
||||
let wrapper;
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryForm onCancel={() => {}} onSubmit={onSubmit} />
|
||||
);
|
||||
});
|
||||
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('should enable save button', () => {
|
||||
expect(wrapper.find('Button[aria-label="Save"]').prop('isDisabled')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('should show expected form fields', () => {
|
||||
expect(wrapper.find('FormGroup[label="Name"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Description"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Organization"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Host filter"]')).toHaveLength(1);
|
||||
expect(wrapper.find('FormGroup[label="Instance Groups"]')).toHaveLength(
|
||||
1
|
||||
);
|
||||
expect(wrapper.find('VariablesField[label="Variables"]')).toHaveLength(1);
|
||||
expect(wrapper.find('Button[aria-label="Save"]')).toHaveLength(1);
|
||||
expect(wrapper.find('Button[aria-label="Cancel"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should enable host filter field when organization field has a value', async () => {
|
||||
expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(true);
|
||||
await act(async () => {
|
||||
wrapper.find('OrganizationLookup').invoke('onBlur')();
|
||||
wrapper.find('OrganizationLookup').invoke('onChange')(
|
||||
mockFormValues.organization
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
expect(wrapper.find('HostFilterLookup').prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
test('should show error when form is saved without a host filter value', async () => {
|
||||
expect(wrapper.find('HostFilterLookup #host-filter-helper').length).toBe(
|
||||
0
|
||||
);
|
||||
wrapper.find('input#name').simulate('change', {
|
||||
target: { value: mockFormValues.name, name: 'name' },
|
||||
});
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
const hostFilterError = wrapper.find(
|
||||
'HostFilterLookup #host-filter-helper'
|
||||
);
|
||||
expect(hostFilterError.length).toBe(1);
|
||||
expect(hostFilterError.text()).toContain('This field must not be blank');
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should display filter chips when host filter has a value', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('HostFilterLookup').invoke('onBlur')();
|
||||
wrapper.find('HostFilterLookup').invoke('onChange')(
|
||||
mockFormValues.host_filter
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
const nameChipGroup = wrapper.find(
|
||||
'HostFilterLookup ChipGroup[categoryName="Name"]'
|
||||
);
|
||||
const groupChipGroup = wrapper.find(
|
||||
'HostFilterLookup ChipGroup[categoryName="Group"]'
|
||||
);
|
||||
expect(nameChipGroup.find('Chip').length).toBe(2);
|
||||
expect(groupChipGroup.find('Chip').length).toBe(1);
|
||||
});
|
||||
|
||||
test('should submit expected form values on save', async () => {
|
||||
await act(async () => {
|
||||
wrapper.find('InstanceGroupsLookup').invoke('onChange')(
|
||||
mockFormValues.instance_groups
|
||||
);
|
||||
});
|
||||
wrapper.update();
|
||||
await act(async () => {
|
||||
wrapper.find('button[aria-label="Save"]').simulate('click');
|
||||
});
|
||||
wrapper.update();
|
||||
expect(onSubmit).toHaveBeenCalledWith(mockFormValues);
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw content error when option request fails', async () => {
|
||||
let wrapper;
|
||||
InventoriesAPI.readOptions.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error())
|
||||
);
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryForm onCancel={() => {}} onSubmit={() => {}} />
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('ContentError').length).toBe(0);
|
||||
wrapper.update();
|
||||
expect(wrapper.find('ContentError').length).toBe(1);
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should throw content error when option request fails', async () => {
|
||||
let wrapper;
|
||||
const error = {
|
||||
response: {
|
||||
data: { detail: 'An error occurred' },
|
||||
},
|
||||
};
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<SmartInventoryForm
|
||||
submitError={error}
|
||||
onCancel={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('FormSubmitError').length).toBe(1);
|
||||
expect(wrapper.find('SmartInventoryForm').prop('submitError')).toEqual(
|
||||
error
|
||||
);
|
||||
wrapper.unmount();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@ -80,7 +80,7 @@
|
||||
"description": "smart inv description",
|
||||
"organization": 1,
|
||||
"kind": "smart",
|
||||
"host_filter": "search=local",
|
||||
"host_filter": "name__icontains=local",
|
||||
"variables": "",
|
||||
"has_active_failures": false,
|
||||
"total_hosts": 1,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user