diff --git a/docs/styles.css b/docs/styles.css index b4e36651d..dcec127fd 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -27,4 +27,4 @@ #preview-container { width: 100vw; height: 75vh; -} \ No newline at end of file +} diff --git a/src/components/ItemList/ItemList.scss b/src/components/ItemList/ItemList.scss new file mode 100644 index 000000000..7e9bf7445 --- /dev/null +++ b/src/components/ItemList/ItemList.scss @@ -0,0 +1,5 @@ +.ba { + .ba-ItemList-row { + padding: 5px 30px 5px 15px; + } +} diff --git a/src/components/ItemList/ItemList.tsx b/src/components/ItemList/ItemList.tsx new file mode 100644 index 000000000..3e3209ad1 --- /dev/null +++ b/src/components/ItemList/ItemList.tsx @@ -0,0 +1,44 @@ +import React, { SyntheticEvent } from 'react'; +import noop from 'lodash/noop'; +import ItemRow from './ItemRow'; +import './ItemList.scss'; + +export type Props = { + activeItemIndex?: number; + itemRowAs?: JSX.Element; + items: T[]; + onActivate?: (index: number) => void; + onSelect: (index: number, event: React.SyntheticEvent) => void; +}; + +const ItemList = ({ + activeItemIndex = 0, + itemRowAs = , + items, + onActivate = noop, + onSelect, + ...rest +}: Props): JSX.Element => ( +
    + {items.map((item, index) => + React.cloneElement(itemRowAs, { + ...item, + key: item.id, + className: 'ba-ItemList-row', + isActive: index === activeItemIndex, + onClick: (event: SyntheticEvent) => { + onSelect(index, event); + }, + /* preventDefault on mousedown so blur doesn't happen before click */ + onMouseDown: (event: SyntheticEvent) => { + event.preventDefault(); + }, + onMouseEnter: () => { + onActivate(index); + }, + }), + )} +
