mirror of
https://github.com/ZwareBear/awx.git
synced 2026-03-20 07:43:35 -05:00
Merge pull request #13629 from marshmalien/constructed-inv-edit-form
[constructed-inventory] Add constructed inventory edit form
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
Reference in New Issue
Block a user