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 && (