Skip to content

Commit

Permalink
feat(mentions): Server-side filtering (#520)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mingze authored Jun 18, 2020
1 parent 5ba56de commit fa96a3b
Show file tree
Hide file tree
Showing 6 changed files with 47 additions and 103 deletions.
1 change: 0 additions & 1 deletion src/common/BaseAnnotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ export default class BaseAnnotator extends EventEmitter {
protected hydrate(): void {
// Redux dispatch method signature doesn't seem to like async actions
this.store.dispatch<any>(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
this.store.dispatch<any>(store.fetchCollaboratorsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
}

protected render(): void {
Expand Down
55 changes: 22 additions & 33 deletions src/components/ReplyField/ReplyField.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import * as React from 'react';
import classnames from 'classnames';
import debounce from 'lodash/debounce';
import {
addMention,
getActiveMentionForEditorState,
} from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector/utils';
import fuzzySearch from 'box-ui-elements/es/utils/fuzzySearch';
import { DraftHandleValue, Editor, EditorState } from 'draft-js';
import PopupList from '../Popups/PopupList';
import { Collaborator } from '../../@types';
Expand All @@ -24,6 +24,7 @@ export type Props = {
collaborators: Collaborator[];
cursorPosition: number;
editorState: EditorState;
fetchCollaborators: (searchString: string) => void;
isDisabled?: boolean;
onChange: (editorState: EditorState) => void;
placeholder?: string;
Expand All @@ -35,13 +36,27 @@ export type State = {
popupReference: VirtualElement | null;
};

export const DEFAULT_COLLAB_DEBOUNCE = 500;

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

state: State = { activeItemIndex: 0, popupReference: null };

fetchCollaborators = debounce((editorState: EditorState): void => {
const { fetchCollaborators } = this.props;

const activeMention = getActiveMentionForEditorState(editorState);
const trimmedQuery = activeMention?.mentionString.trim();
if (!trimmedQuery) {
return;
}

fetchCollaborators(trimmedQuery);
}, DEFAULT_COLLAB_DEBOUNCE);

componentDidUpdate({ editorState: prevEditorState }: Props): void {
const { editorState } = this.props;

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

getCollaborators = (): Collaborator[] => {
const { collaborators, editorState } = this.props;

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) {
Expand Down Expand Up @@ -133,14 +121,14 @@ export default class ReplyField extends React.Component<Props, State> {
handleChange = (nextEditorState: EditorState): void => {
const { onChange } = this.props;

this.fetchCollaborators(nextEditorState);
onChange(nextEditorState);
};

handleSelect = (index: number): void => {
const { editorState } = this.props;
const { collaborators, editorState } = this.props;

const activeMention = getActiveMentionForEditorState(editorState);
const collaborators = this.getCollaborators();
const editorStateWithLink = addMention(editorState, activeMention, collaborators[index]);

this.handleChange(editorStateWithLink);
Expand All @@ -161,8 +149,9 @@ export default class ReplyField extends React.Component<Props, State> {
};

handleArrow = (event: React.KeyboardEvent): number => {
const { collaborators } = this.props;
const { popupReference } = this.state;
const { length } = this.getCollaborators();
const { length } = collaborators;

if (!popupReference || !length) {
return 0;
Expand Down Expand Up @@ -196,7 +185,7 @@ export default class ReplyField extends React.Component<Props, State> {
};

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

return (
Expand All @@ -217,7 +206,7 @@ export default class ReplyField extends React.Component<Props, State> {
{popupReference && (
<PopupList
activeItemIndex={activeItemIndex}
items={this.getCollaborators()}
items={collaborators}
onActivate={this.setPopupListActiveItem}
onSelect={this.handleSelect}
reference={popupReference}
Expand Down
3 changes: 2 additions & 1 deletion src/components/ReplyField/ReplyFieldContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import { AppState, getCollaborators, getCreatorCursor, setCursorAction } from '../../store';
import { AppState, fetchCollaboratorsAction, getCollaborators, getCreatorCursor, setCursorAction } from '../../store';
import ReplyField from './ReplyField';
import { Collaborator } from '../../@types';

Expand All @@ -14,6 +14,7 @@ export const mapStateToProps = (state: AppState): Props => ({
});

export const mapDispatchToProps = {
fetchCollaborators: fetchCollaboratorsAction,
setCursorPosition: setCursorAction,
};

Expand Down
82 changes: 18 additions & 64 deletions src/components/ReplyField/__tests__/ReplyField-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jest.mock('box-ui-elements/es/components/form-elements/draft-js-mention-selector
getFormattedCommentText: jest.fn(() => ({ hasMention: false, text: 'test' })),
}));

jest.mock('lodash/debounce', () => (func: Function) => func);

describe('components/Popups/ReplyField', () => {
const defaults: Props = {
className: 'ba-Popup-field',
Expand All @@ -32,6 +34,7 @@ describe('components/Popups/ReplyField', () => {
],
cursorPosition: 0,
editorState: mockEditorState,
fetchCollaborators: jest.fn(),
isDisabled: false,
onChange: jest.fn(),
setCursorPosition: jest.fn(),
Expand Down Expand Up @@ -66,80 +69,36 @@ describe('components/Popups/ReplyField', () => {
test('should handle the editor change event', () => {
const wrapper = getWrapper();
const editor = wrapper.find(Editor);
const instance = wrapper.instance();

const fetchCollaboratorsSpy = jest.spyOn(instance, 'fetchCollaborators');

editor.simulate('change', mockEditorState);

expect(defaults.onChange).toBeCalledWith(mockEditorState);
expect(fetchCollaboratorsSpy).toHaveBeenCalled();
});
});

describe('getCollaborators()', () => {
test('should return empty list if no activeMention', () => {
describe('fetchCollaborators()', () => {
test('should not call fetchCollaborators if no activeMention or empty query', () => {
const wrapper = getWrapper();
const instance = wrapper.instance();

getActiveMentionForEditorState.mockReturnValueOnce(null);
instance.fetchCollaborators(mockEditorState);

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();
expect(defaults.fetchCollaborators).not.toHaveBeenCalled();

getActiveMentionForEditorState.mockReturnValueOnce(mockMentionTest2);

expect(instance.getCollaborators()).toMatchObject([defaults.collaborators[1]]);
});
getActiveMentionForEditorState.mockReturnValueOnce({ mentionString: '' });
instance.fetchCollaborators(mockEditorState);

test('should filter items based on item email', () => {
const mockCollabs = [
{
id: 'testid3',
name: 'test3',
item: { id: 'testid3', name: 'test3', type: 'group', email: '[email protected]' },
},
...defaults.collaborators,
];
const mockMentionEmail = {
...mockMention,
mentionString: 'box.com',
};

const wrapper = getWrapper({ collaborators: mockCollabs });
const instance = wrapper.instance();
expect(defaults.fetchCollaborators).not.toHaveBeenCalled();

getActiveMentionForEditorState.mockReturnValueOnce(mockMentionEmail);
getActiveMentionForEditorState.mockReturnValueOnce({ mentionString: 'test' });
instance.fetchCollaborators(mockEditorState);

expect(instance.getCollaborators()).toMatchObject([mockCollabs[0]]);
expect(defaults.fetchCollaborators).toHaveBeenCalledWith('test');
});
});

Expand Down Expand Up @@ -266,7 +225,6 @@ describe('components/Popups/ReplyField', () => {
let mockKeyboardEvent: React.KeyboardEvent<HTMLDivElement>;
let wrapper: ShallowWrapper<Props, State, ReplyField>;
let instance: ReplyField;
let getCollaboratorsSpy: jest.SpyInstance;
let stopDefaultEventSpy: jest.SpyInstance;
let setActiveItemSpy: jest.SpyInstance;

Expand All @@ -280,10 +238,6 @@ describe('components/Popups/ReplyField', () => {
wrapper.setState({ activeItemIndex: 0, popupReference: ('popupReference' as unknown) as VirtualElement });
instance = wrapper.instance();

getCollaboratorsSpy = jest.spyOn(instance, 'getCollaborators').mockReturnValue([
{ id: 'testid1', name: 'test1', item: { id: 'testid1', name: 'test1', type: 'user' } },
{ id: 'testid2', name: 'test2', item: { id: 'testid2', name: 'test2', type: 'group' } },
]);
stopDefaultEventSpy = jest.spyOn(instance, 'stopDefaultEvent');
setActiveItemSpy = jest.spyOn(instance, 'setPopupListActiveItem');
});
Expand All @@ -302,7 +256,7 @@ describe('components/Popups/ReplyField', () => {
});

test('should do nothing if collaborators length is 0', () => {
getCollaboratorsSpy.mockReturnValueOnce([]);
wrapper.setProps({ collaborators: [] });
instance.handleArrow(mockKeyboardEvent);

expect(stopDefaultEventSpy).not.toBeCalled();
Expand Down
4 changes: 2 additions & 2 deletions src/store/users/__tests__/reducer-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ describe('store/users/reducer', () => {
const newState = reducer(
state,
fetchCollaboratorsAction.fulfilled(
{ entries: collaborators, limit: 10, next_marker: null, previous_marker: null },
{ entries: collaborators, limit: 25, next_marker: null, previous_marker: null },
'fulfilled',
undefined,
'test',
),
);

Expand Down
5 changes: 3 additions & 2 deletions src/store/users/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { AppThunkAPI } from '../types';
import { Collaborator } from '../../@types';
import { getFileId } from '../options';

export const fetchCollaboratorsAction = createAsyncThunk<APICollection<Collaborator>, undefined, AppThunkAPI>(
export const fetchCollaboratorsAction = createAsyncThunk<APICollection<Collaborator>, string, AppThunkAPI>(
'FETCH_COLLABORATORS',
async (arg, { extra, getState, signal }) => {
async (searchString = '', { extra, getState, signal }) => {
// Create a new client for each request
const client = extra.api.getCollaboratorsAPI();
const state = getState();
Expand All @@ -20,6 +20,7 @@ export const fetchCollaboratorsAction = createAsyncThunk<APICollection<Collabora
// Wrap the client request in a promise to allow it to be returned and cancelled
return new Promise<APICollection<Collaborator>>((resolve, reject) => {
client.getFileCollaborators(fileId, resolve, reject, {
filter_term: searchString, // eslint-disable-line @typescript-eslint/camelcase
include_groups: false, // eslint-disable-line @typescript-eslint/camelcase
include_uploader_collabs: false, // eslint-disable-line @typescript-eslint/camelcase
});
Expand Down

0 comments on commit fa96a3b

Please sign in to comment.