useProjects.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import {useEffect, useRef, useState} from 'react';
  2. import uniqBy from 'lodash/uniqBy';
  3. import type {Client} from 'sentry/api';
  4. import ProjectsStore from 'sentry/stores/projectsStore';
  5. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  6. import type {AvatarProject, Project} from 'sentry/types/project';
  7. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  8. import type RequestError from 'sentry/utils/requestError/requestError';
  9. import useApi from 'sentry/utils/useApi';
  10. import useOrganization from 'sentry/utils/useOrganization';
  11. type ProjectPlaceholder = AvatarProject;
  12. type State = {
  13. /**
  14. * The error that occurred if fetching failed
  15. */
  16. fetchError: null | RequestError;
  17. /**
  18. * This is state for when fetching data from API
  19. */
  20. fetching: boolean;
  21. /**
  22. * Indicates that Project results (from API) are paginated and there are more
  23. * projects that are not in the initial response
  24. */
  25. hasMore: null | boolean;
  26. /**
  27. * Reflects whether or not the initial fetch for the requested projects
  28. * was fulfilled. This accounts for both the store and specifically loaded
  29. * slugs.
  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 update the current
  44. * projects result set using a simple search query.
  45. *
  46. * Will always add new options into the store.
  47. */
  48. onSearch: (searchTerm: string) => Promise<void>;
  49. /**
  50. * When loading specific slugs, placeholder objects will be returned
  51. */
  52. placeholders: ProjectPlaceholder[];
  53. /**
  54. * The loaded projects list
  55. */
  56. projects: Project[];
  57. /**
  58. * Allows consumers to force refetch project data.
  59. */
  60. reloadProjects: () => Promise<void>;
  61. } & Pick<State, 'fetching' | 'hasMore' | 'fetchError' | 'initiallyLoaded'>;
  62. type Options = {
  63. /**
  64. * Number of projects to return when not using `props.slugs`
  65. */
  66. limit?: number;
  67. /**
  68. * Specify an orgId, overriding the organization in the current context
  69. */
  70. orgId?: string;
  71. /**
  72. * List of slugs to look for summaries for, this can be from `props.projects`,
  73. * otherwise fetch from API
  74. */
  75. slugs?: string[];
  76. };
  77. type FetchProjectsOptions = {
  78. cursor?: State['nextCursor'];
  79. lastSearch?: State['lastSearch'];
  80. limit?: Options['limit'];
  81. search?: State['lastSearch'];
  82. slugs?: string[];
  83. };
  84. /**
  85. * Helper function to actually load projects
  86. */
  87. async function fetchProjects(
  88. api: Client,
  89. orgId: string,
  90. {slugs, search, limit, lastSearch, cursor}: FetchProjectsOptions = {}
  91. ) {
  92. const query: {
  93. collapse: string[];
  94. all_projects?: number;
  95. cursor?: typeof cursor;
  96. per_page?: number;
  97. query?: string;
  98. } = {
  99. // Never return latestDeploys project property from api
  100. collapse: ['latestDeploys', 'unusedFeatures'],
  101. };
  102. if (slugs !== undefined && slugs.length > 0) {
  103. query.query = slugs.map(slug => `slug:${slug}`).join(' ');
  104. }
  105. if (search) {
  106. query.query = `${query.query ?? ''}${search}`.trim();
  107. }
  108. const prevSearchMatches = (!lastSearch && !search) || lastSearch === search;
  109. if (prevSearchMatches && cursor) {
  110. query.cursor = cursor;
  111. }
  112. if (limit !== undefined) {
  113. query.per_page = limit;
  114. }
  115. let hasMore: null | boolean = false;
  116. let nextCursor: null | string = null;
  117. const [data, , resp] = await api.requestPromise(`/organizations/${orgId}/projects/`, {
  118. includeAllArgs: true,
  119. query,
  120. });
  121. const pageLinks = resp?.getResponseHeader('Link');
  122. if (pageLinks) {
  123. const paginationObject = parseLinkHeader(pageLinks);
  124. hasMore = paginationObject?.next?.results || paginationObject?.previous?.results;
  125. nextCursor = paginationObject?.next?.cursor;
  126. }
  127. return {results: data, hasMore, nextCursor};
  128. }
  129. /**
  130. * Provides projects from the ProjectsStore
  131. *
  132. * This hook also provides a way to select specific project slugs, and search
  133. * (type-ahead) for more projects that may not be in the project store.
  134. *
  135. * NOTE: Currently ALL projects are always loaded, but this hook is designed
  136. * for future-compat in a world where we do _not_ load all projects.
  137. */
  138. function useProjects({limit, slugs, orgId: propOrgId}: Options = {}) {
  139. const api = useApi();
  140. const organization = useOrganization({allowNull: true});
  141. const store = useLegacyStore(ProjectsStore);
  142. const orgId = propOrgId ?? organization?.slug ?? organization?.slug;
  143. const storeSlugs = new Set(store.projects.map(t => t.slug));
  144. const slugsToLoad = slugs?.filter(slug => !storeSlugs.has(slug)) ?? [];
  145. const shouldLoadSlugs = slugsToLoad.length > 0;
  146. const [state, setState] = useState<State>({
  147. initiallyLoaded: !store.loading && !shouldLoadSlugs,
  148. fetching: shouldLoadSlugs,
  149. hasMore: null,
  150. lastSearch: null,
  151. nextCursor: null,
  152. fetchError: null,
  153. });
  154. const slugsRef = useRef<Set<string> | null>(null);
  155. // Only initialize slugsRef.current once and modify it when we receive new
  156. // slugs determined through set equality
  157. if (slugs !== undefined) {
  158. if (slugsRef.current === null) {
  159. slugsRef.current = new Set(slugs);
  160. }
  161. if (
  162. slugs.length !== slugsRef.current.size ||
  163. slugs.some(slug => !slugsRef.current?.has(slug))
  164. ) {
  165. slugsRef.current = new Set(slugs);
  166. }
  167. }
  168. async function loadProjectsBySlug() {
  169. if (orgId === undefined) {
  170. // eslint-disable-next-line no-console
  171. console.error('Cannot use useProjects({slugs}) without an organization in context');
  172. return;
  173. }
  174. setState(prev => ({...prev, fetching: true}));
  175. try {
  176. const {results, hasMore, nextCursor} = await fetchProjects(api, orgId, {
  177. slugs: slugsToLoad,
  178. limit,
  179. });
  180. // Note the order of uniqBy: we prioritize project data recently fetched over previously cached data
  181. const fetchedProjects = uniqBy([...results, ...store.projects], ({slug}) => slug);
  182. ProjectsStore.loadInitialData(fetchedProjects);
  183. setState(prev => ({
  184. ...prev,
  185. hasMore,
  186. fetching: false,
  187. initiallyLoaded: true,
  188. nextCursor,
  189. }));
  190. } catch (err) {
  191. console.error(err); // eslint-disable-line no-console
  192. setState(prev => ({
  193. ...prev,
  194. fetching: false,
  195. initiallyLoaded: !store.loading,
  196. fetchError: err,
  197. }));
  198. }
  199. }
  200. async function handleSearch(search: string) {
  201. const {lastSearch} = state;
  202. const cursor = state.nextCursor;
  203. if (search === '') {
  204. return;
  205. }
  206. if (orgId === undefined) {
  207. // eslint-disable-next-line no-console
  208. console.error('Cannot use useProjects.onSearch without an organization in context');
  209. return;
  210. }
  211. setState(prev => ({...prev, fetching: true}));
  212. try {
  213. api.clear();
  214. const {results, hasMore, nextCursor} = await fetchProjects(api, orgId, {
  215. search,
  216. limit,
  217. lastSearch,
  218. cursor,
  219. });
  220. const fetchedProjects = uniqBy([...store.projects, ...results], ({slug}) => slug);
  221. // Only update the store if we have more items
  222. if (fetchedProjects.length > store.projects.length) {
  223. ProjectsStore.loadInitialData(fetchedProjects);
  224. }
  225. setState(prev => ({
  226. ...prev,
  227. hasMore,
  228. fetching: false,
  229. lastSearch: search,
  230. nextCursor,
  231. }));
  232. } catch (err) {
  233. console.error(err); // eslint-disable-line no-console
  234. setState(prev => ({...prev, fetching: false, fetchError: err}));
  235. }
  236. }
  237. useEffect(() => {
  238. // Load specified team slugs
  239. if (shouldLoadSlugs) {
  240. loadProjectsBySlug();
  241. return;
  242. }
  243. // eslint-disable-next-line react-hooks/exhaustive-deps
  244. }, [slugsRef.current]);
  245. // Update initiallyLoaded when we finish loading within the projectStore
  246. useEffect(() => {
  247. const storeLoaded = !store.loading;
  248. if (state.initiallyLoaded === storeLoaded) {
  249. return;
  250. }
  251. if (shouldLoadSlugs) {
  252. return;
  253. }
  254. setState(prev => ({...prev, initiallyLoaded: storeLoaded}));
  255. // eslint-disable-next-line react-hooks/exhaustive-deps
  256. }, [store.loading]);
  257. const {initiallyLoaded, fetching, fetchError, hasMore} = state;
  258. const filteredProjects = slugs
  259. ? store.projects.filter(t => slugs.includes(t.slug))
  260. : store.projects;
  261. const placeholders = slugsToLoad.map(slug => ({slug}));
  262. const result: Result = {
  263. projects: filteredProjects,
  264. placeholders,
  265. fetching: fetching || store.loading,
  266. initiallyLoaded,
  267. fetchError,
  268. hasMore,
  269. onSearch: handleSearch,
  270. reloadProjects: loadProjectsBySlug,
  271. };
  272. return result;
  273. }
  274. export default useProjects;