cozy-sharing 40.1.9 → 40.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,16 +10,35 @@ import _regeneratorRuntime from "@babel/runtime/regenerator";
10
10
  import { models } from 'cozy-client';
11
11
  import flag from 'cozy-flags';
12
12
  import { validateEmail } from '../helpers/email';
13
+ import { DOCTYPE_CONTACTS, DOCTYPE_GROUPS } from '../models';
13
14
  var ContactModel = models.contact; // Return the contact object if present
14
15
  // Else check if a contact exist with this email address, if yes return it
15
16
  // if not create it
17
+ // With contactsSuggestEnabled, people are sent by email and the
18
+ // stack finds or creates the contact: an org member's id is not on this instance
16
19
 
17
20
  export var getOrCreateFromArray = /*#__PURE__*/function () {
18
- var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee2(client, contacts, createContact) {
21
+ var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee2(client, contacts, createContact, contactsSuggestEnabled) {
19
22
  return _regeneratorRuntime.wrap(function _callee2$(_context2) {
20
23
  while (1) switch (_context2.prev = _context2.next) {
21
24
  case 0:
22
- _context2.next = 2;
25
+ if (!contactsSuggestEnabled) {
26
+ _context2.next = 2;
27
+ break;
28
+ }
29
+
30
+ return _context2.abrupt("return", contacts.map(function (contact) {
31
+ if (contact._type === DOCTYPE_GROUPS) return contact;
32
+ var email = ContactModel.getPrimaryEmail(contact);
33
+ if (validateEmail(email)) return {
34
+ _type: DOCTYPE_CONTACTS,
35
+ email: email
36
+ };
37
+ return contact._id ? contact : undefined;
38
+ }));
39
+
40
+ case 2:
41
+ _context2.next = 4;
23
42
  return Promise.all(contacts.map( /*#__PURE__*/function () {
24
43
  var _ref2 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(contact) {
25
44
  var matchedContact, resp;
@@ -83,24 +102,64 @@ export var getOrCreateFromArray = /*#__PURE__*/function () {
83
102
  }, _callee);
84
103
  }));
85
104
 
86
- return function (_x4) {
105
+ return function (_x5) {
87
106
  return _ref2.apply(this, arguments);
88
107
  };
89
108
  }()));
90
109
 
91
- case 2:
110
+ case 4:
92
111
  return _context2.abrupt("return", _context2.sent);
93
112
 
94
- case 3:
113
+ case 5:
95
114
  case "end":
96
115
  return _context2.stop();
97
116
  }
98
117
  }, _callee2);
99
118
  }));
100
119
 
101
- return function getOrCreateFromArray(_x, _x2, _x3) {
120
+ return function getOrCreateFromArray(_x, _x2, _x3, _x4) {
102
121
  return _ref.apply(this, arguments);
103
122
  };
123
+ }(); // A suggestion without id has no contact document, and only a fullname,
124
+ // which getDisplayName does not read
125
+
126
+ export var suggestContacts = /*#__PURE__*/function () {
127
+ var _ref3 = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee3(client, q) {
128
+ var _yield$client$getStac, data;
129
+
130
+ return _regeneratorRuntime.wrap(function _callee3$(_context3) {
131
+ while (1) switch (_context3.prev = _context3.next) {
132
+ case 0:
133
+ _context3.next = 2;
134
+ return client.getStackClient().fetchJSON('GET', "/contacts/suggest?q=".concat(encodeURIComponent(q)));
135
+
136
+ case 2:
137
+ _yield$client$getStac = _context3.sent;
138
+ data = _yield$client$getStac.data;
139
+ return _context3.abrupt("return", data.map(function (_ref4) {
140
+ var id = _ref4.id,
141
+ type = _ref4.type,
142
+ attributes = _ref4.attributes;
143
+ return _objectSpread(_objectSpread(_objectSpread({}, attributes), id ? {
144
+ _id: id,
145
+ id: id
146
+ } : {
147
+ displayName: attributes.fullname
148
+ }), {}, {
149
+ _type: type
150
+ });
151
+ }));
152
+
153
+ case 5:
154
+ case "end":
155
+ return _context3.stop();
156
+ }
157
+ }, _callee3);
158
+ }));
159
+
160
+ return function suggestContacts(_x6, _x7) {
161
+ return _ref3.apply(this, arguments);
162
+ };
104
163
  }();