+); + +export default ItemList; diff --git a/src/components/ItemList/ItemRow.scss b/src/components/ItemList/ItemRow.scss new file mode 100644 index 000000000..0ffc10ce4 --- /dev/null +++ b/src/components/ItemList/ItemRow.scss @@ -0,0 +1,11 @@ +@import '~box-ui-elements/es/styles/variables'; + +.ba-ItemRow-name { + line-height: 15px; +} + +.ba-ItemRow-email { + color: $bdl-gray-62; + font-size: 11px; + line-height: 15px; +} diff --git a/src/components/ItemList/ItemRow.tsx b/src/components/ItemList/ItemRow.tsx new file mode 100644 index 000000000..3b61430e4 --- /dev/null +++ b/src/components/ItemList/ItemRow.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import DatalistItem from 'box-ui-elements/es/components/datalist-item'; +import { UserMini, GroupMini } from '../../@types'; +import './ItemRow.scss'; + +export type Props = { + id?: string; + item?: UserMini | GroupMini; + name?: string; +}; + +const ItemRow = ({ item, ...rest }: Props): JSX.Element | null => { + if (!item || !item.name) { + return null; + } + + return ( + +
+ {item.name} +
+ {'email' in item && ( +
+ {item.email} +
+ )} +
+ ); +}; + +export default ItemRow; diff --git a/src/components/ItemList/__tests__/ItemList-test.tsx b/src/components/ItemList/__tests__/ItemList-test.tsx new file mode 100644 index 000000000..6363bb180 --- /dev/null +++ b/src/components/ItemList/__tests__/ItemList-test.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import ItemList, { Props } from '../ItemList'; +import { Collaborator } from '../../../@types'; + +describe('components/ItemList', () => { + const defaults: Props = { + items: [ + { id: 'testid1', name: 'test1' }, + { id: 'testid2', name: 'test2' }, + ], + onSelect: jest.fn(), + }; + + const getWrapper = (props = {}): ShallowWrapper => shallow(); + + describe('render()', () => { + test('should render elements with correct props', () => { + const wrapper = getWrapper({ activeItemIndex: 1 }); + + const itemList = wrapper.find('[data-testid="ba-ItemList"]'); + const firstChild = itemList.childAt(0); + const secondChild = itemList.childAt(1); + + expect(itemList.props()).toMatchObject({ + role: 'listbox', + }); + expect(itemList.children()).toHaveLength(2); + expect(firstChild.prop('className')).toEqual('ba-ItemList-row'); + expect(firstChild.prop('isActive')).toEqual(false); + expect(secondChild.prop('isActive')).toEqual(true); + }); + + test('should trigger events', () => { + const mockSetIndex = jest.fn(); + const mockEvent = { + preventDefault: jest.fn(), + }; + + const wrapper = getWrapper({ onActivate: mockSetIndex }); + const firstChild = wrapper.find('[data-testid="ba-ItemList"]').childAt(0); + + firstChild.simulate('click', mockEvent); + expect(defaults.onSelect).toBeCalledWith(0, mockEvent); + + firstChild.simulate('mousedown', mockEvent); + expect(mockEvent.preventDefault).toBeCalled(); + + firstChild.simulate('mouseenter'); + expect(mockSetIndex).toBeCalledWith(0); + }); + }); +}); diff --git a/src/components/ItemList/__tests__/ItemRow-test.tsx b/src/components/ItemList/__tests__/ItemRow-test.tsx new file mode 100644 index 000000000..39948f36e --- /dev/null +++ b/src/components/ItemList/__tests__/ItemRow-test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import DatalistItem from 'box-ui-elements/es/components/datalist-item'; +import ItemRow, { Props } from '../ItemRow'; + +describe('components/Popups/ReplyField/ItemRow', () => { + const defaults: Props = { + id: 'testid', + item: { email: 'test@box.com', id: 'testid', name: 'testname', type: 'user' }, + name: 'testname', + }; + + const getWrapper = (props = {}): ShallowWrapper => shallow(); + + describe('render()', () => { + test('should render DatalistItem with correct props', () => { + const wrapper = getWrapper(); + + expect(wrapper.find(DatalistItem).props()).toMatchObject({ + id: 'testid', + name: 'testname', + }); + }); + + test('should not render anything if no item', () => { + const wrapper = getWrapper({ item: null }); + + expect(wrapper.exists(DatalistItem)).toBeFalsy(); + }); + + test('should not render anything if no item name', () => { + const wrapper = getWrapper({ item: {} }); + + expect(wrapper.exists(DatalistItem)).toBeFalsy(); + }); + + test('should render item name and email', () => { + const wrapper = getWrapper(); + + expect(wrapper.find('[data-testid="ba-ItemRow-name"]').text()).toBe('testname'); + expect(wrapper.find('[data-testid="ba-ItemRow-email"]').text()).toBe('test@box.com'); + }); + + test('should not render email if item has no email', () => { + const wrapper = getWrapper({ item: { id: 'testid', name: 'testname', type: 'group' } }); + + expect(wrapper.exists('[data-testid="ba-ItemRow-email"]')).toBeFalsy(); + }); + }); +}); diff --git a/src/components/ItemList/index.ts b/src/components/ItemList/index.ts new file mode 100644 index 000000000..868357110 --- /dev/null +++ b/src/components/ItemList/index.ts @@ -0,0 +1 @@ +export { default } from './ItemList'; diff --git a/src/components/Popups/Popper.ts b/src/components/Popups/Popper.ts index c43a8584b..539a81009 100644 --- a/src/components/Popups/Popper.ts +++ b/src/components/Popups/Popper.ts @@ -4,6 +4,7 @@ import unionBy from 'lodash/unionBy'; export type Instance = popper.Instance; export type Options = popper.Options; +export type State = popper.State; export type VirtualElement = popper.VirtualElement; export type PopupReference = Element | VirtualElement; diff --git a/src/components/Popups/ReplyField/MentionItem.scss b/src/components/Popups/ReplyField/MentionItem.scss new file mode 100644 index 000000000..21b3f9705 --- /dev/null +++ b/src/components/Popups/ReplyField/MentionItem.scss @@ -0,0 +1,6 @@ +@import '~box-ui-elements/es/styles/variables'; + +.ba-MentionItem-link { + color: $bdl-box-blue; + font-weight: bold; +} diff --git a/src/components/Popups/ReplyField/MentionItem.tsx b/src/components/Popups/ReplyField/MentionItem.tsx new file mode 100644 index 000000000..2096d5ab7 --- /dev/null +++ b/src/components/Popups/ReplyField/MentionItem.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { ContentState } from 'draft-js'; +import './MentionItem.scss'; + +export type Props = { + children: React.ReactNode; + contentState: ContentState; + entityKey: string; +}; + +const MentionItem = ({ contentState, entityKey, children }: Props): JSX.Element => { + const { id } = contentState.getEntity(entityKey).getData(); + + return id ? ( + + {children} + + ) : ( + + {children} + + ); +}; + +export default MentionItem; diff --git a/src/components/Popups/ReplyField/PopupList.scss b/src/components/Popups/ReplyField/PopupList.scss index 647591682..c677146d2 100644 --- a/src/components/Popups/ReplyField/PopupList.scss +++ b/src/components/Popups/ReplyField/PopupList.scss @@ -1,7 +1,7 @@ @import '~box-ui-elements/es/styles/variables'; .ba-PopupList { - font-size: 13px; + @include common-typography; .ba-Popup-arrow { display: none; @@ -9,16 +9,14 @@ .ba-Popup-content { padding-top: 10px; + padding-bottom: 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; + padding-right: 10px; + padding-left: 10px; } } diff --git a/src/components/Popups/ReplyField/PopupList.tsx b/src/components/Popups/ReplyField/PopupList.tsx index fd26ff343..69c936cf8 100644 --- a/src/components/Popups/ReplyField/PopupList.tsx +++ b/src/components/Popups/ReplyField/PopupList.tsx @@ -1,12 +1,19 @@ -import * as React from 'react'; +import React from 'react'; +import isEmpty from 'lodash/isEmpty'; import { FormattedMessage } from 'react-intl'; +import ItemList from '../../ItemList/ItemList'; import messages from '../messages'; import PopupBase from '../PopupBase'; import { Options, PopupReference } from '../Popper'; - import './PopupList.scss'; -export type Props = { +export type Props = { + activeItemIndex?: number; + itemRowAs?: JSX.Element; + items: T[]; + onActivate?: (index: number) => void; + onSelect: (index: number, event: React.SyntheticEvent) => void; + options?: Partial; reference: PopupReference; }; @@ -28,11 +35,15 @@ const options: Partial = { placement: 'bottom-start', }; -const PopupList = ({ reference, ...rest }: Props): JSX.Element => ( - -
- -
+const PopupList = ({ items, reference, ...rest }: Props): JSX.Element => ( + + {isEmpty(items) ? ( +
+ +
+ ) : ( + + )}
); diff --git a/src/components/Popups/ReplyField/ReplyField.tsx b/src/components/Popups/ReplyField/ReplyField.tsx index bd6cc655d..319d1decc 100644 --- a/src/components/Popups/ReplyField/ReplyField.tsx +++ b/src/components/Popups/ReplyField/ReplyField.tsx @@ -1,8 +1,13 @@ import * as React from 'react'; import classnames from 'classnames'; -import { getActiveMentionForEditorState } from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector'; -import { ContentState, Editor, EditorState, SelectionState } from 'draft-js'; +import { + addMention, + getActiveMentionForEditorState, +} from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector'; +import fuzzySearch from 'box-ui-elements/es/utils/fuzzySearch'; +import { ContentState, DraftHandleValue, Editor, EditorState, SelectionState } from 'draft-js'; import PopupList from './PopupList'; +import withMentionDecorator from './withMentionDecorator'; import { Collaborator } from '../../../@types'; import { VirtualElement } from '../Popper'; import './ReplyField.scss'; @@ -20,15 +25,16 @@ export type Props = { collaborators: Collaborator[]; cursorPosition: number; isDisabled?: boolean; + itemRowAs?: JSX.Element; onChange: (text?: string) => void; onClick: (event: React.SyntheticEvent) => void; - onMention?: (mention: string) => void; placeholder?: string; setCursorPosition: (cursorPosition: number) => void; value?: string; }; export type State = { + activeItemIndex: number; editorState: EditorState; popupReference: VirtualElement | null; }; @@ -46,7 +52,7 @@ export default class ReplyField extends React.Component { const { value, cursorPosition } = props; const contentState = ContentState.createFromText(value as string); - let prevEditorState = EditorState.createWithContent(contentState); + let prevEditorState = withMentionDecorator(EditorState.createWithContent(contentState)); let selectionState = prevEditorState.getSelection(); selectionState = selectionState.merge({ @@ -56,6 +62,7 @@ export default class ReplyField extends React.Component { prevEditorState = EditorState.forceSelection(prevEditorState, selectionState); this.state = { + activeItemIndex: 0, editorState: prevEditorState, popupReference: null, }; @@ -69,6 +76,34 @@ export default class ReplyField extends React.Component { this.saveCursorPosition(); } + getCollaborators = (): Collaborator[] => { + const { collaborators } = this.props; + const { editorState } = this.state; + + const activeMention = getActiveMentionForEditorState(editorState); + if (!activeMention) { + return []; + } + + const trimmedQuery = activeMention.mentionString.trim(); + // fuzzySearch doesn't match anything if query length is less than 2 + // Compared to empty list, full list has a better user experience + if (trimmedQuery.length < 2) { + return collaborators; + } + + return collaborators.filter(({ item }) => { + if (!item) { + return false; + } + + const isNameMatch = fuzzySearch(trimmedQuery, item.name, 0); + const isEmailMatch = 'email' in item && fuzzySearch(trimmedQuery, item.email, 0); + + return isNameMatch || isEmailMatch; + }); + }; + getVirtualElement = (activeMention: Mention): VirtualElement | null => { const selection = window.getSelection(); if (!selection?.focusNode) { @@ -77,37 +112,32 @@ export default class ReplyField extends React.Component { const range = selection.getRangeAt(0); const textNode = range.endContainer; + const currentCursor = range.endOffset; - range.setStart(textNode, activeMention.start); - range.setEnd(textNode, activeMention.start); + const { mentionString, mentionTrigger } = activeMention; + const offset = mentionString.length + mentionTrigger.length; + const mentionStart = currentCursor - offset; + + range.setStart(textNode, mentionStart); + range.setEnd(textNode, mentionStart); const mentionRect = range.getBoundingClientRect(); - // restore selection - range.setStart(textNode, activeMention.end); - range.setEnd(textNode, activeMention.end); + // restore cursor position + range.setStart(textNode, currentCursor); + range.setEnd(textNode, currentCursor); return { getBoundingClientRect: () => mentionRect, }; }; - handleChange = (nextEditorState: EditorState): void => { - const { onChange, onMention } = this.props; - - onChange(nextEditorState.getCurrentContent().getPlainText()); - - const activeMention = getActiveMentionForEditorState(nextEditorState); + updatePopupReference = (): void => { + const { editorState } = this.state; - if (onMention && activeMention) { - onMention(activeMention.mentionString); - } + const activeMention = getActiveMentionForEditorState(editorState); - 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 }); - }); + this.setState({ popupReference: activeMention ? this.getVirtualElement(activeMention) : null }); }; focusEditor = (): void => { @@ -124,9 +154,85 @@ export default class ReplyField extends React.Component { setCursorPosition(editorState.getSelection().getFocusOffset()); }; + stopDefaultEvent = (event: React.SyntheticEvent): void => { + event.preventDefault(); + event.stopPropagation(); + }; + + setPopupListActiveItem = (index: number): void => this.setState({ activeItemIndex: index }); + + handleChange = (nextEditorState: EditorState): void => { + const { onChange } = this.props; + + onChange(nextEditorState.getCurrentContent().getPlainText()); + + // In order to get correct selection, getVirtualElement in this.updatePopupReference + // has to be called after new texts are rendered in editor + this.setState({ editorState: nextEditorState }, this.updatePopupReference); + }; + + handleSelect = (index: number): void => { + const { editorState } = this.state; + + const activeMention = getActiveMentionForEditorState(editorState); + const collaborators = this.getCollaborators(); + const editorStateWithLink = addMention(editorState, activeMention, collaborators[index]); + + this.handleChange(editorStateWithLink); + this.setPopupListActiveItem(0); + }; + + handleReturn = (event: React.KeyboardEvent): DraftHandleValue => { + const { activeItemIndex, popupReference } = this.state; + + if (!popupReference) { + return 'not-handled'; + } + + this.stopDefaultEvent(event); + this.handleSelect(activeItemIndex); + + return 'handled'; + }; + + handleArrow = (event: React.KeyboardEvent): number => { + const { popupReference } = this.state; + const { length } = this.getCollaborators(); + + if (!popupReference || !length) { + return 0; + } + + this.stopDefaultEvent(event); + + return length; + }; + + handleUpArrow = (event: React.KeyboardEvent): void => { + const { activeItemIndex } = this.state; + const length = this.handleArrow(event); + + if (!length) { + return; + } + + this.setPopupListActiveItem(activeItemIndex === 0 ? length - 1 : activeItemIndex - 1); + }; + + handleDownArrow = (event: React.KeyboardEvent): void => { + const { activeItemIndex } = this.state; + const length = this.handleArrow(event); + + if (!length) { + return; + } + + this.setPopupListActiveItem(activeItemIndex === length - 1 ? 0 : activeItemIndex + 1); + }; + render(): JSX.Element { - const { className, isDisabled, placeholder, ...rest } = this.props; - const { editorState, popupReference } = this.state; + const { className, isDisabled, itemRowAs, placeholder, ...rest } = this.props; + const { activeItemIndex, editorState, popupReference } = this.state; return (
@@ -134,14 +240,26 @@ export default class ReplyField extends React.Component { {...rest} ref={this.editorRef} editorState={editorState} + handleReturn={this.handleReturn} onChange={this.handleChange} + onDownArrow={this.handleDownArrow} + onUpArrow={this.handleUpArrow} placeholder={placeholder} readOnly={isDisabled} stripPastedStyles webDriverTestID="ba-ReplyField-editor" /> - {popupReference && } + {popupReference && ( + + )}
); } diff --git a/src/components/Popups/ReplyField/__tests__/MentionItem-test.tsx b/src/components/Popups/ReplyField/__tests__/MentionItem-test.tsx new file mode 100644 index 000000000..6787714a0 --- /dev/null +++ b/src/components/Popups/ReplyField/__tests__/MentionItem-test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ContentState } from 'draft-js'; +import { shallow, ShallowWrapper } from 'enzyme'; +import MentionItem, { Props } from '../MentionItem'; + +describe('components/Popups/ReplyField/MentionItem', () => { + const defaults: Props = { + children:
, + contentState: ({ + getEntity: () => ({ + getData: () => ({ id: 'testid' }), + }), + } as unknown) as ContentState, + entityKey: 'testEntityKey', + }; + + const getWrapper = (props = {}): ShallowWrapper => shallow(); + + describe('render()', () => { + test('should render link with correct props', () => { + const wrapper = getWrapper(); + + expect(wrapper.find('[data-testid="ba-MentionItem-link"]').props()).toMatchObject({ + className: 'ba-MentionItem-link', + href: '/profile/testid', + }); + }); + + test('should not render link if no id', () => { + const wrapper = getWrapper({ + contentState: ({ + getEntity: () => ({ + getData: () => ({}), + }), + } as unknown) as ContentState, + }); + + expect(wrapper.exists('[data-testid="ba-MentionItem-link"]')).toBeFalsy(); + expect(wrapper.exists('[data-testid="ba-MentionItem-text"]')).toBeTruthy(); + }); + }); +}); diff --git a/src/components/Popups/ReplyField/__tests__/PopupList-test.tsx b/src/components/Popups/ReplyField/__tests__/PopupList-test.tsx index b8995fca2..c3ecc7628 100644 --- a/src/components/Popups/ReplyField/__tests__/PopupList-test.tsx +++ b/src/components/Popups/ReplyField/__tests__/PopupList-test.tsx @@ -2,9 +2,12 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; import PopupBase from '../../PopupBase'; import PopupList, { Props } from '../PopupList'; +import { Collaborator } from '../../../../@types'; describe('components/Popups/ReplyField/PopupList', () => { - const defaults: Props = { + const defaults: Props = { + items: [], + onSelect: jest.fn(), reference: document.createElement('div'), }; diff --git a/src/components/Popups/ReplyField/__tests__/ReplyField-test.tsx b/src/components/Popups/ReplyField/__tests__/ReplyField-test.tsx index d1378050f..ddfafb17d 100644 --- a/src/components/Popups/ReplyField/__tests__/ReplyField-test.tsx +++ b/src/components/Popups/ReplyField/__tests__/ReplyField-test.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { getActiveMentionForEditorState } from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector'; import { shallow, ShallowWrapper } from 'enzyme'; import { Editor, EditorState } from 'draft-js'; import PopupList from '../PopupList'; @@ -7,25 +8,33 @@ import { VirtualElement } from '../../Popper'; const mockMention = { blockID: '12345', - end: 1, + end: 12, // start + mentionString.length + mentionTrigger.length = 0 + 1 + 11 = 12 mentionString: 'testMention', mentionTrigger: '@', start: 0, }; +const mockEditorState = ({ + getCurrentContent: () => ({ getPlainText: () => 'test' }), + getSelection: () => ({ getFocusOffset: () => 0 }), +} as unknown) as EditorState; + jest.mock('box-ui-elements/es/components/form-elements/draft-js-mention-selector', () => ({ - getActiveMentionForEditorState: () => mockMention, + addMention: jest.fn(() => mockEditorState), + getActiveMentionForEditorState: jest.fn(() => mockMention), })); describe('components/Popups/ReplyField', () => { const defaults: Props = { className: 'ba-Popup-field', - collaborators: [], + collaborators: [ + { id: 'testid1', name: 'test1', item: { id: 'testid1', name: 'test1', type: 'user' } }, + { id: 'testid2', name: 'test2', item: { id: 'testid2', name: 'test2', type: 'user' } }, + ], cursorPosition: 0, isDisabled: false, onChange: jest.fn(), onClick: jest.fn(), - onMention: jest.fn(), setCursorPosition: jest.fn(), value: '', }; @@ -33,11 +42,6 @@ describe('components/Popups/ReplyField', () => { const getWrapper = (props = {}): ShallowWrapper => shallow(); - const mockEditorState = ({ - getCurrentContent: () => ({ getPlainText: () => 'test' }), - getSelection: () => ({ getFocusOffset: () => 0 }), - } as unknown) as EditorState; - describe('render()', () => { test('should render the editor with right props', () => { const wrapper = getWrapper(); @@ -63,7 +67,6 @@ describe('components/Popups/ReplyField', () => { editor.simulate('change', mockEditorState); expect(defaults.onChange).toBeCalledWith('test'); - expect(defaults.onMention).toBeCalledWith('testMention'); expect(instance.getVirtualElement).toBeCalledWith(mockMention); expect(wrapper.state('popupReference')).toBe('reference'); }); @@ -78,6 +81,76 @@ describe('components/Popups/ReplyField', () => { }); }); + describe('getCollaborators()', () => { + test('should return empty list if no activeMention', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + getActiveMentionForEditorState.mockReturnValueOnce(null); + + expect(instance.getCollaborators()).toHaveLength(0); + }); + + test('should return full collaborators list if mentionString length is less than 2', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + const mockMentionShort = { + ...mockMention, + mentionString: '', + }; + + getActiveMentionForEditorState.mockReturnValueOnce(mockMentionShort); + + expect(instance.getCollaborators()).toMatchObject(defaults.collaborators); + }); + + test('should filter invalid items in collaborators', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + // mockMention and defaults.collaborators don't match + + expect(instance.getCollaborators()).toHaveLength(0); + }); + + test('should filter items based on item name', () => { + const mockMentionTest2 = { + ...mockMention, + mentionString: 'test2', + }; + + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + getActiveMentionForEditorState.mockReturnValueOnce(mockMentionTest2); + + expect(instance.getCollaborators()).toMatchObject([defaults.collaborators[1]]); + }); + + test('should filter items based on item email', () => { + const mockCollabs = [ + { + id: 'testid3', + name: 'test3', + item: { id: 'testid3', name: 'test3', type: 'group', email: 'test3@box.com' }, + }, + ...defaults.collaborators, + ]; + const mockMentionEmail = { + ...mockMention, + mentionString: 'box.com', + }; + + const wrapper = getWrapper({ collaborators: mockCollabs }); + const instance = wrapper.instance(); + + getActiveMentionForEditorState.mockReturnValueOnce(mockMentionEmail); + + expect(instance.getCollaborators()).toMatchObject([mockCollabs[0]]); + }); + }); + describe('getVirtualElement()', () => { let getSelectionSpy: jest.SpyInstance; @@ -117,6 +190,7 @@ describe('components/Popups/ReplyField', () => { const mockTextNode = document.createTextNode(''); const mockRange = { endContainer: mockTextNode, + endOffset: mockMention.end, setStart: jest.fn(), setEnd: jest.fn(), getBoundingClientRect: () => mockMentionRect, @@ -129,14 +203,41 @@ describe('components/Popups/ReplyField', () => { const virtualElement = instance.getVirtualElement(mockMention) as VirtualElement; expect(mockRange.setStart).toHaveBeenNthCalledWith(1, mockTextNode, 0); - expect(mockRange.setStart).toHaveBeenNthCalledWith(2, mockTextNode, 1); + expect(mockRange.setStart).toHaveBeenNthCalledWith(2, mockTextNode, 12); expect(mockRange.setEnd).toHaveBeenNthCalledWith(1, mockTextNode, 0); - expect(mockRange.setEnd).toHaveBeenNthCalledWith(2, mockTextNode, 1); + expect(mockRange.setEnd).toHaveBeenNthCalledWith(2, mockTextNode, 12); expect(virtualElement.getBoundingClientRect()).toBe(mockMentionRect); }); }); + describe('updatePopupReference()', () => { + test('should call getVirtualElement and set state', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + const getVirtualElementSpy = jest + .spyOn(instance, 'getVirtualElement') + .mockReturnValueOnce(('virtualElement' as unknown) as VirtualElement); + instance.updatePopupReference(); + + expect(getVirtualElementSpy).toBeCalledWith(mockMention); + expect(wrapper.state('popupReference')).toBe('virtualElement'); + }); + }); + + describe('handleSelect()', () => { + test('should call handle Change with updated editorState', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + + const handleChangeSpy = jest.spyOn(instance, 'handleChange'); + instance.handleSelect(0); + + expect(handleChangeSpy).toBeCalledWith(mockEditorState); + }); + }); + describe('focusEditor()', () => { test('should call editor ref focus', () => { const wrapper = getWrapper(); @@ -163,4 +264,86 @@ describe('components/Popups/ReplyField', () => { expect(defaults.setCursorPosition).toBeCalledWith(0); }); }); + + describe('stopDefaultEvent()', () => { + test('should prevent default and stop propagation', () => { + const mockEvent = ({ + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown) as React.SyntheticEvent; + + const wrapper = getWrapper(); + wrapper.instance().stopDefaultEvent(mockEvent); + + expect(mockEvent.preventDefault).toBeCalled(); + expect(mockEvent.stopPropagation).toBeCalled(); + }); + }); + + describe('setPopupListActiveItem()', () => { + const wrapper = getWrapper(); + wrapper.instance().setPopupListActiveItem(1); + + expect(wrapper.state('activeItemIndex')).toBe(1); + }); + + describe('handleKeyDown', () => { + let mockKeyboardEvent: React.KeyboardEvent; + let wrapper: ShallowWrapper; + let instance: ReplyField; + let getCollaboratorsSpy: jest.SpyInstance; + let stopDefaultEventSpy: jest.SpyInstance; + let setActiveItemSpy: jest.SpyInstance; + + beforeEach(() => { + mockKeyboardEvent = ({ + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } as unknown) as React.KeyboardEvent; + + wrapper = getWrapper(); + wrapper.setState({ activeItemIndex: 0, popupReference: ('popupReference' as unknown) as VirtualElement }); + instance = wrapper.instance(); + + getCollaboratorsSpy = jest.spyOn(instance, 'getCollaborators').mockReturnValue([ + { id: 'testid1', name: 'test1' }, + { id: 'testid2', name: 'test2' }, + ]); + stopDefaultEventSpy = jest.spyOn(instance, 'stopDefaultEvent'); + setActiveItemSpy = jest.spyOn(instance, 'setPopupListActiveItem'); + }); + + test('should return not-handled if popup is not showing', () => { + wrapper.setState({ popupReference: null }); + + expect(instance.handleReturn(mockKeyboardEvent)).toEqual('not-handled'); + }); + + test('should call handleSelect', () => { + instance.handleReturn(mockKeyboardEvent); + + expect(stopDefaultEventSpy).toBeCalledWith(mockKeyboardEvent); + expect(setActiveItemSpy).toBeCalledWith(0); + }); + + test('should do nothing if collaborators length is 0', () => { + getCollaboratorsSpy.mockReturnValueOnce([]); + instance.handleArrow(mockKeyboardEvent); + + expect(stopDefaultEventSpy).not.toBeCalled(); + expect(setActiveItemSpy).not.toBeCalled(); + }); + + test('should increase index if key is down', () => { + instance.handleDownArrow(mockKeyboardEvent); + expect(setActiveItemSpy).toBeCalledWith(1); + }); + + test('should decrease index if key is up', () => { + wrapper.setState({ activeItemIndex: 1 }); + + instance.handleUpArrow(mockKeyboardEvent); + expect(setActiveItemSpy).toBeCalledWith(0); + }); + }); }); diff --git a/src/components/Popups/ReplyField/__tests__/ReplyFieldContainer-test.tsx b/src/components/Popups/ReplyField/__tests__/ReplyFieldContainer-test.tsx index 7370b1c83..19cbef6a2 100644 --- a/src/components/Popups/ReplyField/__tests__/ReplyFieldContainer-test.tsx +++ b/src/components/Popups/ReplyField/__tests__/ReplyFieldContainer-test.tsx @@ -6,7 +6,7 @@ import { createStore } from '../../../../store'; jest.mock('../ReplyField'); -describe('components/Popups/ReplyFieldContainer', () => { +describe('components/Popups/ReplyField/ReplyFieldContainer', () => { const store = createStore({ creator: { cursor: 0, diff --git a/src/components/Popups/ReplyField/__tests__/withMentionDecorator-test.tsx b/src/components/Popups/ReplyField/__tests__/withMentionDecorator-test.tsx new file mode 100644 index 000000000..8df6091da --- /dev/null +++ b/src/components/Popups/ReplyField/__tests__/withMentionDecorator-test.tsx @@ -0,0 +1,27 @@ +import { EditorState, CompositeDecorator, ContentState } from 'draft-js'; +import withMentionDecorator, { mentionStrategy } from '../withMentionDecorator'; + +describe('components/Popups/ReplyField/withMentionDecorator', () => { + test('should set decorator', () => { + const mockEditorState = EditorState.createEmpty(); + + jest.spyOn(EditorState, 'set'); + + const newEditorState = withMentionDecorator(mockEditorState); + + expect(EditorState.set).toBeCalledWith(mockEditorState, { decorator: expect.any(CompositeDecorator) }); + expect(newEditorState.getDecorator()).not.toBeNull(); + }); + + test('should call findEntityRanges', () => { + const mockContentState = ContentState.createFromText('test'); + const mockContentBlock = mockContentState.getFirstBlock(); + const mockCallback = jest.fn(); + + jest.spyOn(mockContentBlock, 'findEntityRanges'); + + mentionStrategy(mockContentBlock, mockCallback, mockContentState); + + expect(mockContentBlock.findEntityRanges).toBeCalledWith(expect.any(Function), mockCallback); + }); +}); diff --git a/src/components/Popups/ReplyField/withMentionDecorator.tsx b/src/components/Popups/ReplyField/withMentionDecorator.tsx new file mode 100644 index 000000000..0454944ab --- /dev/null +++ b/src/components/Popups/ReplyField/withMentionDecorator.tsx @@ -0,0 +1,24 @@ +import { CompositeDecorator, ContentBlock, ContentState, EditorState } from 'draft-js'; +import MentionItem from './MentionItem'; + +export const mentionStrategy = ( + contentBlock: ContentBlock, + callback: (start: number, end: number) => void, + contentState: ContentState, +): void => { + contentBlock.findEntityRanges((character: { getEntity: () => string }) => { + const entityKey = character.getEntity(); + return entityKey !== null && contentState.getEntity(entityKey).getType() === 'MENTION'; + }, callback); +}; + +export default function withMentionDecorator(editorState: EditorState): EditorState { + const mentionDecorator = new CompositeDecorator([ + { + strategy: mentionStrategy, + component: MentionItem, + }, + ]); + + return EditorState.set(editorState, { decorator: mentionDecorator }); +}