Skip to content

Commit fa96a3b

Browse files
author
Mingze
authored
feat(mentions): Server-side filtering (#520)
1 parent 5ba56de commit fa96a3b

File tree

6 files changed

+47
-103
lines changed

6 files changed

+47
-103
lines changed

src/common/BaseAnnotator.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,6 @@ export default class BaseAnnotator extends EventEmitter {
176176
protected hydrate(): void {
177177
// Redux dispatch method signature doesn't seem to like async actions
178178
this.store.dispatch<any>(store.fetchAnnotationsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
179-
this.store.dispatch<any>(store.fetchCollaboratorsAction()); // eslint-disable-line @typescript-eslint/no-explicit-any
180179
}
181180

182181
protected render(): void {

src/components/ReplyField/ReplyField.tsx

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as React from 'react';
22
import classnames from 'classnames';
3+
import debounce from 'lodash/debounce';
34
import {
45
addMention,
56
getActiveMentionForEditorState,
67
} from 'box-ui-elements/es/components/form-elements/draft-js-mention-selector/utils';
7-
import fuzzySearch from 'box-ui-elements/es/utils/fuzzySearch';
88
import { DraftHandleValue, Editor, EditorState } from 'draft-js';
99
import PopupList from '../Popups/PopupList';
1010
import { Collaborator } from '../../@types';
@@ -24,6 +24,7 @@ export type Props = {
2424
collaborators: Collaborator[];
2525
cursorPosition: number;
2626
editorState: EditorState;
27+
fetchCollaborators: (searchString: string) => void;
2728
isDisabled?: boolean;
2829
onChange: (editorState: EditorState) => void;
2930
placeholder?: string;
@@ -35,13 +36,27 @@ export type State = {
3536
popupReference: VirtualElement | null;
3637
};
3738

39+
export const DEFAULT_COLLAB_DEBOUNCE = 500;
40+
3841
export default class ReplyField extends React.Component<Props, State> {
3942
static defaultProps = {
4043
isDisabled: false,
4144
};
4245

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

48+
fetchCollaborators = debounce((editorState: EditorState): void => {
49+
const { fetchCollaborators } = this.props;
50+
51+
const activeMention = getActiveMentionForEditorState(editorState);
52+
const trimmedQuery = activeMention?.mentionString.trim();
53+
if (!trimmedQuery) {
54+
return;
55+
}
56+
57+
fetchCollaborators(trimmedQuery);
58+
}, DEFAULT_COLLAB_DEBOUNCE);
59+
4560
componentDidUpdate({ editorState: prevEditorState }: Props): void {
4661
const { editorState } = this.props;
4762

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

57-
getCollaborators = (): Collaborator[] => {
58-
const { collaborators, editorState } = this.props;
59-
60-
const activeMention = getActiveMentionForEditorState(editorState);
61-
if (!activeMention) {
62-
return [];
63-
}
64-
65-
const trimmedQuery = activeMention.mentionString.trim();
66-
// fuzzySearch doesn't match anything if query length is less than 2
67-
// Compared to empty list, full list has a better user experience
68-
if (trimmedQuery.length < 2) {
69-
return collaborators;
70-
}
71-
72-
return collaborators.filter(({ item }) => {
73-
if (!item) {
74-
return false;
75-
}
76-
77-
const isNameMatch = fuzzySearch(trimmedQuery, item.name, 0);
78-
const isEmailMatch = 'email' in item && fuzzySearch(trimmedQuery, item.email, 0);
79-
80-
return isNameMatch || isEmailMatch;
81-
});
82-
};
83-
8472
getVirtualElement = (activeMention: Mention): VirtualElement | null => {
8573
const selection = window.getSelection();
8674
if (!selection?.focusNode) {
@@ -133,14 +121,14 @@ export default class ReplyField extends React.Component<Props, State> {
133121
handleChange = (nextEditorState: EditorState): void => {
134122
const { onChange } = this.props;
135123

124+
this.fetchCollaborators(nextEditorState);
136125
onChange(nextEditorState);
137126
};
138127

139128
handleSelect = (index: number): void => {
140-
const { editorState } = this.props;
129+
const { collaborators, editorState } = this.props;
141130

142131
const activeMention = getActiveMentionForEditorState(editorState);
143-
const collaborators = this.getCollaborators();
144132
const editorStateWithLink = addMention(editorState, activeMention, collaborators[index]);
145133

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

163151
handleArrow = (event: React.KeyboardEvent): number => {
152+
const { collaborators } = this.props;
164153
const { popupReference } = this.state;
165-
const { length } = this.getCollaborators();
154+
const { length } = collaborators;
166155

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

198187
render(): JSX.Element {
199-
const { className, editorState, isDisabled, placeholder, ...rest } = this.props;
188+
const { className, collaborators, editorState, isDisabled, placeholder, ...rest } = this.props;
200189
const { activeItemIndex, popupReference } = this.state;
201190

202191
return (
@@ -217,7 +206,7 @@ export default class ReplyField extends React.Component<Props, State> {
217206
{popupReference && (
218207
<PopupList
219208
activeItemIndex={activeItemIndex}
220-
items={this.getCollaborators()}
209+
items={collaborators}
221210
onActivate={this.setPopupListActiveItem}
222211
onSelect={this.handleSelect}
223212
reference={popupReference}

src/components/ReplyField/ReplyFieldContainer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { connect } from 'react-redux';
2-
import { AppState, getCollaborators, getCreatorCursor, setCursorAction } from '../../store';
2+
import { AppState, fetchCollaboratorsAction, getCollaborators, getCreatorCursor, setCursorAction } from '../../store';
33
import ReplyField from './ReplyField';
44
import { Collaborator } from '../../@types';
55

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

1616
export const mapDispatchToProps = {
17+
fetchCollaborators: fetchCollaboratorsAction,
1718
setCursorPosition: setCursorAction,
1819
};
1920

src/components/ReplyField/__tests__/ReplyField-test.tsx

Lines changed: 18 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jest.mock('box-ui-elements/es/components/form-elements/draft-js-mention-selector
2323
getFormattedCommentText: jest.fn(() => ({ hasMention: false, text: 'test' })),
2424
}));
2525

26+
jest.mock('lodash/debounce', () => (func: Function) => func);
27+
2628
describe('components/Popups/ReplyField', () => {
2729
const defaults: Props = {
2830
className: 'ba-Popup-field',
@@ -32,6 +34,7 @@ describe('components/Popups/ReplyField', () => {
3234
],
3335
cursorPosition: 0,
3436
editorState: mockEditorState,
37+
fetchCollaborators: jest.fn(),
3538
isDisabled: false,
3639
onChange: jest.fn(),
3740
setCursorPosition: jest.fn(),
@@ -66,80 +69,36 @@ describe('components/Popups/ReplyField', () => {
6669
test('should handle the editor change event', () => {
6770
const wrapper = getWrapper();
6871
const editor = wrapper.find(Editor);
72+
const instance = wrapper.instance();
73+
74+
const fetchCollaboratorsSpy = jest.spyOn(instance, 'fetchCollaborators');
6975

7076
editor.simulate('change', mockEditorState);
7177

7278
expect(defaults.onChange).toBeCalledWith(mockEditorState);
79+
expect(fetchCollaboratorsSpy).toHaveBeenCalled();
7380
});
7481
});
7582

76-
describe('getCollaborators()', () => {
77-
test('should return empty list if no activeMention', () => {
83+
describe('fetchCollaborators()', () => {
84+
test('should not call fetchCollaborators if no activeMention or empty query', () => {
7885
const wrapper = getWrapper();
7986
const instance = wrapper.instance();
8087

8188
getActiveMentionForEditorState.mockReturnValueOnce(null);
89+
instance.fetchCollaborators(mockEditorState);
8290

83-
expect(instance.getCollaborators()).toHaveLength(0);
84-
});
85-
86-
test('should return full collaborators list if mentionString length is less than 2', () => {
87-
const wrapper = getWrapper();
88-
const instance = wrapper.instance();
89-
90-
const mockMentionShort = {
91-
...mockMention,
92-
mentionString: '',
93-
};
94-
95-
getActiveMentionForEditorState.mockReturnValueOnce(mockMentionShort);
96-
97-
expect(instance.getCollaborators()).toMatchObject(defaults.collaborators);
98-
});
99-
100-
test('should filter invalid items in collaborators', () => {
101-
const wrapper = getWrapper();
102-
const instance = wrapper.instance();
103-
104-
// mockMention and defaults.collaborators don't match
105-
106-
expect(instance.getCollaborators()).toHaveLength(0);
107-
});
108-
109-
test('should filter items based on item name', () => {
110-
const mockMentionTest2 = {
111-
...mockMention,
112-
mentionString: 'test2',
113-
};
114-
115-
const wrapper = getWrapper();
116-
const instance = wrapper.instance();
91+
expect(defaults.fetchCollaborators).not.toHaveBeenCalled();
11792

118-
getActiveMentionForEditorState.mockReturnValueOnce(mockMentionTest2);
119-
120-
expect(instance.getCollaborators()).toMatchObject([defaults.collaborators[1]]);
121-
});
93+
getActiveMentionForEditorState.mockReturnValueOnce({ mentionString: '' });
94+
instance.fetchCollaborators(mockEditorState);
12295

123-
test('should filter items based on item email', () => {
124-
const mockCollabs = [
125-
{
126-
id: 'testid3',
127-
name: 'test3',
128-
item: { id: 'testid3', name: 'test3', type: 'group', email: 'test3@box.com' },
129-
},
130-
...defaults.collaborators,
131-
];
132-
const mockMentionEmail = {
133-
...mockMention,
134-
mentionString: 'box.com',
135-
};
136-
137-
const wrapper = getWrapper({ collaborators: mockCollabs });
138-
const instance = wrapper.instance();
96+
expect(defaults.fetchCollaborators).not.toHaveBeenCalled();
13997

140-
getActiveMentionForEditorState.mockReturnValueOnce(mockMentionEmail);
98+
getActiveMentionForEditorState.mockReturnValueOnce({ mentionString: 'test' });
99+
instance.fetchCollaborators(mockEditorState);
141100

142-
expect(instance.getCollaborators()).toMatchObject([mockCollabs[0]]);
101+
expect(defaults.fetchCollaborators).toHaveBeenCalledWith('test');
143102
});
144103
});
145104

@@ -266,7 +225,6 @@ describe('components/Popups/ReplyField', () => {
266225
let mockKeyboardEvent: React.KeyboardEvent<HTMLDivElement>;
267226
let wrapper: ShallowWrapper<Props, State, ReplyField>;
268227
let instance: ReplyField;
269-
let getCollaboratorsSpy: jest.SpyInstance;
270228
let stopDefaultEventSpy: jest.SpyInstance;
271229
let setActiveItemSpy: jest.SpyInstance;
272230

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

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

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

308262
expect(stopDefaultEventSpy).not.toBeCalled();

src/store/users/__tests__/reducer-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ describe('store/users/reducer', () => {
1313
const newState = reducer(
1414
state,
1515
fetchCollaboratorsAction.fulfilled(
16-
{ entries: collaborators, limit: 10, next_marker: null, previous_marker: null },
16+
{ entries: collaborators, limit: 25, next_marker: null, previous_marker: null },
1717
'fulfilled',
18-
undefined,
18+
'test',
1919
),
2020
);
2121

src/store/users/actions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { AppThunkAPI } from '../types';
44
import { Collaborator } from '../../@types';
55
import { getFileId } from '../options';
66

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

0 commit comments

Comments
 (0)