useMembers.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import uniqBy from 'lodash/uniqBy';
  3. import {Client} from 'sentry/api';
  4. import MemberListStore from 'sentry/stores/memberListStore';
  5. import OrganizationStore from 'sentry/stores/organizationStore';
  6. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  7. import {Member, User} from 'sentry/types';
  8. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  9. import RequestError from 'sentry/utils/requestError/requestError';
  10. import useApi from 'sentry/utils/useApi';
  11. type State = {
  12. /**
  13. * The error that occurred if fetching failed
  14. */
  15. fetchError: null | RequestError;
  16. /**
  17. * This is state for when fetching data from API
  18. */
  19. fetching: boolean;
  20. /**
  21. * Indicates that User results (from API) are paginated and there are more
  22. * Users that are not in the initial response.
  23. *
  24. * A null value indicates that we don't know if there are more values.
  25. */
  26. hasMore: null | boolean;
  27. /**
  28. * Reflects whether or not the initial fetch for the requested Users was
  29. * fulfilled
  30. */
  31. initiallyLoaded: boolean;
  32. /**
  33. * The last query we searched. Used to validate the cursor
  34. */
  35. lastSearch: null | string;
  36. /**
  37. * Pagination
  38. */
  39. nextCursor?: null | string;
  40. };
  41. type Result = {
  42. /**
  43. * This is an action provided to consumers for them to request more members
  44. * to be loaded. Additional members will be fetched and loaded into the store.
  45. */
  46. loadMore: (searchTerm?: string) => Promise<void>;
  47. /**
  48. * The loaded members list.
  49. *
  50. * XXX(epurkhiser): This is a misnomer, these are actually the *users* who are
  51. * members of the organiation, Members is a different object type.
  52. */
  53. members: User[];
  54. /**
  55. * This is an action provided to consumers for them to update the current
  56. * users result set using a simple search query.
  57. *
  58. * Will always add new options into the store.
  59. */
  60. onSearch: (searchTerm: string) => Promise<void>;
  61. } & Pick<State, 'fetching' | 'hasMore' | 'fetchError' | 'initiallyLoaded'>;
  62. type Options = {
  63. /**
  64. * When provided, fetches specified members by email if necessary and only
  65. * provides those members.
  66. */
  67. emails?: string[];
  68. /**
  69. * When provided, fetches specified members by id if necessary and only
  70. * provides those members.
  71. */
  72. ids?: string[];
  73. /**
  74. * Number of members to return when not using `props.slugs`
  75. */
  76. limit?: number;
  77. };
  78. type FetchMemberOptions = {
  79. cursor?: State['nextCursor'];
  80. emails?: string[];
  81. ids?: Options['ids'];
  82. lastSearch?: State['lastSearch'];
  83. limit?: Options['limit'];
  84. search?: State['lastSearch'];
  85. };
  86. /**
  87. * Helper function to actually load members
  88. */
  89. async function fetchMembers(
  90. api: Client,
  91. orgId: string,
  92. {emails, ids, search, limit, lastSearch, cursor}: FetchMemberOptions = {}
  93. ) {
  94. const query: {
  95. cursor?: typeof cursor;
  96. per_page?: number;
  97. query?: string;
  98. } = {};
  99. if (ids !== undefined && ids.length > 0) {
  100. query.query = ids.map(id => `user.id:${id}`).join(' ');
  101. }
  102. if (emails !== undefined && emails.length > 0) {
  103. query.query = emails.map(email => `email:${email}`).join(' ');
  104. }
  105. if (search) {
  106. query.query = `${query.query ?? ''} ${search}`.trim();
  107. }
  108. const isSameSearch = lastSearch === search || (!lastSearch && !search);
  109. if (isSameSearch && cursor) {
  110. query.cursor = cursor;
  111. }
  112. if (limit !== undefined) {
  113. query.per_page = limit;
  114. }
  115. // XXX(epurkhiser): Very confusingly right now we actually store users in the
  116. // members store, so here we're fetching member objects, but later we just
  117. // extract out the user object from this.
  118. let hasMore: null | boolean = false;
  119. let nextCursor: null | string = null;
  120. const [data, , resp] = await api.requestPromise(`/organizations/${orgId}/members/`, {
  121. includeAllArgs: true,
  122. query,
  123. });
  124. const pageLinks = resp?.getResponseHeader('Link');
  125. if (pageLinks) {
  126. const paginationObject = parseLinkHeader(pageLinks);
  127. hasMore = paginationObject?.next?.results;
  128. nextCursor = paginationObject?.next?.cursor;
  129. }
  130. return {results: data as Member[], hasMore, nextCursor};
  131. }
  132. // TODO: Paging for items which have already exist in the store is not
  133. // correctly implemented.
  134. /**
  135. * Provides members from the MemberListStore
  136. *
  137. * This hook also provides a way to select specific emails to ensure they are
  138. * loaded, as well as search (type-ahead) for more members that may not be in the
  139. * MemberListStore.
  140. *
  141. * NOTE: It is NOT guaranteed that all members for an organization will be
  142. * loaded, so you should use this hook with the intention of providing specific
  143. * emails, or loading more through search.
  144. */
  145. export function useMembers({ids, emails, limit}: Options = {}) {
  146. const api = useApi();
  147. const {organization} = useLegacyStore(OrganizationStore);
  148. const store = useLegacyStore(MemberListStore);
  149. const orgId = organization?.slug;
  150. // Keep track of what queries we failed to find results for, otherwilse we'll
  151. // just keep trying to look those up since they'll never end up in the store
  152. // and {ids,emails}ToLoad will never be empty
  153. const [idsFailedToLoad, setIdsFailedToLoad] = useState<Set<string>>(new Set());
  154. const [emailsFailedToLoad, setEmailsFailedToLoad] = useState<Set<string>>(new Set());
  155. const storeIds = useMemo(() => new Set(store.members.map(u => u.id)), [store.members]);
  156. const idsToLoad = useMemo(
  157. () => ids?.filter(id => !storeIds.has(id) && !idsFailedToLoad.has(id)) ?? [],
  158. [ids, idsFailedToLoad, storeIds]
  159. );
  160. const storeEmails = useMemo(
  161. () => new Set(store.members.map(u => u.email)),
  162. [store.members]
  163. );
  164. const emailsToLoad = useMemo(
  165. () =>
  166. emails?.filter(
  167. email => !storeEmails.has(email) && !emailsFailedToLoad.has(email)
  168. ) ?? [],
  169. [emails, emailsFailedToLoad, storeEmails]
  170. );
  171. const shouldLoadByQuery = emailsToLoad.length > 0 || idsToLoad.length > 0;
  172. // If we don't need to make a request either for emails and we have members,
  173. // set initiallyLoaded to true
  174. const initiallyLoaded = !shouldLoadByQuery && store.members.length > 0;
  175. const [state, setState] = useState<State>({
  176. initiallyLoaded,
  177. fetching: false,
  178. hasMore: store.hasMore,
  179. lastSearch: null,
  180. nextCursor: store.cursor,
  181. fetchError: null,
  182. });
  183. const loadMembersByQuery = useCallback(
  184. async function () {
  185. if (orgId === undefined) {
  186. return;
  187. }
  188. setState(prev => ({...prev, fetching: true}));
  189. try {
  190. const {results, hasMore, nextCursor} = await fetchMembers(api, orgId, {
  191. ids: idsToLoad,
  192. emails: emailsToLoad,
  193. limit,
  194. });
  195. const memberUsers = results
  196. .map(m => m.user)
  197. .filter((user): user is User => user !== null);
  198. // Unique by `id` to avoid duplicates due to renames and state store data
  199. const fetchedMembers = uniqBy<User>(
  200. [...memberUsers, ...store.members],
  201. ({id}) => id
  202. );
  203. // Track member identifiers we couldn't load to exclude them from future requests
  204. const failedIds = idsToLoad.filter(
  205. id => !results.some(member => member.user?.id === id)
  206. );
  207. if (failedIds.length > 0) {
  208. setIdsFailedToLoad(prev => new Set([...prev, ...failedIds]));
  209. }
  210. const failedEmails = emailsToLoad.filter(
  211. email => !results.some(member => member.user?.email === email)
  212. );
  213. if (failedEmails.length > 0) {
  214. setEmailsFailedToLoad(prev => new Set([...prev, ...failedEmails]));
  215. }
  216. MemberListStore.loadInitialData(fetchedMembers);
  217. setState(prev => ({
  218. ...prev,
  219. hasMore,
  220. fetching: false,
  221. initiallyLoaded: true,
  222. nextCursor,
  223. }));
  224. } catch (err) {
  225. console.error(err); // eslint-disable-line no-console
  226. setState(prev => ({
  227. ...prev,
  228. fetching: false,
  229. initiallyLoaded: true,
  230. fetchError: err,
  231. }));
  232. }
  233. },
  234. [api, emailsToLoad, idsToLoad, limit, orgId, store.members]
  235. );
  236. const handleFetchAdditionalMembers = useCallback(
  237. async function (search?: string) {
  238. const lastSearch = state.lastSearch;
  239. // Use the store cursor if there is no search keyword provided
  240. const cursor = search ? state.nextCursor : store.cursor;
  241. if (orgId === undefined) {
  242. // eslint-disable-next-line no-console
  243. console.error('Cannot fetch members without an organization in context');
  244. return;
  245. }
  246. setState(prev => ({...prev, fetching: true}));
  247. try {
  248. api.clear();
  249. const {results, hasMore, nextCursor} = await fetchMembers(api, orgId, {
  250. search,
  251. limit,
  252. lastSearch,
  253. cursor,
  254. });
  255. const memberUsers = results
  256. .map(m => m.user)
  257. .filter((user): user is User => user !== null);
  258. const fetchedMembers = uniqBy<User>(
  259. [...store.members, ...memberUsers],
  260. ({email}) => email
  261. );
  262. if (search) {
  263. // Only update the store if we have more items
  264. if (fetchedMembers.length > store.members.length) {
  265. MemberListStore.loadInitialData(fetchedMembers);
  266. }
  267. } else {
  268. // If we fetched a page of members without a search query, add cursor
  269. // data to the store
  270. MemberListStore.loadInitialData(fetchedMembers, hasMore, nextCursor);
  271. }
  272. setState(prev => ({
  273. ...prev,
  274. hasMore: hasMore && store.hasMore,
  275. fetching: false,
  276. lastSearch: search ?? null,
  277. nextCursor,
  278. }));
  279. } catch (err) {
  280. console.error(err); // eslint-disable-line no-console
  281. setState(prev => ({...prev, fetching: false, fetchError: err}));
  282. }
  283. },
  284. [
  285. api,
  286. limit,
  287. orgId,
  288. state.lastSearch,
  289. state.nextCursor,
  290. store.cursor,
  291. store.hasMore,
  292. store.members,
  293. ]
  294. );
  295. const handleSearch = useCallback(
  296. function (search: string) {
  297. if (search !== '') {
  298. return handleFetchAdditionalMembers(search);
  299. }
  300. // Reset pagination state to match store if doing an empty search
  301. if (state.hasMore !== store.hasMore || state.nextCursor !== store.cursor) {
  302. setState(prev => ({
  303. ...prev,
  304. lastSearch: search,
  305. hasMore: store.hasMore,
  306. nextCursor: store.cursor,
  307. }));
  308. }
  309. return Promise.resolve();
  310. },
  311. [
  312. handleFetchAdditionalMembers,
  313. state.hasMore,
  314. state.nextCursor,
  315. store.cursor,
  316. store.hasMore,
  317. ]
  318. );
  319. // Load specified team slugs
  320. useEffect(() => {
  321. if (shouldLoadByQuery) {
  322. loadMembersByQuery();
  323. }
  324. }, [shouldLoadByQuery, loadMembersByQuery]);
  325. const filteredMembers = useMemo(
  326. () =>
  327. emails || ids
  328. ? store.members.filter(m => emails?.includes(m.email) || ids?.includes(m.id))
  329. : store.members,
  330. [emails, store.members, ids]
  331. );
  332. const result: Result = {
  333. members: filteredMembers,
  334. fetching: state.fetching || store.loading,
  335. initiallyLoaded: state.initiallyLoaded,
  336. fetchError: state.fetchError,
  337. hasMore: state.hasMore ?? store.hasMore,
  338. onSearch: handleSearch,
  339. loadMore: handleFetchAdditionalMembers,
  340. };
  341. return result;
  342. }