Adds Sync Functionality

This commit is contained in:
Alex Corey 2020-04-30 14:00:35 -04:00
parent 6d07064ca2
commit 77fd2d677a
6 changed files with 487 additions and 50 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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 (
<>
<div>{i18n._(t`MOST RECENT SYNC`)}</div>
<div>
{i18n._(t`JOB ID:`)} {job.id}
</div>
<div>
{i18n._(t`STATUS:`)} {job.status.toUpperCase()}
</div>
{job.finished && (
<div>
{i18n._(t`FINISHED:`)} {job.finished}
</div>
)}
</>
);
};
return (
<DataListItem aria-labelledby={`check-action-${source.id}`}>
<DataListItemRow>
<DataListCheck
id={`select-source-${source.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={`check-action-${source.id}`}
/>
<DataListItemCells
dataListCells={[
<DataListCell aria-label={i18n._(t`name`)} key="name">
<span>
<Link to={`${detailUrl}/details`}>
<b>{source.name}</b>
</Link>
</span>
</DataListCell>,
<DataListCell aria-label={i18n._(t`type`)} key="type">
{label}
</DataListCell>,
]}
/>
<DataListAction
id="actions"
aria-labelledby="actions"
aria-label="actions"
>
{source.summary_fields.user_capabilities.edit && (
<Button
aria-label={i18n._(t`Edit Source`)}
variant="plain"
component={Link}
to={`${detailUrl}/edit`}
>
<PencilAltIcon />
</Button>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
<>
<DataListItem aria-labelledby={`check-action-${source.id}`}>
<DataListItemRow>
<DataListCheck
isDisabled={isDisabled}
id={`select-source-${source.id}`}
checked={isSelected}
onChange={onSelect}
aria-labelledby={`check-action-${source.id}`}
/>
<DataListItemCells
dataListCells={[
<DataListCell key="status" isFilled={false}>
{source.summary_fields.last_job && (
<Tooltip
position="top"
content={generateLastJobTooltip(
source.summary_fields.last_job
)}
key={source.summary_fields.last_job.id}
>
<Link
to={`/jobs/inventory/${source.summary_fields.last_job.id}`}
>
<StatusIcon
status={source.summary_fields.last_job.status}
/>
</Link>
</Tooltip>
)}
</DataListCell>,
<DataListCell aria-label={i18n._(t`name`)} key="name">
<span>
<Link to={`${detailUrl}/details`}>
<b>{source.name}</b>
</Link>
</span>
</DataListCell>,
<DataListCell aria-label={i18n._(t`type`)} key="type">
{label}
</DataListCell>,
]}
/>
<DataListAction
id="actions"
aria-labelledby="actions"
aria-label="actions"
>
{source.summary_fields.user_capabilities.start && (
<InventorySourceSyncButton
onCancelSyncLoading={isLoading =>
setIsCancelSyncLoading(isLoading)
}
onStartSyncLoading={isLoading =>
setIsStartSyncLoading(isLoading)
}
source={source}
/>
)}
{source.summary_fields.user_capabilities.edit && (
<Button
aria-label={i18n._(t`Edit Source`)}
variant="plain"
component={Link}
isDisabled={isDisabled}
to={`${detailUrl}/edit`}
>
<PencilAltIcon />
</Button>
)}
</DataListAction>
</DataListItemRow>
</DataListItem>
</>
);
}
export default withI18n()(InventorySourceListItem);

View File

@ -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('<InventorySourceListItem />', () => {
let wrapper;
@ -37,19 +49,28 @@ describe('<InventorySourceListItem />', () => {
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('<InventorySourceListItem />', () => {
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(
<InventorySourceListItem
source={{
...source,
summary_fields: { user_capabilities: { edit: false, start: true } },
summary_fields: {
user_capabilities: { start: true, edit: true },
last_job: null,
},
}}
isSelected={false}
onSelect={onSelect}
label="Source Bar"
/>
);
expect(wrapper.find('StatusIcon').length).toBe(0);
});
test('should not render sync buttons', async () => {
const onSelect = jest.fn();
wrapper = mountWithContexts(
<InventorySourceListItem
source={{
...source,
summary_fields: { user_capabilities: { start: false, edit: true } },
}}
isSelected={false}
onSelect={onSelect}
/>
);
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(
<InventorySourceListItem
source={{
...source,
summary_fields: { user_capabilities: { start: true, edit: false } },
}}
isSelected={false}
onSelect={onSelect}
@ -81,5 +136,6 @@ describe('<InventorySourceListItem />', () => {
/>
);
expect(wrapper.find('Button[aria-label="Edit Source"]').length).toBe(0);
expect(wrapper.find('InventorySourceSyncButton').length).toBe(1);
});
});

View File

@ -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' ? (
<Tooltip content={i18n._(t`Cancel sync process`)} position="top">
<Button
isDisabled={cancelSyncLoading || startSyncLoading}
aria-label={i18n._(t`Cancel sync source`)}
variant="plain"
onClick={cancelSyncProcess}
>
<MinusCircleIcon />
</Button>
</Tooltip>
) : (
<Tooltip content={i18n._(t`Start sync process`)} position="top">
<Button
isDisabled={cancelSyncLoading || startSyncLoading}
aria-label={i18n._(t`Start sync source`)}
variant="plain"
onClick={startSyncProcess}
>
<SyncIcon />
</Button>
</Tooltip>
)}
{error && (
<AlertModal
isOpen={error}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
>
{startSyncError
? i18n._(t`Failed to sync inventory source.`)
: i18n._(t`Failed to cancel inventory source sync.`)}
<ErrorDetail error={error} />
</AlertModal>
)}
</>
);
}
InventorySourceSyncButton.propTypes = {
onCancelSyncLoading: PropTypes.func.isRequired,
onStartSyncLoading: PropTypes.func.isRequired,
source: PropTypes.shape({}),
};
export default withI18n()(InventorySourceSyncButton);

View File

@ -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('<InventorySourceSyncButton />', () => {
let wrapper;
beforeEach(() => {
wrapper = mountWithContexts(
<InventorySourceSyncButton
source={source}
onCancelSyncLoading={onCancelSyncLoading}
onStartSyncLoading={onStartSyncLoading}
/>
);
});
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(
<InventorySourceSyncButton
source={{ status: 'pending', ...source }}
onCancelSyncLoading={onCancelSyncLoading}
onStartSyncLoading={onStartSyncLoading}
/>
);
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(
<InventorySourceSyncButton
source={{ status: 'pending', ...source }}
onCancelSyncLoading={onCancelSyncLoading}
onStartSyncLoading={onStartSyncLoading}
/>
);
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(
<InventorySourceSyncButton
source={{ status: 'pending', ...source }}
onCancelSyncLoading={onCancelSyncLoading}
onStartSyncLoading={onStartSyncLoading}
/>
);
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
);
});
});