Merge pull request #7921 from mabashian/6172-schedule-detail-vars

Adds extra variables to schedule details

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-08-24 20:45:32 +00:00
committed by GitHub
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);