mirror of
https://github.com/ansible/awx.git
synced 2026-01-15 20:00:43 -03:30
Add modal to show a preview of node prompt values
This commit is contained in:
parent
090349a49b
commit
caaefef900
153
awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
Normal file
153
awx/ui_next/src/components/PromptDetail/PromptDetail.jsx
Normal 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);
|
||||
125
awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
Normal file
125
awx/ui_next/src/components/PromptDetail/PromptDetail.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/components/PromptDetail/index.js
Normal file
1
awx/ui_next/src/components/PromptDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './PromptDetail';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user