105
164
  export var getContactsFromGroupId = function getContactsFromGroupId() {
106
165
  var contacts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
@@ -144,12 +203,12 @@ var isCurrentUser = function isCurrentUser(contact) {
144
203
  */
145
204
 
146
205
 
147
- export var computeContactsAndGroups = function computeContactsAndGroups(_ref3) {
148
- var reachableContacts = _ref3.reachableContacts,
149
- unreachableContactsWithGroups = _ref3.unreachableContactsWithGroups,
150
- contactGroups = _ref3.contactGroups,
151
- currentRecipients = _ref3.currentRecipients,
152
- pendingRecipients = _ref3.pendingRecipients;
206
+ export var computeContactsAndGroups = function computeContactsAndGroups(_ref5) {
207
+ var reachableContacts = _ref5.reachableContacts,
208
+ unreachableContactsWithGroups = _ref5.unreachableContactsWithGroups,
209
+ contactGroups = _ref5.contactGroups,
210
+ currentRecipients = _ref5.currentRecipients,
211
+ pendingRecipients = _ref5.pendingRecipients;
153
212
  var suggestedContacts = reachableContacts.filter(function (contact) {
154
213
  return !isCurrentUser(contact) && // We do not want contact already in the sharing
155
214
  !currentRecipients.find(function (r) {
@@ -162,12 +221,12 @@ export var computeContactsAndGroups = function computeContactsAndGroups(_ref3) {
162
221
  var unreachableContacts = unreachableContactsWithGroups.filter(function (contact) {
163
222
  return !isCurrentUser(contact);
164
223
  });
165
- var currentRecipientGroups = currentRecipients.map(function (_ref4) {
166
- var id = _ref4.id;
224
+ var currentRecipientGroups = currentRecipients.map(function (_ref6) {
225
+ var id = _ref6.id;
167
226
  return id;
168
227
  }).filter(Boolean);
169
- var suggestedGroups = contactGroups.filter(function (_ref5) {
170
- var _id = _ref5._id;
228
+ var suggestedGroups = contactGroups.filter(function (_ref7) {
229
+ var _id = _ref7._id;
171
230
  return !flag('sharing.show-recipient-groups') || !currentRecipientGroups.includes(_id);
172
231
  }).map(function (contactGroup) {
173
232
  var reachableMembers = getContactsFromGroupId(suggestedContacts, contactGroup.id).map(function (contact) {
@@ -8,7 +8,7 @@ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { va
8
8
 
9
9
  import _regeneratorRuntime from "@babel/runtime/regenerator";
10
10
  import flag from 'cozy-flags';
11
- import { computeContactsAndGroups, getOrCreateFromArray } from './contacts';
11
+ import { computeContactsAndGroups, getOrCreateFromArray, suggestContacts } from './contacts';
12
12
  jest.mock('cozy-flags', function () {
13
13
  return {
14
14
  __esModule: true,
@@ -184,4 +184,153 @@ describe('computeContactsAndGroups', function () {
184
184
  return c._id;
185
185
  })).toEqual(['alice', 'bob']);
186
186
  });
187
+ });
188
+ describe('getOrCreateFromArray with contactsSuggestEnabled', function () {
189
+ it('should send people by email and keep groups, without creating contacts', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee2() {
190
+ var createContact, group, recipients;
191
+ return _regeneratorRuntime.wrap(function _callee2$(_context2) {
192
+ while (1) switch (_context2.prev = _context2.next) {
193
+ case 0:
194
+ createContact = jest.fn();
195
+ group = {
196
+ _id: 'group-1',
197
+ _type: 'io.cozy.contacts.groups'
198
+ };
199
+ _context2.next = 4;
200
+ return getOrCreateFromArray(mockClient, [{
201
+ _id: 'org-member-1',
202
+ _type: 'io.cozy.contacts',
203
+ email: [{
204
+ address: 'member@org.example',
205
+ primary: true
206
+ }],
207
+ meta: {
208
+ kind: 'member'
209
+ }
210
+ }, {
211
+ email: 'unknown@friends.example'
212
+ }, group], createContact, true);
213
+
214
+ case 4:
215
+ recipients = _context2.sent;
216
+ expect(recipients).toEqual([{
217
+ _type: 'io.cozy.contacts',
218
+ email: 'member@org.example'
219
+ }, {
220
+ _type: 'io.cozy.contacts',
221
+ email: 'unknown@friends.example'
222
+ }, group]);
223
+ expect(createContact).not.toHaveBeenCalled();
224
+
225
+ case 7:
226
+ case "end":
227
+ return _context2.stop();
228
+ }
229
+ }, _callee2);
230
+ })));
231
+ it('should keep a contact without email by id, and drop a recipient with neither', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee3() {
232
+ var cozyOnlyContact, recipients;
233
+ return _regeneratorRuntime.wrap(function _callee3$(_context3) {
234
+ while (1) switch (_context3.prev = _context3.next) {
235
+ case 0:
236
+ cozyOnlyContact = {
237
+ _id: 'contact-1',
238
+ _type: 'io.cozy.contacts',
239
+ cozy: [{
240
+ url: 'https://bob.example'
241
+ }]
242
+ };
243
+ _context3.next = 3;
244
+ return getOrCreateFromArray(mockClient, [cozyOnlyContact, {
245
+ instance: 'https://alice.example'
246
+ }], jest.fn(), true);
247
+
248
+ case 3:
249
+ recipients = _context3.sent;
250
+ expect(recipients).toEqual([cozyOnlyContact, undefined]);
251
+
252
+ case 5:
253
+ case "end":
254
+ return _context3.stop();
255
+ }
256
+ }, _callee3);
257
+ })));
258
+ });
259
+ describe('suggestContacts', function () {
260
+ it('should map the suggestions of the stack to contacts', /*#__PURE__*/_asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee4() {
261
+ var fetchJSON, client, contacts;
262
+ return _regeneratorRuntime.wrap(function _callee4$(_context4) {
263
+ while (1) switch (_context4.prev = _context4.next) {
264
+ case 0:
265
+ fetchJSON = jest.fn().mockResolvedValue({
266
+ data: [{
267
+ type: 'io.cozy.contacts',
268
+ id: 'org-member-1',
269
+ attributes: {
270
+ fullname: 'Jean Dupont',
271
+ name: {
272
+ givenName: 'Jean',
273
+ familyName: 'Dupont'
274
+ },
275
+ email: [{
276
+ address: 'jean@org.example',
277
+ primary: true
278
+ }]
279
+ },
280
+ meta: {
281
+ kind: 'member'
282
+ }
283
+ }, {
284
+ type: 'io.cozy.contacts',
285
+ attributes: {
286
+ fullname: 'Paul Dupuis',
287
+ email: [{
288
+ address: 'paul@friends.example',
289
+ primary: true
290
+ }]
291
+ }
292
+ }]
293
+ });
294
+ client = {
295
+ getStackClient: function getStackClient() {
296
+ return {
297
+ fetchJSON: fetchJSON
298
+ };
299
+ }
300
+ };
301
+ _context4.next = 4;
302
+ return suggestContacts(client, 'dup & co');
303
+
304
+ case 4:
305
+ contacts = _context4.sent;
306
+ expect(fetchJSON).toHaveBeenCalledWith('GET', '/contacts/suggest?q=dup%20%26%20co');
307
+ expect(contacts).toEqual([{
308
+ _id: 'org-member-1',
309
+ id: 'org-member-1',
310
+ _type: 'io.cozy.contacts',
311
+ fullname: 'Jean Dupont',
312
+ name: {
313
+ givenName: 'Jean',
314
+ familyName: 'Dupont'
315
+ },
316
+ email: [{
317
+ address: 'jean@org.example',
318
+ primary: true
319
+ }]
320
+ }, {
321
+ _type: 'io.cozy.contacts',
322
+ fullname: 'Paul Dupuis',
323
+ displayName: 'Paul Dupuis',
324
+ email: [{
325
+ address: 'paul@friends.example',
326
+ primary: true
327
+ }]
328
+ }]);
329
+
330
+ case 7:
331
+ case "end":
332
+ return _context4.stop();
333
+ }
334
+ }, _callee4);
335
+ })));
187
336
  });
@@ -2,6 +2,7 @@ import _asyncToGenerator from "@babel/runtime/helpers/asyncToGenerator";
2
2
  import _regeneratorRuntime from "@babel/runtime/regenerator";
3
3
  import { useCallback } from 'react';
4
4
  import { useClient } from 'cozy-client';
5
+ import { useSharingContext } from './useSharingContext';
5
6
  import { getOrCreateFromArray } from '../helpers/contacts';
6
7
  import { DOCTYPE_CONTACTS } from '../models';
7
8
  /**
@@ -13,7 +14,10 @@ import { DOCTYPE_CONTACTS } from '../models';
13
14
  */
14
15
 
15
16
  export var useResolveRecipients = function useResolveRecipients() {
17
+ var _useSharingContext;
18
+
16
19
  var client = useClient();
20
+ var contactsSuggestEnabled = (_useSharingContext = useSharingContext()) === null || _useSharingContext === void 0 ? void 0 : _useSharingContext.contactsSuggestEnabled;
17
21
  return useCallback( /*#__PURE__*/function () {
18
22
  var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime.mark(function _callee(pendingRecipients) {
19
23
  var selectedOption,
@@ -29,7 +33,7 @@ export var useResolveRecipients = function useResolveRecipients() {
29
33
  return client.create(DOCTYPE_CONTACTS, contact);
30
34
  };
31
35
  _context.next = 4;
32
- return getOrCreateFromArray(client, pendingRecipients, createContact);
36
+ return getOrCreateFromArray(client, pendingRecipients, createContact, contactsSuggestEnabled);
33
37
 
34
38
  case 4:
35
39
  contacts = _context.sent;
@@ -49,5 +53,5 @@ export var useResolveRecipients = function useResolveRecipients() {
49
53
  return function (_x) {
50
54
  return _ref.apply(this, arguments);
51
55
  };
52
- }(), [client]);
56
+ }(), [client, contactsSuggestEnabled]);
53
57
  };
@@ -18,6 +18,15 @@ jest.mock('../helpers/contacts', function () {
18
18
  getOrCreateFromArray: jest.fn()
19
19
  };
20
20
  });
21
+ jest.mock('./useSharingContext', function () {
22
+ return {
23
+ useSharingContext: function useSharingContext() {
24
+ return {
25
+ contactsSuggestEnabled: true
26
+ };
27
+ }
28
+ };
29
+ });
21
30
  var contacts = [{
22
31
  _id: 'alice'
23
32
  }, {
@@ -102,7 +111,7 @@ describe('useResolveRecipients', function () {
102
111
  address: 'alice@cozy.cc'
103
112
  }]
104
113
  });
105
- expect(getOrCreateFromArray).toHaveBeenCalledWith(mockClient, pendingRecipients, expect.any(Function));
114
+ expect(getOrCreateFromArray).toHaveBeenCalledWith(mockClient, pendingRecipients, expect.any(Function), true);
106
115
  expect(mockClient.create).toHaveBeenCalledWith('io.cozy.contacts', {
107
116
  email: [{
108
117
  address: 'alice@cozy.cc'
@@ -129,7 +138,7 @@ describe('useResolveRecipients', function () {
129
138
  return result.current([], 'readWrite', createContact);
130
139
 
131
140
  case 4:
132
- expect(getOrCreateFromArray).toHaveBeenCalledWith(mockClient, [], createContact);
141
+ expect(getOrCreateFromArray).toHaveBeenCalledWith(mockClient, [], createContact, true);
133
142
 
134
143
  case 5:
135
144
  case "end":
@@ -1,3 +1,9 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+
3
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
4
+
5
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { _defineProperty(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
6
+
1
7
  import { Q, fetchPolicies } from 'cozy-client';
2
8
  import { DOCTYPE_CONTACTS, DOCTYPE_GROUPS } from '../models';
3
9
  var DEFAULT_CACHE_TIMEOUT_QUERIES = 9 * 60 * 1000;
@@ -12,12 +18,15 @@ export var buildSharingsByIdQuery = function buildSharingsByIdQuery(sharingId) {
12
18
  };
13
19
  };
14
20
  export var buildReachableContactsQuery = function buildReachableContactsQuery() {
21
+ var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
22
+ inGroups = _ref.inGroups;
23
+
15
24
  return {
16
25
  definition: Q(DOCTYPE_CONTACTS).where({
17
26
  _id: {
18
27
  $gt: null
19
28
  }
20
- }).partialIndex({
29
+ }).partialIndex(_objectSpread({
21
30
  trashed: {
22
31
  $or: [{
23
32
  $eq: false
@@ -38,9 +47,13 @@ export var buildReachableContactsQuery = function buildReachableContactsQuery()
38
47
  }
39
48
  }
40
49
  }]
41
- }).indexFields(['_id']).limitBy(1000),
50
+ }, inGroups && {
51
+ 'relationships.groups.data': {
52
+ $exists: true
53
+ }
54
+ })).indexFields(['_id']).limitBy(1000),
42
55
  options: {
43
- as: 'io.cozy.contacts/reachable'
56
+ as: inGroups ? 'io.cozy.contacts/reachable-with-groups' : 'io.cozy.contacts/reachable'
44
57
  }
45
58
  };
46
59
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-sharing",
3
- "version": "40.1.9",
3
+ "version": "40.2.0",
4
4
  "description": "Provides sharing login for React applications.",
5
5
  "main": "dist/index.js",
6
6
  "author": "Cozy",
@@ -58,7 +58,7 @@
58
58
  "cozy-intent": "^2.31.1",
59
59
  "cozy-minilog": "^3.10.1",
60
60
  "cozy-ui": "^140.5.0",
61
- "cozy-ui-plus": "^14.1.4",
61
+ "cozy-ui-plus": "^14.1.5",
62
62
  "jest": "30.3.0",
63
63
  "jest-environment-jsdom": "30.3.0",
64
64
  "react": "16.12.0",
@@ -84,5 +84,5 @@
84
84
  "sideEffects": [
85
85
  "*.css"
86
86
  ],
87
- "gitHead": "4dc5561f74fe7d2d000b112026294e208221a4d7"
87
+ "gitHead": "5641a18cdc0008e4a73d0d00ffc7b7e58b124185"
88
88
  }
@@ -745,7 +745,12 @@ export class SharingProvider extends Component {
745
745
  // but if we don't, consumers don't rerender when the state changes after loading the sharings,
746
746
  // probably because the state object remains the same...
747
747
  return (
748
- <SharingContext.Provider value={{ ...this.state }}>
748
+ <SharingContext.Provider
749
+ value={{
750
+ ...this.state,
751
+ contactsSuggestEnabled: this.props.contactsSuggestEnabled
752
+ }}
753
+ >
749
754
  {this.props.children}
750
755
  </SharingContext.Provider>
751
756
  )
@@ -160,7 +160,9 @@ describe('PermissionTypeMenu component', () => {
160
160
  }
161
161
 
162
162
  beforeEach(() => {
163
- flag.mockReturnValue(true)
163
+ flag.mockImplementation(
164
+ name => name === 'drive.federated-shared-folder.enabled'
165
+ )
164
166
  mockHasSharedParent.mockReturnValue(true)
165
167
  mockGetSharingById.mockReturnValue({
166
168
  id: 'sharing-123',
@@ -1,5 +1,5 @@
1
1
  import PropTypes from 'prop-types'
2
- import React, { useState, useRef } from 'react'
2
+ import React, { useEffect, useState, useRef } from 'react'
3
3
  import Autosuggest from 'react-autosuggest'
4
4
 
5
5
  import Chip from 'cozy-ui/transpiled/react/Chips'
@@ -10,10 +10,14 @@ import { MemberAvatar } from './Avatar/MemberAvatar'
10
10
  import ContactSuggestion from './ContactSuggestion'
11
11
  import { isContactToBeCreated } from '../helpers/contacts'
12
12
  import { extractEmails, validateEmail } from '../helpers/email'
13
+ import logger from '../logger'
13
14
  import { getDisplayName, DOCTYPE_CONTACTS, DOCTYPE_GROUPS } from '../models'
14
15
  import styles from '../styles/autosuggest.styl'
15
16
  import { contactOrGroupMatch } from '../suggestionMatchers'
16
17
 
18
+ const FETCH_CONTACTS_DEBOUNCE = 300
19
+ const FETCH_CONTACTS_MIN_LENGTH = 3
20
+
17
21
  const ShareAutosuggest = ({
18
22
  loading,
19
23
  disabled,
@@ -24,15 +28,21 @@ const ShareAutosuggest = ({
24
28
  enableCreateContact,
25
29
  placeholder,
26
30
  autoFocus,
27
- endAdornment
31
+ endAdornment,
32
+ fetchContacts
28
33
  }) => {
29
34
  const [inputValue, setInputValue] = useState('')
30
35
  const [suggestions, setSuggestions] = useState([])
31
36
  const [isPasted, setIsPasted] = useState(false)
32
37
  const [isLoadingDisplayed, setLoadingDisplayed] = useState(false)
38
+ const [isFetching, setIsFetching] = useState(false)
33
39
 
34
40
  const autosuggestRef = useRef(null)
35
41
  const containerRef = useRef(null)
42
+ const fetchTimeoutRef = useRef(null)
43
+ const fetchedValueRef = useRef(null)
44
+
45
+ useEffect(() => () => clearTimeout(fetchTimeoutRef.current), [])
36
46
 
37
47
  const computeSuggestions = value => {
38
48
  const inputValue = value.trim().toLowerCase()
@@ -44,9 +54,7 @@ const ShareAutosuggest = ({
44
54
  )
45
55
  }
46
56
 
47
- const onSuggestionsFetchRequested = ({ value }) => {
48
- const computedSuggestions = computeSuggestions(value)
49
-
57
+ const showSuggestions = (computedSuggestions, value) => {
50
58
  if (computedSuggestions.length > 0) {
51
59
  setSuggestions(computedSuggestions)
52
60
  } else if (enableCreateContact && validateEmail(value)) {
@@ -61,7 +69,36 @@ const ShareAutosuggest = ({
61
69
  }
62
70
  }
63
71
 
72
+ const onSuggestionsFetchRequested = ({ value }) => {
73
+ const computedSuggestions = computeSuggestions(value)
74
+ clearTimeout(fetchTimeoutRef.current)
75
+ fetchedValueRef.current = value
76
+
77
+ if (!fetchContacts || value.trim().length < FETCH_CONTACTS_MIN_LENGTH) {
78
+ setIsFetching(false)
79
+ showSuggestions(computedSuggestions, value)
80
+ return
81
+ }
82
+
83
+ setIsFetching(true)
84
+ fetchTimeoutRef.current = setTimeout(async () => {
85
+ let contacts = []
86
+ try {
87
+ contacts = await fetchContacts(value.trim())
88
+ } catch (err) {
89
+ logger.error('Failed to fetch contact suggestions', err)
90
+ }
91
+ // The user kept typing or left the input while the stack was answering
92
+ if (fetchedValueRef.current !== value) return
93
+ setIsFetching(false)
94
+ showSuggestions([...contacts, ...computedSuggestions], value)
95
+ }, FETCH_CONTACTS_DEBOUNCE)
96
+ }
97
+
64
98
  const onSuggestionsClearRequested = () => {
99
+ clearTimeout(fetchTimeoutRef.current)
100
+ fetchedValueRef.current = null
101
+ setIsFetching(false)
65
102
  setSuggestions([])
66
103
  }
67
104
 
@@ -174,7 +211,7 @@ const ShareAutosuggest = ({
174
211
  )
175
212
  })}
176
213
  <input {...inputProps} onKeyPress={onKeyPress} onKeyUp={onKeyUp} />
177
- {loading && isLoadingDisplayed ? (
214
+ {(loading || isFetching) && isLoadingDisplayed ? (
178
215
  <Spinner color="var(--primaryColor)" />
179
216
  ) : null}
180
217
  {endAdornment ? (
@@ -231,7 +268,8 @@ ShareAutosuggest.propTypes = {
231
268
  enableCreateContact: PropTypes.bool,
232
269
  placeholder: PropTypes.string,
233
270
  autoFocus: PropTypes.bool,
234
- endAdornment: PropTypes.node
271
+ endAdornment: PropTypes.node,
272
+ fetchContacts: PropTypes.func
235
273
  }
236
274
 
237
275
  export default ShareAutosuggest
@@ -1,4 +1,4 @@
1
- import { render, fireEvent, screen } from '@testing-library/react'
1
+ import { act, render, fireEvent, screen } from '@testing-library/react'
2
2
  import React from 'react'
3
3
 
4
4
  import ShareAutosuggest from './ShareAutosuggest'
@@ -13,7 +13,8 @@ describe('ShareAutosuggest', () => {
13
13
  onPick,
14
14
  onRemove,
15
15
  loading = false,
16
- enableCreateContact = true
16
+ enableCreateContact = true,
17
+ fetchContacts
17
18
  }) => {
18
19
  render(
19
20
  <AppLike>
@@ -25,6 +26,7 @@ describe('ShareAutosuggest', () => {
25
26
  recipients={[]}
26
27
  onRemove={onRemove}
27
28
  enableCreateContact={enableCreateContact}
29
+ fetchContacts={fetchContacts}
28
30
  />
29
31
  </AppLike>
30
32
  )
@@ -88,4 +90,59 @@ describe('ShareAutosuggest', () => {
88
90
 
89
91
  expect(screen.queryByText('loading')).toBeNull()
90
92
  })
93
+
94
+ describe('with fetchContacts', () => {
95
+ beforeEach(() => {
96
+ jest.useFakeTimers()
97
+ // Not implemented by jsdom, called when a suggestion is highlighted
98
+ Element.prototype.scrollIntoView = jest.fn()
99
+ })
100
+
101
+ afterEach(() => {
102
+ jest.useRealTimers()
103
+ })
104
+
105
+ it('should suggest the fetched contacts, debounced and from 3 characters', async () => {
106
+ const fetchContacts = jest.fn().mockResolvedValue([
107
+ {
108
+ _id: 'org-member-1',
109
+ _type: 'io.cozy.contacts',
110
+ displayName: 'Jean Dupont',
111
+ email: [{ address: 'jean@org.example', primary: true }]
112
+ }
113
+ ])
114
+
115
+ setup({ onPick: jest.fn(), onRemove: jest.fn(), fetchContacts })
116
+
117
+ const inputNode = screen.getByPlaceholderText('myPlaceHolder')
118
+ fireEvent.change(inputNode, { target: { value: 'du' } })
119
+ act(() => jest.runAllTimers())
120
+ expect(fetchContacts).not.toHaveBeenCalled()
121
+
122
+ fireEvent.change(inputNode, { target: { value: 'dup' } })
123
+ fireEvent.change(inputNode, { target: { value: 'dupo' } })
124
+ act(() => jest.runAllTimers())
125
+ expect(fetchContacts).toHaveBeenCalledTimes(1)
126
+ expect(fetchContacts).toHaveBeenCalledWith('dupo')
127
+
128
+ expect(await screen.findByText('Jean Dupont')).toBeInTheDocument()
129
+ })
130
+
131
+ it('should show the loading indicator until the contacts are fetched', async () => {
132
+ let resolveFetch
133
+ const fetchContacts = jest.fn(
134
+ () => new Promise(resolve => (resolveFetch = resolve))
135
+ )
136
+
137
+ setup({ onPick: jest.fn(), onRemove: jest.fn(), fetchContacts })
138
+
139
+ const inputNode = screen.getByPlaceholderText('myPlaceHolder')
140
+ fireEvent.change(inputNode, { target: { value: 'dup' } })
141
+ expect(screen.getByText('loading')).toBeInTheDocument()
142
+
143
+ act(() => jest.runAllTimers())
144
+ await act(async () => resolveFetch([]))
145
+ expect(screen.queryByText('loading')).toBeNull()
146
+ })
147
+ })
91
148
  })