useApiRequests.tsx 14 KB

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