Merge pull request #9585 from keithjgrant/8031-variables-expand-2

Add Expand button to variables fields

SUMMARY
Adds expand button to variables fields and details. Clicking it opens the code editor & YAML/JSON toggles in a large modal
Addresses #8031
ISSUE TYPE

Feature Pull Request

COMPONENT NAME

UI

ADDITIONAL INFORMATION

Reviewed-by: Kersom <None>
Reviewed-by: Mat Wilson <mawilson@redhat.com>
This commit is contained in:
softwarefactory-project-zuul[bot] 2021-03-30 22:37:40 +00:00 committed by GitHub
commit 45d9ec94ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 275 additions and 80 deletions

View File

@ -77,7 +77,8 @@
"resizeOrientation",
"src",
"theme",
"gridColumns"
"gridColumns",
"rows"
],
"ignore": ["Ansible", "Tower", "JSON", "YAML", "lg"],
"ignoreComponent": [

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { oneOf, bool, number, string, func } from 'prop-types';
import { oneOf, bool, number, string, func, oneOfType } from 'prop-types';
import ReactAce from 'react-ace';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/mode-javascript';
@ -77,6 +77,13 @@ function CodeEditor({
className,
i18n,
}) {
if (rows && typeof rows !== 'number' && rows !== 'auto') {
// eslint-disable-next-line no-console
console.warning(
`CodeEditor: Unexpected value for 'rows': ${rows}; expected number or 'auto'`
);
}
const wrapper = useRef(null);
const editor = useRef(null);
@ -117,7 +124,8 @@ function CodeEditor({
jinja2: 'django',
};
const numRows = fullHeight ? value.split('\n').length : rows;
const numRows = rows === 'auto' ? value.split('\n').length : rows;
const height = fullHeight ? '50vh' : `${numRows * LINE_HEIGHT + PADDING}px`;
return (
<>
@ -132,7 +140,7 @@ function CodeEditor({
editorProps={{ $blockScrolling: true }}
fontSize={16}
width="100%"
height={`${numRows * LINE_HEIGHT + PADDING}px`}
height={height}
hasErrors={hasErrors}
setOptions={{
readOnly,
@ -178,7 +186,7 @@ CodeEditor.propTypes = {
readOnly: bool,
hasErrors: bool,
fullHeight: bool,
rows: number,
rows: oneOfType([number, string]),
className: string,
};
CodeEditor.defaultProps = {

View File

@ -1,8 +1,16 @@
import 'styled-components/macro';
import React, { useState, useEffect } from 'react';
import { node, number, oneOfType, shape, string, arrayOf } from 'prop-types';
import { Trans, withI18n } from '@lingui/react';
import { Split, SplitItem, TextListItemVariants } from '@patternfly/react-core';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Split,
SplitItem,
TextListItemVariants,
Button,
Modal,
} from '@patternfly/react-core';
import { ExpandArrowsAltIcon } from '@patternfly/react-icons';
import { DetailName, DetailValue } from '../DetailList';
import MultiButtonToggle from '../MultiButtonToggle';
import Popover from '../Popover';
@ -29,13 +37,14 @@ function getValueAsMode(value, mode) {
return mode === YAML_MODE ? jsonToYaml(value) : yamlToJson(value);
}
function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
function VariablesDetail({ dataCy, helpText, value, label, rows, i18n }) {
const [mode, setMode] = useState(
isJsonObject(value) || isJsonString(value) ? JSON_MODE : YAML_MODE
);
const [currentValue, setCurrentValue] = useState(
isJsonObject(value) ? JSON.stringify(value, null, 2) : value || '---'
);
const [isExpanded, setIsExpanded] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
@ -60,7 +69,112 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
fullWidth
css="grid-column: 1 / -1"
>
<Split hasGutter>
<ModeToggle
label={label}
helpText={helpText}
dataCy={dataCy}
mode={mode}
setMode={setMode}
currentValue={currentValue}
setCurrentValue={setCurrentValue}
setError={setError}
onExpand={() => setIsExpanded(true)}
i18n={i18n}
/>
</DetailName>
<DetailValue
data-cy={valueCy}
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
>
<CodeEditor
mode={mode}
value={currentValue}
readOnly
rows={rows}
css="margin-top: 10px"
/>
{error && (
<div
css="color: var(--pf-global--danger-color--100);
font-size: var(--pf-global--FontSize--sm"
>
{i18n._(t`Error:`)} {error.message}
</div>
)}
</DetailValue>
<Modal
variant="xlarge"
title={label}
isOpen={isExpanded}
onClose={() => setIsExpanded(false)}
actions={[
<Button
aria-label={i18n._(t`Done`)}
key="select"
variant="primary"
onClick={() => setIsExpanded(false)}
ouiaId={`${dataCy}-unexpand`}
>
{i18n._(t`Done`)}
</Button>,
]}
>
<div className="pf-c-form">
<ModeToggle
label={label}
helpText={helpText}
dataCy={dataCy}
mode={mode}
setMode={setMode}
currentValue={currentValue}
setCurrentValue={setCurrentValue}
setError={setError}
i18n={i18n}
/>
<CodeEditor
mode={mode}
value={currentValue}
readOnly
rows={rows}
fullHeight
css="margin-top: 10px"
/>
</div>
</Modal>
</>
);
}
VariablesDetail.propTypes = {
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
rows: oneOfType([number, string]),
dataCy: string,
helpText: string,
};
VariablesDetail.defaultProps = {
rows: null,
dataCy: '',
helpText: '',
};
function ModeToggle({
label,
helpText,
dataCy,
currentValue,
setCurrentValue,
mode,
setMode,
setError,
onExpand,
i18n,
}) {
return (
<Split hasGutter>
<SplitItem isFilled>
<Split hasGutter css="align-items: baseline">
<SplitItem>
<div className="pf-c-form__label">
<span
@ -92,44 +206,21 @@ function VariablesDetail({ dataCy, helpText, value, label, rows, fullHeight }) {
/>
</SplitItem>
</Split>
</DetailName>
<DetailValue
data-cy={valueCy}
component={TextListItemVariants.dd}
fullWidth
css="grid-column: 1 / -1; margin-top: -20px"
>
<CodeEditor
mode={mode}
value={currentValue}
readOnly
rows={rows}
fullHeight={fullHeight}
css="margin-top: 10px"
/>
{error && (
<div
css="color: var(--pf-global--danger-color--100);
font-size: var(--pf-global--FontSize--sm"
</SplitItem>
{onExpand && (
<SplitItem>
<Button
variant="plain"
aria-label={i18n._(t`Expand input`)}
onClick={onExpand}
ouiaId={`${dataCy}-expand`}
>
<Trans>Error:</Trans> {error.message}
</div>
)}
</DetailValue>
</>
<ExpandArrowsAltIcon />
</Button>
</SplitItem>
)}
</Split>
);
}
VariablesDetail.propTypes = {
value: oneOfType([shape({}), arrayOf(string), string]).isRequired,
label: node.isRequired,
rows: number,
dataCy: string,
helpText: string,
};
VariablesDetail.defaultProps = {
rows: null,
dataCy: '',
helpText: '',
};
export default withI18n()(VariablesDetail);

View File

@ -4,7 +4,8 @@ import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
import styled from 'styled-components';
import { Split, SplitItem } from '@patternfly/react-core';
import { Split, SplitItem, Button, Modal } from '@patternfly/react-core';
import { ExpandArrowsAltIcon } from '@patternfly/react-icons';
import { CheckboxField } from '../FormField';
import MultiButtonToggle from '../MultiButtonToggle';
import { yamlToJson, jsonToYaml, isJsonString } from '../../util/yaml';
@ -20,6 +21,7 @@ const FieldHeader = styled.div`
const StyledCheckboxField = styled(CheckboxField)`
--pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize);
margin-left: auto;
`;
function VariablesField({
@ -31,10 +33,92 @@ function VariablesField({
promptId,
tooltip,
}) {
const [field, meta, helpers] = useField(name);
const [field, meta] = useField(name);
const [mode, setMode] = useState(
isJsonString(field.value) ? JSON_MODE : YAML_MODE
);
const [isExpanded, setIsExpanded] = useState(false);
return (
<>
<VariablesFieldInternals
i18n={i18n}
id={id}
name={name}
label={label}
readOnly={readOnly}
promptId={promptId}
tooltip={tooltip}
onExpand={() => setIsExpanded(true)}
mode={mode}
setMode={setMode}
/>
<Modal
variant="xlarge"
title={label}
isOpen={isExpanded}
onClose={() => setIsExpanded(false)}
actions={[
<Button
aria-label={i18n._(t`Done`)}
key="select"
variant="primary"
onClick={() => setIsExpanded(false)}
ouiaId={`${id}-variables-unexpand`}
>
{i18n._(t`Done`)}
</Button>,
]}
>
<div className="pf-c-form">
<VariablesFieldInternals
i18n={i18n}
id={`${id}-expanded`}
name={name}
label={label}
readOnly={readOnly}
promptId={promptId}
tooltip={tooltip}
fullHeight
mode={mode}
setMode={setMode}
/>
</div>
</Modal>
{meta.error ? (
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
{meta.error}
</div>
) : null}
</>
);
}
VariablesField.propTypes = {
id: string.isRequired,
name: string.isRequired,
label: string.isRequired,
readOnly: bool,
promptId: string,
};
VariablesField.defaultProps = {
readOnly: false,
promptId: null,
};
function VariablesFieldInternals({
i18n,
id,
name,
label,
readOnly,
promptId,
tooltip,
fullHeight,
mode,
setMode,
onExpand,
}) {
const [field, meta, helpers] = useField(name);
return (
<div className="pf-c-form__group">
@ -75,6 +159,16 @@ function VariablesField({
name="ask_variables_on_launch"
/>
)}
{onExpand && (
<Button
variant="plain"
aria-label={i18n._(t`Expand input`)}
onClick={onExpand}
ouiaId={`${id}-variables-expand`}
>
<ExpandArrowsAltIcon />
</Button>
)}
</FieldHeader>
<CodeEditor
mode={mode}
@ -83,26 +177,11 @@ function VariablesField({
onChange={newVal => {
helpers.setValue(newVal);
}}
fullHeight={fullHeight}
hasErrors={!!meta.error}
/>
{meta.error ? (
<div className="pf-c-form__helper-text pf-m-error" aria-live="polite">
{meta.error}
</div>
) : null}
</div>
);
}
VariablesField.propTypes = {
id: string.isRequired,
name: string.isRequired,
label: string.isRequired,
readOnly: bool,
promptId: string,
};
VariablesField.defaultProps = {
readOnly: false,
promptId: null,
};
export default withI18n()(VariablesField);

View File

@ -32,7 +32,7 @@ describe('VariablesField', () => {
</Formik>
);
const buttons = wrapper.find('Button');
expect(buttons).toHaveLength(2);
expect(buttons).toHaveLength(3);
expect(buttons.at(0).prop('variant')).toEqual('primary');
expect(buttons.at(1).prop('variant')).toEqual('secondary');
await act(async () => {
@ -136,4 +136,27 @@ describe('VariablesField', () => {
expect(wrapper.find('CodeEditor').prop('mode')).toEqual('javascript');
});
it('should open modal when expanded', async () => {
const value = '---';
const wrapper = mountWithContexts(
<Formik initialValues={{ variables: value }} onSubmit={jest.fn()}>
{formik => (
<form onSubmit={formik.handleSubmit}>
<VariablesField id="the-field" name="variables" label="Variables" />
<button type="submit" id="submit">
Submit
</button>
</form>
)}
</Formik>
);
expect(wrapper.find('Modal').prop('isOpen')).toEqual(false);
wrapper.find('Button[variant="plain"]').invoke('onClick')();
wrapper.update();
expect(wrapper.find('Modal').prop('isOpen')).toEqual(true);
expect(wrapper.find('Modal CodeEditor')).toHaveLength(1);
});
});

View File

@ -14,15 +14,7 @@ import { DetailName, DetailValue } from './Detail';
import CodeEditor from '../CodeEditor';
import Popover from '../Popover';
function CodeDetail({
value,
label,
mode,
rows,
fullHeight,
helpText,
dataCy,
}) {
function CodeDetail({ value, label, mode, rows, helpText, dataCy }) {
const labelCy = dataCy ? `${dataCy}-label` : null;
const valueCy = dataCy ? `${dataCy}-value` : null;
@ -57,7 +49,6 @@ function CodeDetail({
value={value}
readOnly
rows={rows}
fullHeight={fullHeight}
css="margin-top: 10px"
/>
</DetailValue>
@ -69,7 +60,7 @@ CodeDetail.propTypes = {
label: node.isRequired,
dataCy: string,
helpText: string,
rows: number,
rows: oneOfType(number, string),
mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired,
};
CodeDetail.defaultProps = {

View File

@ -36,7 +36,7 @@ function HostFacts({ i18n, host }) {
return (
<CardBody>
<DetailList gutter="sm">
<VariablesDetail label={i18n._(t`Facts`)} fullHeight value={facts} />
<VariablesDetail label={i18n._(t`Facts`)} rows="auto" value={facts} />
</DetailList>
</CardBody>
);

View File

@ -72,11 +72,12 @@ describe('<InventoryGroupDetail />', () => {
});
test('should open delete modal and then call api to delete the group', async () => {
expect(wrapper.find('Modal').length).toBe(1); // variables modal already mounted
await act(async () => {
wrapper.find('button[aria-label="Delete"]').simulate('click');
});
await waitForElement(wrapper, 'Modal', el => el.length === 1);
expect(wrapper.find('Modal').length).toBe(1);
wrapper.update();
expect(wrapper.find('Modal').length).toBe(2);
await act(async () => {
wrapper.find('Radio[id="radio-delete"]').invoke('onChange')();
});

View File

@ -35,7 +35,7 @@ function InventoryHostFacts({ i18n, host }) {
return (
<CardBody>
<DetailList gutter="sm">
<VariablesDetail label={i18n._(t`Facts`)} fullHeight value={result} />
<VariablesDetail label={i18n._(t`Facts`)} rows="auto" value={result} />
</DetailList>
</CardBody>
);

View File

@ -286,7 +286,7 @@ const ObjectField = withI18n()(({ i18n, name, config, isRequired = false }) => {
>
<CodeEditor
{...field}
fullHeight
rows="auto"
id={name}
mode="javascript"
onChange={value => {

View File

@ -371,6 +371,7 @@ function JobTemplateDetail({ i18n, template }) {
value={extra_vars}
rows={4}
label={i18n._(t`Variables`)}
dataCy={`jt-details-${template.id}`}
/>
</DetailList>
<CardActionsRow>