useApiRequests.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  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> {
  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: false,
  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. // setUseWhatChange();
  216. // useWhatChanged([endpoints]);
  217. const fetchData = useCallback(
  218. async (extraState: Partial<State<T>> = {}) => {
  219. // Nothing to fetch if enpoints are empty
  220. if (!endpoints.length) {
  221. setState(prevState => ({
  222. ...prevState,
  223. data: {} as T,
  224. isLoading: false,
  225. hasError: false,
  226. }));
  227. return;
  228. }
  229. // Cancel any in flight requests
  230. api.clear();
  231. setState(prevState => ({
  232. ...prevState,
  233. isLoading: true,
  234. hasError: false,
  235. remainingRequests: endpoints.length,
  236. ...extraState,
  237. }));
  238. await Promise.all(
  239. endpoints.map(async ([stateKey, endpoint, parameters, options]) => {
  240. options = options ?? {};
  241. // If you're using nested async components/views make sure to pass the
  242. // props through so that the child component has access to props.location
  243. const locationQuery = (location && location.query) || {};
  244. let query = (parameters && parameters.query) || {};
  245. // If paginate option then pass entire `query` object to API call
  246. // It should only be expecting `query.cursor` for pagination
  247. if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
  248. query = {...locationQuery, ...query};
  249. }
  250. try {
  251. const results = await api.requestPromise(endpoint, {
  252. method: 'GET',
  253. ...parameters,
  254. query,
  255. includeAllArgs: true,
  256. });
  257. const [data, _, resp] = results;
  258. handleRequestSuccess({stateKey, data, resp}, true);
  259. } catch (error) {
  260. handleError(error, [stateKey, endpoint, parameters, options]);
  261. }
  262. })
  263. );
  264. },
  265. [api, endpoints, handleError, handleRequestSuccess, location]
  266. );
  267. const reloadData = useCallback(() => fetchData({isReloading: true}), [fetchData]);
  268. const handleMount = useCallback(async () => {
  269. try {
  270. await fetchData();
  271. } catch (error) {
  272. setState(prevState => ({...prevState, hasError: true}));
  273. throw error;
  274. }
  275. }, [fetchData]);
  276. // Trigger fetch on mount
  277. // eslint-disable-next-line react-hooks/exhaustive-deps
  278. useEffect(() => void handleMount(), []);
  279. const handleFullReload = useCallback(() => {
  280. if (shouldReload) {
  281. return reloadData();
  282. }
  283. setState({...initialState});
  284. return fetchData();
  285. }, [initialState, reloadData, fetchData, shouldReload]);
  286. // Trigger fetch on location or parameter change
  287. // useEffectAfterFirstRender to avoid calling at the same time as handleMount
  288. useEffectAfterFirstRender(
  289. () => void handleFullReload(),
  290. // eslint-disable-next-line react-hooks/exhaustive-deps
  291. [location?.search, location?.state, params]
  292. );
  293. const visibilityReloader = useCallback(
  294. () => !state.isLoading && !document.hidden && reloadData(),
  295. [state.isLoading, reloadData]
  296. );
  297. // Trigger fetch on visible change when using visibilityReloader
  298. useEffect(() => {
  299. if (reloadOnVisible) {
  300. document.addEventListener('visibilitychange', visibilityReloader);
  301. }
  302. return () => document.removeEventListener('visibilitychange', visibilityReloader);
  303. }, [reloadOnVisible, visibilityReloader]);
  304. // Trigger onLoadAllEndpointsSuccess when everything has been loaded
  305. useEffect(
  306. () => {
  307. if (endpoints.length && state.remainingRequests === 0 && !state.hasError) {
  308. onLoadAllEndpointsSuccess();
  309. }
  310. },
  311. // eslint-disable-next-line react-hooks/exhaustive-deps
  312. [state.remainingRequests, state.hasError, endpoints.length]
  313. );
  314. const renderError = useCallback(
  315. (error?: Error, disableLog = false): React.ReactElement => {
  316. const errors = state.errors;
  317. // 401s are captured by SudoModal, but may be passed back to AsyncComponent
  318. // if they close the modal without identifying
  319. const unauthorizedErrors = Object.values(errors).some(resp => resp?.status === 401);
  320. // Look through endpoint results to see if we had any 403s, means their
  321. // role can not access resource
  322. const permissionErrors = Object.values(errors).some(resp => resp?.status === 403);
  323. // If all error responses have status code === 0, then show error message
  324. // but don't log it to sentry
  325. const shouldLogSentry =
  326. !!Object.values(errors).some(resp => resp?.status !== 0) || disableLog;
  327. if (unauthorizedErrors) {
  328. return (
  329. <LoadingError message={t('You are not authorized to access this resource.')} />
  330. );
  331. }
  332. if (permissionErrors) {
  333. return <PermissionDenied />;
  334. }
  335. if (shouldRenderBadRequests) {
  336. const badRequests = Object.values(errors)
  337. .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
  338. .map(resp => resp.responseJSON.detail);
  339. if (badRequests.length) {
  340. return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
  341. }
  342. }
  343. return (
  344. <RouteError
  345. error={error}
  346. disableLogSentry={!shouldLogSentry}
  347. disableReport={disableErrorReport}
  348. />
  349. );
  350. },
  351. [state.errors, disableErrorReport, shouldRenderBadRequests]
  352. );
  353. const shouldRenderLoading = state.isLoading && (!shouldReload || !state.isReloading);
  354. const renderComponent = useCallback(
  355. (children: React.ReactElement) =>
  356. shouldRenderLoading
  357. ? renderLoading()
  358. : state.hasError
  359. ? renderError(new Error('Unable to load all required endpoints'))
  360. : children,
  361. [shouldRenderLoading, state.hasError, renderError]
  362. );
  363. return {...state, renderComponent};
  364. }
  365. export default useApiRequests;
  366. type MetricsState = {
  367. error: boolean;
  368. finished: boolean;
  369. hasMeasured: boolean;
  370. };
  371. type MetricUpdate = Partial<Pick<MetricsState, 'finished' | 'error'>>;
  372. /**
  373. * Helper hook that marks a measurement when the component mounts.
  374. *
  375. * Use the `triggerMeasurement` function to trigger a measurement when the
  376. * useApiRequests hook has finished loading all requests. Will only trigger once
  377. */
  378. function useMeasureApiRequests() {
  379. const routes = useRoutes();
  380. const measurement = useRef<MetricsState>({
  381. hasMeasured: false,
  382. finished: false,
  383. error: false,
  384. });
  385. // Start measuring immediately upon mount. We re-mark if the route list has
  386. // changed, since the component is now being used under a different route
  387. useEffect(() => {
  388. // Reset the measurement object
  389. measurement.current = {
  390. hasMeasured: false,
  391. finished: false,
  392. error: false,
  393. };
  394. if (routes && routes.length) {
  395. metric.mark({name: `async-component-${getRouteStringFromRoutes(routes)}`});
  396. }
  397. }, [routes]);
  398. const triggerMeasurement = useCallback(
  399. ({finished, error}: MetricUpdate) => {
  400. if (!routes) {
  401. return;
  402. }
  403. if (finished) {
  404. measurement.current.finished = true;
  405. }
  406. if (error) {
  407. measurement.current.error = true;
  408. }
  409. if (!measurement.current.hasMeasured && measurement.current.finished) {
  410. const routeString = getRouteStringFromRoutes(routes);
  411. metric.measure({
  412. name: 'app.component.async-component',
  413. start: `async-component-${routeString}`,
  414. data: {
  415. route: routeString,
  416. error: measurement.current.error,
  417. },
  418. });
  419. measurement.current.hasMeasured = true;
  420. }
  421. },
  422. [routes]
  423. );
  424. return triggerMeasurement;
  425. }