useApiRequests.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {ResponseMeta} from 'sentry/api';
  4. import LoadingError from 'sentry/components/loadingError';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import {t} from 'sentry/locale';
  7. import {metric} from 'sentry/utils/analytics';
  8. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  9. import useApi from 'sentry/utils/useApi';
  10. import {useLocation} from 'sentry/utils/useLocation';
  11. import {useParams} from 'sentry/utils/useParams';
  12. import {useRoutes} from 'sentry/utils/useRoutes';
  13. import PermissionDenied from 'sentry/views/permissionDenied';
  14. import RouteError from 'sentry/views/routeError';
  15. import RequestError from './requestError/requestError';
  16. import {useEffectAfterFirstRender} from './useEffectAfterFirstRender';
  17. /**
  18. * Turn {foo: X} into {foo: X, fooPageLinks: string}
  19. */
  20. type UseApiRequestData<T extends Record<string, any>> = {
  21. // Keys can be null on error
  22. [Property in keyof T]: T[Property] | null;
  23. } & {
  24. // Make request cursors available
  25. [Property in keyof T as `${Property & string}PageLinks`]: string | null;
  26. };
  27. interface State<T extends Record<string, any>> {
  28. /**
  29. * Mapping of results from the configured endpoints
  30. */
  31. data: UseApiRequestData<T>;
  32. /**
  33. * Errors from the configured endpoionts
  34. */
  35. errors: Record<string, RequestError>;
  36. /**
  37. * Did *any* of the endpoints fail?
  38. */
  39. hasError: boolean;
  40. /**
  41. * Are the endpoints currently loading?
  42. */
  43. isLoading: boolean;
  44. /**
  45. * Are we *reloading* data without the loading state being set to true?
  46. */
  47. isReloading: boolean;
  48. /**
  49. * How many requests are still pending?
  50. */
  51. remainingRequests: number;
  52. }
  53. interface Result<T extends Record<string, any>> extends State<T> {
  54. /**
  55. * renderComponent is a helper function that is used to render loading and
  56. * errors state for you, and will only render your component once all endpoints
  57. * have resolved.
  58. *
  59. * Typically you would use this when returning react for your component.
  60. *
  61. * return renderComponent(
  62. * <div>{data.someEndpoint.resultKey}</div>
  63. * )
  64. *
  65. * The react element will only be rendered once all endpoints have been loaded.
  66. */
  67. renderComponent: (children: React.ReactElement) => React.ReactElement;
  68. }
  69. type EndpointRequestOptions = {
  70. /**
  71. * Function to check if the error is allowed
  72. */
  73. allowError?: (error: any) => void;
  74. /**
  75. * Do not pass query parameters to the API
  76. */
  77. disableEntireQuery?: boolean;
  78. /**
  79. * If set then pass entire `query` object to API call
  80. */
  81. paginate?: boolean;
  82. };
  83. export type EndpointDefinition<T extends Record<string, any>> = [
  84. key: keyof T,
  85. url: string,
  86. urlOptions?: {query?: Record<string, string>},
  87. requestOptions?: EndpointRequestOptions
  88. ];
  89. type Options<T extends Record<string, any>> = {
  90. endpoints: EndpointDefinition<T>[];
  91. /**
  92. * If a request fails and is not a bad request, and if `disableErrorReport`
  93. * is set to false, the UI will display an error modal.
  94. *
  95. * It is recommended to enable this property ideally only when the subclass
  96. * is used by a top level route.
  97. */
  98. disableErrorReport?: boolean;
  99. onLoadAllEndpointsSuccess?: () => void;
  100. onRequestError?: (error: RequestError, args: Options<T>['endpoints'][0]) => void;
  101. onRequestSuccess?: (data: {data: any; stateKey: keyof T; resp?: ResponseMeta}) => void;
  102. /**
  103. * Override this flag to have the component reload its state when the window
  104. * becomes visible again. This will set the loading and reloading state, but
  105. * will not render a loading state during reloading.
  106. *
  107. * eslint-disable-next-line react/sort-comp
  108. */
  109. reloadOnVisible?: boolean;
  110. /**
  111. * This affects how the component behaves when `remountComponent` is called
  112. *
  113. * By default, the component gets put back into a "loading" state when
  114. * re-fetching data. If this is true, then when we fetch data, the original
  115. * ready component remains mounted and it will need to handle any additional
  116. * "reloading" states
  117. */
  118. shouldReload?: boolean;
  119. /**
  120. * should `renderError` render the `detail` attribute of a 400 error
  121. */
  122. shouldRenderBadRequests?: boolean;
  123. };
  124. function renderLoading() {
  125. return <LoadingIndicator />;
  126. }
  127. function useApiRequests<T extends Record<string, any>>({
  128. endpoints,
  129. reloadOnVisible = false,
  130. shouldReload = false,
  131. shouldRenderBadRequests = false,
  132. disableErrorReport = true,
  133. onLoadAllEndpointsSuccess = () => {},
  134. onRequestSuccess = _data => {},
  135. onRequestError = (_error, _args) => {},
  136. }: Options<T>): Result<T> {
  137. const api = useApi();
  138. const location = useLocation<any>();
  139. const params = useParams();
  140. // Memoize the initialState so we can easily reuse it later
  141. const initialState = useMemo<State<T>>(
  142. () => ({
  143. data: {} as T,
  144. isLoading: true,
  145. hasError: false,
  146. isReloading: false,
  147. errors: {},
  148. remainingRequests: endpoints.length,
  149. }),
  150. [endpoints.length]
  151. );
  152. const [state, setState] = useState<State<T>>(initialState);
  153. // Begin measuring the use of the hook for the given route
  154. const triggerMeasurement = useMeasureApiRequests();
  155. const handleRequestSuccess = useCallback(
  156. (
  157. {stateKey, data, resp}: {data: any; stateKey: keyof T; resp?: ResponseMeta},
  158. initialRequest?: boolean
  159. ) => {
  160. setState(prevState => {
  161. const newState = {
  162. ...prevState,
  163. data: {
  164. ...prevState.data,
  165. [stateKey]: data,
  166. [`${stateKey as string}PageLinks`]: resp?.getResponseHeader('Link'),
  167. },
  168. };
  169. if (initialRequest) {
  170. newState.remainingRequests = prevState.remainingRequests - 1;
  171. newState.isLoading = prevState.remainingRequests > 1;
  172. newState.isReloading = prevState.isReloading && newState.isLoading;
  173. triggerMeasurement({finished: newState.remainingRequests === 0});
  174. }
  175. return newState;
  176. });
  177. // if everything is loaded and we don't have an error, call the callback
  178. onRequestSuccess({stateKey, data, resp});
  179. },
  180. [onRequestSuccess, triggerMeasurement]
  181. );
  182. const handleError = useCallback(
  183. (error: RequestError, args: EndpointDefinition<T>) => {
  184. const [stateKey] = args;
  185. if (error && error.responseText) {
  186. Sentry.addBreadcrumb({
  187. message: error.responseText,
  188. category: 'xhr',
  189. level: 'error',
  190. });
  191. }
  192. setState(prevState => {
  193. const isLoading = prevState.remainingRequests > 1;
  194. const newState = {
  195. errors: {
  196. ...prevState.errors,
  197. [stateKey]: error,
  198. },
  199. data: {
  200. ...prevState.data,
  201. [stateKey]: null,
  202. },
  203. hasError: prevState.hasError || !!error,
  204. remainingRequests: prevState.remainingRequests - 1,
  205. isLoading,
  206. isReloading: prevState.isReloading && isLoading,
  207. };
  208. triggerMeasurement({finished: newState.remainingRequests === 0, error: true});
  209. return newState;
  210. });
  211. onRequestError(error, args);
  212. },
  213. [triggerMeasurement, onRequestError]
  214. );
  215. const fetchData = useCallback(
  216. async (extraState: Partial<State<T>> = {}) => {
  217. // Nothing to fetch if enpoints are empty
  218. if (!endpoints.length) {
  219. setState(prevState => ({
  220. ...prevState,
  221. data: {} as T,
  222. isLoading: false,
  223. hasError: false,
  224. }));
  225. return;
  226. }
  227. // Cancel any in flight requests
  228. api.clear();
  229. setState(prevState => ({
  230. ...prevState,
  231. isLoading: true,
  232. hasError: false,
  233. remainingRequests: endpoints.length,
  234. ...extraState,
  235. }));
  236. await Promise.all(
  237. endpoints.map(async ([stateKey, endpoint, parameters, options]) => {
  238. options = options ?? {};
  239. // If you're using nested async components/views make sure to pass the
  240. // props through so that the child component has access to props.location
  241. const locationQuery = (location && location.query) || {};
  242. let query = (parameters && parameters.query) || {};
  243. // If paginate option then pass entire `query` object to API call
  244. // It should only be expecting `query.cursor` for pagination
  245. if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
  246. query = {...locationQuery, ...query};
  247. }
  248. try {
  249. const results = await api.requestPromise(endpoint, {
  250. method: 'GET',
  251. ...parameters,
  252. query,
  253. includeAllArgs: true,
  254. });
  255. const [data, _, resp] = results;
  256. handleRequestSuccess({stateKey, data, resp}, true);
  257. } catch (error) {
  258. handleError(error, [stateKey, endpoint, parameters, options]);
  259. }
  260. })
  261. );
  262. },
  263. [api, endpoints, handleError, handleRequestSuccess, location]
  264. );
  265. const reloadData = useCallback(() => fetchData({isReloading: true}), [fetchData]);
  266. const handleMount = useCallback(async () => {
  267. try {
  268. await fetchData();
  269. } catch (error) {
  270. setState(prevState => ({...prevState, hasError: true}));
  271. throw error;
  272. }
  273. }, [fetchData]);
  274. // Trigger fetch on mount
  275. // eslint-disable-next-line react-hooks/exhaustive-deps
  276. useEffect(() => void handleMount(), []);
  277. const handleFullReload = useCallback(() => {
  278. if (shouldReload) {
  279. return reloadData();
  280. }
  281. setState({...initialState});
  282. return fetchData();
  283. }, [initialState, reloadData, fetchData, shouldReload]);
  284. // Trigger fetch on location or parameter change
  285. // useEffectAfterFirstRender to avoid calling at the same time as handleMount
  286. useEffectAfterFirstRender(
  287. () => void handleFullReload(),
  288. // eslint-disable-next-line react-hooks/exhaustive-deps
  289. [location?.search, location?.state, params]
  290. );
  291. const visibilityReloader = useCallback(
  292. () => !state.isLoading && !document.hidden && reloadData(),
  293. [state.isLoading, reloadData]
  294. );
  295. // Trigger fetch on visible change when using visibilityReloader
  296. useEffect(() => {
  297. if (reloadOnVisible) {
  298. document.addEventListener('visibilitychange', visibilityReloader);
  299. }
  300. return () => document.removeEventListener('visibilitychange', visibilityReloader);
  301. }, [reloadOnVisible, visibilityReloader]);
  302. // Trigger onLoadAllEndpointsSuccess when everything has been loaded
  303. useEffect(
  304. () => {
  305. if (endpoints.length && state.remainingRequests === 0 && !state.hasError) {
  306. onLoadAllEndpointsSuccess();
  307. }
  308. },
  309. // eslint-disable-next-line react-hooks/exhaustive-deps
  310. [state.remainingRequests, state.hasError, endpoints.length]
  311. );
  312. const renderError = useCallback(
  313. (error?: Error, disableLog = false): React.ReactElement => {
  314. const errors = state.errors;
  315. // 401s are captured by SudoModal, but may be passed back to AsyncComponent
  316. // if they close the modal without identifying
  317. const unauthorizedErrors = Object.values(errors).some(resp => resp?.status === 401);
  318. // Look through endpoint results to see if we had any 403s, means their
  319. // role can not access resource
  320. const permissionErrors = Object.values(errors).some(resp => resp?.status === 403);
  321. // If all error responses have status code === 0, then show error message
  322. // but don't log it to sentry
  323. const shouldLogSentry =
  324. !!Object.values(errors).some(resp => resp?.status !== 0) || disableLog;
  325. if (unauthorizedErrors) {
  326. return (
  327. <LoadingError message={t('You are not authorized to access this resource.')} />
  328. );
  329. }
  330. if (permissionErrors) {
  331. return <PermissionDenied />;
  332. }
  333. if (shouldRenderBadRequests) {
  334. const badRequests = Object.values(errors)
  335. .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
  336. .map(resp => resp.responseJSON.detail);
  337. if (badRequests.length) {
  338. return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
  339. }
  340. }
  341. return (
  342. <RouteError
  343. error={error}
  344. disableLogSentry={!shouldLogSentry}
  345. disableReport={disableErrorReport}
  346. />
  347. );
  348. },
  349. [state.errors, disableErrorReport, shouldRenderBadRequests]
  350. );
  351. const shouldRenderLoading = state.isLoading && (!shouldReload || !state.isReloading);
  352. const renderComponent = useCallback(
  353. (children: React.ReactElement) =>
  354. shouldRenderLoading
  355. ? renderLoading()
  356. : state.hasError
  357. ? renderError(new Error('Unable to load all required endpoints'))
  358. : children,
  359. [shouldRenderLoading, state.hasError, renderError]
  360. );
  361. return {...state, renderComponent};
  362. }
  363. export default useApiRequests;
  364. type MetricsState = {
  365. error: boolean;
  366. finished: boolean;
  367. hasMeasured: boolean;
  368. };
  369. type MetricUpdate = Partial<Pick<MetricsState, 'finished' | 'error'>>;
  370. /**
  371. * Helper hook that marks a measurement when the component mounts.
  372. *
  373. * Use the `triggerMeasurement` function to trigger a measurement when the
  374. * useApiRequests hook has finished loading all requests. Will only trigger once
  375. */
  376. function useMeasureApiRequests() {
  377. const routes = useRoutes();
  378. const measurement = useRef<MetricsState>({
  379. hasMeasured: false,
  380. finished: false,
  381. error: false,
  382. });
  383. // Start measuring immediately upon mount. We re-mark if the route list has
  384. // changed, since the component is now being used under a different route
  385. useEffect(() => {
  386. // Reset the measurement object
  387. measurement.current = {
  388. hasMeasured: false,
  389. finished: false,
  390. error: false,
  391. };
  392. if (routes && routes.length) {
  393. metric.mark({name: `async-component-${getRouteStringFromRoutes(routes)}`});
  394. }
  395. }, [routes]);
  396. const triggerMeasurement = useCallback(
  397. ({finished, error}: MetricUpdate) => {
  398. if (!routes) {
  399. return;
  400. }
  401. if (finished) {
  402. measurement.current.finished = true;
  403. }
  404. if (error) {
  405. measurement.current.error = true;
  406. }
  407. if (!measurement.current.hasMeasured && measurement.current.finished) {
  408. const routeString = getRouteStringFromRoutes(routes);
  409. metric.measure({
  410. name: 'app.component.async-component',
  411. start: `async-component-${routeString}`,
  412. data: {
  413. route: routeString,
  414. error: measurement.current.error,
  415. },
  416. });
  417. measurement.current.hasMeasured = true;
  418. }
  419. },
  420. [routes]
  421. );
  422. return triggerMeasurement;
  423. }