client/components/mma/shared/AsyncLoader.tsx (119 lines of code) (raw):

import * as Sentry from '@sentry/browser'; import * as React from 'react'; import { trackEvent } from '../../../utilities/analytics'; import { GenericErrorScreen } from '../../shared/GenericErrorScreen'; import type { LoadingProps } from '../../shared/Spinner'; import { Spinner } from '../../shared/Spinner'; import { WithStandardTopMargin } from '../../shared/WithStandardTopMargin'; type ReaderOnOK<T> = (resp: Response) => Promise<T>; export type ReFetch = () => void; interface AsyncLoaderProps<T> extends LoadingProps { readonly fetch: () => Promise<Response | Response[]>; readonly readerOnOK?: ReaderOnOK<T>; // json reader by default readonly shouldPreventRender?: (data: T) => boolean; readonly render: (data: T, reFetch: ReFetch) => React.ReactNode; readonly loadingMessage: string; readonly shouldPreventErrorRender?: () => boolean; readonly errorRender?: () => React.ReactNode; readonly inline?: true; readonly spinnerScale?: number; } enum LoadingState { Loading, Loaded, Error, } interface AsyncLoaderState<T> { readonly data?: T; readonly loadingState: LoadingState; } export class AsyncLoader< T extends NonNullable<unknown>, > extends React.Component<AsyncLoaderProps<T>, AsyncLoaderState<T>> { public state: AsyncLoaderState<T> = { loadingState: LoadingState.Loading }; private readerOnOK = this.props.readerOnOK || ((resp: Response) => resp.json()); public componentDidMount(): void { this.props .fetch() .then((resp) => Array.isArray(resp) ? Promise.all(resp.map(this.processResponse)) : this.processResponse(resp), ) .then((data) => { if ( !( this.props.shouldPreventRender && this.props.shouldPreventRender(data) ) && data !== null ) { this.setState({ data, loadingState: LoadingState.Loaded }); } }) .catch((exception) => this.handleError(exception)); } public render(): React.ReactNode { if (this.state.loadingState === LoadingState.Loading) { return this.props.inline ? ( <Spinner loadingMessage={this.props.loadingMessage} inline={this.props.inline} scale={this.props.spinnerScale} /> ) : ( <WithStandardTopMargin> <Spinner loadingMessage={this.props.loadingMessage} /> </WithStandardTopMargin> ); } else if ( this.state.loadingState === LoadingState.Loaded && this.state.data !== undefined ) { return this.props.render(this.state.data, () => this.setState( { loadingState: LoadingState.Loading }, // eslint-disable-next-line -- supress @typescript-eslint/unbound-method on this line this.componentDidMount, ), ); } else if (this.props.errorRender) { return this.props.errorRender(); } return <GenericErrorScreen />; } private processResponse = ( resp: Response, _?: number, // index allResponses?: Response[], ) => { const locationHeader = resp.headers.get('Location'); const allResponsesAreOK = (allResponses || [resp]).filter((res) => !res.ok).length === 0; if (resp.status === 401 && locationHeader && window !== undefined) { window.location.replace(locationHeader); return Promise.resolve(null); } else if (allResponsesAreOK) { return this.readerOnOK(resp); } throw new Error(`${resp.status} (${resp.statusText})`); }; private handleError(error: Error | ErrorEvent | string): void { if ( !( this.props.shouldPreventErrorRender && this.props.shouldPreventErrorRender() ) ) { this.setState({ loadingState: LoadingState.Error }); } trackEvent({ eventCategory: 'asyncLoader', eventAction: 'error', // eslint-disable-next-line @typescript-eslint/no-base-to-string -- Error.toString will output a string eventLabel: error ? error.toString() : undefined, }); Sentry.captureException(error); } }