mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 18:40:01 -03:30
Adds Sync Functionality
This commit is contained in:
parent
6d07064ca2
commit
77fd2d677a
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user