From 77fd2d677ac8124154e7b841e0a036ffeac8f9e5 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 30 Apr 2020 14:00:35 -0400 Subject: [PATCH] Adds Sync Functionality --- .../src/api/models/InventorySources.js | 14 +- .../src/api/models/InventoryUpdates.js | 11 +- .../InventorySourceListItem.jsx | 143 +++++++++++----- .../InventorySourceListItem.test.jsx | 70 +++++++- .../InventorySourceSyncButton.jsx | 139 +++++++++++++++ .../InventorySourceSyncButton.test.jsx | 160 ++++++++++++++++++ 6 files changed, 487 insertions(+), 50 deletions(-) create mode 100644 awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx create mode 100644 awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.test.jsx diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index be43f988eb..716fa61a76 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -5,7 +5,19 @@ class InventorySources extends LaunchUpdateMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_sources/'; + + this.allowSyncStart = this.allowSyncStart.bind(this); + this.startSyncSource = this.startSyncSource.bind(this); + } + + allowSyncStart(sourceId) { + return this.http.get(`${this.baseUrl}${sourceId}/update/`); + } + + startSyncSource(sourceId, extraVars) { + return this.http.post(`${this.baseUrl}${sourceId}/update/`, { + extra_vars: extraVars, + }); } } - export default InventorySources; diff --git a/awx/ui_next/src/api/models/InventoryUpdates.js b/awx/ui_next/src/api/models/InventoryUpdates.js index a4dc05b392..c860b6267b 100644 --- a/awx/ui_next/src/api/models/InventoryUpdates.js +++ b/awx/ui_next/src/api/models/InventoryUpdates.js @@ -5,7 +5,16 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_updates/'; + this.allowSyncCancel = this.allowSyncCancel.bind(this); + this.cancelSyncSource = this.cancelSyncSource.bind(this); + } + + allowSyncCancel(sourceId) { + return this.http.get(`${this.baseUrl}${sourceId}/cancel/`); + } + + cancelSyncSource(sourceId) { + return this.http.post(`${this.baseUrl}${sourceId}/cancel/`); } } - export default InventoryUpdates; diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.jsx index 153311bd13..455698144e 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 from 'react'; +import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { Link } from 'react-router-dom'; import { t } from '@lingui/macro'; @@ -10,8 +10,12 @@ import { DataListItemCells, DataListCell, DataListAction, + Tooltip, } from '@patternfly/react-core'; import { PencilAltIcon } from '@patternfly/react-icons'; +import StatusIcon from '@components/StatusIcon'; + +import InventorySourceSyncButton from './InventorySourceSyncButton'; function InventorySourceListItem({ source, @@ -21,47 +25,104 @@ function InventorySourceListItem({ detailUrl, label, }) { + const [isCancelSyncLoading, setIsCancelSyncLoading] = useState(false); + const [isStartSyncLoading, setIsStartSyncLoading] = useState(false); + + const isDisabled = isCancelSyncLoading || isStartSyncLoading; + + const generateLastJobTooltip = job => { + return ( + <> +
{i18n._(t`MOST RECENT SYNC`)}
+
+ {i18n._(t`JOB ID:`)} {job.id} +
+
+ {i18n._(t`STATUS:`)} {job.status.toUpperCase()} +
+ {job.finished && ( +
+ {i18n._(t`FINISHED:`)} {job.finished} +
+ )} + + ); + }; return ( - - - - - - - {source.name} - - - , - - {label} - , - ]} - /> - - {source.summary_fields.user_capabilities.edit && ( - - )} - - - + <> + + + + + {source.summary_fields.last_job && ( + + + + + + )} + , + + + + {source.name} + + + , + + {label} + , + ]} + /> + + {source.summary_fields.user_capabilities.start && ( + + setIsCancelSyncLoading(isLoading) + } + onStartSyncLoading={isLoading => + setIsStartSyncLoading(isLoading) + } + source={source} + /> + )} + {source.summary_fields.user_capabilities.edit && ( + + )} + + + + ); } export default withI18n()(InventorySourceListItem); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx index c0555ced67..531ebdffa4 100644 --- a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceListItem.test.jsx @@ -6,7 +6,19 @@ const source = { id: 1, name: 'Foo', source: 'Source Bar', - summary_fields: { user_capabilities: { start: true, edit: true } }, + summary_fields: { + user_capabilities: { start: true, edit: true }, + last_job: { + canceled_on: '2020-04-30T18:56:46.054087Z', + description: '', + failed: true, + finished: '2020-04-30T18:56:46.054031Z', + id: 664, + license_error: false, + name: ' Inventory 1 Org 0 - source 4', + status: 'canceled', + }, + }, }; describe('', () => { let wrapper; @@ -37,19 +49,28 @@ describe('', () => { label="Source Bar" /> ); - expect(wrapper.find('DataListCheck').length).toBe(1); + expect(wrapper.find('StatusIcon').length).toBe(1); expect( wrapper - .find('DataListCell') + .find('Link') .at(0) - .text() - ).toBe('Foo'); + .prop('to') + ).toBe('/jobs/inventory/664'); + expect(wrapper.find('DataListCheck').length).toBe(1); + expect(); expect( wrapper .find('DataListCell') .at(1) .text() + ).toBe('Foo'); + expect( + wrapper + .find('DataListCell') + .at(2) + .text() ).toBe('Source Bar'); + expect(wrapper.find('InventorySourceSyncButton').length).toBe(1); expect(wrapper.find('PencilAltIcon').length).toBe(1); }); @@ -67,13 +88,47 @@ describe('', () => { expect(wrapper.find('DataListCheck').prop('checked')).toBe(true); }); - test(' should render edit buttons', () => { + test('should not render status icon', () => { const onSelect = jest.fn(); wrapper = mountWithContexts( + ); + expect(wrapper.find('StatusIcon').length).toBe(0); + }); + test('should not render sync buttons', async () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + + ); + expect(wrapper.find('InventorySourceSyncButton').length).toBe(0); + expect(wrapper.find('Button[aria-label="Edit Source"]').length).toBe(1); + }); + + test('should not render edit buttons', async () => { + const onSelect = jest.fn(); + wrapper = mountWithContexts( + ', () => { /> ); expect(wrapper.find('Button[aria-label="Edit Source"]').length).toBe(0); + expect(wrapper.find('InventorySourceSyncButton').length).toBe(1); }); }); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx new file mode 100644 index 0000000000..47f6cf293b --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx @@ -0,0 +1,139 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import PropTypes from 'prop-types'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { SyncIcon, MinusCircleIcon } from '@patternfly/react-icons'; +import useRequest, { useDismissableError } from '@util/useRequest'; +import AlertModal from '@components/AlertModal/AlertModal'; +import ErrorDetail from '@components/ErrorDetail/ErrorDetail'; +import { InventoryUpdatesAPI, InventorySourcesAPI } from '@api'; + +function InventorySourceSyncButton({ + onCancelSyncLoading, + onStartSyncLoading, + source, + i18n, +}) { + const [updateStatus, setUpdateStatus] = useState(source.status); + + const { + isLoading: startSyncLoading, + error: startSyncError, + request: startSyncProcess, + } = useRequest( + useCallback(async () => { + let syncStatus; + + const { + data: { can_update }, + } = await InventorySourcesAPI.allowSyncStart(source.id); + if (can_update) { + syncStatus = await InventorySourcesAPI.startSyncSource(source.id); + } else { + throw new Error( + i18n._( + t`You do not have permission to start this inventory source sync` + ) + ); + } + + setUpdateStatus(syncStatus.data.status); + + return syncStatus.data.status; + }, [source.id, i18n]), + {} + ); + + const { + isLoading: cancelSyncLoading, + error: cancelSyncError, + request: cancelSyncProcess, + } = useRequest( + useCallback(async () => { + const { + data: { + summary_fields: { + current_update: { id }, + }, + }, + } = await InventorySourcesAPI.readDetail(source.id); + const { + data: { can_cancel }, + } = await InventoryUpdatesAPI.allowSyncCancel(id); + if (can_cancel) { + await InventoryUpdatesAPI.cancelSyncSource(id); + setUpdateStatus(null); + } else { + throw new Error( + i18n._( + t`You do not have permission to cancel this inventory source sync` + ) + ); + } + }, [source.id, i18n]) + ); + + useEffect(() => onStartSyncLoading(startSyncLoading), [ + onStartSyncLoading, + startSyncLoading, + ]); + + useEffect(() => onCancelSyncLoading(cancelSyncLoading), [ + onCancelSyncLoading, + cancelSyncLoading, + ]); + + const { error, dismissError } = useDismissableError( + cancelSyncError || startSyncError + ); + + return ( + <> + {updateStatus === 'pending' ? ( + + + + ) : ( + + + + )} + {error && ( + + {startSyncError + ? i18n._(t`Failed to sync inventory source.`) + : i18n._(t`Failed to cancel inventory source sync.`)} + + + )} + + ); +} + +InventorySourceSyncButton.propTypes = { + onCancelSyncLoading: PropTypes.func.isRequired, + onStartSyncLoading: PropTypes.func.isRequired, + source: PropTypes.shape({}), +}; + +export default withI18n()(InventorySourceSyncButton); diff --git a/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.test.jsx b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.test.jsx new file mode 100644 index 0000000000..c6fdcc6f51 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { InventoryUpdatesAPI, InventorySourcesAPI } from '@api'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import InventorySourceSyncButton from './InventorySourceSyncButton'; + +jest.mock('@api/models/InventoryUpdates'); +jest.mock('@api/models/InventorySources'); + +const source = { id: 1, name: 'Foo', source: 'Source Bar' }; +const onCancelSyncLoading = jest.fn(); +const onStartSyncLoading = jest.fn(); + +describe('', () => { + let wrapper; + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', async () => { + expect(wrapper.find('InventorySourceSyncButton').length).toBe(1); + }); + + test('should render start sync button', async () => { + expect(wrapper.find('SyncIcon').length).toBe(1); + expect( + wrapper.find('Button[aria-label="Start sync source"]').prop('isDisabled') + ).toBe(false); + }); + + test('should render cancel sync button', async () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('MinusCircleIcon').length).toBe(1); + }); + + test('should start sync properly', async () => { + InventorySourcesAPI.allowSyncStart.mockResolvedValue({ + data: { can_update: true }, + }); + InventorySourcesAPI.startSyncSource.mockResolvedValue({ + data: { status: 'pending' }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Start sync source"]').simulate('click') + ); + expect(InventorySourcesAPI.allowSyncStart).toBeCalledWith(1); + expect(InventorySourcesAPI.startSyncSource).toBeCalledWith(1); + wrapper.update(); + expect(wrapper.find('Button[aria-label="Cancel sync source"]').length).toBe( + 1 + ); + }); + test('should cancel sync properly', async () => { + InventorySourcesAPI.readDetail.mockResolvedValue({ + data: { summary_fields: { current_update: { id: 120 } } }, + }); + InventoryUpdatesAPI.allowSyncCancel.mockResolvedValue({ + data: { can_cancel: true }, + }); + InventoryUpdatesAPI.cancelSyncSource.mockResolvedValue({ + data: { status: '' }, + }); + + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Button[aria-label="Cancel sync source"]').length).toBe( + 1 + ); + + await act(async () => + wrapper.find('Button[aria-label="Cancel sync source"]').simulate('click') + ); + + expect(InventorySourcesAPI.readDetail).toBeCalledWith(1); + expect(InventoryUpdatesAPI.allowSyncCancel).toBeCalledWith(120); + expect(InventoryUpdatesAPI.cancelSyncSource).toBeCalledWith(120); + + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Start sync source"]').length).toBe( + 1 + ); + }); + test('Should prevent user from starting sync', async () => { + InventorySourcesAPI.allowSyncStart.mockResolvedValue({ + data: { can_update: false }, + }); + InventorySourcesAPI.startSyncSource.mockResolvedValue({ + data: { status: 'pending' }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Start sync source"]').simulate('click') + ); + expect(InventorySourcesAPI.allowSyncStart).toBeCalledWith(1); + expect(InventorySourcesAPI.startSyncSource).not.toBeCalledWith(); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + expect(wrapper.find('Button[aria-label="Start sync source"]').length).toBe( + 1 + ); + }); + test('should prevent user from canceling sync', async () => { + InventorySourcesAPI.readDetail.mockResolvedValue({ + data: { summary_fields: { current_update: { id: 120 } } }, + }); + InventoryUpdatesAPI.allowSyncCancel.mockResolvedValue({ + data: { can_cancel: false }, + }); + InventoryUpdatesAPI.cancelSyncSource.mockResolvedValue({ + data: { status: '' }, + }); + + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Button[aria-label="Cancel sync source"]').length).toBe( + 1 + ); + + await act(async () => + wrapper.find('Button[aria-label="Cancel sync source"]').simulate('click') + ); + + expect(InventorySourcesAPI.readDetail).toBeCalledWith(1); + expect(InventoryUpdatesAPI.allowSyncCancel).toBeCalledWith(120); + expect(InventoryUpdatesAPI.cancelSyncSource).not.toBeCalledWith(120); + + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + expect(wrapper.find('Button[aria-label="Cancel sync source"]').length).toBe( + 1 + ); + }); +});