|
@@ -0,0 +1,472 @@
|
|
|
+import {Component} from 'react';
|
|
|
+import {RouteComponentProps} from 'react-router';
|
|
|
+import * as Sentry from '@sentry/react';
|
|
|
+import isEqual from 'lodash/isEqual';
|
|
|
+import * as PropTypes from 'prop-types';
|
|
|
+
|
|
|
+import {Client, ResponseMeta} from 'sentry/api';
|
|
|
+import AsyncComponentSearchInput from 'sentry/components/asyncComponentSearchInput';
|
|
|
+import LoadingError from 'sentry/components/loadingError';
|
|
|
+import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
|
+import {t} from 'sentry/locale';
|
|
|
+import {metric} from 'sentry/utils/analytics';
|
|
|
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
|
|
|
+import PermissionDenied from 'sentry/views/permissionDenied';
|
|
|
+import RouteError from 'sentry/views/routeError';
|
|
|
+
|
|
|
+export interface AsyncComponentProps extends Partial<RouteComponentProps<{}, {}>> {}
|
|
|
+
|
|
|
+export interface AsyncComponentState {
|
|
|
+ [key: string]: any;
|
|
|
+ error: boolean;
|
|
|
+ errors: Record<string, ResponseMeta>;
|
|
|
+ loading: boolean;
|
|
|
+ reloading: boolean;
|
|
|
+ remainingRequests?: number;
|
|
|
+}
|
|
|
+
|
|
|
+type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
|
|
|
+
|
|
|
+type RenderSearchInputArgs = Omit<
|
|
|
+ SearchInputProps,
|
|
|
+ 'api' | 'onSuccess' | 'onError' | 'url' | keyof RouteComponentProps<{}, {}>
|
|
|
+> & {
|
|
|
+ stateKey?: string;
|
|
|
+ url?: SearchInputProps['url'];
|
|
|
+};
|
|
|
+
|
|
|
+/**
|
|
|
+ * Wraps methods on the AsyncComponent to catch errors and set the `error`
|
|
|
+ * state on error.
|
|
|
+ */
|
|
|
+function wrapErrorHandling<T extends any[], U>(
|
|
|
+ component: DeprecatedAsyncComponent,
|
|
|
+ fn: (...args: T) => U
|
|
|
+) {
|
|
|
+ return (...args: T): U | null => {
|
|
|
+ try {
|
|
|
+ return fn(...args);
|
|
|
+ } catch (error) {
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
+ console.error(error);
|
|
|
+ window.setTimeout(() => {
|
|
|
+ throw error;
|
|
|
+ });
|
|
|
+ component.setState({error});
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @deprecated use useApiQuery instead
|
|
|
+ *
|
|
|
+ * Read the dev docs page on network requests for more information [1].
|
|
|
+ *
|
|
|
+ * [1]: https://develop.sentry.dev/frontend/network-requests/
|
|
|
+ */
|
|
|
+class DeprecatedAsyncComponent<
|
|
|
+ P extends AsyncComponentProps = AsyncComponentProps,
|
|
|
+ S extends AsyncComponentState = AsyncComponentState
|
|
|
+> extends Component<P, S> {
|
|
|
+ static contextTypes = {
|
|
|
+ router: PropTypes.object,
|
|
|
+ };
|
|
|
+
|
|
|
+ constructor(props: P, context: any) {
|
|
|
+ super(props, context);
|
|
|
+
|
|
|
+ this.api = new Client();
|
|
|
+ this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
|
|
|
+ this.render = wrapErrorHandling(this, this.render.bind(this));
|
|
|
+
|
|
|
+ this.state = this.getDefaultState() as Readonly<S>;
|
|
|
+
|
|
|
+ this._measurement = {
|
|
|
+ hasMeasured: false,
|
|
|
+ };
|
|
|
+ if (props.routes) {
|
|
|
+ metric.mark({name: `async-component-${getRouteStringFromRoutes(props.routes)}`});
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ componentDidMount() {
|
|
|
+ this.fetchData();
|
|
|
+
|
|
|
+ if (this.reloadOnVisible) {
|
|
|
+ document.addEventListener('visibilitychange', this.visibilityReloader);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ componentDidUpdate(prevProps: P, prevContext: any) {
|
|
|
+ const isRouterInContext = !!prevContext.router;
|
|
|
+ const isLocationInProps = prevProps.location !== undefined;
|
|
|
+
|
|
|
+ const currentLocation = isLocationInProps
|
|
|
+ ? this.props.location
|
|
|
+ : isRouterInContext
|
|
|
+ ? this.context.router.location
|
|
|
+ : null;
|
|
|
+ const prevLocation = isLocationInProps
|
|
|
+ ? prevProps.location
|
|
|
+ : isRouterInContext
|
|
|
+ ? prevContext.router.location
|
|
|
+ : null;
|
|
|
+
|
|
|
+ if (!(currentLocation && prevLocation)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Take a measurement from when this component is initially created until it finishes it's first
|
|
|
+ // set of API requests
|
|
|
+ if (
|
|
|
+ !this._measurement.hasMeasured &&
|
|
|
+ this._measurement.finished &&
|
|
|
+ this.props.routes
|
|
|
+ ) {
|
|
|
+ const routeString = getRouteStringFromRoutes(this.props.routes);
|
|
|
+ metric.measure({
|
|
|
+ name: 'app.component.async-component',
|
|
|
+ start: `async-component-${routeString}`,
|
|
|
+ data: {
|
|
|
+ route: routeString,
|
|
|
+ error: this._measurement.error,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ this._measurement.hasMeasured = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Re-fetch data when router params change.
|
|
|
+ if (
|
|
|
+ !isEqual(this.props.params, prevProps.params) ||
|
|
|
+ currentLocation.search !== prevLocation.search ||
|
|
|
+ currentLocation.state !== prevLocation.state
|
|
|
+ ) {
|
|
|
+ this.remountComponent();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ componentWillUnmount() {
|
|
|
+ this.api.clear();
|
|
|
+ document.removeEventListener('visibilitychange', this.visibilityReloader);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Override this flag to have the component reload its state when the window
|
|
|
+ * becomes visible again. This will set the loading and reloading state, but
|
|
|
+ * will not render a loading state during reloading.
|
|
|
+ *
|
|
|
+ * eslint-disable-next-line react/sort-comp
|
|
|
+ */
|
|
|
+ reloadOnVisible = false;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * When enabling reloadOnVisible, this flag may be used to turn on and off
|
|
|
+ * the reloading. This is useful if your component only needs to reload when
|
|
|
+ * becoming visible during certain states.
|
|
|
+ *
|
|
|
+ * eslint-disable-next-line react/sort-comp
|
|
|
+ */
|
|
|
+ shouldReloadOnVisible = false;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * This affects how the component behaves when `remountComponent` is called
|
|
|
+ * By default, the component gets put back into a "loading" state when re-fetching data.
|
|
|
+ * If this is true, then when we fetch data, the original ready component remains mounted
|
|
|
+ * and it will need to handle any additional "reloading" states
|
|
|
+ */
|
|
|
+ shouldReload = false;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * should `renderError` render the `detail` attribute of a 400 error
|
|
|
+ */
|
|
|
+ shouldRenderBadRequests = false;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * If a request fails and is not a bad request, and if `disableErrorReport` is set to false,
|
|
|
+ * the UI will display an error modal.
|
|
|
+ *
|
|
|
+ * It is recommended to enable this property ideally only when the subclass is used by a top level route.
|
|
|
+ */
|
|
|
+ disableErrorReport = true;
|
|
|
+
|
|
|
+ api: Client = new Client();
|
|
|
+ private _measurement: any;
|
|
|
+
|
|
|
+ // XXX: can't call this getInitialState as React whines
|
|
|
+ getDefaultState(): AsyncComponentState {
|
|
|
+ const endpoints = this.getEndpoints();
|
|
|
+
|
|
|
+ const state = {
|
|
|
+ // has all data finished requesting?
|
|
|
+ loading: true,
|
|
|
+ // is the component reload
|
|
|
+ reloading: false,
|
|
|
+ // is there an error loading ANY data?
|
|
|
+ error: false,
|
|
|
+ errors: {},
|
|
|
+ // We will fetch immeditaely upon mount
|
|
|
+ remainingRequests: endpoints.length || undefined,
|
|
|
+ };
|
|
|
+
|
|
|
+ // We are not loading if there are no endpoints
|
|
|
+ if (!endpoints.length) {
|
|
|
+ state.loading = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ endpoints.forEach(([stateKey, _endpoint]) => {
|
|
|
+ state[stateKey] = null;
|
|
|
+ });
|
|
|
+ return state;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if we should measure render time for this component
|
|
|
+ markShouldMeasure = ({
|
|
|
+ remainingRequests,
|
|
|
+ error,
|
|
|
+ }: {error?: any; remainingRequests?: number} = {}) => {
|
|
|
+ if (!this._measurement.hasMeasured) {
|
|
|
+ this._measurement.finished = remainingRequests === 0;
|
|
|
+ this._measurement.error = error || this._measurement.error;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ remountComponent = () => {
|
|
|
+ if (this.shouldReload) {
|
|
|
+ this.reloadData();
|
|
|
+ } else {
|
|
|
+ this.setState(this.getDefaultState(), this.fetchData);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ visibilityReloader = () =>
|
|
|
+ this.shouldReloadOnVisible &&
|
|
|
+ !this.state.loading &&
|
|
|
+ !document.hidden &&
|
|
|
+ this.reloadData();
|
|
|
+
|
|
|
+ reloadData() {
|
|
|
+ this.fetchData({reloading: true});
|
|
|
+ }
|
|
|
+
|
|
|
+ fetchData = (extraState?: object) => {
|
|
|
+ const endpoints = this.getEndpoints();
|
|
|
+
|
|
|
+ if (!endpoints.length) {
|
|
|
+ this.setState({loading: false, error: false});
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Cancel any in flight requests
|
|
|
+ this.api.clear();
|
|
|
+
|
|
|
+ this.setState({
|
|
|
+ loading: true,
|
|
|
+ error: false,
|
|
|
+ remainingRequests: endpoints.length,
|
|
|
+ ...extraState,
|
|
|
+ });
|
|
|
+
|
|
|
+ endpoints.forEach(([stateKey, endpoint, params, options]) => {
|
|
|
+ options = options || {};
|
|
|
+ // If you're using nested async components/views make sure to pass the
|
|
|
+ // props through so that the child component has access to props.location
|
|
|
+ const locationQuery = (this.props.location && this.props.location.query) || {};
|
|
|
+ let query = (params && params.query) || {};
|
|
|
+ // If paginate option then pass entire `query` object to API call
|
|
|
+ // It should only be expecting `query.cursor` for pagination
|
|
|
+ if ((options.paginate || locationQuery.cursor) && !options.disableEntireQuery) {
|
|
|
+ query = {...locationQuery, ...query};
|
|
|
+ }
|
|
|
+
|
|
|
+ this.api.request(endpoint, {
|
|
|
+ method: 'GET',
|
|
|
+ ...params,
|
|
|
+ query,
|
|
|
+ success: (data, _, resp) => {
|
|
|
+ this.handleRequestSuccess({stateKey, data, resp}, true);
|
|
|
+ },
|
|
|
+ error: error => {
|
|
|
+ // Allow endpoints to fail
|
|
|
+ // allowError can have side effects to handle the error
|
|
|
+ if (options.allowError && options.allowError(error)) {
|
|
|
+ error = null;
|
|
|
+ }
|
|
|
+ this.handleError(error, [stateKey, endpoint, params, options]);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ onRequestSuccess(_resp /* {stateKey, data, resp} */) {
|
|
|
+ // Allow children to implement this
|
|
|
+ }
|
|
|
+
|
|
|
+ onRequestError(_resp, _args) {
|
|
|
+ // Allow children to implement this
|
|
|
+ }
|
|
|
+
|
|
|
+ onLoadAllEndpointsSuccess() {
|
|
|
+ // Allow children to implement this
|
|
|
+ }
|
|
|
+
|
|
|
+ handleRequestSuccess({stateKey, data, resp}, initialRequest?: boolean) {
|
|
|
+ this.setState(
|
|
|
+ prevState => {
|
|
|
+ const state = {
|
|
|
+ [stateKey]: data,
|
|
|
+ // TODO(billy): This currently fails if this request is retried by SudoModal
|
|
|
+ [`${stateKey}PageLinks`]: resp?.getResponseHeader('Link'),
|
|
|
+ };
|
|
|
+
|
|
|
+ if (initialRequest) {
|
|
|
+ state.remainingRequests = prevState.remainingRequests! - 1;
|
|
|
+ state.loading = prevState.remainingRequests! > 1;
|
|
|
+ state.reloading = prevState.reloading && state.loading;
|
|
|
+ this.markShouldMeasure({remainingRequests: state.remainingRequests});
|
|
|
+ }
|
|
|
+
|
|
|
+ return state;
|
|
|
+ },
|
|
|
+ () => {
|
|
|
+ // if everything is loaded and we don't have an error, call the callback
|
|
|
+ if (this.state.remainingRequests === 0 && !this.state.error) {
|
|
|
+ this.onLoadAllEndpointsSuccess();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+ this.onRequestSuccess({stateKey, data, resp});
|
|
|
+ }
|
|
|
+
|
|
|
+ handleError(error, args) {
|
|
|
+ const [stateKey] = args;
|
|
|
+ if (error && error.responseText) {
|
|
|
+ Sentry.addBreadcrumb({
|
|
|
+ message: error.responseText,
|
|
|
+ category: 'xhr',
|
|
|
+ level: 'error',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.setState(prevState => {
|
|
|
+ const loading = prevState.remainingRequests! > 1;
|
|
|
+ const state: AsyncComponentState = {
|
|
|
+ [stateKey]: null,
|
|
|
+ errors: {
|
|
|
+ ...prevState.errors,
|
|
|
+ [stateKey]: error,
|
|
|
+ },
|
|
|
+ error: prevState.error || !!error,
|
|
|
+ remainingRequests: prevState.remainingRequests! - 1,
|
|
|
+ loading,
|
|
|
+ reloading: prevState.reloading && loading,
|
|
|
+ };
|
|
|
+ this.markShouldMeasure({remainingRequests: state.remainingRequests, error: true});
|
|
|
+
|
|
|
+ return state;
|
|
|
+ });
|
|
|
+ this.onRequestError(error, args);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Return a list of endpoint queries to make.
|
|
|
+ *
|
|
|
+ * return [
|
|
|
+ * ['stateKeyName', '/endpoint/', {optional: 'query params'}, {options}]
|
|
|
+ * ]
|
|
|
+ */
|
|
|
+ getEndpoints(): Array<[string, string, any?, any?]> {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
|
|
|
+ const [firstEndpoint] = this.getEndpoints() || [null];
|
|
|
+ const stateKeyOrDefault = stateKey || (firstEndpoint && firstEndpoint[0]);
|
|
|
+ const urlOrDefault = url || (firstEndpoint && firstEndpoint[1]);
|
|
|
+ return (
|
|
|
+ <AsyncComponentSearchInput
|
|
|
+ url={urlOrDefault}
|
|
|
+ {...props}
|
|
|
+ api={this.api}
|
|
|
+ onSuccess={(data, resp) => {
|
|
|
+ this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, resp});
|
|
|
+ }}
|
|
|
+ onError={() => {
|
|
|
+ this.renderError(new Error('Error with AsyncComponentSearchInput'));
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ renderLoading(): React.ReactNode {
|
|
|
+ return <LoadingIndicator />;
|
|
|
+ }
|
|
|
+
|
|
|
+ renderError(error?: Error, disableLog = false): React.ReactNode {
|
|
|
+ const {errors} = this.state;
|
|
|
+
|
|
|
+ // 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
|
|
|
+ const unauthorizedErrors = Object.values(errors).find(resp => resp?.status === 401);
|
|
|
+
|
|
|
+ // Look through endpoint results to see if we had any 403s, means their role can not access resource
|
|
|
+ const permissionErrors = Object.values(errors).find(resp => resp?.status === 403);
|
|
|
+
|
|
|
+ // If all error responses have status code === 0, then show error message but don't
|
|
|
+ // log it to sentry
|
|
|
+ const shouldLogSentry =
|
|
|
+ !!Object.values(errors).find(resp => resp?.status !== 0) || disableLog;
|
|
|
+
|
|
|
+ if (unauthorizedErrors) {
|
|
|
+ return (
|
|
|
+ <LoadingError message={t('You are not authorized to access this resource.')} />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (permissionErrors) {
|
|
|
+ return <PermissionDenied />;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.shouldRenderBadRequests) {
|
|
|
+ const badRequests = Object.values(errors)
|
|
|
+ .filter(resp => resp?.status === 400 && resp?.responseJSON?.detail)
|
|
|
+ .map(resp => resp.responseJSON.detail);
|
|
|
+
|
|
|
+ if (badRequests.length) {
|
|
|
+ return <LoadingError message={[...new Set(badRequests)].join('\n')} />;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <RouteError
|
|
|
+ error={error}
|
|
|
+ disableLogSentry={!shouldLogSentry}
|
|
|
+ disableReport={this.disableErrorReport}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ get shouldRenderLoading() {
|
|
|
+ return this.state.loading && (!this.shouldReload || !this.state.reloading);
|
|
|
+ }
|
|
|
+
|
|
|
+ renderComponent() {
|
|
|
+ return this.shouldRenderLoading
|
|
|
+ ? this.renderLoading()
|
|
|
+ : this.state.error
|
|
|
+ ? this.renderError()
|
|
|
+ : this.renderBody();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Renders once all endpoints have been loaded
|
|
|
+ */
|
|
|
+ renderBody(): React.ReactNode {
|
|
|
+ // Allow children to implement this
|
|
|
+ throw new Error('Not implemented');
|
|
|
+ }
|
|
|
+
|
|
|
+ render() {
|
|
|
+ return this.renderComponent();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default DeprecatedAsyncComponent;
|