mirror of
https://github.com/ansible/awx.git
synced 2026-04-29 05:35:27 -02:30
Fix edit constructed inventory hanging loading state (#14343)
This commit is contained in:
@@ -6,5 +6,20 @@ class ConstructedInventories extends InstanceGroupsMixin(Base) {
|
|||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = 'api/v2/constructed_inventories/';
|
this.baseUrl = 'api/v2/constructed_inventories/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readConstructedInventoryOptions(id, method) {
|
||||||
|
const {
|
||||||
|
data: { actions },
|
||||||
|
} = await this.http.options(`${this.baseUrl}${id}/`);
|
||||||
|
|
||||||
|
if (actions[method]) {
|
||||||
|
return actions[method];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`You have insufficient access to this Constructed Inventory.
|
||||||
|
Please contact your system administrator if there is an issue with your access.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default ConstructedInventories;
|
export default ConstructedInventories;
|
||||||
|
|||||||
51
awx/ui/src/api/models/ConstructedInventories.test.js
Normal file
51
awx/ui/src/api/models/ConstructedInventories.test.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import ConstructedInventories from './ConstructedInventories';
|
||||||
|
|
||||||
|
describe('ConstructedInventoriesAPI', () => {
|
||||||
|
const constructedInventoryId = 1;
|
||||||
|
const constructedInventoryMethod = 'PUT';
|
||||||
|
let ConstructedInventoriesAPI;
|
||||||
|
let mockHttp;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const optionsPromise = () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: {
|
||||||
|
actions: {
|
||||||
|
PUT: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockHttp = {
|
||||||
|
options: jest.fn(optionsPromise),
|
||||||
|
};
|
||||||
|
ConstructedInventoriesAPI = new ConstructedInventories(mockHttp);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readConstructedInventoryOptions calls options with the expected params', async () => {
|
||||||
|
await ConstructedInventoriesAPI.readConstructedInventoryOptions(
|
||||||
|
constructedInventoryId,
|
||||||
|
constructedInventoryMethod
|
||||||
|
);
|
||||||
|
expect(mockHttp.options).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockHttp.options).toHaveBeenCalledWith(
|
||||||
|
`api/v2/constructed_inventories/${constructedInventoryId}/`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readConstructedInventory should throw an error if action method is missing', async () => {
|
||||||
|
try {
|
||||||
|
await ConstructedInventoriesAPI.readConstructedInventoryOptions(
|
||||||
|
constructedInventoryId,
|
||||||
|
'POST'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error.message).toContain(
|
||||||
|
'You have insufficient access to this Constructed Inventory.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,43 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Card, PageSection } from '@patternfly/react-core';
|
import { Card, PageSection } from '@patternfly/react-core';
|
||||||
import { ConstructedInventoriesAPI, InventoriesAPI } from 'api';
|
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';
|
import ConstructedInventoryForm from '../shared/ConstructedInventoryForm';
|
||||||
|
|
||||||
function ConstructedInventoryAdd() {
|
function ConstructedInventoryAdd() {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const [submitError, setSubmitError] = useState(null);
|
const [submitError, setSubmitError] = useState(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: isLoadingOptions,
|
||||||
|
error: optionsError,
|
||||||
|
request: fetchOptions,
|
||||||
|
result: options,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(async () => {
|
||||||
|
const res = await ConstructedInventoriesAPI.readOptions();
|
||||||
|
const { data } = res;
|
||||||
|
return data.actions.POST;
|
||||||
|
}, []),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOptions();
|
||||||
|
}, [fetchOptions]);
|
||||||
|
|
||||||
|
if (isLoadingOptions || (!options && !optionsError)) {
|
||||||
|
return <ContentLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsError) {
|
||||||
|
return <ContentError error={optionsError} />;
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
history.push('/inventories');
|
history.push('/inventories');
|
||||||
};
|
};
|
||||||
@@ -48,6 +77,7 @@ function ConstructedInventoryAdd() {
|
|||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitError={submitError}
|
submitError={submitError}
|
||||||
|
options={options}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe('<ConstructedInventoryAdd />', () => {
|
|||||||
context: { router: { history } },
|
context: { router: { history } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ function ConstructedInventoryEdit({ inventory }) {
|
|||||||
const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`;
|
const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`;
|
||||||
const constructedInventoryId = inventory.id;
|
const constructedInventoryId = inventory.id;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: isLoadingOptions,
|
||||||
|
error: optionsError,
|
||||||
|
request: fetchOptions,
|
||||||
|
result: options,
|
||||||
|
} = useRequest(
|
||||||
|
useCallback(
|
||||||
|
() =>
|
||||||
|
ConstructedInventoriesAPI.readConstructedInventoryOptions(
|
||||||
|
constructedInventoryId,
|
||||||
|
'PUT'
|
||||||
|
),
|
||||||
|
[constructedInventoryId]
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOptions();
|
||||||
|
}, [fetchOptions]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
result: { initialInstanceGroups, initialInputInventories },
|
result: { initialInstanceGroups, initialInputInventories },
|
||||||
request: fetchedRelatedData,
|
request: fetchedRelatedData,
|
||||||
@@ -44,6 +65,7 @@ function ConstructedInventoryEdit({ inventory }) {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchedRelatedData();
|
fetchedRelatedData();
|
||||||
}, [fetchedRelatedData]);
|
}, [fetchedRelatedData]);
|
||||||
@@ -99,12 +121,12 @@ function ConstructedInventoryEdit({ inventory }) {
|
|||||||
|
|
||||||
const handleCancel = () => history.push(detailsUrl);
|
const handleCancel = () => history.push(detailsUrl);
|
||||||
|
|
||||||
if (isLoading) {
|
if (contentError || optionsError) {
|
||||||
return <ContentLoading />;
|
return <ContentError error={contentError || optionsError} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentError) {
|
if (isLoading || isLoadingOptions || (!options && !optionsError)) {
|
||||||
return <ContentError error={contentError} />;
|
return <ContentLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,6 +138,7 @@ function ConstructedInventoryEdit({ inventory }) {
|
|||||||
constructedInventory={inventory}
|
constructedInventory={inventory}
|
||||||
instanceGroups={initialInstanceGroups}
|
instanceGroups={initialInstanceGroups}
|
||||||
inputInventories={initialInputInventories}
|
inputInventories={initialInputInventories}
|
||||||
|
options={options}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,27 +51,22 @@ describe('<ConstructedInventoryEdit />', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
ConstructedInventoriesAPI.readOptions.mockResolvedValue({
|
ConstructedInventoriesAPI.readConstructedInventoryOptions.mockResolvedValue(
|
||||||
data: {
|
{
|
||||||
related: {},
|
limit: {
|
||||||
actions: {
|
label: 'Limit',
|
||||||
POST: {
|
help_text: '',
|
||||||
limit: {
|
|
||||||
label: 'Limit',
|
|
||||||
help_text: '',
|
|
||||||
},
|
|
||||||
update_cache_timeout: {
|
|
||||||
label: 'Update cache timeout',
|
|
||||||
help_text: 'help',
|
|
||||||
},
|
|
||||||
verbosity: {
|
|
||||||
label: 'Verbosity',
|
|
||||||
help_text: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
update_cache_timeout: {
|
||||||
});
|
label: 'Update cache timeout',
|
||||||
|
help_text: 'help',
|
||||||
|
},
|
||||||
|
verbosity: {
|
||||||
|
label: 'Verbosity',
|
||||||
|
help_text: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
InventoriesAPI.readInstanceGroups.mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
results: associatedInstanceGroups,
|
results: associatedInstanceGroups,
|
||||||
@@ -169,6 +164,21 @@ describe('<ConstructedInventoryEdit />', () => {
|
|||||||
expect(wrapper.find('ContentError').length).toBe(1);
|
expect(wrapper.find('ContentError').length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should throw content error if user has insufficient options permissions', async () => {
|
||||||
|
expect(wrapper.find('ContentError').length).toBe(0);
|
||||||
|
ConstructedInventoriesAPI.readConstructedInventoryOptions.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 () => {
|
test('unsuccessful form submission should show an error message', async () => {
|
||||||
const error = {
|
const error = {
|
||||||
response: {
|
response: {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Formik, useField, useFormikContext } from 'formik';
|
import { Formik, useField, useFormikContext } from 'formik';
|
||||||
import { func, shape } from 'prop-types';
|
import { func, shape } from 'prop-types';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { ConstructedInventoriesAPI } from 'api';
|
|
||||||
import { minMaxValue, required } from 'util/validators';
|
import { minMaxValue, required } from 'util/validators';
|
||||||
import useRequest from 'hooks/useRequest';
|
|
||||||
import { Form, FormGroup } from '@patternfly/react-core';
|
import { Form, FormGroup } from '@patternfly/react-core';
|
||||||
import { VariablesField } from 'components/CodeEditor';
|
import { VariablesField } from 'components/CodeEditor';
|
||||||
import ContentError from 'components/ContentError';
|
|
||||||
import ContentLoading from 'components/ContentLoading';
|
|
||||||
import FormActionGroup from 'components/FormActionGroup/FormActionGroup';
|
import FormActionGroup from 'components/FormActionGroup/FormActionGroup';
|
||||||
import FormField, { FormSubmitError } from 'components/FormField';
|
import FormField, { FormSubmitError } from 'components/FormField';
|
||||||
import { FormFullWidthLayout, FormColumnLayout } from 'components/FormLayout';
|
import { FormFullWidthLayout, FormColumnLayout } from 'components/FormLayout';
|
||||||
@@ -165,6 +161,7 @@ function ConstructedInventoryForm({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
submitError,
|
submitError,
|
||||||
|
options,
|
||||||
}) {
|
}) {
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
kind: 'constructed',
|
kind: 'constructed',
|
||||||
@@ -179,32 +176,6 @@ function ConstructedInventoryForm({
|
|||||||
source_vars: constructedInventory?.source_vars || '---',
|
source_vars: constructedInventory?.source_vars || '---',
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
request: fetchOptions,
|
|
||||||
result: options,
|
|
||||||
} = useRequest(
|
|
||||||
useCallback(async () => {
|
|
||||||
const res = await ConstructedInventoriesAPI.readOptions();
|
|
||||||
const { data } = res;
|
|
||||||
return data.actions.POST;
|
|
||||||
}, []),
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchOptions();
|
|
||||||
}, [fetchOptions]);
|
|
||||||
|
|
||||||
if (isLoading || (!options && !error)) {
|
|
||||||
return <ContentLoading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <ContentError error={error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||||
{(formik) => (
|
{(formik) => (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { act } from 'react-dom/test-utils';
|
import { act } from 'react-dom/test-utils';
|
||||||
import { ConstructedInventoriesAPI } from 'api';
|
|
||||||
import {
|
import {
|
||||||
mountWithContexts,
|
mountWithContexts,
|
||||||
waitForElement,
|
waitForElement,
|
||||||
@@ -19,38 +18,35 @@ const mockFormValues = {
|
|||||||
inputInventories: [{ id: 100, name: 'East' }],
|
inputInventories: [{ id: 100, name: 'East' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
limit: {
|
||||||
|
label: 'Limit',
|
||||||
|
help_text: '',
|
||||||
|
},
|
||||||
|
update_cache_timeout: {
|
||||||
|
label: 'Update cache timeout',
|
||||||
|
help_text: 'help',
|
||||||
|
},
|
||||||
|
verbosity: {
|
||||||
|
label: 'Verbosity',
|
||||||
|
help_text: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
describe('<ConstructedInventoryForm />', () => {
|
describe('<ConstructedInventoryForm />', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
const onSubmit = jest.fn();
|
const onSubmit = jest.fn();
|
||||||
|
|
||||||
beforeEach(async () => {
|
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: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
wrapper = mountWithContexts(
|
wrapper = mountWithContexts(
|
||||||
<ConstructedInventoryForm onCancel={() => {}} onSubmit={onSubmit} />
|
<ConstructedInventoryForm
|
||||||
|
onCancel={() => {}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -104,20 +100,4 @@ describe('<ConstructedInventoryForm />', () => {
|
|||||||
'The plugin parameter is required.'
|
'The plugin parameter is required.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw content error when option request fails', async () => {
|
|
||||||
let newWrapper;
|
|
||||||
ConstructedInventoriesAPI.readOptions.mockImplementationOnce(() =>
|
|
||||||
Promise.reject(new Error())
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
newWrapper = mountWithContexts(
|
|
||||||
<ConstructedInventoryForm onCancel={() => {}} onSubmit={() => {}} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(newWrapper.find('ContentError').length).toBe(0);
|
|
||||||
newWrapper.update();
|
|
||||||
expect(newWrapper.find('ContentError').length).toBe(1);
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user