Add JSON/YAML components (#267)

Add CodeMirrorInput and VariablesField

Add components for syntax highlighting, YAML/JSON toggle
This commit is contained in:
Keith Grant 2019-06-18 12:32:22 -07:00 committed by GitHub
parent 0b10ff7fe6
commit e3cb8d0447
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 459 additions and 2 deletions

View 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();
});
});

View File

@ -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
View File

@ -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",

View File

@ -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"

View 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;

View 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;

View 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);
});
});

View 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;

View 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'
});
});
});

View 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
View 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);
}