mirror of
https://github.com/ansible/awx.git
synced 2026-01-22 06:58:06 -03:30
Add JSON/YAML components (#267)
Add CodeMirrorInput and VariablesField Add components for syntax highlighting, YAML/JSON toggle
This commit is contained in:
parent
0b10ff7fe6
commit
e3cb8d0447
70
__tests__/util/yaml.test.js
Normal file
70
__tests__/util/yaml.test.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { yamlToJson, jsonToYaml } from '../../src/util/yaml';
|
||||
|
||||
describe('yamlToJson', () => {
|
||||
test('should convert to json', () => {
|
||||
const yaml = `
|
||||
---
|
||||
one: 1
|
||||
two: two
|
||||
`;
|
||||
expect(yamlToJson(yaml)).toEqual(`{
|
||||
"one": 1,
|
||||
"two": "two"
|
||||
}`);
|
||||
});
|
||||
|
||||
test('should remove comments', () => {
|
||||
const yaml = `
|
||||
---
|
||||
one: 1
|
||||
# comment
|
||||
two: two
|
||||
# comment two
|
||||
`;
|
||||
expect(yamlToJson(yaml)).toEqual(`{
|
||||
"one": 1,
|
||||
"two": "two"
|
||||
}`);
|
||||
});
|
||||
|
||||
test('should convert empty string to {}', () => {
|
||||
expect(yamlToJson('')).toEqual('{}');
|
||||
});
|
||||
|
||||
test('should convert null to {}', () => {
|
||||
expect(yamlToJson(null)).toEqual('{}');
|
||||
});
|
||||
|
||||
test('should convert empty yaml to {}', () => {
|
||||
expect(yamlToJson('---')).toEqual('{}');
|
||||
});
|
||||
|
||||
test('should throw if invalid yaml given', () => {
|
||||
expect(() => yamlToJson('foo')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonToYaml', () => {
|
||||
test('should convert to yaml', () => {
|
||||
const json = `{
|
||||
"one": 1,
|
||||
"two": "two"
|
||||
}
|
||||
`;
|
||||
expect(jsonToYaml(json)).toEqual(`one: 1
|
||||
two: two
|
||||
`);
|
||||
});
|
||||
|
||||
test('should convert empty object to empty yaml doc', () => {
|
||||
expect(jsonToYaml('{}')).toEqual('---\n');
|
||||
});
|
||||
|
||||
test('should convert empty string to empty yaml doc', () => {
|
||||
expect(jsonToYaml('')).toEqual('---\n');
|
||||
});
|
||||
|
||||
test('should throw if invalid json given', () => {
|
||||
expect(() => jsonToYaml('bad data')).toThrow();
|
||||
});
|
||||
});
|
||||
@ -8,12 +8,15 @@ module.exports = {
|
||||
moduleNameMapper: {
|
||||
'\\.(css|scss|less)$': '<rootDir>/__mocks__/styleMock.js'
|
||||
},
|
||||
setupFiles: [
|
||||
'@nteract/mockument'
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
snapshotSerializers: [
|
||||
"enzyme-to-json/serializer"
|
||||
],
|
||||
testMatch: [
|
||||
'<rootDir>/__tests__/**/*.test.{js,jsx}'
|
||||
'<rootDir>/**/*.test.{js,jsx}'
|
||||
],
|
||||
testEnvironment: 'jsdom',
|
||||
testURL: 'http://127.0.0.1:3001',
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@ -1774,6 +1774,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@nteract/mockument": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nteract/mockument/-/mockument-1.0.4.tgz",
|
||||
"integrity": "sha1-9/hf2T5Dgo7HQcX0xXMRgu2w7LI=",
|
||||
"dev": true
|
||||
},
|
||||
"@patternfly/patternfly": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-2.7.0.tgz",
|
||||
@ -3295,7 +3301,7 @@
|
||||
},
|
||||
"babel-plugin-syntax-object-rest-spread": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
|
||||
"resolved": "http://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz",
|
||||
"integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U="
|
||||
},
|
||||
"babel-plugin-syntax-trailing-function-commas": {
|
||||
@ -4575,6 +4581,11 @@
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"dev": true
|
||||
},
|
||||
"codemirror": {
|
||||
"version": "5.47.0",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.47.0.tgz",
|
||||
"integrity": "sha512-kV49Fr+NGFHFc/Imsx6g180hSlkGhuHxTSDDmDHOuyln0MQYFLixDY4+bFkBVeCEiepYfDimAF/e++9jPJk4QA=="
|
||||
},
|
||||
"collection-visit": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz",
|
||||
@ -12984,6 +12995,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"react-codemirror2": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-codemirror2/-/react-codemirror2-6.0.0.tgz",
|
||||
"integrity": "sha512-D7y9qZ05FbUh9blqECaJMdDwKluQiO3A9xB+fssd5jKM7YAXucRuEOlX32mJQumUvHUkHRHqXIPBjm6g0FW0Ag=="
|
||||
},
|
||||
"react-dom": {
|
||||
"version": "16.8.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@lingui/cli": "^2.7.4",
|
||||
"@lingui/macro": "^2.7.2",
|
||||
"@nteract/mockument": "^1.0.4",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.0.1",
|
||||
"babel-jest": "^24.7.1",
|
||||
@ -56,9 +57,12 @@
|
||||
"@patternfly/react-icons": "^3.7.5",
|
||||
"@patternfly/react-tokens": "^2.3.3",
|
||||
"axios": "^0.18.0",
|
||||
"codemirror": "^5.47.0",
|
||||
"formik": "^1.5.1",
|
||||
"js-yaml": "^3.13.1",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.4.1",
|
||||
"react-codemirror2": "^6.0.0",
|
||||
"react-dom": "^16.4.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"styled-components": "^4.2.0"
|
||||
|
||||
32
src/components/ButtonGroup.jsx
Normal file
32
src/components/ButtonGroup.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Group = styled.div`
|
||||
display: inline-flex;
|
||||
|
||||
& > .pf-c-button:not(:last-child) {
|
||||
&,
|
||||
&::after {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > .pf-c-button:not(:first-child) {
|
||||
&,
|
||||
&::after {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function ButtonGroup ({ children }) {
|
||||
return (
|
||||
<Group>
|
||||
{children}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default ButtonGroup;
|
||||
66
src/components/CodeMirrorInput/CodeMirrorInput.jsx
Normal file
66
src/components/CodeMirrorInput/CodeMirrorInput.jsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { oneOf, bool, number, string, func } from 'prop-types';
|
||||
import { Controlled as ReactCodeMirror } from 'react-codemirror2';
|
||||
import styled from 'styled-components';
|
||||
import 'codemirror/mode/javascript/javascript';
|
||||
import 'codemirror/mode/yaml/yaml';
|
||||
import 'codemirror/mode/jinja2/jinja2';
|
||||
import 'codemirror/lib/codemirror.css';
|
||||
|
||||
const LINE_HEIGHT = 24;
|
||||
const PADDING = 12;
|
||||
|
||||
const CodeMirror = styled(ReactCodeMirror)`
|
||||
&& {
|
||||
height: initial;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
& > .CodeMirror {
|
||||
height: ${props => (props.rows * LINE_HEIGHT + PADDING)}px;
|
||||
font-family: var(--pf-global--FontFamily--monospace);
|
||||
}
|
||||
|
||||
${props => props.hasErrors && `
|
||||
&& {
|
||||
--pf-c-form-control--PaddingRight: var(--pf-c-form-control--invalid--PaddingRight);
|
||||
--pf-c-form-control--BorderBottomColor: var(--pf-c-form-control--invalid--BorderBottomColor);
|
||||
padding-right: 24px;
|
||||
padding-bottom: var(--pf-c-form-control--invalid--PaddingBottom);
|
||||
background: var(--pf-c-form-control--invalid--Background);
|
||||
border-bottom-width: var(--pf-c-form-control--invalid--BorderBottomWidth);
|
||||
}`}
|
||||
`;
|
||||
|
||||
function CodeMirrorInput ({ value, onChange, mode, readOnly, hasErrors, rows }) {
|
||||
return (
|
||||
<CodeMirror
|
||||
className="pf-c-form-control"
|
||||
value={value}
|
||||
onBeforeChange={(editor, data, val) => onChange(val)}
|
||||
mode={mode}
|
||||
hasErrors={hasErrors}
|
||||
options={{
|
||||
smartIndent: false,
|
||||
lineNumbers: true,
|
||||
readOnly
|
||||
}}
|
||||
rows={rows}
|
||||
/>
|
||||
);
|
||||
}
|
||||
CodeMirrorInput.propTypes = {
|
||||
value: string.isRequired,
|
||||
onChange: func.isRequired,
|
||||
mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired,
|
||||
readOnly: bool,
|
||||
hasErrors: bool,
|
||||
rows: number,
|
||||
};
|
||||
CodeMirrorInput.defaultProps = {
|
||||
readOnly: false,
|
||||
rows: 6,
|
||||
hasErrors: false,
|
||||
};
|
||||
|
||||
export default CodeMirrorInput;
|
||||
39
src/components/CodeMirrorInput/CodeMirrorInput.test.jsx
Normal file
39
src/components/CodeMirrorInput/CodeMirrorInput.test.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
|
||||
describe('CodeMirrorInput', () => {
|
||||
beforeEach(() => {
|
||||
document.body.createTextRange = jest.fn();
|
||||
});
|
||||
|
||||
it('should trigger onChange prop', () => {
|
||||
const onChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<CodeMirrorInput
|
||||
value="---\n"
|
||||
onChange={onChange}
|
||||
mode="yaml"
|
||||
/>
|
||||
);
|
||||
const codemirror = wrapper.find('Controlled');
|
||||
expect(codemirror.prop('mode')).toEqual('yaml');
|
||||
expect(codemirror.prop('options').readOnly).toEqual(false);
|
||||
codemirror.prop('onBeforeChange')(null, null, 'newvalue');
|
||||
expect(onChange).toHaveBeenCalledWith('newvalue');
|
||||
});
|
||||
|
||||
it('should render in read only mode', () => {
|
||||
const onChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<CodeMirrorInput
|
||||
value="---\n"
|
||||
onChange={onChange}
|
||||
mode="yaml"
|
||||
readOnly
|
||||
/>
|
||||
);
|
||||
const codemirror = wrapper.find('Controlled');
|
||||
expect(codemirror.prop('options').readOnly).toEqual(true);
|
||||
});
|
||||
});
|
||||
95
src/components/CodeMirrorInput/VariablesField.jsx
Normal file
95
src/components/CodeMirrorInput/VariablesField.jsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
import { string, bool } from 'prop-types';
|
||||
import { Field } from 'formik';
|
||||
import { Button, Split, SplitItem } from '@patternfly/react-core';
|
||||
import styled from 'styled-components';
|
||||
import ButtonGroup from '../ButtonGroup';
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
import { yamlToJson, jsonToYaml } from '../../util/yaml';
|
||||
|
||||
const YAML_MODE = 'yaml';
|
||||
const JSON_MODE = 'javascript';
|
||||
|
||||
const SmallButton = styled(Button)`
|
||||
padding: 3px 8px;
|
||||
font-size: var(--pf-global--FontSize--xs);
|
||||
`;
|
||||
|
||||
function VariablesField ({ id, name, label, readOnly }) {
|
||||
const [mode, setMode] = useState(YAML_MODE);
|
||||
|
||||
return (
|
||||
<Field
|
||||
name={name}
|
||||
render={({ field, form }) => (
|
||||
<div className="pf-c-form__group">
|
||||
<Split gutter="sm">
|
||||
<SplitItem>
|
||||
<label htmlFor={id} className="pf-c-form__label">{label}</label>
|
||||
</SplitItem>
|
||||
<SplitItem>
|
||||
<ButtonGroup>
|
||||
<SmallButton
|
||||
onClick={() => {
|
||||
if (mode === YAML_MODE) { return; }
|
||||
try {
|
||||
form.setFieldValue(name, jsonToYaml(field.value));
|
||||
setMode(YAML_MODE);
|
||||
} catch (err) {
|
||||
form.setFieldError(name, err.message);
|
||||
}
|
||||
}}
|
||||
variant={mode === YAML_MODE ? 'primary' : 'secondary'}
|
||||
>
|
||||
YAML
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => {
|
||||
if (mode === JSON_MODE) { return; }
|
||||
try {
|
||||
form.setFieldValue(name, yamlToJson(field.value));
|
||||
setMode(JSON_MODE);
|
||||
} catch (err) {
|
||||
form.setFieldError(name, err.message);
|
||||
}
|
||||
}}
|
||||
variant={mode === JSON_MODE ? 'primary' : 'secondary'}
|
||||
>
|
||||
JSON
|
||||
</SmallButton>
|
||||
</ButtonGroup>
|
||||
</SplitItem>
|
||||
</Split>
|
||||
<CodeMirrorInput
|
||||
mode={mode}
|
||||
readOnly={readOnly}
|
||||
{...field}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue(name, value);
|
||||
}}
|
||||
hasErrors={!!form.errors[field.name]}
|
||||
/>
|
||||
{form.errors[field.name] ? (
|
||||
<div
|
||||
className="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
{form.errors[field.name]}
|
||||
</div>
|
||||
) : null }
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
VariablesField.propTypes = {
|
||||
id: string.isRequired,
|
||||
name: string.isRequired,
|
||||
label: string.isRequired,
|
||||
readOnly: bool,
|
||||
};
|
||||
VariablesField.defaultProps = {
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export default VariablesField;
|
||||
111
src/components/CodeMirrorInput/VariablesField.test.jsx
Normal file
111
src/components/CodeMirrorInput/VariablesField.test.jsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { Formik } from 'formik';
|
||||
import { sleep } from '../../../__tests__/testUtils';
|
||||
import VariablesField from './VariablesField';
|
||||
|
||||
describe('VariablesField', () => {
|
||||
beforeEach(() => {
|
||||
document.body.createTextRange = jest.fn();
|
||||
});
|
||||
|
||||
it('should render code mirror input', () => {
|
||||
const value = '---\n';
|
||||
const wrapper = mount(
|
||||
<Formik
|
||||
initialValues={{ variables: value }}
|
||||
render={() => (
|
||||
<VariablesField
|
||||
id="the-field"
|
||||
name="variables"
|
||||
label="Variables"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
const codemirror = wrapper.find('Controlled');
|
||||
expect(codemirror.prop('value')).toEqual(value);
|
||||
});
|
||||
|
||||
it('should render yaml/json toggles', () => {
|
||||
const value = '---\n';
|
||||
const wrapper = mount(
|
||||
<Formik
|
||||
initialValues={{ variables: value }}
|
||||
render={() => (
|
||||
<VariablesField
|
||||
id="the-field"
|
||||
name="variables"
|
||||
label="Variables"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
const buttons = wrapper.find('Button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(buttons.at(0).prop('variant')).toEqual('primary');
|
||||
expect(buttons.at(1).prop('variant')).toEqual('secondary');
|
||||
|
||||
buttons.at(1).simulate('click');
|
||||
wrapper.update(0);
|
||||
expect(wrapper.find('CodeMirrorInput').prop('mode')).toEqual('javascript');
|
||||
const buttons2 = wrapper.find('Button');
|
||||
expect(buttons2.at(0).prop('variant')).toEqual('secondary');
|
||||
expect(buttons2.at(1).prop('variant')).toEqual('primary');
|
||||
buttons2.at(0).simulate('click');
|
||||
wrapper.update(0);
|
||||
expect(wrapper.find('CodeMirrorInput').prop('mode')).toEqual('yaml');
|
||||
});
|
||||
|
||||
it('should set Formik error if yaml is invalid', () => {
|
||||
const value = '---\nfoo bar\n';
|
||||
const wrapper = mount(
|
||||
<Formik
|
||||
initialValues={{ variables: value }}
|
||||
render={() => (
|
||||
<VariablesField
|
||||
id="the-field"
|
||||
name="variables"
|
||||
label="Variables"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
wrapper.find('Button').at(1).simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
const field = wrapper.find('CodeMirrorInput');
|
||||
expect(field.prop('hasErrors')).toEqual(true);
|
||||
expect(wrapper.find('.pf-m-error')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should submit value through Formik', async () => {
|
||||
const value = '---\nfoo: bar\n';
|
||||
const handleSubmit = jest.fn();
|
||||
const wrapper = mount(
|
||||
<Formik
|
||||
initialValues={{ variables: value }}
|
||||
onSubmit={handleSubmit}
|
||||
render={(formik) => (
|
||||
<form onSubmit={formik.handleSubmit}>
|
||||
<VariablesField
|
||||
id="the-field"
|
||||
name="variables"
|
||||
label="Variables"
|
||||
/>
|
||||
<button type="submit" id="submit">Submit</button>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
wrapper.find('CodeMirrorInput').prop('onChange')('---\nnewval: changed');
|
||||
wrapper.find('form').simulate('submit');
|
||||
await sleep(1);
|
||||
await sleep(1);
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalled();
|
||||
expect(handleSubmit.mock.calls[0][0]).toEqual({
|
||||
variables: '---\nnewval: changed'
|
||||
});
|
||||
});
|
||||
});
|
||||
4
src/components/CodeMirrorInput/index.js
Normal file
4
src/components/CodeMirrorInput/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
import CodeMirrorInput from './CodeMirrorInput';
|
||||
|
||||
export default CodeMirrorInput;
|
||||
export { default as VariablesField } from './VariablesField';
|
||||
17
src/util/yaml.js
Normal file
17
src/util/yaml.js
Normal file
@ -0,0 +1,17 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export function yamlToJson (yamlString) {
|
||||
const value = yaml.safeLoad(yamlString);
|
||||
if (!value) { return '{}'; }
|
||||
if (typeof value !== 'object') {
|
||||
throw new Error('yaml is not in object format');
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export function jsonToYaml (jsonString) {
|
||||
if (jsonString.trim() === '') { return '---\n'; }
|
||||
const value = JSON.parse(jsonString);
|
||||
if (Object.entries(value).length === 0) { return '---\n'; }
|
||||
return yaml.safeDump(value);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user