Add modal to show a preview of node prompt values

This commit is contained in:
Marliana Lara 2020-03-30 17:23:22 -04:00
parent 090349a49b
commit caaefef900
No known key found for this signature in database
GPG Key ID: 38C73B40DFA809EE
5 changed files with 512 additions and 18 deletions

View File

@ -0,0 +1,153 @@
import React from 'react';
import { shape } from 'prop-types';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Chip, ChipGroup } from '@patternfly/react-core';
import { VariablesDetail } from '@components/CodeMirrorInput';
import { DetailList, Detail } from '@components/DetailList';
import styled from 'styled-components';
const PromptHeader = styled.h2`
font-weight: bold;
`;
function hasPromptData(launchData) {
return (
launchData.ask_credential_on_launch ||
launchData.ask_diff_mode_on_launch ||
launchData.ask_inventory_on_launch ||
launchData.ask_job_type_on_launch ||
launchData.ask_limit_on_launch ||
launchData.ask_scm_branch_on_launch ||
launchData.ask_skip_tags_on_launch ||
launchData.ask_tags_on_launch ||
launchData.ask_variables_on_launch ||
launchData.ask_verbosity_on_launch
);
}
function PromptDetail({ i18n, resource, launchConfig = {} }) {
const { defaults = {} } = launchConfig;
const VERBOSITY = {
0: i18n._(t`0 (Normal)`),
1: i18n._(t`1 (Verbose)`),
2: i18n._(t`2 (More Verbose)`),
3: i18n._(t`3 (Debug)`),
4: i18n._(t`4 (Connection Debug)`),
};
return (
<>
<DetailList gutter="sm">
<Detail label={i18n._(t`Name`)} value={resource.name} />
<Detail label={i18n._(t`Description`)} value={resource.description} />
<Detail
label={i18n._(t`Type`)}
value={resource.unified_job_type || resource.type}
/>
</DetailList>
{hasPromptData(launchConfig) && (
<>
<br />
<PromptHeader>{i18n._(t`Prompted Values`)}</PromptHeader>
<br />
<DetailList>
{launchConfig.ask_job_type_on_launch && (
<Detail label={i18n._(t`Job Type`)} value={defaults?.job_type} />
)}
{launchConfig.ask_credential_on_launch && (
<Detail
fullWidth
label={i18n._(t`Credential`)}
rows={4}
value={
<ChipGroup numChips={5}>
{defaults?.credentials.map(cred => (
<Chip key={cred.id} isReadOnly>
{cred.name}
</Chip>
))}
</ChipGroup>
}
/>
)}
{launchConfig.ask_inventory_on_launch && (
<Detail
label={i18n._(t`Inventory`)}
value={defaults?.inventory?.name}
/>
)}
{launchConfig.ask_scm_branch_on_launch && (
<Detail
label={i18n._(t`SCM Branch`)}
value={defaults?.scm_branch}
/>
)}
{launchConfig.ask_limit_on_launch && (
<Detail label={i18n._(t`Limit`)} value={defaults?.limit} />
)}
{launchConfig.ask_verbosity_on_launch && (
<Detail
label={i18n._(t`Verbosity`)}
value={VERBOSITY[(defaults?.verbosity)]}
/>
)}
{launchConfig.ask_tags_on_launch && (
<Detail
fullWidth
label={i18n._(t`Job Tags`)}
value={
<ChipGroup numChips={5}>
{defaults?.job_tags.split(',').map(jobTag => (
<Chip key={jobTag} isReadOnly>
{jobTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{launchConfig.ask_skip_tags_on_launch && (
<Detail
fullWidth
label={i18n._(t`Skip Tags`)}
value={
<ChipGroup numChips={5}>
{defaults?.skip_tags.split(',').map(skipTag => (
<Chip key={skipTag} isReadOnly>
{skipTag}
</Chip>
))}
</ChipGroup>
}
/>
)}
{launchConfig.ask_diff_mode_on_launch && (
<Detail
label={i18n._(t`Diff Mode`)}
value={
defaults?.diff_mode === true ? i18n._(t`On`) : i18n._(t`Off`)
}
/>
)}
{launchConfig.ask_variables_on_launch && (
<VariablesDetail
label={i18n._(t`Variables`)}
rows={4}
value={defaults?.extra_vars}
/>
)}
</DetailList>
</>
)}
</>
);
}
PromptDetail.propTypes = {
resource: shape({}).isRequired,
launchConfig: shape({}),
};
export default withI18n()(PromptDetail);

View File

@ -0,0 +1,125 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import PromptDetail from './PromptDetail';
const mockTemplate = {
name: 'Mock Template',
description: 'mock description',
unified_job_type: 'job',
};
const mockPromptLaunch = {
ask_credential_on_launch: true,
ask_diff_mode_on_launch: true,
ask_inventory_on_launch: true,
ask_job_type_on_launch: true,
ask_limit_on_launch: true,
ask_scm_branch_on_launch: true,
ask_skip_tags_on_launch: true,
ask_tags_on_launch: true,
ask_variables_on_launch: true,
ask_verbosity_on_launch: true,
defaults: {
extra_vars: '---foo: bar',
diff_mode: false,
limit: 3,
job_tags: 'one,two,three',
skip_tags: 'skip',
job_type: 'run',
verbosity: 1,
inventory: {
name: 'Demo Inventory',
id: 1,
},
credentials: [
{
id: 1,
name: 'Demo Credential',
credential_type: 1,
passwords_needed: [],
},
],
scm_branch: '123',
},
};
describe('PromptDetail', () => {
describe('With prompt values', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(
<PromptDetail launchConfig={mockPromptLaunch} resource={mockTemplate} />
);
});
afterAll(() => {
wrapper.unmount();
});
test('should render successfully', () => {
expect(wrapper.find('PromptDetail').length).toBe(1);
});
test('should render expected details', () => {
function assertDetail(label, value) {
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
}
expect(wrapper.find('PromptDetail h2').text()).toBe('Prompted Values');
assertDetail('Name', 'Mock Template');
assertDetail('Description', 'mock description');
assertDetail('Type', 'job');
assertDetail('Job Type', 'run');
assertDetail('Credential', 'Demo Credential');
assertDetail('Inventory', 'Demo Inventory');
assertDetail('SCM Branch', '123');
assertDetail('Limit', '3');
assertDetail('Verbosity', '1 (Verbose)');
assertDetail('Job Tags', 'onetwothree');
assertDetail('Skip Tags', 'skip');
assertDetail('Diff Mode', 'Off');
expect(wrapper.find('VariablesDetail').prop('value')).toEqual(
'---foo: bar'
);
});
});
describe('Without prompt values', () => {
let wrapper;
beforeAll(() => {
wrapper = mountWithContexts(<PromptDetail resource={mockTemplate} />);
});
afterAll(() => {
wrapper.unmount();
});
test('should render basic detail values', () => {
expect(wrapper.find(`Detail[label="Name"]`).length).toBe(1);
expect(wrapper.find(`Detail[label="Description"]`).length).toBe(1);
expect(wrapper.find(`Detail[label="Type"]`).length).toBe(1);
});
test('should not render promptable details', () => {
function assertNoDetail(label) {
expect(wrapper.find(`Detail[label="${label}"]`).length).toBe(0);
}
[
'Job Type',
'Credential',
'Inventory',
'SCM Branch',
'Limit',
'Verbosity',
'Job Tags',
'Skip Tags',
'Diff Mode',
].forEach(label => assertNoDetail(label));
expect(wrapper.find('PromptDetail h2').length).toBe(0);
expect(wrapper.find('VariablesDetail').length).toBe(0);
});
});
});

View File

@ -0,0 +1 @@
export { default } from './PromptDetail';

View File

@ -1,20 +1,90 @@
import React, { useContext } from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { Modal } from '@patternfly/react-core';
import React, { useContext, useEffect, useCallback } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { Button, Modal } from '@patternfly/react-core';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import PromptDetail from '@components/PromptDetail';
import useRequest from '@util/useRequest';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
function NodeViewModal({ i18n }) {
const dispatch = useContext(WorkflowDispatchContext);
const { nodeToView } = useContext(WorkflowStateContext);
const { unifiedJobTemplate } = nodeToView;
const jobType =
unifiedJobTemplate.unified_job_type || unifiedJobTemplate.type;
const {
result: launchConfig,
isLoading,
error,
request: fetchLaunchConfig,
} = useRequest(
useCallback(async () => {
const readLaunch = ['workflow_job', 'workflow_job_template'].includes(
jobType
)
? WorkflowJobTemplatesAPI.readLaunch(unifiedJobTemplate.id)
: JobTemplatesAPI.readLaunch(unifiedJobTemplate.id);
const { data } = await readLaunch;
return data;
}, [jobType, unifiedJobTemplate]),
{}
);
useEffect(() => {
if (
['workflow_job', 'workflow_job_template', 'job', 'job_template'].includes(
jobType
)
) {
fetchLaunchConfig();
}
}, [jobType, fetchLaunchConfig]);
const handleEdit = () => {
dispatch({ type: 'SET_NODE_TO_VIEW', value: null });
dispatch({ type: 'SET_NODE_TO_EDIT', value: nodeToView });
};
let Content;
if (isLoading) {
Content = <ContentLoading />;
} else if (error) {
Content = <ContentError error={error} />;
} else {
Content = (
<PromptDetail launchConfig={launchConfig} resource={unifiedJobTemplate} />
);
}
return (
<Modal
isLarge
isOpen
isFooterLeftAligned
title={i18n._(t`Node Details`)}
title={unifiedJobTemplate.name}
onClose={() => dispatch({ type: 'SET_NODE_TO_VIEW', value: null })}
actions={[
<Button
key="edit"
aria-label={i18n._(t`Edit Node`)}
onClick={handleEdit}
>
{i18n._(t`Edit`)}
</Button>,
]}
>
Coming soon :)
{Content}
</Modal>
);
}

View File

@ -1,22 +1,167 @@
import React from 'react';
import { WorkflowDispatchContext } from '@contexts/Workflow';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { act } from 'react-dom/test-utils';
import {
WorkflowDispatchContext,
WorkflowStateContext,
} from '@contexts/Workflow';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '@api';
import NodeViewModal from './NodeViewModal';
let wrapper;
jest.mock('@api/models/JobTemplates');
jest.mock('@api/models/WorkflowJobTemplates');
WorkflowJobTemplatesAPI.readLaunch.mockResolvedValue({});
JobTemplatesAPI.readLaunch.mockResolvedValue({});
const dispatch = jest.fn();
function waitForLoaded(wrapper) {
return waitForElement(
wrapper,
'NodeViewModal',
el => el.find('ContentLoading').length === 0
);
}
describe('NodeViewModal', () => {
test('Close button dispatches as expected', () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<NodeViewModal />
</WorkflowDispatchContext.Provider>
);
wrapper.find('TimesIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_VIEW',
value: null,
describe('Workflow job template node', () => {
let wrapper;
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
unified_job_type: 'workflow_job',
},
},
};
beforeAll(async () => {
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
});
afterAll(() => {
jest.clearAllMocks();
wrapper.unmount();
});
test('should render prompt detail', () => {
expect(wrapper.find('PromptDetail').length).toBe(1);
});
test('should fetch workflow template launch data', () => {
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(WorkflowJobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
});
test('Close button dispatches as expected', () => {
wrapper.find('TimesIcon').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_VIEW',
value: null,
});
});
test('Edit button dispatches as expected', () => {
wrapper.find('button[aria-label="Edit Node"]').simulate('click');
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_VIEW',
value: null,
});
expect(dispatch).toHaveBeenCalledWith({
type: 'SET_NODE_TO_EDIT',
value: workflowContext.nodeToView,
});
});
});
describe('Job template node', () => {
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
type: 'job_template',
},
},
};
test('should fetch job template launch data', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readLaunch).toHaveBeenCalledWith(1);
wrapper.unmount();
jest.clearAllMocks();
});
test('should show content error when read call unsuccessful', async () => {
let wrapper;
JobTemplatesAPI.readLaunch.mockRejectedValue(new Error({}));
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
expect(wrapper.find('ContentError').length).toBe(1);
wrapper.unmount();
jest.clearAllMocks();
});
});
describe('Project node', () => {
const workflowContext = {
nodeToView: {
unifiedJobTemplate: {
id: 1,
name: 'Mock Node',
description: '',
type: 'project_update',
},
},
};
test('should not fetch launch data', async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<WorkflowDispatchContext.Provider value={dispatch}>
<WorkflowStateContext.Provider value={workflowContext}>
<NodeViewModal />
</WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider>
);
});
waitForLoaded(wrapper);
expect(WorkflowJobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
expect(JobTemplatesAPI.readLaunch).not.toHaveBeenCalled();
wrapper.unmount();
jest.clearAllMocks();
});
});
});