diff --git a/awx/ui_next/src/api/models/Inventories.js b/awx/ui_next/src/api/models/Inventories.js
index 9769eb4707..ab828e32d6 100644
--- a/awx/ui_next/src/api/models/Inventories.js
+++ b/awx/ui_next/src/api/models/Inventories.js
@@ -88,6 +88,12 @@ class Inventories extends InstanceGroupsMixin(Base) {
`How did you get here? Source not found for Inventory ID: ${inventoryId}`
);
}
+
+ syncAllSources(inventoryId) {
+ return this.http.post(
+ `${this.baseUrl}${inventoryId}/update_inventory_sources/`
+ );
+ }
}
export default Inventories;
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
index 8d5b11ebf9..a11614e2bf 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx
@@ -2,7 +2,12 @@ import React, { useCallback, useEffect } from 'react';
import { useParams, useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import useRequest, { useDeleteItems } from '../../../util/useRequest';
+import { Button, Tooltip } from '@patternfly/react-core';
+
+import useRequest, {
+ useDeleteItems,
+ useDismissableError,
+} from '../../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import { InventoriesAPI, InventorySourcesAPI } from '../../../api';
import PaginatedDataList, {
@@ -28,7 +33,7 @@ function InventorySourceList({ i18n }) {
const {
isLoading,
- error,
+ error: fetchError,
result: { sources, sourceCount, sourceChoices, sourceChoicesOptions },
request: fetchSources,
} = useRequest(
@@ -38,7 +43,6 @@ function InventorySourceList({ i18n }) {
InventoriesAPI.readSources(id, params),
InventorySourcesAPI.readOptions(),
]);
-
return {
sources: results[0].data.results,
sourceCount: results[0].data.count,
@@ -49,8 +53,23 @@ function InventorySourceList({ i18n }) {
{
sources: [],
sourceCount: 0,
+ sourceChoices: [],
}
);
+ const canSyncSources =
+ sources.length > 0 &&
+ sources.every(source => source.summary_fields.user_capabilities.start);
+ const {
+ isLoading: isSyncAllLoading,
+ error: syncAllError,
+ request: syncAll,
+ } = useRequest(
+ useCallback(async () => {
+ if (canSyncSources) {
+ await InventoriesAPI.syncAllSources(id);
+ }
+ }, [id, canSyncSources])
+ );
useEffect(() => {
fetchSources();
@@ -80,6 +99,7 @@ function InventorySourceList({ i18n }) {
qsConfig: QS_CONFIG,
}
);
+ const { error: syncError, dismissError } = useDismissableError(syncAllError);
const handleDelete = async () => {
await handleDeleteSources();
@@ -92,8 +112,8 @@ function InventorySourceList({ i18n }) {
return (
<>
,
+ ...(canSyncSources
+ ? [
+
+
+ ,
+ ]
+ : []),
]}
/>
)}
@@ -139,15 +176,28 @@ function InventorySourceList({ i18n }) {
);
}}
/>
+ {syncError && (
+
+ {i18n._(t`Failed to sync some or all inventory sources.`)}
+
+
+ )}
+
{deletionError && (
- {i18n._(t`Failed to delete one or more Inventory Sources.`)}
+ {i18n._(t`Failed to delete one or more inventory sources.`)}
)}
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
index b34ed5952c..9ed97cfb1f 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx
@@ -7,39 +7,57 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
+
import InventorySourceList from './InventorySourceList';
jest.mock('../../../api/models/InventorySources');
jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/InventoryUpdates');
+const sources = {
+ data: {
+ results: [
+ {
+ id: 1,
+ name: 'Source Foo',
+ status: '',
+ source: 'ec2',
+ url: '/api/v2/inventory_sources/56/',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ start: true,
+ schedule: true,
+ },
+ },
+ },
+ {
+ id: 2,
+ name: 'Source Bar',
+ status: '',
+ source: 'scm',
+ url: '/api/v2/inventory_sources/57/',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: true,
+ start: true,
+ schedule: true,
+ },
+ },
+ },
+ ],
+ count: 1,
+ },
+};
+
describe('', () => {
let wrapper;
let history;
beforeEach(async () => {
- InventoriesAPI.readSources.mockResolvedValue({
- data: {
- results: [
- {
- id: 1,
- name: 'Source Foo',
- status: '',
- source: 'ec2',
- url: '/api/v2/inventory_sources/56/',
- summary_fields: {
- user_capabilities: {
- edit: true,
- delete: true,
- start: true,
- schedule: true,
- },
- },
- },
- ],
- count: 1,
- },
- });
+ InventoriesAPI.readSources.mockResolvedValue(sources);
InventorySourcesAPI.readOptions.mockResolvedValue({
data: {
actions: {
@@ -81,9 +99,11 @@ describe('', () => {
wrapper.unmount();
jest.clearAllMocks();
});
+
test('should mount properly', async () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
});
+
test('api calls should be made on mount', async () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
expect(InventoriesAPI.readSources).toHaveBeenCalledWith('1', {
@@ -94,15 +114,23 @@ describe('', () => {
});
expect(InventorySourcesAPI.readOptions).toHaveBeenCalled();
});
+
test('source data should render properly', async () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
- expect(wrapper.find('PFDataListCell[aria-label="name"]').text()).toBe(
- 'Source Foo'
- );
- expect(wrapper.find('PFDataListCell[aria-label="type"]').text()).toBe(
- 'EC2'
- );
+ expect(
+ wrapper
+ .find("DataListItem[aria-labelledby='check-action-1']")
+ .find('PFDataListCell[aria-label="name"]')
+ .text()
+ ).toBe('Source Foo');
+ expect(
+ wrapper
+ .find("DataListItem[aria-labelledby='check-action-1']")
+ .find('PFDataListCell[aria-label="type"]')
+ .text()
+ ).toBe('EC2');
});
+
test('add button is not disabled and delete button is disabled', async () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
const addButton = wrapper.find('ToolbarAddButton').find('Link');
@@ -118,7 +146,7 @@ describe('', () => {
expect(deleteButton.prop('isDisabled')).toBe(true);
await act(async () =>
- wrapper.find('DataListCheck').prop('onChange')({ id: 1 })
+ wrapper.find('DataListCheck#select-source-1').prop('onChange')({ id: 1 })
);
wrapper.update();
expect(wrapper.find('input#select-source-1').prop('checked')).toBe(true);
@@ -134,6 +162,7 @@ describe('', () => {
);
expect(InventorySourcesAPI.destroy).toHaveBeenCalledWith(1);
});
+
test('should throw error after deletion failure', async () => {
InventorySourcesAPI.destroy.mockRejectedValue(
new Error({
@@ -151,7 +180,7 @@ describe('', () => {
await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
await act(async () =>
- wrapper.find('DataListCheck').prop('onChange')({ id: 1 })
+ wrapper.find('DataListCheck#select-source-1').prop('onChange')({ id: 1 })
);
wrapper.update();
@@ -164,10 +193,11 @@ describe('', () => {
wrapper.find('Button[aria-label="confirm delete"]').prop('onClick')()
);
wrapper.update();
- expect(wrapper.find("AlertModal[aria-label='Delete Error']").length).toBe(
+ expect(wrapper.find("AlertModal[aria-label='Delete error']").length).toBe(
1
);
});
+
test('displays error after unsuccessful read sources fetch', async () => {
InventorySourcesAPI.readOptions.mockRejectedValue(
new Error({
@@ -225,32 +255,52 @@ describe('', () => {
expect(wrapper.find('ContentError').length).toBe(1);
});
+
+ test('displays error after unsuccessful sync all button', async () => {
+ InventoriesAPI.syncAllSources.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'post',
+ url: '/api/v2/inventories/',
+ },
+ data: 'An error occurred',
+ status: 403,
+ },
+ })
+ );
+ await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0);
+ await act(async () =>
+ wrapper.find('Button[aria-label="Sync all"]').prop('onClick')()
+ );
+ expect(InventoriesAPI.syncAllSources).toBeCalled();
+ wrapper.update();
+ expect(wrapper.find("AlertModal[aria-label='Sync error']").length).toBe(1);
+ });
+
+ test('should render sync all button and make api call to start sync for all', async () => {
+ await waitForElement(
+ wrapper,
+ 'InventorySourceListItem',
+ el => el.length > 0
+ );
+ const syncAllButton = wrapper.find('Button[aria-label="Sync all"]');
+ expect(syncAllButton.length).toBe(1);
+ await act(async () => syncAllButton.prop('onClick')());
+ expect(InventoriesAPI.syncAllSources).toBeCalled();
+ expect(InventoriesAPI.readSources).toBeCalled();
+ });
});
describe(' RBAC testing', () => {
test('should not render add button', async () => {
- InventoriesAPI.readSources.mockResolvedValue({
- data: {
- results: [
- {
- id: 1,
- name: 'Source Foo',
- status: '',
- source: 'ec2',
- url: '/api/v2/inventory_sources/56/',
- summary_fields: {
- user_capabilities: {
- edit: true,
- delete: true,
- start: true,
- schedule: true,
- },
- },
- },
- ],
- count: 1,
- },
- });
+ sources.data.results[0].summary_fields.user_capabilities = {
+ edit: true,
+ delete: true,
+ start: true,
+ schedule: true,
+ };
+ InventoriesAPI.readSources.mockResolvedValue(sources);
InventorySourcesAPI.readOptions.mockResolvedValue({
data: {
actions: {
@@ -296,4 +346,44 @@ describe(' RBAC testing', () => {
newWrapper.unmount();
jest.clearAllMocks();
});
+
+ test('should not render Sync All button', async () => {
+ sources.data.results[0].summary_fields.user_capabilities = {
+ edit: true,
+ delete: true,
+ start: false,
+ schedule: true,
+ };
+ InventoriesAPI.readSources.mockResolvedValue(sources);
+ let newWrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/inventories/inventory/2/sources'],
+ });
+ await act(async () => {
+ newWrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: { search: '' },
+ match: { params: { id: 2 } },
+ },
+ },
+ },
+ }
+ );
+ });
+ await waitForElement(
+ newWrapper,
+ 'InventorySourceList',
+ el => el.length > 0
+ );
+ expect(newWrapper.find('Button[aria-label="Sync All"]').length).toBe(0);
+ newWrapper.unmount();
+ jest.clearAllMocks();
+ });
});
diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
index 4af6cf0c97..948faf60ec 100644
--- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
+++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React from 'react';
import { withI18n } from '@lingui/react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
@@ -25,8 +25,6 @@ function InventorySourceListItem({
detailUrl,
label,
}) {
- const [isSyncLoading, setIsSyncLoading] = useState(false);
-
const generateLastJobTooltip = job => {
return (
<>
@@ -50,7 +48,6 @@ function InventorySourceListItem({
{source.summary_fields.user_capabilities.start && (
- {
- setIsSyncLoading(isLoading);
- }}
- source={source}
- />
+
)}
{source.summary_fields.user_capabilities.edit && (