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:
softwarefactory-project-zuul[bot] 2020-08-10 19:08:36 +00:00 committed by GitHub
commit ddb6c5d0cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1402 additions and 19 deletions

View 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));

View File

@ -40,7 +40,7 @@ function OrganizationLookup({
organizations: data.results,
itemCount: data.count,
};
}, [history.location]),
}, [history.location.search]),
{
organizations: [],
itemCount: 0,

View File

@ -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';

View 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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

View File

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

View File

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