Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AceEditor - codemirror replacement #9281

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This is a list of high-level changes for each release of AWX. A full list of com
- Added pending workflow approval count to the application header https://github.com/ansible/awx/pull/9334
- Added user interface for management jobs: https://github.com/ansible/awx/pull/9224
- Added toast message to show notification template test result to notification templates list https://github.com/ansible/awx/pull/9318
- Replaced CodeMirror with AceEditor for editing template variables and notification templates https://github.com/ansible/awx/pull/9281

# 17.1.0 (March 9th, 2021)
- Addressed a security issue in AWX (CVE-2021-20253)
Expand Down
40 changes: 28 additions & 12 deletions awx/ui_next/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions awx/ui_next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
"@patternfly/react-core": "^4.90.2",
"@patternfly/react-icons": "4.7.22",
"@patternfly/react-table": "^4.19.15",
"ace-builds": "^1.4.12",
"ansi-to-html": "^0.6.11",
"axios": "^0.21.1",
"codemirror": "^5.47.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😌

"d3": "^5.12.0",
"dagre": "^0.8.4",
"formik": "^2.1.2",
Expand All @@ -22,7 +22,7 @@
"js-yaml": "^3.13.1",
"prop-types": "^15.6.2",
"react": "^16.13.1",
"react-codemirror2": "^6.0.0",
"react-ace": "^9.3.0",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2",
"react-virtualized": "^9.21.1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import styled from 'styled-components';
import { BrandName } from '../../variables';
import AnsibleSelect from '../AnsibleSelect';
import FormField from '../FormField';
import { VariablesField } from '../CodeMirrorInput';
import { VariablesField } from '../CodeEditor';
import {
FormColumnLayout,
FormFullWidthLayout,
Expand Down
193 changes: 193 additions & 0 deletions awx/ui_next/src/components/CodeEditor/CodeEditor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { oneOf, bool, number, string, func } from 'prop-types';
import ReactAce from 'react-ace';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/mode-javascript';
import 'ace-builds/src-noconflict/mode-yaml';
import 'ace-builds/src-noconflict/mode-django';
import 'ace-builds/src-noconflict/theme-github';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import styled from 'styled-components';
import debounce from '../../util/debounce';

const LINE_HEIGHT = 24;
const PADDING = 12;

const FocusWrapper = styled.div`
&& + .keyboard-help-text {
opacity: 0;
transition: opacity 0.1s linear;
}

&:focus-within + .keyboard-help-text {
opacity: 1;
}

& .ace_hidden-cursors .ace_cursor {
opacity: 0;
}
`;

const AceEditor = styled(ReactAce)`
font-family: var(--pf-global--FontFamily--monospace);
max-height: 90vh;

& .ace_gutter,
& .ace_scroller {
padding-top: 4px;
padding-bottom: 4px;
}

& .ace_mobile-menu {
display: none;
}

${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);
}`}

${props =>
props.setOptions.readOnly &&
`
&& .ace_cursor {
opacity: 0;
}
`}
`;
AceEditor.displayName = 'AceEditor';

function CodeEditor({
id,
value,
onChange,
mode,
readOnly,
hasErrors,
rows,
fullHeight,
className,
i18n,
}) {
const wrapper = useRef(null);
const editor = useRef(null);

useEffect(
function removeTextareaTabIndex() {
const editorInput = editor.current.refEditor?.querySelector('textarea');
if (editorInput && !readOnly) {
editorInput.tabIndex = -1;
}
},
[readOnly]
);

const listen = useCallback(event => {
if (wrapper.current === document.activeElement && event.key === 'Enter') {
const editorInput = editor.current.refEditor?.querySelector('textarea');
if (!editorInput) {
return;
}
event.preventDefault();
event.stopPropagation();
editorInput.focus();
}
}, []);

useEffect(function addKeyEventListeners() {
const wrapperEl = wrapper.current;
wrapperEl.addEventListener('keydown', listen);

return () => {
wrapperEl.removeEventListener('keydown', listen);
};
});

const aceModes = {
javascript: 'json',
yaml: 'yaml',
jinja2: 'django',
};

const numRows = fullHeight ? value.split('\n').length : rows;

return (
<>
<FocusWrapper ref={wrapper} tabIndex={readOnly ? -1 : 0}>
<AceEditor
mode={aceModes[mode] || 'text'}
className={`pf-c-form-control ${className}`}
theme="github"
onChange={debounce(onChange, 250)}
value={value}
name={id || 'code-editor'}
editorProps={{ $blockScrolling: true }}
fontSize={16}
width="100%"
height={`${numRows * LINE_HEIGHT + PADDING}px`}
hasErrors={hasErrors}
setOptions={{
readOnly,
highlightActiveLine: !readOnly,
highlightGutterLine: !readOnly,
useWorker: false,
showPrintMargin: false,
}}
commands={[
{
name: 'escape',
bindKey: { win: 'Esc', mac: 'Esc' },
exec: () => {
wrapper.current.focus();
},
},
{
name: 'tab escape',
bindKey: { win: 'Shift-Tab', mac: 'Shift-Tab' },
exec: () => {
wrapper.current.focus();
},
},
]}
ref={editor}
/>
</FocusWrapper>
{!readOnly && (
<div
className="pf-c-form__helper-text keyboard-help-text"
aria-live="polite"
>
{i18n._(t`Press Enter to edit. Press ESC to stop editing.`)}
</div>
)}
</>
);
}
CodeEditor.propTypes = {
value: string.isRequired,
onChange: func,
mode: oneOf(['javascript', 'yaml', 'jinja2']).isRequired,
readOnly: bool,
hasErrors: bool,
fullHeight: bool,
rows: number,
className: string,
};
CodeEditor.defaultProps = {
readOnly: false,
onChange: () => {},
rows: 6,
fullHeight: false,
hasErrors: false,
className: '',
};

export default withI18n()(CodeEditor);
44 changes: 44 additions & 0 deletions awx/ui_next/src/components/CodeEditor/CodeEditor.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
import CodeEditor from './CodeEditor';
import debounce from '../../util/debounce';

jest.mock('../../util/debounce');

describe('CodeEditor', () => {
beforeEach(() => {
document.body.createTextRange = jest.fn();
});

it('should pass value and mode through to ace editor', () => {
const onChange = jest.fn();
const wrapper = mountWithContexts(
<CodeEditor value={'---\nfoo: bar'} onChange={onChange} mode="yaml" />
);
const aceEditor = wrapper.find('AceEditor');
expect(aceEditor.prop('mode')).toEqual('yaml');
expect(aceEditor.prop('setOptions').readOnly).toEqual(false);
expect(aceEditor.prop('value')).toEqual('---\nfoo: bar');
});

it('should trigger onChange prop', () => {
debounce.mockImplementation(fn => fn);
const onChange = jest.fn();
const wrapper = mountWithContexts(
<CodeEditor value="---" onChange={onChange} mode="yaml" />
);
const aceEditor = wrapper.find('AceEditor');
aceEditor.prop('onChange')('newvalue');
expect(onChange).toHaveBeenCalledWith('newvalue');
});

it('should render in read only mode', () => {
const onChange = jest.fn();
const wrapper = mountWithContexts(
<CodeEditor value="---" onChange={onChange} mode="yaml" readOnly />
);
const aceEditor = wrapper.find('AceEditor');
expect(aceEditor.prop('setOptions').readOnly).toEqual(true);
expect(aceEditor.prop('value')).toEqual('---');
});
});