Merge pull request #13629 from marshmalien/constructed-inv-edit-form

[constructed-inventory] Add constructed inventory edit form
This commit is contained in:
Sarah Akus
2023-03-03 11:27:04 -05:00
committed by GitHub
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.promoteGroup = this.promoteGroup.bind(this);
this.readInputInventories = this.readInputInventories.bind(this); this.readInputInventories = this.readInputInventories.bind(this);
this.associateInventory = this.associateInventory.bind(this); this.associateInventory = this.associateInventory.bind(this);
this.disassociateInventory = this.disassociateInventory.bind(this);
} }
readAccessList(id, params) { readAccessList(id, params) {
@@ -144,6 +145,13 @@ class Inventories extends InstanceGroupsMixin(Base) {
id: inputInventoryId, id: inputInventoryId,
}); });
} }
disassociateInventory(id, inputInventoryId) {
return this.http.post(`${this.baseUrl}${id}/input_inventories/`, {
id: inputInventoryId,
disassociate: true,
});
}
} }
export default Inventories; export default Inventories;

View File

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

View File

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

View File

@@ -1,11 +1,122 @@
/* eslint i18next/no-literal-string: "off" */ import React, { useState, useCallback, useEffect } from 'react';
import React from 'react'; import { useHistory } from 'react-router-dom';
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
import useRequest from 'hooks/useRequest';
import { CardBody } from 'components/Card'; 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 ( return (
<CardBody> <CardBody>
<div>Coming Soon!</div> <ConstructedInventoryForm
onCancel={handleCancel}
onSubmit={handleSubmit}
submitError={submitError}
constructedInventory={inventory}
instanceGroups={initialInstanceGroups}
inputInventories={initialInputInventories}
/>
</CardBody> </CardBody>
); );
} }

View File

@@ -1,15 +1,196 @@
import React from 'react'; import React from 'react';
import { act } from 'react-dom/test-utils'; 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'; import ConstructedInventoryEdit from './ConstructedInventoryEdit';
jest.mock('api');
describe('<ConstructedInventoryEdit />', () => { describe('<ConstructedInventoryEdit />', () => {
test('initially renders successfully', async () => { let wrapper;
let wrapper; let history;
await act(async () => {
wrapper = mountWithContexts(<ConstructedInventoryEdit />); 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); InventoriesAPI.readInstanceGroups.mockResolvedValue({
expect(wrapper.find('ConstructedInventoryEdit').length).toBe(1); 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`} aria-label={t`Edit Inventory`}
variant="plain" variant="plain"
component={Link} component={Link}
to={`${getInventoryPath(inventory)}edit`} to={`${getInventoryPath(inventory)}/edit`}
> >
<PencilAltIcon /> <PencilAltIcon />
</Button> </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 = { const initialValues = {
description: '',
instanceGroups: [],
kind: 'constructed', kind: 'constructed',
inputInventories: [], description: constructedInventory?.description || '',
limit: '', instanceGroups: instanceGroups || [],
name: '', inputInventories: inputInventories || [],
organization: null, limit: constructedInventory?.limit || '',
source_vars: '---', name: constructedInventory?.name || '',
update_cache_timeout: 0, organization: constructedInventory?.summary_fields?.organization || null,
verbosity: 0, update_cache_timeout: constructedInventory?.update_cache_timeout || 0,
verbosity: constructedInventory?.verbosity || 0,
source_vars: constructedInventory?.source_vars || '---',
}; };
const { const {
@@ -204,7 +211,7 @@ function ConstructedInventoryForm({ onCancel, onSubmit, submitError }) {
<Form role="form" autoComplete="off" onSubmit={formik.handleSubmit}> <Form role="form" autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout> <FormColumnLayout>
<ConstructedInventoryFormFields options={options} /> <ConstructedInventoryFormFields options={options} />
<FormSubmitError error={submitError} /> {submitError && <FormSubmitError error={submitError} />}
<FormActionGroup <FormActionGroup
onCancel={onCancel} onCancel={onCancel}
onSubmit={formik.handleSubmit} onSubmit={formik.handleSubmit}

View File

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