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. * Number of teams to return when not using `props.slugs`
  64. */
  65. limit?: number;
  66. /**
  67. * When true, fetches user's teams if necessary and only provides user's
  68. * teams (isMember = true).
  69. *
  70. * @deprecated use `useUserTeams()`
  71. */
  72. provideUserTeams?: boolean;
  73. /**
  74. * When provided, fetches specified teams by slug if necessary and only provides those teams.
  75. *
  76. * @deprecated use `useTeamsById({slugs: []})`
  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. */
  92. async function fetchTeams(
  93. api: Client,
  94. orgId: string,
  95. {slugs, ids, search, limit, lastSearch, cursor}: FetchTeamOptions = {}
  96. ) {
  97. const query: {
  98. cursor?: typeof cursor;
  99. per_page?: number;
  100. query?: string;
  101. } = {};
  102. if (slugs !== undefined && slugs.length > 0) {
  103. query.query = slugs.map(slug => `slug:${slug}`).join(' ');
  104. }
  105. if (ids !== undefined && ids.length > 0) {
  106. query.query = ids.map(id => `id:${id}`).join(' ');
  107. }
  108. if (search) {
  109. query.query = `${query.query ?? ''} ${search}`.trim();
  110. }
  111. const isSameSearch = lastSearch === search || (!lastSearch && !search);
  112. if (isSameSearch && cursor) {
  113. query.cursor = cursor;
  114. }
  115. if (limit !== undefined) {
  116. query.per_page = limit;
  117. }
  118. let hasMore: null | boolean = false;
  119. let nextCursor: null | string = null;
  120. const [data, , resp] = await api.requestPromise(`/organizations/${orgId}/teams/`, {
  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, hasMore, nextCursor};
  131. }
  132. // TODO: Paging for items which have already exist in the store is not
  133. // correctly implemented.
  134. /**
  135. * Provides teams from the TeamStore
  136. *
  137. * This hook also provides a way to select specific slugs to ensure they are
  138. * loaded, as well as search (type-ahead) for more slugs that may not be in the
  139. * TeamsStore.
  140. *
  141. * NOTE: It is NOT guaranteed that all teams for an organization will be
  142. * loaded, so you should use this hook with the intention of providing specific
  143. * slugs, or loading more through search.
  144. *
  145. * @deprecated use the alternatives to this hook (except for search and pagination)
  146. * new alternatives:
  147. * - useTeamsById({ids: []}) - get teams by id
  148. * - useTeamsById({slugs: []}) - get teams by slug
  149. * - useTeamsById() - just reading from the teams store
  150. * - useUserTeams() - same as `provideUserTeams: true`
  151. *
  152. */
  153. export function useTeams({limit, slugs, provideUserTeams}: Options = {}) {
  154. const api = useApi();
  155. const {organization} = useLegacyStore(OrganizationStore);
  156. const store = useLegacyStore(TeamStore);
  157. const orgId = organization?.slug;
  158. const storeSlugs = useMemo(() => new Set(store.teams.map(t => t.slug)), [store.teams]);
  159. const slugsToLoad = useMemo(
  160. () => slugs?.filter(slug => !storeSlugs.has(slug)) ?? [],
  161. [slugs, storeSlugs]
  162. );
  163. const shouldLoadByQuery = slugsToLoad.length > 0;
  164. const shouldLoadUserTeams = provideUserTeams && !store.loadedUserTeams;
  165. // If we don't need to make a request either for slugs or user teams, set
  166. // initiallyLoaded to true
  167. const initiallyLoaded = !shouldLoadUserTeams && !shouldLoadByQuery;
  168. const [state, setState] = useState<State>({
  169. initiallyLoaded,
  170. fetching: false,
  171. hasMore: store.hasMore,
  172. lastSearch: null,
  173. nextCursor: store.cursor,
  174. fetchError: null,
  175. });
  176. const loadUserTeams = useCallback(
  177. async function () {
  178. if (orgId === undefined) {
  179. return;
  180. }
  181. setState(prev => ({...prev, fetching: true}));
  182. try {
  183. await fetchUserTeams(api, {orgId});
  184. setState(prev => ({...prev, fetching: false, initiallyLoaded: true}));
  185. } catch (err) {
  186. console.error(err); // eslint-disable-line no-console
  187. setState(prev => ({
  188. ...prev,
  189. fetching: false,
  190. initiallyLoaded: true,
  191. fetchError: err,
  192. }));
  193. }
  194. },
  195. [api, orgId]
  196. );
  197. const loadTeamsByQuery = useCallback(
  198. async function () {
  199. if (orgId === undefined) {
  200. return;
  201. }
  202. setState(prev => ({...prev, fetching: true}));
  203. try {
  204. const {results, hasMore, nextCursor} = await fetchTeams(api, orgId, {
  205. slugs: slugsToLoad,
  206. limit,
  207. });
  208. // Unique by `id` to avoid duplicates due to renames and state store data
  209. const fetchedTeams = uniqBy<Team>([...results, ...store.teams], ({id}) => id);
  210. TeamStore.loadInitialData(fetchedTeams);
  211. setState(prev => ({
  212. ...prev,
  213. hasMore,
  214. fetching: false,
  215. initiallyLoaded: true,
  216. nextCursor,
  217. }));
  218. } catch (err) {
  219. console.error(err); // eslint-disable-line no-console
  220. setState(prev => ({
  221. ...prev,
  222. fetching: false,
  223. initiallyLoaded: true,
  224. fetchError: err,
  225. }));
  226. }
  227. },
  228. [api, limit, orgId, slugsToLoad, store.teams]
  229. );
  230. const handleFetchAdditionalTeams = useCallback(
  231. async function (search?: string) {
  232. const lastSearch = state.lastSearch;
  233. // Use the store cursor if there is no search keyword provided
  234. const cursor = search ? state.nextCursor : store.cursor;
  235. if (orgId === undefined) {
  236. // eslint-disable-next-line no-console
  237. console.error(
  238. 'Cannot fetch teams without an organization in context and in the Store'
  239. );
  240. return;
  241. }
  242. setState(prev => ({...prev, fetching: true}));
  243. try {
  244. api.clear();
  245. const {results, hasMore, nextCursor} = await fetchTeams(api, orgId, {
  246. search,
  247. limit,
  248. lastSearch,
  249. cursor,
  250. });
  251. const fetchedTeams = uniqBy<Team>([...store.teams, ...results], ({slug}) => slug);
  252. if (search) {
  253. // Only update the store if we have more items
  254. if (fetchedTeams.length > store.teams.length) {
  255. TeamStore.loadInitialData(fetchedTeams);
  256. }
  257. } else {
  258. // If we fetched a page of teams without a search query, add cursor data to the store
  259. TeamStore.loadInitialData(fetchedTeams, hasMore, nextCursor);
  260. }
  261. setState(prev => ({
  262. ...prev,
  263. hasMore: hasMore && store.hasMore,
  264. fetching: false,
  265. lastSearch: search ?? null,
  266. nextCursor,
  267. }));
  268. } catch (err) {
  269. console.error(err); // eslint-disable-line no-console
  270. setState(prev => ({...prev, fetching: false, fetchError: err}));
  271. }
  272. },
  273. [
  274. api,
  275. limit,
  276. orgId,
  277. state.lastSearch,
  278. state.nextCursor,
  279. store.cursor,
  280. store.hasMore,
  281. store.teams,
  282. ]
  283. );
  284. const handleSearch = useCallback(
  285. function (search: string) {
  286. if (search !== '') {
  287. return handleFetchAdditionalTeams(search);
  288. }
  289. // Reset pagination state to match store if doing an empty search
  290. if (state.hasMore !== store.hasMore || state.nextCursor !== store.cursor) {
  291. setState(prev => ({
  292. ...prev,
  293. lastSearch: search,
  294. hasMore: store.hasMore,
  295. nextCursor: store.cursor,
  296. }));
  297. }
  298. return Promise.resolve();
  299. },
  300. [
  301. handleFetchAdditionalTeams,
  302. state.hasMore,
  303. state.nextCursor,
  304. store.cursor,
  305. store.hasMore,
  306. ]
  307. );
  308. // Load specified team slugs
  309. useEffect(() => {
  310. if (shouldLoadByQuery) {
  311. loadTeamsByQuery();
  312. }
  313. }, [shouldLoadByQuery, loadTeamsByQuery]);
  314. useEffect(() => {
  315. if (shouldLoadUserTeams) {
  316. loadUserTeams();
  317. }
  318. }, [shouldLoadUserTeams, loadUserTeams]);
  319. const isSuperuser = isActiveSuperuser();
  320. const filteredTeams = useMemo(() => {
  321. return slugs
  322. ? store.teams.filter(t => slugs.includes(t.slug))
  323. : provideUserTeams && !isSuperuser
  324. ? store.teams.filter(t => t.isMember)
  325. : store.teams;
  326. }, [store.teams, slugs, provideUserTeams, isSuperuser]);
  327. const result: Result = {
  328. teams: filteredTeams,
  329. fetching: state.fetching || store.loading,
  330. initiallyLoaded: state.initiallyLoaded,
  331. fetchError: state.fetchError,
  332. hasMore: state.hasMore ?? store.hasMore,
  333. onSearch: handleSearch,
  334. loadMore: handleFetchAdditionalTeams,
  335. };
  336. return result;
  337. }