Merge pull request #7085 from marshmalien/inv-src-detail-sync-btn

Add inventory source detail sync button

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-05-21 18:44:46 +00:00 committed by GitHub
commit 0dab3e920f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 220 additions and 58 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
@ -8,14 +8,19 @@ import AlertModal from '../../../components/AlertModal';
import { CardBody, CardActionsRow } from '../../../components/Card';
import ChipGroup from '../../../components/ChipGroup';
import { VariablesDetail } from '../../../components/CodeMirrorInput';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import CredentialChip from '../../../components/CredentialChip';
import DeleteButton from '../../../components/DeleteButton';
import { FieldTooltip } from '../../../components/FormField';
import InventorySourceSyncButton from '../shared/InventorySourceSyncButton';
import {
DetailList,
Detail,
UserDateDetail,
} from '../../../components/DetailList';
import ErrorDetail from '../../../components/ErrorDetail';
import useRequest from '../../../util/useRequest';
import { InventorySourcesAPI } from '../../../api';
function InventorySourceDetail({ inventorySource, i18n }) {
@ -53,12 +58,28 @@ function InventorySourceDetail({ inventorySource, i18n }) {
const history = useHistory();
const isMounted = useRef(null);
const {
result: sourceChoices,
error,
isLoading,
request: fetchSourceChoices,
} = useRequest(
useCallback(async () => {
const { data } = await InventorySourcesAPI.readOptions();
return Object.assign(
...data.actions.GET.source.choices.map(([key, val]) => ({ [key]: val }))
);
}, []),
{}
);
useEffect(() => {
isMounted.current = true;
fetchSourceChoices();
return () => {
isMounted.current = false;
};
}, []);
}, [fetchSourceChoices]);
const handleDelete = async () => {
try {
@ -68,9 +89,9 @@ function InventorySourceDetail({ inventorySource, i18n }) {
InventorySourcesAPI.destroy(id),
]);
history.push(`/inventories/inventory/${inventory.id}/sources`);
} catch (error) {
} catch (err) {
if (isMounted.current) {
setDeletionError(error);
setDeletionError(err);
}
}
};
@ -90,24 +111,87 @@ function InventorySourceDetail({ inventorySource, i18n }) {
) {
optionsList = (
<List>
{overwrite && <ListItem>{i18n._(t`Overwrite`)}</ListItem>}
{overwrite_vars && (
<ListItem>{i18n._(t`Overwrite variables`)}</ListItem>
{overwrite && (
<ListItem>
{i18n._(t`Overwrite`)}
<FieldTooltip
content={
<>
{i18n._(t`If checked, any hosts and groups that were
previously present on the external source but are now removed
will be removed from the Tower inventory. Hosts and groups
that were not managed by the inventory source will be promoted
to the next manually created group or if there is no manually
created group to promote them into, they will be left in the "all"
default group for the inventory.`)}
<br />
<br />
{i18n._(t`When not checked, local child
hosts and groups not found on the external source will remain
untouched by the inventory update process.`)}
</>
}
/>
</ListItem>
)}
{overwrite_vars && (
<ListItem>
{i18n._(t`Overwrite variables`)}
<FieldTooltip
content={
<>
{i18n._(t`If checked, all variables for child groups
and hosts will be removed and replaced by those found
on the external source.`)}
<br />
<br />
{i18n._(t`When not checked, a merge will be performed,
combining local variables with those found on the
external source.`)}
</>
}
/>
</ListItem>
)}
{update_on_launch && (
<ListItem>
{i18n._(t`Update on launch`)}
<FieldTooltip
content={i18n._(t`Each time a job runs using this inventory,
refresh the inventory from the selected source before
executing job tasks.`)}
/>
</ListItem>
)}
{update_on_launch && <ListItem>{i18n._(t`Update on launch`)}</ListItem>}
{update_on_project_update && (
<ListItem>{i18n._(t`Update on project update`)}</ListItem>
<ListItem>
{i18n._(t`Update on project update`)}
<FieldTooltip
content={i18n._(t`After every project update where the SCM revision
changes, refresh the inventory from the selected source
before executing job tasks. This is intended for static content,
like the Ansible inventory .ini file format.`)}
/>
</ListItem>
)}
</List>
);
}
if (isLoading) {
return <ContentLoading />;
}
if (error) {
return <ContentError error={error} />;
}
return (
<CardBody>
<DetailList>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
<Detail label={i18n._(t`Source`)} value={source} />
<Detail label={i18n._(t`Source`)} value={sourceChoices[source]} />
{organization && (
<Detail
label={i18n._(t`Organization`)}
@ -132,7 +216,10 @@ function InventorySourceDetail({ inventorySource, i18n }) {
}
/>
)}
<Detail label={i18n._(t`Inventory file`)} value={source_path} />
<Detail
label={i18n._(t`Inventory file`)}
value={source_path === '' ? i18n._(t`/ (project root)`) : source_path}
/>
<Detail
label={i18n._(t`Custom inventory script`)}
value={source_script?.name}
@ -233,6 +320,9 @@ function InventorySourceDetail({ inventorySource, i18n }) {
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities?.start && (
<InventorySourceSyncButton source={inventorySource} icon={false} />
)}
{user_capabilities?.delete && (
<DeleteButton
name={name}

View File

@ -10,6 +10,30 @@ import mockInvSource from '../shared/data.inventory_source.json';
import { InventorySourcesAPI } from '../../../api';
jest.mock('../../../api/models/InventorySources');
InventorySourcesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {
source: {
choices: [
['file', 'File, Directory or Script'],
['scm', 'Sourced from a Project'],
['ec2', 'Amazon EC2'],
['gce', 'Google Compute Engine'],
['azure_rm', 'Microsoft Azure Resource Manager'],
['vmware', 'VMware vCenter'],
['satellite6', 'Red Hat Satellite 6'],
['cloudforms', 'Red Hat CloudForms'],
['openstack', 'OpenStack'],
['rhv', 'Red Hat Virtualization'],
['tower', 'Ansible Tower'],
['custom', 'Custom Script'],
],
},
},
},
},
});
function assertDetail(wrapper, label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
@ -24,14 +48,17 @@ describe('InventorySourceDetail', () => {
jest.clearAllMocks();
});
test('should render expected details', () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
test('should render expected details', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('InventorySourceDetail')).toHaveLength(1);
assertDetail(wrapper, 'Name', 'mock inv source');
assertDetail(wrapper, 'Description', 'mock description');
assertDetail(wrapper, 'Source', 'scm');
assertDetail(wrapper, 'Source', 'Sourced from a Project');
assertDetail(wrapper, 'Organization', 'Mock Org');
assertDetail(wrapper, 'Ansible environment', '/venv/custom');
assertDetail(wrapper, 'Project', 'Mock Project');
@ -69,44 +96,51 @@ describe('InventorySourceDetail', () => {
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'---\nfoo: bar'
);
expect(
wrapper
.find('Detail[label="Options"]')
.containsAllMatchingElements([
<li>Overwrite</li>,
<li>Overwrite variables</li>,
<li>Update on launch</li>,
<li>Update on project update</li>,
])
).toEqual(true);
wrapper.find('Detail[label="Options"] li').forEach(option => {
expect([
'Overwrite',
'Overwrite variables',
'Update on launch',
'Update on project update',
]).toContain(option.text());
});
});
test('should show edit and delete button for users with permissions', () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
test('should display expected action buttons for users with permissions', async () => {
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
const editButton = wrapper.find('Button[aria-label="edit"]');
expect(editButton.text()).toEqual('Edit');
expect(editButton.prop('to')).toBe(
'/inventories/inventory/2/sources/123/edit'
);
expect(wrapper.find('DeleteButton')).toHaveLength(1);
expect(wrapper.find('InventorySourceSyncButton')).toHaveLength(1);
});
test('should hide edit and delete button for users without permissions', () => {
test('should hide expected action buttons for users without permissions', async () => {
const userCapabilities = {
edit: false,
delete: false,
start: false,
};
const invSource = {
...mockInvSource,
summary_fields: { ...userCapabilities },
};
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={invSource} />
);
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={invSource} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Button[aria-label="edit"]')).toHaveLength(0);
expect(wrapper.find('DeleteButton')).toHaveLength(0);
expect(wrapper.find('InventorySourceSyncButton')).toHaveLength(0);
});
test('expected api call is made for delete', async () => {
@ -135,13 +169,33 @@ describe('InventorySourceDetail', () => {
);
});
test('Content error shown for failed options request', async () => {
InventorySourcesAPI.readOptions.mockImplementationOnce(() =>
Promise.reject(new Error())
);
expect(InventorySourcesAPI.readOptions).toHaveBeenCalledTimes(0);
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
});
expect(InventorySourcesAPI.readOptions).toHaveBeenCalledTimes(1);
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual(
'Something went wrong...'
);
});
test('Error dialog shown for failed deletion', async () => {
InventorySourcesAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())
);
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
await act(async () => {
wrapper = mountWithContexts(
<InventorySourceDetail inventorySource={mockInvSource} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
expect(wrapper.find('Modal[title="Error!"]')).toHaveLength(0);
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();

View File

@ -14,8 +14,7 @@ import {
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import StatusIcon from '../../../components/StatusIcon';
import InventorySourceSyncButton from './InventorySourceSyncButton';
import InventorySourceSyncButton from '../shared/InventorySourceSyncButton';
function InventorySourceListItem({
source,

View File

@ -9,7 +9,7 @@ import AlertModal from '../../../components/AlertModal/AlertModal';
import ErrorDetail from '../../../components/ErrorDetail/ErrorDetail';
import { InventoryUpdatesAPI, InventorySourcesAPI } from '../../../api';
function InventorySourceSyncButton({ source, i18n }) {
function InventorySourceSyncButton({ source, icon, i18n }) {
const {
isLoading: startSyncLoading,
error: startSyncError,
@ -43,21 +43,26 @@ function InventorySourceSyncButton({ source, i18n }) {
}, [source.id])
);
const { error, dismissError } = useDismissableError(
cancelSyncError || startSyncError
);
const {
error: startError,
dismissError: dismissStartError,
} = useDismissableError(startSyncError);
const {
error: cancelError,
dismissError: dismissCancelError,
} = useDismissableError(cancelSyncError);
return (
<>
{source.status === 'pending' ? (
{['running', 'pending', 'updating'].includes(source.status) ? (
<Tooltip content={i18n._(t`Cancel sync process`)} position="top">
<Button
isDisabled={cancelSyncLoading || startSyncLoading}
aria-label={i18n._(t`Cancel sync source`)}
variant="plain"
variant={icon ? 'plain' : 'secondary'}
onClick={cancelSyncProcess}
>
<MinusCircleIcon />
{icon ? <MinusCircleIcon /> : i18n._(t`Cancel sync`)}
</Button>
</Tooltip>
) : (
@ -65,24 +70,33 @@ function InventorySourceSyncButton({ source, i18n }) {
<Button
isDisabled={cancelSyncLoading || startSyncLoading}
aria-label={i18n._(t`Start sync source`)}
variant="plain"
variant={icon ? 'plain' : 'secondary'}
onClick={startSyncProcess}
>
<SyncIcon />
{icon ? <SyncIcon /> : i18n._(t`Sync`)}
</Button>
</Tooltip>
)}
{error && (
{startError && (
<AlertModal
isOpen={error}
isOpen={startError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissError}
onClose={dismissStartError}
>
{startSyncError
? i18n._(t`Failed to sync inventory source.`)
: i18n._(t`Failed to cancel inventory source sync.`)}
<ErrorDetail error={error} />
{i18n._(t`Failed to sync inventory source.`)}
<ErrorDetail error={startError} />
</AlertModal>
)}
{cancelError && (
<AlertModal
isOpen={cancelError}
variant="error"
title={i18n._(t`Error!`)}
onClose={dismissCancelError}
>
{i18n._(t`Failed to cancel inventory source sync.`)}
<ErrorDetail error={cancelError} />
</AlertModal>
)}
</>
@ -91,10 +105,12 @@ function InventorySourceSyncButton({ source, i18n }) {
InventorySourceSyncButton.defaultProps = {
source: {},
icon: true,
};
InventorySourceSyncButton.propTypes = {
source: PropTypes.shape({}),
icon: PropTypes.bool,
};
export default withI18n()(InventorySourceSyncButton);

View File

@ -25,18 +25,19 @@ describe('<InventorySourceSyncButton />', () => {
wrapper.unmount();
jest.clearAllMocks();
});
test('should mount properly', async () => {
test('should mount properly', () => {
expect(wrapper.find('InventorySourceSyncButton').length).toBe(1);
});
test('should render start sync button', async () => {
test('should render start sync button', () => {
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 () => {
test('should render cancel sync button', () => {
wrapper = mountWithContexts(
<InventorySourceSyncButton
source={{ status: 'pending', ...source }}
@ -57,6 +58,7 @@ describe('<InventorySourceSyncButton />', () => {
);
expect(InventorySourcesAPI.createSyncStart).toBeCalledWith(1);
});
test('should cancel sync properly', async () => {
InventorySourcesAPI.readDetail.mockResolvedValue({
data: { summary_fields: { current_update: { id: 120 } } },
@ -83,6 +85,7 @@ describe('<InventorySourceSyncButton />', () => {
expect(InventorySourcesAPI.readDetail).toBeCalledWith(1);
expect(InventoryUpdatesAPI.createSyncCancel).toBeCalledWith(120);
});
test('should throw error on sync start properly', async () => {
InventorySourcesAPI.createSyncStart.mockRejectedValueOnce(
new Error({