1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-30 22:21:13 +03:00

Add smart inventory edit form

This commit is contained in:
Marliana Lara 2020-07-17 16:28:14 -04:00
parent 6a304dce55
commit 8e6d475a9d
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
3 changed files with 277 additions and 7 deletions

View File

@ -29,7 +29,7 @@ export function toSearchParams(string = '') {
* Convert params object to an encoded namespaced url query string * Convert params object to an encoded namespaced url query string
* Used to put into url bar when modal opens * Used to put into url bar when modal opens
* @param {object} config Config object for namespacing params * @param {object} config Config object for namespacing params
* @param {object} obj A string or array of strings keyed by query param key * @param {object} searchParams A string or array of strings keyed by query param key
* @return {string} URL query string * @return {string} URL query string
*/ */
export function toQueryString(config, searchParams = {}) { export function toQueryString(config, searchParams = {}) {
@ -54,7 +54,7 @@ export function toQueryString(config, searchParams = {}) {
/** /**
* Convert params object to host filter string * Convert params object to host filter string
* @param {object} obj A string or array of strings keyed by query param key * @param {object} searchParams A string or array of strings keyed by query param key
* @return {string} Host filter string * @return {string} Host filter string
*/ */
export function toHostFilter(searchParams = {}) { export function toHostFilter(searchParams = {}) {

View File

@ -1,10 +1,120 @@
import React, { Component } from 'react'; import React, { useCallback, useEffect } from 'react';
import { PageSection } from '@patternfly/react-core'; 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 { function SmartInventoryEdit({ inventory }) {
render() { const history = useHistory();
return <PageSection>Coming soon :)</PageSection>; 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; 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
);
});
});