diff --git a/awx/ui/src/api/models/ConstructedInventories.js b/awx/ui/src/api/models/ConstructedInventories.js index d1384e915e..4ab8417d44 100644 --- a/awx/ui/src/api/models/ConstructedInventories.js +++ b/awx/ui/src/api/models/ConstructedInventories.js @@ -6,5 +6,20 @@ class ConstructedInventories extends InstanceGroupsMixin(Base) { super(http); 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; diff --git a/awx/ui/src/api/models/ConstructedInventories.test.js b/awx/ui/src/api/models/ConstructedInventories.test.js new file mode 100644 index 0000000000..aa5b9abf9f --- /dev/null +++ b/awx/ui/src/api/models/ConstructedInventories.test.js @@ -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.' + ); + } + }); +}); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js index 4263088d5f..49b52476b9 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.js @@ -1,14 +1,43 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { Card, PageSection } from '@patternfly/react-core'; 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 ConstructedInventoryAdd() { const history = useHistory(); 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 ; + } + + if (optionsError) { + return ; + } + const handleCancel = () => { history.push('/inventories'); }; @@ -48,6 +77,7 @@ function ConstructedInventoryAdd() { onCancel={handleCancel} onSubmit={handleSubmit} submitError={submitError} + options={options} /> diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js index f2397064e5..1d2249b7b9 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryAdd/ConstructedInventoryAdd.test.js @@ -55,6 +55,7 @@ describe('', () => { context: { router: { history } }, }); }); + await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); }); afterEach(() => { diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js index bb87534379..d1c2a63e1d 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.js @@ -20,6 +20,27 @@ function ConstructedInventoryEdit({ inventory }) { const detailsUrl = `/inventories/constructed_inventory/${inventory.id}/details`; 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 { result: { initialInstanceGroups, initialInputInventories }, request: fetchedRelatedData, @@ -44,6 +65,7 @@ function ConstructedInventoryEdit({ inventory }) { isLoading: true, } ); + useEffect(() => { fetchedRelatedData(); }, [fetchedRelatedData]); @@ -99,12 +121,12 @@ function ConstructedInventoryEdit({ inventory }) { const handleCancel = () => history.push(detailsUrl); - if (isLoading) { - return ; + if (contentError || optionsError) { + return ; } - if (contentError) { - return ; + if (isLoading || isLoadingOptions || (!options && !optionsError)) { + return ; } return ( @@ -116,6 +138,7 @@ function ConstructedInventoryEdit({ inventory }) { constructedInventory={inventory} instanceGroups={initialInstanceGroups} inputInventories={initialInputInventories} + options={options} /> ); diff --git a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js index ee52a8ca1b..2592f67093 100644 --- a/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js +++ b/awx/ui/src/screens/Inventory/ConstructedInventoryEdit/ConstructedInventoryEdit.test.js @@ -51,27 +51,22 @@ describe('', () => { }; 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: '', - }, - }, + ConstructedInventoriesAPI.readConstructedInventoryOptions.mockResolvedValue( + { + limit: { + label: 'Limit', + help_text: '', }, - }, - }); + update_cache_timeout: { + label: 'Update cache timeout', + help_text: 'help', + }, + verbosity: { + label: 'Verbosity', + help_text: '', + }, + } + ); InventoriesAPI.readInstanceGroups.mockResolvedValue({ data: { results: associatedInstanceGroups, @@ -169,6 +164,21 @@ describe('', () => { 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( + + ); + }); + + 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: { diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js index 470ab61366..faa5029703 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.js @@ -1,14 +1,10 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import { Formik, useField, useFormikContext } from 'formik'; import { func, shape } from 'prop-types'; import { t } from '@lingui/macro'; -import { ConstructedInventoriesAPI } from 'api'; import { minMaxValue, required } from 'util/validators'; -import useRequest from 'hooks/useRequest'; import { Form, FormGroup } from '@patternfly/react-core'; import { VariablesField } from 'components/CodeEditor'; -import ContentError from 'components/ContentError'; -import ContentLoading from 'components/ContentLoading'; import FormActionGroup from 'components/FormActionGroup/FormActionGroup'; import FormField, { FormSubmitError } from 'components/FormField'; import { FormFullWidthLayout, FormColumnLayout } from 'components/FormLayout'; @@ -165,6 +161,7 @@ function ConstructedInventoryForm({ onCancel, onSubmit, submitError, + options, }) { const initialValues = { kind: 'constructed', @@ -179,32 +176,6 @@ function ConstructedInventoryForm({ 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 ; - } - - if (error) { - return ; - } - return ( {(formik) => ( diff --git a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js index e3f50f1b93..a447c7d2f9 100644 --- a/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js +++ b/awx/ui/src/screens/Inventory/shared/ConstructedInventoryForm.test.js @@ -1,6 +1,5 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { ConstructedInventoriesAPI } from 'api'; import { mountWithContexts, waitForElement, @@ -19,38 +18,35 @@ const mockFormValues = { 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('', () => { let wrapper; const onSubmit = jest.fn(); 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 () => { wrapper = mountWithContexts( - {}} onSubmit={onSubmit} /> + {}} + onSubmit={onSubmit} + options={options} + /> ); }); - await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0); }); afterEach(() => { @@ -104,20 +100,4 @@ describe('', () => { '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( - {}} onSubmit={() => {}} /> - ); - }); - expect(newWrapper.find('ContentError').length).toBe(0); - newWrapper.update(); - expect(newWrapper.find('ContentError').length).toBe(1); - jest.clearAllMocks(); - }); });