From e3cb8d0447a03b4017f2287114a2a4710377d6f0 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Tue, 18 Jun 2019 12:32:22 -0700 Subject: [PATCH] Add JSON/YAML components (#267) Add CodeMirrorInput and VariablesField Add components for syntax highlighting, YAML/JSON toggle --- __tests__/util/yaml.test.js | 70 +++++++++++ jest.config.js | 5 +- package-lock.json | 18 ++- package.json | 4 + src/components/ButtonGroup.jsx | 32 +++++ .../CodeMirrorInput/CodeMirrorInput.jsx | 66 +++++++++++ .../CodeMirrorInput/CodeMirrorInput.test.jsx | 39 ++++++ .../CodeMirrorInput/VariablesField.jsx | 95 +++++++++++++++ .../CodeMirrorInput/VariablesField.test.jsx | 111 ++++++++++++++++++ src/components/CodeMirrorInput/index.js | 4 + src/util/yaml.js | 17 +++ 11 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 __tests__/util/yaml.test.js create mode 100644 src/components/ButtonGroup.jsx create mode 100644 src/components/CodeMirrorInput/CodeMirrorInput.jsx create mode 100644 src/components/CodeMirrorInput/CodeMirrorInput.test.jsx create mode 100644 src/components/CodeMirrorInput/VariablesField.jsx create mode 100644 src/components/CodeMirrorInput/VariablesField.test.jsx create mode 100644 src/components/CodeMirrorInput/index.js create mode 100644 src/util/yaml.js diff --git a/__tests__/util/yaml.test.js b/__tests__/util/yaml.test.js new file mode 100644 index 0000000000..bdbc113fe6 --- /dev/null +++ b/__tests__/util/yaml.test.js @@ -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(); + }); +}); diff --git a/jest.config.js b/jest.config.js index c0f2b6ece0..00fe0f1d72 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,12 +8,15 @@ module.exports = { moduleNameMapper: { '\\.(css|scss|less)$': '/__mocks__/styleMock.js' }, + setupFiles: [ + '@nteract/mockument' + ], setupFilesAfterEnv: ['/jest.setup.js'], snapshotSerializers: [ "enzyme-to-json/serializer" ], testMatch: [ - '/__tests__/**/*.test.{js,jsx}' + '/**/*.test.{js,jsx}' ], testEnvironment: 'jsdom', testURL: 'http://127.0.0.1:3001', diff --git a/package-lock.json b/package-lock.json index d04dc26da4..e6264fbbf3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0560116610..3df0d6e926 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/ButtonGroup.jsx b/src/components/ButtonGroup.jsx new file mode 100644 index 0000000000..bc6a8c8506 --- /dev/null +++ b/src/components/ButtonGroup.jsx @@ -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 ( + + {children} + + ); +} + +export default ButtonGroup; diff --git a/src/components/CodeMirrorInput/CodeMirrorInput.jsx b/src/components/CodeMirrorInput/CodeMirrorInput.jsx new file mode 100644 index 0000000000..9c01c8a231 --- /dev/null +++ b/src/components/CodeMirrorInput/CodeMirrorInput.jsx @@ -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 ( + 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; diff --git a/src/components/CodeMirrorInput/CodeMirrorInput.test.jsx b/src/components/CodeMirrorInput/CodeMirrorInput.test.jsx new file mode 100644 index 0000000000..9198328543 --- /dev/null +++ b/src/components/CodeMirrorInput/CodeMirrorInput.test.jsx @@ -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( + + ); + 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( + + ); + const codemirror = wrapper.find('Controlled'); + expect(codemirror.prop('options').readOnly).toEqual(true); + }); +}); diff --git a/src/components/CodeMirrorInput/VariablesField.jsx b/src/components/CodeMirrorInput/VariablesField.jsx new file mode 100644 index 0000000000..0d88e795d5 --- /dev/null +++ b/src/components/CodeMirrorInput/VariablesField.jsx @@ -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 ( + ( +
+ + + + + + + { + 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 + + { + 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 + + + + + { + form.setFieldValue(name, value); + }} + hasErrors={!!form.errors[field.name]} + /> + {form.errors[field.name] ? ( +
+ {form.errors[field.name]} +
+ ) : null } +
+ )} + /> + ); +} +VariablesField.propTypes = { + id: string.isRequired, + name: string.isRequired, + label: string.isRequired, + readOnly: bool, +}; +VariablesField.defaultProps = { + readOnly: false, +}; + +export default VariablesField; diff --git a/src/components/CodeMirrorInput/VariablesField.test.jsx b/src/components/CodeMirrorInput/VariablesField.test.jsx new file mode 100644 index 0000000000..94711209fc --- /dev/null +++ b/src/components/CodeMirrorInput/VariablesField.test.jsx @@ -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( + ( + + )} + /> + ); + const codemirror = wrapper.find('Controlled'); + expect(codemirror.prop('value')).toEqual(value); + }); + + it('should render yaml/json toggles', () => { + const value = '---\n'; + const wrapper = mount( + ( + + )} + /> + ); + 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( + ( + + )} + /> + ); + 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( + ( +
+ + + + )} + /> + ); + 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' + }); + }); +}); diff --git a/src/components/CodeMirrorInput/index.js b/src/components/CodeMirrorInput/index.js new file mode 100644 index 0000000000..932883224b --- /dev/null +++ b/src/components/CodeMirrorInput/index.js @@ -0,0 +1,4 @@ +import CodeMirrorInput from './CodeMirrorInput'; + +export default CodeMirrorInput; +export { default as VariablesField } from './VariablesField'; diff --git a/src/util/yaml.js b/src/util/yaml.js new file mode 100644 index 0000000000..79e19bede1 --- /dev/null +++ b/src/util/yaml.js @@ -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); +}