mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 18:09:57 -03:30
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:
commit
0dab3e920f
@ -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}
|
||||
|
||||
@ -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')();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
@ -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({
|
||||
Loading…
x
Reference in New Issue
Block a user