asyncComponent.tsx 14 KB

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