123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483 |
- import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
- import * as Sentry from '@sentry/react';
- import {ResponseMeta} from 'sentry/api';
- 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 useApi from 'sentry/utils/useApi';
- import {useLocation} from 'sentry/utils/useLocation';
- import {useParams} from 'sentry/utils/useParams';
- import {useRoutes} from 'sentry/utils/useRoutes';
- import PermissionDenied from 'sentry/views/permissionDenied';
- import RouteError from 'sentry/views/routeError';
- import RequestError from './requestError/requestError';
- import {useEffectAfterFirstRender} from './useEffectAfterFirstRender';
- /**
- * Turn {foo: X} into {foo: X, fooPageLinks: string}
- */
- type UseApiRequestData<T extends Record<string, any>> = {
- // Keys can be null on error
- [Property in keyof T]: T[Property] | null;
- } & {
- // Make request cursors available
- [Property in keyof T as `${Property & string}PageLinks`]: string | null;
- };
- interface State<T extends Record<string, any>> {
- /**
- * Mapping of results from the configured endpoints
- */
- data: UseApiRequestData<T>;
- /**
- * Errors from the configured endpoionts
- */
- errors: Record<string, RequestError>;
- /**
- * Did *any* of the endpoints fail?
- */
- hasError: boolean;
- /**
- * Are the endpoints currently loading?
- */
- isLoading: boolean;
- /**
- * Are we *reloading* data without the loading state being set to true?
- */
- isReloading: boolean;
- /**
- * How many requests are still pending?
- */
- remainingRequests: number;
- }
- interface Result<T extends Record<string, any>> extends State<T> {
- /**
- * renderComponent is a helper function that is used to render loading and
- * errors state for you, and will only render your component once all endpoints
- * have resolved.
- *
- * Typically you would use this when returning react for your component.
- *
- * return renderComponent(
- * <div>{data.someEndpoint.resultKey}</div>
- * )
- *
- * The react element will only be rendered once all endpoints have been loaded.
- */
- renderComponent: (children: React.ReactElement) => React.ReactElement;
- }
- type EndpointRequestOptions = {
- /**
- * Function to check if the error is allowed
- */
- allowError?: (error: any) => void;
- /**
- * Do not pass query parameters to the API
- */
- disableEntireQuery?: boolean;
- /**
- * If set then pass entire `query` object to API call
- */
- paginate?: boolean;
- };
- export type EndpointDefinition<T extends Record<string, any>> = [
- key: keyof T,
- url: string,
- urlOptions?: {query?: Record<string, string>},
- requestOptions?: EndpointRequestOptions
- ];
- type Options<T extends Record<string, any>> = {
- endpoints: EndpointDefinition<T>[];
- /**
- * 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?: boolean;
- onLoadAllEndpointsSuccess?: () => void;
- onRequestError?: (error: RequestError, args: Options<T>['endpoints'][0]) => void;
- onRequestSuccess?: (data: {data: any; stateKey: keyof T; resp?: ResponseMeta}) => void;
- /**
- * 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?: boolean;
- /**
- * 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?: boolean;
- /**
- * should `renderError` render the `detail` attribute of a 400 error
- */
- shouldRenderBadRequests?: boolean;
- };
- function renderLoading() {
- return <LoadingIndicator />;
- }
- function useApiRequests<T extends Record<string, any>>({
- endpoints,
- reloadOnVisible = false,
- shouldReload = false,
- shouldRenderBadRequests = false,
- disableErrorReport = true,
- onLoadAllEndpointsSuccess = () => {},
- onRequestSuccess = _data => {},
- onRequestError = (_error, _args) => {},
- }: Options<T>): Result<T> {
- const api = useApi();
- const location = useLocation<any>();
- const params = useParams();
- // Memoize the initialState so we can easily reuse it later
- const initialState = useMemo<State<T>>(
- () => ({
- data: {} as T,
- isLoading: true,
- hasError: false,
- isReloading: false,
- errors: {},
- remainingRequests: endpoints.length,
- }),
- [endpoints.length]
- );
- const [state, setState] = useState<State<T>>(initialState);
- // Begin measuring the use of the hook for the given route
- const triggerMeasurement = useMeasureApiRequests();
- const handleRequestSuccess = useCallback(
- (
- {stateKey, data, resp}: {data: any; stateKey: keyof T; resp?: ResponseMeta},
- initialRequest?: boolean
- ) => {
- setState(prevState => {
- const newState = {
- ...prevState,
- data: {
- ...prevState.data,
- [stateKey]: data,
- [`${stateKey as string}PageLinks`]: resp?.getResponseHeader('Link'),
- },
- };
- if (initialRequest) {
- newState.remainingRequests = prevState.remainingRequests - 1;
- newState.isLoading = prevState.remainingRequests > 1;
- newState.isReloading = prevState.isReloading && newState.isLoading;
- triggerMeasurement({finished: newState.remainingRequests === 0});
- }
- return newState;
- });
- // if everything is loaded and we don't have an error, call the callback
- onRequestSuccess({stateKey, data, resp});
- },
- [onRequestSuccess, triggerMeasurement]
- );
- const handleError = useCallback(
- (error: RequestError, args: EndpointDefinition<T>) => {
- const [stateKey] = args;
- if (error && error.responseText) {
- Sentry.addBreadcrumb({
- message: error.responseText,
- category: 'xhr',
- level: 'error',
- });
- }
- setState(prevState => {
- const isLoading = prevState.remainingRequests > 1;
- const newState = {
- errors: {
- ...prevState.errors,
- [stateKey]: error,
- },
- data: {
- ...prevState.data,
- [stateKey]: null,
- },
- hasError: prevState.hasError || !!error,
- remainingRequests: prevState.remainingRequests - 1,
- isLoading,
- isReloading: prevState.isReloading && isLoading,
- };
- triggerMeasurement({finished: newState.remainingRequests === 0, error: true});
- return newState;
- });
- onRequestError(error, args);
- },
- [triggerMeasurement, onRequestError]
- );
- const fetchData = useCallback(
- async (extraState: Partial<State<T>> = {}) => {
- // Nothing to fetch if enpoints are empty
- if (!endpoints.length) {
- setState(prevState => ({
- ...prevState,
- data: {} as T,
- isLoading: false,
- hasError: false,
- }));
- return;
- }
- // Cancel any in flight requests
- api.clear();
- setState(prevState => ({
- ...prevState,
- isLoading: true,
- hasError: false,
- remainingRequests: endpoints.length,
- ...extraState,
- }));
- await Promise.all(
- endpoints.map(async ([stateKey, endpoint, parameters, 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 = (location && location.query) || {};
- let query = (parameters && parameters.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};
- }
- try {
- const results = await api.requestPromise(endpoint, {
- method: 'GET',
- ...parameters,
- query,
- includeAllArgs: true,
- });
- const [data, _, resp] = results;
- handleRequestSuccess({stateKey, data, resp}, true);
- } catch (error) {
- handleError(error, [stateKey, endpoint, parameters, options]);
- }
- })
- );
- },
- [api, endpoints, handleError, handleRequestSuccess, location]
- );
- const reloadData = useCallback(() => fetchData({isReloading: true}), [fetchData]);
- const handleMount = useCallback(async () => {
- try {
- await fetchData();
- } catch (error) {
- setState(prevState => ({...prevState, hasError: true}));
- throw error;
- }
- }, [fetchData]);
- // Trigger fetch on mount
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => void handleMount(), []);
- const handleFullReload = useCallback(() => {
- if (shouldReload) {
- return reloadData();
- }
- setState({...initialState});
- return fetchData();
- }, [initialState, reloadData, fetchData, shouldReload]);
- // Trigger fetch on location or parameter change
- // useEffectAfterFirstRender to avoid calling at the same time as handleMount
- useEffectAfterFirstRender(
- () => void handleFullReload(),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [location?.search, location?.state, params]
- );
- const visibilityReloader = useCallback(
- () => !state.isLoading && !document.hidden && reloadData(),
- [state.isLoading, reloadData]
- );
- // Trigger fetch on visible change when using visibilityReloader
- useEffect(() => {
- if (reloadOnVisible) {
- document.addEventListener('visibilitychange', visibilityReloader);
- }
- return () => document.removeEventListener('visibilitychange', visibilityReloader);
- }, [reloadOnVisible, visibilityReloader]);
- // Trigger onLoadAllEndpointsSuccess when everything has been loaded
- useEffect(
- () => {
- if (endpoints.length && state.remainingRequests === 0 && !state.hasError) {
- onLoadAllEndpointsSuccess();
- }
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [state.remainingRequests, state.hasError, endpoints.length]
- );
- const renderError = useCallback(
- (error?: Error, disableLog = false): React.ReactElement => {
- const errors = state.errors;
- // 401s are captured by SudoModal, but may be passed back to AsyncComponent
- // if they close the modal without identifying
- const unauthorizedErrors = Object.values(errors).some(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).some(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).some(resp => resp?.status !== 0) || disableLog;
- if (unauthorizedErrors) {
- return (
- <LoadingError message={t('You are not authorized to access this resource.')} />
- );
- }
- if (permissionErrors) {
- return <PermissionDenied />;
- }
- if (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={disableErrorReport}
- />
- );
- },
- [state.errors, disableErrorReport, shouldRenderBadRequests]
- );
- const shouldRenderLoading = state.isLoading && (!shouldReload || !state.isReloading);
- const renderComponent = useCallback(
- (children: React.ReactElement) =>
- shouldRenderLoading
- ? renderLoading()
- : state.hasError
- ? renderError(new Error('Unable to load all required endpoints'))
- : children,
- [shouldRenderLoading, state.hasError, renderError]
- );
- return {...state, renderComponent};
- }
- export default useApiRequests;
- type MetricsState = {
- error: boolean;
- finished: boolean;
- hasMeasured: boolean;
- };
- type MetricUpdate = Partial<Pick<MetricsState, 'finished' | 'error'>>;
- /**
- * Helper hook that marks a measurement when the component mounts.
- *
- * Use the `triggerMeasurement` function to trigger a measurement when the
- * useApiRequests hook has finished loading all requests. Will only trigger once
- */
- function useMeasureApiRequests() {
- const routes = useRoutes();
- const measurement = useRef<MetricsState>({
- hasMeasured: false,
- finished: false,
- error: false,
- });
- // Start measuring immediately upon mount. We re-mark if the route list has
- // changed, since the component is now being used under a different route
- useEffect(() => {
- // Reset the measurement object
- measurement.current = {
- hasMeasured: false,
- finished: false,
- error: false,
- };
- if (routes && routes.length) {
- metric.mark({name: `async-component-${getRouteStringFromRoutes(routes)}`});
- }
- }, [routes]);
- const triggerMeasurement = useCallback(
- ({finished, error}: MetricUpdate) => {
- if (!routes) {
- return;
- }
- if (finished) {
- measurement.current.finished = true;
- }
- if (error) {
- measurement.current.error = true;
- }
- if (!measurement.current.hasMeasured && measurement.current.finished) {
- const routeString = getRouteStringFromRoutes(routes);
- metric.measure({
- name: 'app.component.async-component',
- start: `async-component-${routeString}`,
- data: {
- route: routeString,
- error: measurement.current.error,
- },
- });
- measurement.current.hasMeasured = true;
- }
- },
- [routes]
- );
- return triggerMeasurement;
- }
|