Skip to content

Commit

Permalink
feat(popups): Add DraftJS Editor (#462)
Browse files Browse the repository at this point in the history
* feat(popups): Add DraftJS Editor

* feat(popups): Save Cursor position

* feat(popups): Address comments

* feat(popups): Address comments

* feat(popups): Address comments

* feat(popups): draft-js
  • Loading branch information
Mingze authored Apr 29, 2020
1 parent 8ca58f9 commit ff35c5c
Show file tree
Hide file tree
Showing 21 changed files with 265 additions and 71 deletions.
2 changes: 2 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ ba.annotationsPost = Post
ba.annotationsSave = Save
# Button label for cancelling the creation of a description, comment, or reply
ba.popups.cancel = Cancel
# Placeholder for reply field editor
ba.popups.placeholder = Type a comment...
# Button label for creating a description, comment, or reply
ba.popups.post = Post
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"axios": "^0.19.2",
"box-ui-elements": "^12.0.0-beta.56",
"classnames": "^2.2.5",
"draft-js": "^0.10.1",
"draft-js": "0.10.5",
"lodash": "^4.17.15",
"react": "^16.9.0",
"react-dom": "^16.9.0",
Expand All @@ -50,6 +50,7 @@
"@commitlint/cli": "^8.3.5",
"@commitlint/config-conventional": "^8.2.0",
"@types/classnames": "^2.2.10",
"@types/draft-js": "^0.10.40",
"@types/enzyme": "^3.10.5",
"@types/jest": "^24.9.0",
"@types/lodash": "^4.14.150",
Expand Down
2 changes: 2 additions & 0 deletions scripts/jest/react-intl-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ export const injectIntl = Component => {
WrapperComponent.displayName = Component.displayName || Component.name || 'Component';
return WrapperComponent;
};

export const useIntl = () => intlMock;
5 changes: 3 additions & 2 deletions src/components/Popups/PopupReply.scss
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
.ba {
.ba-Popup-text {
.ba-Popup-field {
&,
&:disabled,
&:hover,
&:focus {
min-height: 40px;
min-width: 300px;
min-height: 80px;
padding: 12px;
border: 1px solid transparent;
box-shadow: none;
Expand Down
27 changes: 9 additions & 18 deletions src/components/Popups/PopupReply.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import Button from 'box-ui-elements/es/components/button';
import PrimaryButton from 'box-ui-elements/es/components/primary-button';
import { KEYS } from 'box-ui-elements/es/constants';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, useIntl } from 'react-intl';
import messages from './messages';
import PopupBase from './PopupBase';
import ReplyField from './ReplyField';
Expand All @@ -26,7 +26,7 @@ export default function PopupReply({
value = '',
...rest
}: Props): JSX.Element {
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const intl = useIntl();

// Event Handlers
const handleEvent = (event: React.SyntheticEvent): void => {
Expand All @@ -37,8 +37,8 @@ export default function PopupReply({
handleEvent(event);
onCancel(value);
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>): void => {
onChange(event.target.value);
const handleChange = (text?: string): void => {
onChange(text);
};
const handleSubmit = (event: React.FormEvent): void => {
event.preventDefault();
Expand All @@ -53,27 +53,18 @@ export default function PopupReply({
handleEvent(event);
onCancel(value);
};
const handleFirstUpdate = (): void => {
const { current: textarea } = textareaRef;

if (textarea) {
textarea.focus();
textarea.selectionStart = value.length; // Force cursor to the end after focus
textarea.selectionEnd = value.length; // Force cursor to the end after focus
}
};

return (
<PopupBase onKeyDown={handleKeyDown} options={{ onFirstUpdate: handleFirstUpdate }} {...rest}>
<PopupBase onKeyDown={handleKeyDown} {...rest}>
<form className="ba-Popup-form" data-testid="ba-Popup-form" onSubmit={handleSubmit}>
<div className="ba-Popup-main">
<ReplyField
ref={textareaRef}
className="ba-Popup-text"
data-testid="ba-Popup-text"
disabled={isPending}
className="ba-Popup-field"
data-testid="ba-Popup-field"
isDisabled={isPending}
onChange={handleChange}
onClick={handleEvent}
placeholder={intl.formatMessage(messages.fieldPlaceholder)}
value={value}
/>
</div>
Expand Down
14 changes: 14 additions & 0 deletions src/components/Popups/ReplyField/ReplyField.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@import '~draft-js/dist/Draft.css';

.ba-ReplyField {
div[contentEditable='true'] {
&,
&:disabled,
&:hover,
&:focus {
padding: 0;
border: none;
box-shadow: none;
}
}
}
88 changes: 81 additions & 7 deletions src/components/Popups/ReplyField/ReplyField.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,92 @@
import * as React from 'react';
import classnames from 'classnames';
import { Editor, EditorState, ContentState, SelectionState } from 'draft-js';

import './ReplyField.scss';

export type Props = {
className?: string;
disabled?: boolean;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
cursorPosition: number;
isDisabled?: boolean;
onChange: (text?: string) => void;
onClick: (event: React.SyntheticEvent) => void;
onMention?: (mention: string) => void;
placeholder?: string;
setCursorPosition: (cursorPosition: number) => void;
value?: string;
};

const ReplyField = (props: Props, ref: React.Ref<HTMLTextAreaElement>): JSX.Element => {
const { className, ...rest } = props;

return <textarea ref={ref} className={classnames('ba-TextArea', className)} {...rest} />;
export type State = {
editorState: EditorState;
};

export default React.forwardRef(ReplyField);
export default class ReplyField extends React.Component<Props, State> {
editorRef: React.MutableRefObject<Editor | null> = React.createRef<Editor | null>();

constructor(props: Props) {
super(props);

const { value, cursorPosition } = props;
const contentState = ContentState.createFromText(value || '');
let prevEditorState = EditorState.createWithContent(contentState);
let selectionState = prevEditorState.getSelection();

selectionState = selectionState.merge({
anchorOffset: cursorPosition,
focusOffset: cursorPosition,
}) as SelectionState;
prevEditorState = EditorState.forceSelection(prevEditorState, selectionState);

this.state = {
editorState: prevEditorState,
};
}

componentDidMount(): void {
this.focusEditor();
}

componentWillUnmount(): void {
this.saveCursorPosition();
}

focusEditor = (): void => {
const { current: editor } = this.editorRef;
if (editor) {
editor.focus();
}
};

saveCursorPosition = (): void => {
const { setCursorPosition } = this.props;
const { editorState } = this.state;

setCursorPosition(editorState.getSelection().getFocusOffset());
};

handleChange = (nextEditorState: EditorState): void => {
const { onChange } = this.props;

this.setState({ editorState: nextEditorState });
onChange(nextEditorState.getCurrentContent().getPlainText());
};

render(): JSX.Element {
const { className, isDisabled, placeholder, ...rest } = this.props;
const { editorState } = this.state;

return (
<div className={classnames(className, 'ba-ReplyField')}>
<Editor
{...rest}
ref={this.editorRef}
editorState={editorState}
onChange={this.handleChange}
placeholder={placeholder}
readOnly={isDisabled}
stripPastedStyles
/>
</div>
);
}
}
17 changes: 17 additions & 0 deletions src/components/Popups/ReplyField/ReplyFieldContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { AppState, getCreatorCursor, setCursorAction } from '../../../store';
import ReplyField from './ReplyField';

export type Props = {
cursorPosition: number;
};

export const mapStateToProps = (state: AppState): Props => ({
cursorPosition: getCreatorCursor(state),
});

export const mapDispatchToProps = {
setCursorPosition: setCursorAction,
};

export default connect(mapStateToProps, mapDispatchToProps)(ReplyField);
72 changes: 59 additions & 13 deletions src/components/Popups/ReplyField/__tests__/ReplyField-test.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,82 @@
import React from 'react';
import { Editor, EditorState } from 'draft-js';
import { shallow, ShallowWrapper } from 'enzyme';
import ReplyField, { Props } from '../ReplyField';
import ReplyField, { Props, State } from '../ReplyField';

describe('components/Popups/ReplyField', () => {
const defaults: Props = {
className: 'ba-Popup-text',
disabled: false,
className: 'ba-Popup-field',
cursorPosition: 0,
isDisabled: false,
onChange: jest.fn(),
onClick: jest.fn(),
setCursorPosition: jest.fn(),
value: '',
};

const getWrapper = (props = {}): ShallowWrapper => shallow(<ReplyField {...defaults} {...props} />);
const getWrapper = (props = {}): ShallowWrapper<Props, State, ReplyField> =>
shallow(<ReplyField {...defaults} {...props} />);

const mockEditorState = ({
getCurrentContent: () => ({ getPlainText: () => 'test' }),
getSelection: () => ({ getFocusOffset: () => 0 }),
} as unknown) as EditorState;

const mockEditor = ({
focus: jest.fn(),
} as unknown) as Editor;

describe('render()', () => {
test('should render the editor with right props', () => {
const wrapper = getWrapper();

expect(wrapper.prop('className')).toBe('ba-Popup-field ba-ReplyField');
});
});

describe('event handlers', () => {
test('should call onChange event', () => {
test('should handle the editor onChange event', () => {
const wrapper = getWrapper();
const editor = wrapper.find(Editor);

editor.simulate('change', mockEditorState);

expect(defaults.onChange).toBeCalledWith('test');
});

test('should handle the editor onClick event', () => {
const wrapper = getWrapper();
const textarea = wrapper.find('textarea');
const editor = wrapper.find(Editor);

textarea.simulate('change', 'test');
textarea.simulate('click', 'test');
editor.simulate('click', 'test');

expect(defaults.onChange).toHaveBeenCalledWith('test');
expect(defaults.onClick).toHaveBeenCalledWith('test');
expect(defaults.onClick).toBeCalledWith('test');
});
});

describe('render()', () => {
test('should render the textarea with right props', () => {
describe('focusEditor()', () => {
test('should call editor ref focus', () => {
const wrapper = getWrapper();
const instance = wrapper.instance();

instance.focusEditor();
expect(mockEditor.focus).not.toBeCalled();

instance.editorRef.current = mockEditor;
instance.focusEditor();

expect(mockEditor.focus).toBeCalled();
});
});

describe('saveCursorPosition()', () => {
test('should call setCursorPosition with cursor position', () => {
const wrapper = getWrapper();
wrapper.setState({ editorState: mockEditorState });

wrapper.instance().saveCursorPosition();

expect(wrapper.prop('className')).toBe('ba-TextArea ba-Popup-text');
expect(defaults.setCursorPosition).toBeCalledWith(0);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import ReplyField from '../ReplyField';
import ReplyFieldContainer, { Props } from '../ReplyFieldContainer';
import { createStore } from '../../../../store';

jest.mock('../ReplyField');

describe('components/Popups/ReplyFieldContainer', () => {
const store = createStore({
creator: {
cursor: 0,
},
});
const defaults = {
onChange: jest.fn(),
onClick: jest.fn(),
store,
};

const getWrapper = (props = {}): ReactWrapper<Props> => mount(<ReplyFieldContainer {...defaults} {...props} />);

describe('render', () => {
test('should connect the underlying component', () => {
const wrapper = getWrapper();

expect(wrapper.find(ReplyField).props()).toMatchObject({
cursorPosition: 0,
onChange: defaults.onChange,
onClick: defaults.onClick,
});
});
});
});
2 changes: 1 addition & 1 deletion src/components/Popups/ReplyField/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { default } from './ReplyField';
export { default } from './ReplyFieldContainer';
Loading

0 comments on commit ff35c5c

Please sign in to comment.