mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 03:10:42 -03:30
Add constructed inventory edit form
This commit is contained in:
parent
d576e65858
commit
2bffddb5fb
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user