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();
- });
});