Adds extra variables to schedule details. Updates parameters by which we display prompt fields on schedule details. Extend VariableDetails component to be able to handle values that come in raw JSON form.

This commit is contained in:
mabashian
2020-08-17 16:49:15 -04:00
parent a659b9d994
commit 222a65c875
7 changed files with 186 additions and 43 deletions

View File

@@ -1,10 +1,15 @@
import 'styled-components/macro'; import 'styled-components/macro';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { string, node, number } from 'prop-types'; import { node, number, oneOfType, shape, string } from 'prop-types';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core'; import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { DetailName, DetailValue } from '../DetailList'; import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle'; import MultiButtonToggle from '../MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml'; import {
yamlToJson,
jsonToYaml,
isJsonObject,
isJsonString,
} from '../../util/yaml';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
import { JSON_MODE, YAML_MODE } from './constants'; import { JSON_MODE, YAML_MODE } from './constants';
@@ -15,7 +20,7 @@ function getValueAsMode(value, mode) {
} }
return '---'; return '---';
} }
const modeMatches = isJson(value) === (mode === JSON_MODE); const modeMatches = isJsonString(value) === (mode === JSON_MODE);
if (modeMatches) { if (modeMatches) {
return value; return value;
} }
@@ -23,12 +28,21 @@ function getValueAsMode(value, mode) {
} }
function VariablesDetail({ value, label, rows, fullHeight }) { function VariablesDetail({ value, label, rows, fullHeight }) {
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE); const [mode, setMode] = useState(
const [currentValue, setCurrentValue] = useState(value || '---'); isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
);
const [currentValue, setCurrentValue] = useState(
isJsonObject(value) ? JSON.stringify(value, null, 2) : value || '---'
);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
setCurrentValue(getValueAsMode(value, mode)); setCurrentValue(
getValueAsMode(
isJsonObject(value) ? JSON.stringify(value, null, 2) : value,
mode
)
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [value]); }, [value]);
@@ -95,7 +109,7 @@ function VariablesDetail({ value, label, rows, fullHeight }) {
); );
} }
VariablesDetail.propTypes = { VariablesDetail.propTypes = {
value: string.isRequired, value: oneOfType([shape({}), string]).isRequired,
label: node.isRequired, label: node.isRequired,
rows: number, rows: number,
}; };

View File

