Add constructed inventory edit form

This commit is contained in:
Marliana Lara 2023-02-23 19:16:12 -05:00 committed by Rick Elrod
parent d576e65858
commit 2bffddb5fb
8 changed files with 337 additions and 31 deletions

View File

@ -15,6 +15,7 @@ class Inventories extends InstanceGroupsMixin(Base) {
this.promoteGroup = this.promoteGroup.bind(this);
this.readInputInventories = this.readInputInventories.bind(this);
this.associateInventory = this.associateInventory.bind(this);
this.disassociateInventory = this.disassociateInventory.bind(this);
}
readAccessList(id, params) {
@ -144,6 +145,13 @@ class Inventories extends InstanceGroupsMixin(Base) {
id: inputInventoryId,
});
}
disassociateInventory(id, inputInventoryId) {
return this.http.post(`${this.baseUrl}${id}/input_inventories/`, {
id: inputInventoryId,
disassociate: true,
});
}
}
export default Inventories;

View File

@ -33,8 +33,8 @@ function ConstructedInventory({ setBreadcrumb }) {
const {
result: inventory,
error: contentError,
isLoading: hasContentLoading,
request: fetchInventory,
isLoading,
} = useRequest(
useCallback(async () => {
const { data } = await ConstructedInventoriesAPI.readDetail(
@ -42,7 +42,7 @@ function ConstructedInventory({ setBreadcrumb }) {
);
return data;
}, [match.params.id]),
{ isLoading: true }
{ inventory: null, isLoading: true }
);
useEffect(() => {
@ -78,7 +78,7 @@ function ConstructedInventory({ setBreadcrumb }) {
{ name: t`Job Templates`, link: `${match.url}/job_templates`, id: 5 },
];
if (hasContentLoading) {
if (isLoading) {
return (
<PageSection>
<Card>
@ -133,16 +133,13 @@ function ConstructedInventory({ setBreadcrumb }) {
path="/inventories/constructed_inventory/:id/details"
key="details"
>
<ConstructedInventoryDetail
inventory={inventory}
hasInventoryLoading={hasContentLoading}
/>
<ConstructedInventoryDetail inventory={inventory} />
</Route>,
<Route
key="edit"
path="/inventories/constructed_inventory/:id/edit"
>
<ConstructedInventoryEdit />
<ConstructedInventoryEdit inventory={inventory} />
</Route>,
<Route
path="/inventories/constructed_inventory/:id/access"

View File

@ -110,6 +110,7 @@ describe('<ConstructedInventoryAdd />', () => {
await act(async () => {
wrapper = mountWithContexts(<ConstructedInventoryAdd />);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('FormSubmitError').length).toBe(0);
await act(async () => {
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(formData);

View File

@ -1,11 +1,122 @@
/* eslint i18next/no-literal-string: "off" */
import React from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
import useRequest from 'hooks/useRequest';
import { CardBody } from 'components/Card';
import ContentError from 'components/ContentError';
import ContentLoading from 'components/ContentLoading';
import ConstructedInventoryForm from '../shared/ConstructedInventoryForm';
function isEqual(array1, array2) {
return (
array1.length === array2.length &&
array1.every((element, index) => element.id === array2[index].id)
);
}
function ConstructedInventoryEdit({ inventory }) {
const history = useHistory();
const [submitError, setSubmitError] = useState(null);
const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`;
const constructedInventoryId = inventory.id;
const {
result: { initialInstanceGroups, initialInputInventories },
request: fetchedRelatedData,
error: contentError,
isLoading,
} = useRequest(
useCallback(async () => {
const [instanceGroupsResponse, inputInventoriesResponse] =
await Promise.all([
InventoriesAPI.readInstanceGroups(constructedInventoryId),
InventoriesAPI.readInputInventories(constructedInventoryId),
]);
return {
initialInstanceGroups: instanceGroupsResponse.data.results,
initialInputInventories: inputInventoriesResponse.data.results,
};
}, [constructedInventoryId]),
{
initialInstanceGroups: [],
initialInputInventories: [],
isLoading: true,
}
);
useEffect(() => {
fetchedRelatedData();
}, [fetchedRelatedData]);
const handleSubmit = async (values) => {
const {
instanceGroups,
inputInventories,
organization,
...remainingValues
} = values;
remainingValues.organization = organization.id;
remainingValues.kind = 'constructed';
try {
await Promise.all([
ConstructedInventoriesAPI.update(
constructedInventoryId,
remainingValues
),
InventoriesAPI.orderInstanceGroups(
constructedInventoryId,
instanceGroups,
initialInstanceGroups
),
]);
/* eslint-disable no-await-in-loop, no-restricted-syntax */
// Resolve Promises sequentially to avoid race condition
if (!isEqual(initialInputInventories, values.inputInventories)) {
for (const inputInventory of initialInputInventories) {
await InventoriesAPI.disassociateInventory(
constructedInventoryId,
inputInventory.id
);
}
for (const inputInventory of values.inputInventories) {
await InventoriesAPI.associateInventory(
constructedInventoryId,
inputInventory.id
);
}
}
/* eslint-enable no-await-in-loop, no-restricted-syntax */
history.push(
`/inventories/constructed_inventory/${constructedInventoryId}/details`
);
} catch (error) {
setSubmitError(error);
}
};
const handleCancel = () => history.push(detailsUrl);
if (isLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
function ConstructedInventoryEdit() {
return (
<CardBody>
<div>Coming Soon!</div>
<ConstructedInventoryForm
onCancel={handleCancel}
onSubmit={handleSubmit}
submitError={submitError}
constructedInventory={inventory}
instanceGroups={initialInstanceGroups}
inputInventories={initialInputInventories}
/>
</CardBody>
);
}

View File

@ -1,15 +1,196 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import { createMemoryHistory } from 'history';
import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
import ConstructedInventoryEdit from './ConstructedInventoryEdit';
jest.mock('api');
describe('<ConstructedInventoryEdit />', () => {
test('initially renders successfully', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(<ConstructedInventoryEdit />);
let wrapper;
let history;
const mockInv = {
name: 'Mock',
id: 7,
description: 'Foo',
organization: { id: 1 },
kind: 'constructed',
source_vars: 'plugin: constructed',
limit: 'product_dev',
};
const associatedInstanceGroups = [
{
id: 1,
name: 'Foo',
},
];
const associatedInputInventories = [
{
id: 123,
name: 'input_inventory_123',
},
{
id: 456,
name: 'input_inventory_456',
},
];
const mockFormValues = {
kind: 'constructed',
name: 'new constructed inventory',
description: '',
organization: { id: 1, name: 'mock organization' },
instanceGroups: associatedInstanceGroups,
source_vars: 'plugin: constructed',
inputInventories: associatedInputInventories,
};
beforeEach(async () => {
ConstructedInventoriesAPI.readOptions.mockResolvedValue({
data: {
related: {},
actions: {
POST: {
limit: {
label: 'Limit',
help_text: '',
},
update_cache_timeout: {
label: 'Update cache timeout',
help_text: 'help',
},
verbosity: {
label: 'Verbosity',
help_text: '',
},
},
},
},
});
expect(wrapper.length).toBe(1);
expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1);
InventoriesAPI.readInstanceGroups.mockResolvedValue({
data: {
results: associatedInstanceGroups,
},
});
InventoriesAPI.readInputInventories.mockResolvedValue({
data: {
results: [
{
id: 456,
name: 'input_inventory_456',
},
],
},
});
history = createMemoryHistory({
initialEntries: ['/inventories/constructed_inventory/7/edit'],
});
await act(async () => {
wrapper = mountWithContexts(
<ConstructedInventoryEdit inventory={mockInv} />,
{
context: { router: { history } },
}
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
afterEach(() => {
jest.resetAllMocks();
});
test('should navigate to inventories details on cancel', async () => {
expect(history.location.pathname).toEqual(
'/inventories/constructed_inventory/7/edit'
);
await act(async () => {
wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
});
expect(history.location.pathname).toEqual(
'/inventories/constructed_inventory/7/details'
);
});
test('should navigate to constructed inventory detail after successful submission', async () => {
ConstructedInventoriesAPI.update.mockResolvedValueOnce({ data: { id: 1 } });
expect(history.location.pathname).toEqual(
'/inventories/constructed_inventory/7/edit'
);
await act(async () => {
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(
mockFormValues
);
});
wrapper.update();
expect(history.location.pathname).toEqual(
'/inventories/constructed_inventory/7/details'
);
});
test('should make expected api requests on submit', async () => {
await act(async () => {
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(
mockFormValues
);
});
expect(ConstructedInventoriesAPI.update).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.associateInstanceGroup).not.toHaveBeenCalled();
expect(InventoriesAPI.disassociateInventory).toHaveBeenCalledTimes(1);
expect(InventoriesAPI.associateInventory).toHaveBeenCalledTimes(2);
expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith(
1,
7,
123
);
expect(InventoriesAPI.associateInventory).toHaveBeenNthCalledWith(
2,
7,
456
);
});
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(
<ConstructedInventoryEdit inventory={mockInv} />
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('ContentError').length).toBe(1);
});
test('unsuccessful form submission should show an error message', async () => {
const error = {
response: {
data: { detail: 'An error occurred' },
},
};
ConstructedInventoriesAPI.update.mockImplementationOnce(() =>
Promise.reject(error)
);
await act(async () => {
wrapper = mountWithContexts(
<ConstructedInventoryEdit inventory={mockInv} />
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
expect(wrapper.find('FormSubmitError').length).toBe(0);
await act(async () => {
wrapper.find('ConstructedInventoryForm').invoke('onSubmit')(
mockFormValues
);
});
wrapper.update();
expect(wrapper.find('FormSubmitError').length).toBe(1);
});
});

View File

@ -146,7 +146,7 @@ function InventoryListItem({
aria-label={t`Edit Inventory`}
variant="plain"
component={Link}
to={`${getInventoryPath(inventory)}edit`}
to={`${getInventoryPath(inventory)}/edit`}
>
<PencilAltIcon />
</Button>

View File

@ -158,18 +158,25 @@ function ConstructedInventoryFormFields({ inventory = {}, options }) {
);
}
function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
function ConstructedInventoryForm({
constructedInventory,
instanceGroups,
inputInventories,
onCancel,
onSubmit,
submitError,
}) {
const initialValues = {
description: '',
instanceGroups: [],
kind: 'constructed',
inputInventories: [],
limit: '',
name: '',
organization: null,
source_vars: '---',
update_cache_timeout: 0,
verbosity: 0,
description: constructedInventory?.description || '',
instanceGroups: instanceGroups || [],
inputInventories: inputInventories || [],
limit: constructedInventory?.limit || '',
name: constructedInventory?.name || '',
organization: constructedInventory?.summary_fields?.organization || null,
update_cache_timeout: constructedInventory?.update_cache_timeout || 0,
verbosity: constructedInventory?.verbosity || 0,
source_vars: constructedInventory?.source_vars || '---',
};
const {
@ -204,7 +211,7 @@ function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
<Form role="form" autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<ConstructedInventoryFormFields options={options} />
<FormSubmitError error={submitError} />
{submitError && <FormSubmitError error={submitError} />}
<FormActionGroup
onCancel={onCancel}
onSubmit={formik.handleSubmit}

View File

@ -10,6 +10,7 @@ const parseHostFilter = (value) => {
export default parseHostFilter;
export function getInventoryPath(inventory) {
if (!inventory) return '/inventories';
const url = {
'': `/inventories/inventory/${inventory.id}`,
smart: `/inventories/smart_inventory/${inventory.id}`,