deprecatedAsyncComponent.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. import {Component} from 'react';
  2. import type {RouteComponentProps, RouteContextInterface} from 'react-router';
  3. import * as Sentry from '@sentry/react';
  4. import isEqual from 'lodash/isEqual';
  5. import type {ResponseMeta} from 'sentry/api';
  6. import {Client} from 'sentry/api';
  7. import AsyncComponentSearchInput from 'sentry/components/asyncComponentSearchInput';
  8. import LoadingError from 'sentry/components/loadingError';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {t} from 'sentry/locale';
  11. import {SentryPropTypeValidators} from 'sentry/sentryPropTypeValidators';
  12. import {metric} from 'sentry/utils/analytics';
  13. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  14. import PermissionDenied from 'sentry/views/permissionDenied';
  15. import RouteError from 'sentry/views/routeError';
  16. export interface AsyncComponentProps extends Partial<RouteComponentProps<{}, {}>> {}
  17. export interface AsyncComponentState {
  18. [key: string]: any;
  19. error: boolean;
  20. errors: Record<string, ResponseMeta>;
  21. loading: boolean;
  22. reloading: boolean;
  23. remainingRequests?: number;
  24. }
  25. type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
  26. type RenderSearchInputArgs = Omit<
  27. SearchInputProps,
  28. 'api' | 'onSuccess' | 'onError' | 'url' | keyof RouteComponentProps<{}, {}>
  29. > & {
  30. stateKey?: string;
  31. url?: SearchInputProps['url'];
  32. };
  33. /**
  34. * Wraps methods on the AsyncComponent to catch errors and set the `error`
  35. * state on error.
  36. */
  37. function wrapErrorHandling<T extends any[], U>(
  38. component: DeprecatedAsyncComponent,
  39. fn: (...args: T) => U
  40. ) {
  41. return (...args: T): U | null => {
  42. try {
  43. return fn(...args);
  44. } catch (error) {
  45. // eslint-disable-next-line no-console
  46. console.error(error);
  47. window.setTimeout(() => {
  48. throw error;
  49. });
  50. component.setState({error});
  51. return null;
  52. }
  53. };
  54. }
  55. /**
  56. * @deprecated use useApiQuery instead
  57. *
  58. * Read the dev docs page on network requests for more information [1].
  59. *
  60. * [1]: https://develop.sentry.dev/frontend/network-requests/
  61. */
  62. class DeprecatedAsyncComponent<
  63. P extends AsyncComponentProps = AsyncComponentProps,
  64. S extends AsyncComponentState = AsyncComponentState,
  65. > extends Component<P, S> {
  66. static contextTypes = {
  67. router: SentryPropTypeValidators.isObject,
  68. };
  69. constructor(props: P, context: any) {
  70. super(props, context);
  71. this.api = new Client();
  72. this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
  73. this.render = wrapErrorHandling(this, this.render.bind(this));
  74. this.state = this.getDefaultState() as Readonly<S>;
  75. this._measurement = {
  76. hasMeasured: false,
  77. };
  78. if (props.routes) {
  79. metric.mark({name: `async-component-${getRouteStringFromRoutes(props.routes)}`});
  80. }
  81. }
  82. componentDidMount() {
  83. this.fetchData();
  84. if (this.reloadOnVisible) {
  85. document.addEventListener('visibilitychange', this.visibilityReloader);
  86. }
  87. }
  88. componentDidUpdate(prevProps: P, prevContext: any) {
  89. const isRouterInContext = !!prevContext.router;
  90. const isLocationInProps = prevProps.location !== undefined;
  91. const currentLocation = isLocationInProps
  92. ? this.props.location
  93. : isRouterInContext
  94. ? this.context.router.location
  95. : null;
  96. const prevLocation = isLocationInProps
  97. ? prevProps.location
  98. : isRouterInContext
  99. ? prevContext.router.location
  100. : null;
  101. if (!(currentLocation && prevLocation)) {
  102. return;
  103. }
  104. // Take a measurement from when this component is initially created until it finishes it's first
  105. // set of API requests
  106. if (
  107. !this._measurement.hasMeasured &&
  108. this._measurement.finished &&
  109. this.props.routes
  110. ) {
  111. const routeString = getRouteStringFromRoutes(this.props.routes);
  112. metric.measure({
  113. name: 'app.component.async-component',
  114. start: `async-component-${routeString}`,
  115. data: {
  116. route: routeString,
  117. error: this._measurement.error,
  118. },
  119. });
  120. this._measurement.hasMeasured = true;
  121. }
  122. // Re-fetch data when router params change.
  123. if (
  124. !isEqual(this.props.params, prevProps.params) ||
  125. currentLocation.search !== prevLocation.search ||
  126. currentLocation.state !== prevLocation.state
  127. ) {
  128. this.remountComponent();
  129. }
  130. }
  131. componentWillUnmount() {
  132. this.api.clear();
  133. document.removeEventListener('visibilitychange', this.visibilityReloader);
  134. }
  135. declare context: {router: RouteContextInterface};
  136. /**
  137. * Override this flag to have the component reload its state when the window
  138. * becomes visible again. This will set the loading and reloading state, but
  139. * will not render a loading state during reloading.
  140. *
  141. * eslint-disable-next-line react/sort-comp
  142. */
  143. reloadOnVisible = false;
  144. /**
  145. * When enabling reloadOnVisible, this flag may be used to turn on and off
  146. * the reloading. This is useful if your component only needs to reload when
  147. * becoming visible during certain states.
  148. *
  149. * eslint-disable-next-line react/sort-comp
  150. */
  151. shouldReloadOnVisible = false;
  152. /**
  153. * This affects how the component behaves when `remountComponent` is called
  154. * By default, the component gets put back into a "loading" state when re-fetching data.
  155. * If this is true, then when we fetch data, the original ready component remains mounted
  156. * and it will need to handle any additional "reloading" states
  157. */
  158. shouldReload = false;
  159. /**
  160. * should `renderError` render the `detail` attribute of a 400 error
  161. */
  162. shouldRenderBadRequests = false;
  163. /**
  164. * If a request fails and is not a bad request, and if `disableErrorReport` is set to false,
  165. * the UI will display an error modal.
  166. *
  167. * It is recommended to enable this property ideally only when the subclass is used by a top level route.
  168. */
  169. disableErrorReport = true;
  170. api: Client = new Client();
  171. private _measurement: any;
  172. // XXX: can't call this getInitialState as React whines
  173. getDefaultState(): AsyncComponentState {
  174. const endpoints = this.getEndpoints();
  175. const state = {
  176. // has all data finished requesting?
  177. loading: true,
  178. // is the component reload
  179. reloading: false,
  180. // is there an error loading ANY data?
  181. error: false,
  182. errors: {},
  183. // We will fetch immeditaely upon mount
  184. remainingRequests: endpoints.length || undefined,
  185. };
  186. // We are not loading if there are no endpoints
  187. if (!endpoints.length) {
  188. state.loading = false;
  189. }
  190. endpoints.forEach(([stateKey, _endpoint]) => {
  191. state[stateKey] = null;
  192. });
  193. return state;
  194. }
  195. // Check if we should measure render time for this component
  196. markShouldMeasure = ({
  197. remainingRequests,
  198. error,
  199. }: {error?: any; remainingRequests?: number} = {}) => {
  200. if (!this._measurement.hasMeasured) {
  201. this._measurement.finished = remainingRequests === 0;
  202. this._measurement.error = error || this._measurement.error;
  203. }
  204. };
  205. remountComponent = () => {
  206. if (this.shouldReload) {
  207. this.reloadData();
  208. } else {
  209. this.setState(this.getDefaultState(), this.fetchData);
  210. }
  211. };
  212. visibilityReloader = () =>
  213. this.shouldReloadOnVisible &&
  214. !this.state.loading &&
  215. !document.hidden &&
  216. this.reloadData();
  217. reloadData() {
  218. this.fetchData({reloading: true});
  219. }
  220. fetchData = (extraState?: object) => {
  221. const endpoints = this.getEndpoints();
  222. if (!endpoints.length) {
  223. this.setState({loading: false, error: false});
  224. return;
  225. }
  226. // Cancel any in flight requests
  227. this.api.clear();
  228. this.setState({
  229. loading: true,
  230. error: false,
  231. remainingRequests: endpoints.length,
  232. ...extraState,
  233. });
  234. endpoints.forEach(([stateKey, endpoint, params, options]) => {
  235. options = options || {};
  236. // If you're using nested async components/views make sure to pass the
  237. // props through so that the child component has access to props.location
  238. const locationQuery = this.props.location?.query || {};
  239. let query = params?.query || {};
  240. // If paginate option then pass entire `query` object to API call
  241. // It should only be expecting `query.cursor` for pagination
  242. if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
  243. query = {...locationQuery, ...query};
  244. }
  245. this.api.request(endpoint, {
  246. method: 'GET',
  247. ...params,
  248. query,
  249. success: (data, _, resp) => {
  250. this.handleRequestSuccess({stateKey, data, resp}, true);
  251. },
  252. error: error => {
  253. // Allow endpoints to fail
  254. // allowError can have side effects to handle the error
  255. if (options.allowError?.(error)) {
  256. error = null;
  257. }
  258. this.handleError(error, [stateKey, endpoint, params, options]);
  259. },
  260. });
  261. });
  262. };
  263. onRequestSuccess(_resp /* {stateKey, data, resp} */) {
  264. // Allow children to implement this
  265. }
  266. onRequestError(_resp, _args) {
  267. // Allow children to implement this
  268. }
  269. onLoadAllEndpointsSuccess() {
  270. // Allow children to implement this
  271. }
  272. handleRequestSuccess({stateKey, data, resp}, initialRequest?: boolean) {
  273. this.setState(
  274. prevState => {
  275. const state = {
  276. [stateKey]: data,
  277. // TODO(billy): This currently fails if this request is retried by SudoModal
  278. [`${stateKey}PageLinks`]: resp?.getResponseHeader('Link'),
  279. };
  280. if (initialRequest) {
  281. state.remainingRequests = prevState.remainingRequests! - 1;
  282. state.loading = prevState.remainingRequests! > 1;
  283. state.reloading = prevState.reloading && state.loading;
  284. this.markShouldMeasure({remainingRequests: state.remainingRequests});
  285. }
  286. return state;
  287. },
  288. () => {
  289. // if everything is loaded and we don't have an error, call the callback
  290. if (this.state.remainingRequests === 0 && !this.state.error) {
  291. this.onLoadAllEndpointsSuccess();
  292. }
  293. }
  294. );
  295. this.onRequestSuccess({stateKey, data, resp});
  296. }
  297. handleError(error, args) {
  298. const [stateKey] = args;
  299. if (error?.responseText) {
  300. Sentry.addBreadcrumb({
  301. message: error.responseText,
  302. category: 'xhr',
  303. level: 'error',
  304. });
  305. }
  306. this.setState(prevState => {
  307. const loading = prevState.remainingRequests! > 1;
  308. const state: AsyncComponentState = {
  309. [stateKey]: null,
  310. errors: {
  311. ...prevState.errors,
  312. [stateKey]: error,
  313. },
  314. error: prevState.error || !!error,
  315. remainingRequests: prevState.remainingRequests! - 1,
  316. loading,
  317. reloading: prevState.reloading && loading,
  318. };
  319. this.markShouldMeasure({remainingRequests: state.remainingRequests, error: true});
  320. return state;
  321. });
  322. this.onRequestError(error, args);
  323. }
  324. /**
  325. * Return a list of endpoint queries to make.
  326. *
  327. * return [
  328. * ['stateKeyName', '/endpoint/', {optional: 'query params'}, {options}]
  329. * ]
  330. */
  331. getEndpoints(): Array<[string, string, any?, any?]> {
  332. return [];
  333. }
  334. renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
  335. const [firstEndpoint] = this.getEndpoints() || [null];
  336. const stateKeyOrDefault = stateKey || firstEndpoint?.[0];
  337. const urlOrDefault = url || firstEndpoint?.[1];
  338. return (
  339. <AsyncComponentSearchInput
  340. url={urlOrDefault}
  341. {...props}
  342. api={this.api}
  343. onSuccess={(data, resp) => {
  344. this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, resp});
  345. }}
  346. onError={() => {
  347. this.renderError(new Error('Error with AsyncComponentSearchInput'));
  348. }}
  349. />
  350. );
  351. }
  352. renderLoading(): React.ReactNode {
  353. return <LoadingIndicator />;
  354. }
  355. renderError(error?: Error, disableLog = false): React.ReactNode {
  356. const {errors} = this.state;
  357. // 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
  358. const unauthorizedErrors = Object.values(errors).find(resp => resp?.status === 401);
  359. // Look through endpoint results to see if we had any 403s, means their role can not access resource
  360. const permissionErrors = Object.values(errors).find(resp => resp?.status === 403);
  361. // If all error responses have status code === 0, then show error message but don't
  362. // log it to sentry
  363. const shouldLogSentry =
  364. !!Object.values(errors).find(resp => resp?.status !== 0) || disableLog;
  365. if (unauthorizedErrors) {
  366. return (
  367. <LoadingError message={t('You are not authorized to access this resource.')} />
  368. );
  369. }
  370. if (permissionErrors) {
  371. return <PermissionDenied />;
  372. }
  373. if (this.shouldRenderBadRequests) {
  374. const badRequests = Object.values(errors)
  375. .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
  376. .map(resp => resp.responseJSON.detail);
  377. if (badRequests.length) {
  378. return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
  379. }
  380. }
  381. return (
  382. <RouteError
  383. error={error}
  384. disableLogSentry={!shouldLogSentry}
  385. disableReport={this.disableErrorReport}
  386. />
  387. );
  388. }
  389. get shouldRenderLoading() {
  390. return this.state.loading && (!this.shouldReload || !this.state.reloading);
  391. }
  392. renderComponent() {
  393. return this.shouldRenderLoading
  394. ? this.renderLoading()
  395. : this.state.error
  396. ? this.renderError()
  397. : this.renderBody();
  398. }
  399. /**
  400. * Renders once all endpoints have been loaded
  401. */
  402. renderBody(): React.ReactNode {
  403. // Allow children to implement this
  404. throw new Error('Not implemented');
  405. }
  406. render() {
  407. return this.renderComponent();
  408. }
  409. }
  410. export default DeprecatedAsyncComponent;