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
+ );
+ });
+});