Skip to content

Commit

Permalink
feat(popups): Add @mentions UI (#470)
Browse files Browse the repository at this point in the history
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
Mingze and mergify[bot] authored May 6, 2020
1 parent cafd44d commit db7033d
Show file tree
Hide file tree
Showing 12 changed files with 547 additions and 37 deletions.
6 changes: 4 additions & 2 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ 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...
# Prompt message for empty popup list
ba.popups.popupList.prompt = Mention someone to notify them
# Button label for creating a description, comment, or reply
ba.popups.post = Post
# Placeholder for reply field editor
ba.popups.replyField.placeholder = Type a comment...
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ module.exports = {
setupFiles: ['jest-canvas-mock'],
setupFilesAfterEnv: ['<rootDir>/scripts/jest/enzyme-adapter.js'],
snapshotSerializers: ['enzyme-to-json/serializer'],
testEnvironment: 'jest-environment-jsdom-sixteen',
transformIgnorePatterns: ['node_modules/(?!(box-ui-elements)/)'],
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"@reduxjs/toolkit": "^1.3.5",
"@types/react-redux": "^7.1.7",
"axios": "^0.19.2",
"box-ui-elements": "^12.0.0-beta.56",
"box-ui-elements": "^12.0.0-beta.62",
"classnames": "^2.2.5",
"draft-js": "0.10.5",
"lodash": "^4.17.15",
Expand Down Expand Up @@ -90,6 +90,7 @@
"husky": "^3.1.0",
"jest": "^24.9.0",
"jest-canvas-mock": "^2.2.0",
"jest-environment-jsdom-sixteen": "^1.0.3",
"lint-staged": "^9.5.0",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.1",
Expand Down
9 changes: 8 additions & 1 deletion src/components/Popups/Popper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import unionBy from 'lodash/unionBy';

export type Instance = popper.Instance;
export type Options = popper.Options;
export type VirtualElement = popper.VirtualElement;

export type PopupReference = Element | VirtualElement;

export const defaults = {
modifiers: [],
Expand All @@ -19,6 +22,10 @@ export const merger = (sourceValue: any, newValue: any): any => {
return undefined; // Default to lodash/merge behavior
};

export default function create(reference: Element, popup: HTMLElement, options: Partial<Options> = {}): Instance {
export default function create(
reference: PopupReference,
popup: HTMLElement,
options: Partial<Options> = {},
): Instance {
return popper.createPopper(reference, popup, mergeWith({}, defaults, options, merger) as Options);
}
4 changes: 2 additions & 2 deletions src/components/Popups/PopupBase.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as React from 'react';
import classNames from 'classnames';
import createPopper, { Instance, Options } from './Popper';
import createPopper, { Instance, Options, PopupReference } from './Popper';
import './PopupBase.scss';

type Props = React.HTMLAttributes<HTMLDivElement> & {
children: React.ReactNode;
className?: string;
options: Partial<Options>;
reference: Element;
reference: PopupReference;
};

export default class PopupBase extends React.PureComponent<Props> {
Expand Down
24 changes: 24 additions & 0 deletions src/components/Popups/ReplyField/PopupList.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
@import '~box-ui-elements/es/styles/variables';

.ba-PopupList {
font-size: 13px;

.ba-Popup-arrow {
display: none;
}

.ba-Popup-content {
padding-top: 10px;
border: solid 1px $bdl-gray-30;
border-radius: $bdl-border-radius-size;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .05);
}

.ba-PopupList-item {
padding: 10px;
}

.ba-PopupList-prompt {
padding-top: 0;
}
}
39 changes: 39 additions & 0 deletions src/components/Popups/ReplyField/PopupList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import messages from '../messages';
import PopupBase from '../PopupBase';
import { Options, PopupReference } from '../Popper';

import './PopupList.scss';

export type Props = {
reference: PopupReference;
};

const options: Partial<Options> = {
modifiers: [
{
name: 'offset',
options: {
offset: [0, 3],
},
},
{
name: 'eventListeners',
options: {
scroll: false,
},
},
],
placement: 'bottom-start',
};

const PopupList = ({ reference, ...rest }: Props): JSX.Element => (
<PopupBase className="ba-PopupList" options={options} reference={reference} {...rest}>
<div className="ba-PopupList-prompt ba-PopupList-item">
<FormattedMessage {...messages.popupListPrompt} />
</div>
</PopupBase>
);

export default PopupList;
75 changes: 64 additions & 11 deletions src/components/Popups/ReplyField/ReplyField.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import * as React from 'react';
import classnames from 'classnames';
import { Editor, EditorState, ContentState, SelectionState } from 'draft-js';

import { getActiveMentionForEditorState } from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector';
import { ContentState, Editor, EditorState, SelectionState } from 'draft-js';
import PopupList from './PopupList';
import { VirtualElement } from '../Popper';
import './ReplyField.scss';

export type Mention = {
blockID: string;
end: number;
mentionString: string;
mentionTrigger: string;
start: number;
};

export type Props = {
className?: string;
cursorPosition: number;
Expand All @@ -18,16 +28,22 @@ export type Props = {

export type State = {
editorState: EditorState;
popupReference: VirtualElement | null;
};

export default class ReplyField extends React.Component<Props, State> {
static defaultProps = {
isDisabled: false,
value: '',
};

editorRef: React.MutableRefObject<Editor | null> = React.createRef<Editor | null>();

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

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

Expand All @@ -39,6 +55,7 @@ export default class ReplyField extends React.Component<Props, State> {

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

Expand All @@ -50,6 +67,47 @@ export default class ReplyField extends React.Component<Props, State> {
this.saveCursorPosition();
}

getVirtualElement = (activeMention: Mention): VirtualElement | null => {
const selection = window.getSelection();
if (!selection?.focusNode) {
return null;
}

const range = selection.getRangeAt(0);
const textNode = range.endContainer;

range.setStart(textNode, activeMention.start);
range.setEnd(textNode, activeMention.start);

const mentionRect = range.getBoundingClientRect();

// restore selection
range.setStart(textNode, activeMention.end);
range.setEnd(textNode, activeMention.end);

return {
getBoundingClientRect: () => mentionRect,
};
};

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

onChange(nextEditorState.getCurrentContent().getPlainText());

const activeMention = getActiveMentionForEditorState(nextEditorState);

if (onMention && activeMention) {
onMention(activeMention.mentionString);
}

this.setState({ editorState: nextEditorState }, () => {
// In order to get correct selection, getVirtualElement has to be called after new texts are rendered
const popupReference = activeMention ? this.getVirtualElement(activeMention) : null;
this.setState({ popupReference });
});
};

focusEditor = (): void => {
const { current: editor } = this.editorRef;
if (editor) {
Expand All @@ -64,16 +122,9 @@ export default class ReplyField extends React.Component<Props, 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;
const { editorState, popupReference } = this.state;

return (
<div className={classnames(className, 'ba-ReplyField')}>
Expand All @@ -87,6 +138,8 @@ export default class ReplyField extends React.Component<Props, State> {
stripPastedStyles
webDriverTestID="ba-ReplyField-editor"
/>

{popupReference && <PopupList reference={popupReference} />}
</div>
);
}
Expand Down
23 changes: 23 additions & 0 deletions src/components/Popups/ReplyField/__tests__/PopupList-test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import PopupBase from '../../PopupBase';
import PopupList, { Props } from '../PopupList';

describe('components/Popups/ReplyField/PopupList', () => {
const defaults: Props = {
reference: document.createElement('div'),
};

const getWrapper = (props = {}): ShallowWrapper => shallow(<PopupList {...defaults} {...props} />);

describe('render()', () => {
test('should render popupBase with correct props', () => {
const wrapper = getWrapper();

expect(wrapper.find(PopupBase).props()).toMatchObject({
className: 'ba-PopupList',
reference: defaults.reference,
});
});
});
});
Loading

0 comments on commit db7033d

Please sign in to comment.