useTeams.tsx 10 KB

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