diff --git a/awx/ui_next/src/api/models/InventorySources.js b/awx/ui_next/src/api/models/InventorySources.js index be43f988eb..d525f992f0 100644 --- a/awx/ui_next/src/api/models/InventorySources.js +++ b/awx/ui_next/src/api/models/InventorySources.js @@ -5,7 +5,14 @@ class InventorySources extends LaunchUpdateMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_sources/'; + + this.createSyncStart = this.createSyncStart.bind(this); + } + + createSyncStart(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..1700c7b26b 100644 --- a/awx/ui_next/src/api/models/InventoryUpdates.js +++ b/awx/ui_next/src/api/models/InventoryUpdates.js @@ -5,7 +5,11 @@ class InventoryUpdates extends LaunchUpdateMixin(Base) { constructor(http) { super(http); this.baseUrl = '/api/v2/inventory_updates/'; + this.createSyncCancel = this.createSyncCancel.bind(this); + } + + createSyncCancel(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..4437daf67d 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,98 @@ function InventorySourceListItem({ detailUrl, label, }) { + const [isSyncLoading, setIsSyncLoading] = useState(false); + + 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 && ( + { + setIsSyncLoading(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..86b4a48d2e 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,48 @@ 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..801f8c170a --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.jsx @@ -0,0 +1,108 @@ +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({ onSyncLoading, source, i18n }) { + const [updateStatus, setUpdateStatus] = useState(source.status); + + const { + isLoading: startSyncLoading, + error: startSyncError, + request: startSyncProcess, + } = useRequest( + useCallback(async () => { + const { + data: { status }, + } = await InventorySourcesAPI.createSyncStart(source.id); + + setUpdateStatus(status); + + return status; + }, [source.id]), + {} + ); + + const { + isLoading: cancelSyncLoading, + error: cancelSyncError, + request: cancelSyncProcess, + } = useRequest( + useCallback(async () => { + const { + data: { + summary_fields: { + current_update: { id }, + }, + }, + } = await InventorySourcesAPI.readDetail(source.id); + + await InventoryUpdatesAPI.createSyncCancel(id); + setUpdateStatus(null); + }, [source.id]) + ); + + useEffect(() => onSyncLoading(startSyncLoading || cancelSyncLoading), [ + onSyncLoading, + startSyncLoading, + 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 = { + onSyncLoading: 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..f31a300089 --- /dev/null +++ b/awx/ui_next/src/screens/Inventory/InventorySources/InventorySourceSyncButton.test.jsx @@ -0,0 +1,93 @@ +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 onSyncLoading = 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.createSyncStart.mockResolvedValue({ + data: { status: 'pending' }, + }); + + await act(async () => + wrapper.find('Button[aria-label="Start sync source"]').simulate('click') + ); + expect(InventorySourcesAPI.createSyncStart).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.createSyncCancel.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.createSyncCancel).toBeCalledWith(120); + + wrapper.update(); + + expect(wrapper.find('Button[aria-label="Start sync source"]').length).toBe( + 1 + ); + }); +});