From 6c4bf5bf7d1904abfd1c766470bce7acaa831981 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 5 May 2020 17:10:46 -0400 Subject: [PATCH 1/2] Adds Inventory Source Sync Button --- awx/ui_next/src/api/models/Inventories.js | 6 + .../InventorySources/InventorySourceList.jsx | 40 +++++- .../InventorySourceList.test.jsx | 117 ++++++++++++++++++ .../InventorySourceListItem.jsx | 2 + .../InventorySourceSyncButton.jsx | 23 ++-- .../InventorySourceSyncButton.test.jsx | 13 +- 6 files changed, 178 insertions(+), 23 deletions(-) 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..9fa43925ab 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.jsx @@ -2,6 +2,8 @@ import React, { useCallback, useEffect } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import { Button, Tooltip } from '@patternfly/react-core'; + import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import { InventoriesAPI, InventorySourcesAPI } from '../../../api'; @@ -38,7 +40,6 @@ function InventorySourceList({ i18n }) { InventoriesAPI.readSources(id, params), InventorySourcesAPI.readOptions(), ]); - return { sources: results[0].data.results, sourceCount: results[0].data.count, @@ -49,8 +50,24 @@ 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); + fetchSources(); + } + }, [id, fetchSources, canSyncSources]) + ); useEffect(() => { fetchSources(); @@ -92,8 +109,8 @@ function InventorySourceList({ i18n }) { return ( <> , + ...(canSyncSources + ? [ + + + , + ] + : []), ]} /> )} 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..2012f4355a 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceList.test.jsx @@ -225,6 +225,47 @@ describe('', () => { expect(wrapper.find('ContentError').length).toBe(1); }); + test('should render sync all button and make api call to start sync for all', async () => { + const readSourcesResponse = { + 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({ ...readSourcesResponse, status: 'pending' }) + .mockResolvedValueOnce(readSourcesResponse); + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, + POST: {}, + }, + }, + }); + await waitForElement(wrapper, 'InventorySourceList', 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', () => { @@ -296,4 +337,80 @@ describe(' RBAC testing', () => { newWrapper.unmount(); jest.clearAllMocks(); }); + test('should not render Sync All 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: false, + 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, + }, + }); + InventorySourcesAPI.readOptions.mockResolvedValue({ + data: { + actions: { + GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, + }, + }, + }); + 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..94e442d96f 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx @@ -24,6 +24,7 @@ function InventorySourceListItem({ i18n, detailUrl, label, + onFetchSources, }) { const [isSyncLoading, setIsSyncLoading] = useState(false); @@ -99,6 +100,7 @@ function InventorySourceListItem({ onSyncLoading={isLoading => { setIsSyncLoading(isLoading); }} + onFetchSources={onFetchSources} source={source} /> )} diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx index 02d66345ac..b7df832ca7 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import PropTypes from 'prop-types'; @@ -9,9 +9,12 @@ import AlertModal from '../../../components/AlertModal/AlertModal'; import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail'; import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api'; -function InventorySourceSyncButton({ onSyncLoading, source, i18n }) { - const [updateStatus, setUpdateStatus] = useState(source.status); - +function InventorySourceSyncButton({ + onSyncLoading, + source, + i18n, + onFetchSources, +}) { const { isLoading: startSyncLoading, error: startSyncError, @@ -21,11 +24,10 @@ function InventorySourceSyncButton({ onSyncLoading, source, i18n }) { const { data: { status }, } = await InventorySourcesAPI.createSyncStart(source.id); - - setUpdateStatus(status); + onFetchSources(); return status; - }, [source.id]), + }, [source.id, onFetchSources]), {} ); @@ -44,8 +46,8 @@ function InventorySourceSyncButton({ onSyncLoading, source, i18n }) { } = await InventorySourcesAPI.readDetail(source.id); await InventoryUpdatesAPI.createSyncCancel(id); - setUpdateStatus(null); - }, [source.id]) + onFetchSources(); + }, [source.id, onFetchSources]) ); useEffect(() => onSyncLoading(startSyncLoading || cancelSyncLoading), [ @@ -60,7 +62,7 @@ function InventorySourceSyncButton({ onSyncLoading, source, i18n }) { return ( <> - {updateStatus === 'pending' ? ( + {source.status === 'pending' ? ( , ] @@ -173,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 2012f4355a..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,42 +255,36 @@ describe('', () => { expect(wrapper.find('ContentError').length).toBe(1); }); - test('should render sync all button and make api call to start sync for all', async () => { - const readSourcesResponse = { - 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, - }, - }, + + test('displays error after unsuccessful sync all button', async () => { + InventoriesAPI.syncAllSources.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/inventories/', }, - ], - count: 1, - }, - }; - InventoriesAPI.readSources - .mockResolvedValue({ ...readSourcesResponse, status: 'pending' }) - .mockResolvedValueOnce(readSourcesResponse); - InventorySourcesAPI.readOptions.mockResolvedValue({ - data: { - actions: { - GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, - POST: {}, + data: 'An error occurred', + status: 403, }, - }, - }); + }) + ); await waitForElement(wrapper, 'InventorySourceList', el => el.length > 0); - const syncAllButton = wrapper.find('Button[aria-label="Sync All"]'); + 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(); @@ -270,28 +294,13 @@ describe('', () => { 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: { @@ -337,51 +346,15 @@ describe(' RBAC testing', () => { newWrapper.unmount(); jest.clearAllMocks(); }); + test('should not render Sync All 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: false, - 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, - }, - }); - InventorySourcesAPI.readOptions.mockResolvedValue({ - data: { - actions: { - GET: { source: { choices: [['scm', 'SCM'], ['ec2', 'EC2']] } }, - }, - }, - }); + 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'], diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx index 94e442d96f..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'; @@ -24,10 +24,7 @@ function InventorySourceListItem({ i18n, detailUrl, label, - onFetchSources, }) { - const [isSyncLoading, setIsSyncLoading] = useState(false); - const generateLastJobTooltip = job => { return ( <> @@ -51,7 +48,6 @@ function InventorySourceListItem({ {source.summary_fields.user_capabilities.start && ( - { - setIsSyncLoading(isLoading); - }} - onFetchSources={onFetchSources} - source={source} - /> + )} {source.summary_fields.user_capabilities.edit && (