@@ -7,7 +7,7 @@ import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core'; import { Split, SplitItem } from '@patternfly/react-core';
import { CheckboxField, FieldTooltip } from '../FormField'; import { CheckboxField, FieldTooltip } from '../FormField';
import MultiButtonToggle from '../MultiButtonToggle'; import MultiButtonToggle from '../MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml'; import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
import { JSON_MODE, YAML_MODE } from './constants'; import { JSON_MODE, YAML_MODE } from './constants';
@@ -30,7 +30,9 @@ function VariablesField({
tooltip, tooltip,
}) { }) {
const [field, meta, helpers] = useField(name); const [field, meta, helpers] = useField(name);
const [mode, setMode] = useState(isJson(field.value) ? JSON_MODE : YAML_MODE); const [mode, setMode] = useState(
isJsonString(field.value) ? JSON_MODE : YAML_MODE
);
return ( return (
<div className="pf-c-form__group"> <div className="pf-c-form__group">

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { string, func, bool, number } from 'prop-types'; import { string, func, bool, number } from 'prop-types';
import { Split, SplitItem } from '@patternfly/react-core'; import { Split, SplitItem } from '@patternfly/react-core';
import styled from 'styled-components'; import styled from 'styled-components';
import { yamlToJson, jsonToYaml, isJson } from '../../util/yaml'; import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
import MultiButtonToggle from '../MultiButtonToggle'; import MultiButtonToggle from '../MultiButtonToggle';
import CodeMirrorInput from './CodeMirrorInput'; import CodeMirrorInput from './CodeMirrorInput';
import { JSON_MODE, YAML_MODE } from './constants'; import { JSON_MODE, YAML_MODE } from './constants';
@@ -18,11 +18,11 @@ const SplitItemRight = styled(SplitItem)`
function VariablesInput(props) { function VariablesInput(props) {
const { id, label, readOnly, rows, error, onError, className } = props; const { id, label, readOnly, rows, error, onError, className } = props;
/* eslint-disable react/destructuring-assignment */ /* eslint-disable react/destructuring-assignment */
const defaultValue = isJson(props.value) const defaultValue = isJsonString(props.value)
? formatJson(props.value) ? formatJson(props.value)
: props.value; : props.value;
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [mode, setMode] = useState(isJson(value) ? JSON_MODE : YAML_MODE); const [mode, setMode] = useState(isJsonString(value) ? JSON_MODE : YAML_MODE);
const isControlled = !!props.onChange; const isControlled = !!props.onChange;
/* eslint-enable react/destructuring-assignment */ /* eslint-enable react/destructuring-assignment */

View File

@@ -6,10 +6,12 @@ import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../testUtils/enzymeHelpers'; } from '../../../testUtils/enzymeHelpers';
import { SchedulesAPI } from '../../api'; import { JobTemplatesAPI, SchedulesAPI } from '../../api';
import Schedule from './Schedule'; import Schedule from './Schedule';
jest.mock('../../api/models/JobTemplates');
jest.mock('../../api/models/Schedules'); jest.mock('../../api/models/Schedules');
jest.mock('../../api/models/WorkflowJobTemplates');
SchedulesAPI.readDetail.mockResolvedValue({ SchedulesAPI.readDetail.mockResolvedValue({
data: { data: {
@@ -62,6 +64,22 @@ SchedulesAPI.readCredentials.mockResolvedValue({
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValue({
data: {
ask_credential_on_launch: false,
ask_diff_mode_on_launch: false,
ask_inventory_on_launch: false,
ask_job_type_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_skip_tags_on_launch: false,
ask_tags_on_launch: false,
ask_variables_on_launch: false,
ask_verbosity_on_launch: false,
survey_enabled: false,
},
});
describe('<Schedule />', () => { describe('<Schedule />', () => {
let wrapper; let wrapper;
let history; let history;

View File

@@ -17,10 +17,15 @@ import ScheduleOccurrences from '../ScheduleOccurrences';
import ScheduleToggle from '../ScheduleToggle'; import ScheduleToggle from '../ScheduleToggle';
import { formatDateString } from '../../../util/dates'; import { formatDateString } from '../../../util/dates';
import useRequest, { useDismissableError } from '../../../util/useRequest'; import useRequest, { useDismissableError } from '../../../util/useRequest';
import { SchedulesAPI } from '../../../api'; import {
JobTemplatesAPI,
SchedulesAPI,
WorkflowJobTemplatesAPI,
} from '../../../api';
import DeleteButton from '../../DeleteButton'; import DeleteButton from '../../DeleteButton';
import ErrorDetail from '../../ErrorDetail'; import ErrorDetail from '../../ErrorDetail';
import ChipGroup from '../../ChipGroup'; import ChipGroup from '../../ChipGroup';
import { VariablesDetail } from '../../CodeMirrorInput';
const PromptTitle = styled(Title)` const PromptTitle = styled(Title)`
--pf-c-title--m-md--FontWeight: 700; --pf-c-title--m-md--FontWeight: 700;
@@ -35,9 +40,9 @@ function ScheduleDetail({ schedule, i18n }) {
diff_mode, diff_mode,
dtend, dtend,
dtstart, dtstart,
extra_data,
job_tags, job_tags,
job_type, job_type,
inventory,
limit, limit,
modified, modified,
name, name,
@@ -67,20 +72,47 @@ function ScheduleDetail({ schedule, i18n }) {
const { error, dismissError } = useDismissableError(deleteError); const { error, dismissError } = useDismissableError(deleteError);
const { const {
result: [credentials, preview], result: [credentials, preview, launchData],
isLoading, isLoading,
error: readContentError, error: readContentError,
request: fetchCredentialsAndPreview, request: fetchCredentialsAndPreview,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
const [{ data }, { data: schedulePreview }] = await Promise.all([ const promises = [
SchedulesAPI.readCredentials(id), SchedulesAPI.readCredentials(id),
SchedulesAPI.createPreview({ SchedulesAPI.createPreview({
rrule, rrule,
}), }),
]); ];
return [data.results, schedulePreview];
}, [id, rrule]), if (
schedule?.summary_fields?.unified_job_template?.unified_job_type ===
'job'
) {
promises.push(
JobTemplatesAPI.readLaunch(
schedule.summary_fields.unified_job_template.id
)
);
} else if (
schedule?.summary_fields?.unified_job_template?.unified_job_type ===
'workflow_job'
) {
promises.push(
WorkflowJobTemplatesAPI.readLaunch(
schedule.summary_fields.unified_job_template.id
)
);
} else {
promises.push(Promise.resolve());
}
const [{ data }, { data: schedulePreview }, launch] = await Promise.all(
promises
);
return [data.results, schedulePreview, launch?.data];
}, [id, schedule, rrule]),
[] []
); );
@@ -93,15 +125,33 @@ function ScheduleDetail({ schedule, i18n }) {
rule.options.freq === RRule.MINUTELY && dtstart === dtend rule.options.freq === RRule.MINUTELY && dtstart === dtend
? i18n._(t`None (Run Once)`) ? i18n._(t`None (Run Once)`)
: rule.toText().replace(/^\w/, c => c.toUpperCase()); : rule.toText().replace(/^\w/, c => c.toUpperCase());
const {
ask_credential_on_launch,
ask_diff_mode_on_launch,
ask_inventory_on_launch,
ask_job_type_on_launch,
ask_limit_on_launch,
ask_scm_branch_on_launch,
ask_skip_tags_on_launch,
ask_tags_on_launch,
ask_variables_on_launch,
ask_verbosity_on_launch,
survey_enabled,
} = launchData || {};
const showPromptedFields = const showPromptedFields =
(credentials && credentials.length > 0) || ask_credential_on_launch ||
job_type || ask_diff_mode_on_launch ||
(inventory && summary_fields.inventory) || ask_inventory_on_launch ||
scm_branch || ask_job_type_on_launch ||
limit || ask_limit_on_launch ||
typeof diff_mode === 'boolean' || ask_scm_branch_on_launch ||
(job_tags && job_tags.length > 0) || ask_skip_tags_on_launch ||
(skip_tags && skip_tags.length > 0); ask_tags_on_launch ||
ask_variables_on_launch ||
ask_verbosity_on_launch ||
survey_enabled;
if (isLoading) { if (isLoading) {
return <ContentLoading />; return <ContentLoading />;
@@ -144,8 +194,10 @@ function ScheduleDetail({ schedule, i18n }) {
<PromptTitle headingLevel="h2"> <PromptTitle headingLevel="h2">
{i18n._(t`Prompted Fields`)} {i18n._(t`Prompted Fields`)}
</PromptTitle> </PromptTitle>
<Detail label={i18n._(t`Job Type`)} value={job_type} /> {ask_job_type_on_launch && (
{inventory && summary_fields.inventory && ( <Detail label={i18n._(t`Job Type`)} value={job_type} />
)}
{ask_inventory_on_launch && (
<Detail <Detail
label={i18n._(t`Inventory`)} label={i18n._(t`Inventory`)}
value={ value={
@@ -161,18 +213,22 @@ function ScheduleDetail({ schedule, i18n }) {
} }
/> />
)} )}
<Detail {ask_scm_branch_on_launch && (
label={i18n._(t`Source Control Branch`)} <Detail
value={scm_branch} label={i18n._(t`Source Control Branch`)}
/> value={scm_branch}
<Detail label={i18n._(t`Limit`)} value={limit} /> />
{typeof diff_mode === 'boolean' && ( )}
{ask_limit_on_launch && (
<Detail label={i18n._(t`Limit`)} value={limit} />
)}
{ask_diff_mode_on_launch && typeof diff_mode === 'boolean' && (
<Detail <Detail
label={i18n._(t`Show Changes`)} label={i18n._(t`Show Changes`)}
value={diff_mode ? 'On' : 'Off'} value={diff_mode ? 'On' : 'Off'}
/> />
)} )}
{credentials && credentials.length > 0 && ( {ask_credential_on_launch && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Credentials`)} label={i18n._(t`Credentials`)}
@@ -185,7 +241,7 @@ function ScheduleDetail({ schedule, i18n }) {
} }
/> />
)} )}
{job_tags && job_tags.length > 0 && ( {ask_tags_on_launch && job_tags && job_tags.length > 0 && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Job Tags`)} label={i18n._(t`Job Tags`)}
@@ -203,7 +259,7 @@ function ScheduleDetail({ schedule, i18n }) {
} }
/> />
)} )}
{skip_tags && skip_tags.length > 0 && ( {ask_skip_tags_on_launch && skip_tags && skip_tags.length > 0 && (
<Detail <Detail
fullWidth fullWidth
label={i18n._(t`Skip Tags`)} label={i18n._(t`Skip Tags`)}
@@ -221,6 +277,13 @@ function ScheduleDetail({ schedule, i18n }) {
} }
/> />
)} )}
{(ask_variables_on_launch || survey_enabled) && (
<VariablesDetail
value={extra_data}
rows={4}
label={i18n._(t`Variables`)}
/>
)}
</> </>
)} )}
</DetailList> </DetailList>

View File

@@ -2,14 +2,48 @@ import React from 'react';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { SchedulesAPI } from '../../../api'; import { SchedulesAPI, JobTemplatesAPI } from '../../../api';
import { import {
mountWithContexts, mountWithContexts,
waitForElement, waitForElement,
} from '../../../../testUtils/enzymeHelpers'; } from '../../../../testUtils/enzymeHelpers';
import ScheduleDetail from './ScheduleDetail'; import ScheduleDetail from './ScheduleDetail';
jest.mock('../../../api/models/JobTemplates');
jest.mock('../../../api/models/Schedules'); jest.mock('../../../api/models/Schedules');
jest.mock('../../../api/models/WorkflowJobTemplates');
const allPrompts = {
data: {
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,
survey_enabled: true,
},
};
const noPrompts = {
data: {
ask_credential_on_launch: false,
ask_diff_mode_on_launch: false,
ask_inventory_on_launch: false,
ask_job_type_on_launch: false,
ask_limit_on_launch: false,
ask_scm_branch_on_launch: false,
ask_skip_tags_on_launch: false,
ask_tags_on_launch: false,
ask_variables_on_launch: false,
ask_verbosity_on_launch: false,
survey_enabled: false,
},
};
const schedule = { const schedule = {
url: '/api/v2/schedules/1', url: '/api/v2/schedules/1',
@@ -53,6 +87,7 @@ const schedule = {
dtstart: '2020-03-16T04:00:00Z', dtstart: '2020-03-16T04:00:00Z',
dtend: '2020-07-06T04:00:00Z', dtend: '2020-07-06T04:00:00Z',
next_run: '2020-03-16T04:00:00Z', next_run: '2020-03-16T04:00:00Z',
extra_data: {},
}; };
SchedulesAPI.createPreview.mockResolvedValue({ SchedulesAPI.createPreview.mockResolvedValue({
@@ -79,6 +114,7 @@ describe('<ScheduleDetail />', () => {
results: [], results: [],
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route
@@ -134,6 +170,7 @@ describe('<ScheduleDetail />', () => {
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0); expect(wrapper.find('Detail[label="Credentials"]').length).toBe(0);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0); expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(0);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0); expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(0);
expect(wrapper.find('VariablesDetail').length).toBe(0);
}); });
test('details should render with the proper values with prompts', async () => { test('details should render with the proper values with prompts', async () => {
SchedulesAPI.readCredentials.mockResolvedValue({ SchedulesAPI.readCredentials.mockResolvedValue({
@@ -151,6 +188,7 @@ describe('<ScheduleDetail />', () => {
], ],
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(allPrompts);
const scheduleWithPrompts = { const scheduleWithPrompts = {
...schedule, ...schedule,
job_type: 'run', job_type: 'run',
@@ -161,6 +199,7 @@ describe('<ScheduleDetail />', () => {
limit: 'localhost', limit: 'localhost',
diff_mode: true, diff_mode: true,
verbosity: 1, verbosity: 1,
extra_data: { foo: 'fii' },
}; };
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
@@ -182,7 +221,6 @@ describe('<ScheduleDetail />', () => {
); );
}); });
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0); await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
// await waitForElement(wrapper, 'Title', el => el.length > 0);
expect( expect(
wrapper wrapper
.find('Detail[label="Name"]') .find('Detail[label="Name"]')
@@ -231,6 +269,7 @@ describe('<ScheduleDetail />', () => {
expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1); expect(wrapper.find('Detail[label="Credentials"]').length).toBe(1);
expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1); expect(wrapper.find('Detail[label="Job Tags"]').length).toBe(1);
expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1); expect(wrapper.find('Detail[label="Skip Tags"]').length).toBe(1);
expect(wrapper.find('VariablesDetail').length).toBe(1);
}); });
test('error shown when error encountered fetching credentials', async () => { test('error shown when error encountered fetching credentials', async () => {
SchedulesAPI.readCredentials.mockRejectedValueOnce( SchedulesAPI.readCredentials.mockRejectedValueOnce(
@@ -245,6 +284,7 @@ describe('<ScheduleDetail />', () => {
}, },
}) })
); );
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route
@@ -274,6 +314,7 @@ describe('<ScheduleDetail />', () => {
results: [], results: [],
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route
@@ -313,6 +354,7 @@ describe('<ScheduleDetail />', () => {
results: [], results: [],
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValueOnce(noPrompts);
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Route <Route

View File

@@ -22,7 +22,11 @@ export function jsonToYaml(jsonString) {
return yaml.safeDump(value); return yaml.safeDump(value);
} }
export function isJson(jsonString) { export function isJsonObject(value) {
return typeof value === 'object' && value !== null;
}
export function isJsonString(jsonString) {
if (typeof jsonString !== 'string') { if (typeof jsonString !== 'string') {
return false; return false;
} }
@@ -40,7 +44,7 @@ export function parseVariableField(variableField) {
if (variableField === '---' || variableField === '{}') { if (variableField === '---' || variableField === '{}') {
return {}; return {};
} }
if (!isJson(variableField)) { if (!isJsonString(variableField)) {
variableField = yamlToJson(variableField); variableField = yamlToJson(variableField);
} }
variableField = JSON.parse(variableField); variableField = JSON.parse(variableField);