airbnb/caravel

View on GitHub
superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadScreenshot.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import {
  logging,
  t,
  SupersetClient,
  SupersetApiError,
} from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import {
  LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
  LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { RootState } from 'src/dashboard/types';
import { useSelector } from 'react-redux';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { last } from 'lodash';
import { getDashboardUrlParams } from 'src/utils/urlUtils';
import { useCallback, useEffect, useRef } from 'react';
import { DownloadScreenshotFormat } from './types';

const RETRY_INTERVAL = 3000;
const MAX_RETRIES = 30;

export default function DownloadScreenshot({
  text,
  logEvent,
  dashboardId,
  format,
  ...rest
}: {
  text: string;
  dashboardId: number;
  logEvent?: Function;
  format: string;
}) {
  const activeTabs = useSelector(
    (state: RootState) => state.dashboardState.activeTabs || undefined,
  );
  const anchor = useSelector(
    (state: RootState) =>
      last(state.dashboardState.directPathToChild) || undefined,
  );
  const dataMask = useSelector(
    (state: RootState) => state.dataMask || undefined,
  );
  const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
  const currentIntervalIds = useRef<NodeJS.Timeout[]>([]);

  const printLoadingToast = () =>
    addInfoToast(
      t('The screenshot is being generated. Please, do not leave the page.'),
      {
        noDuplicate: true,
      },
    );

  const printFailureToast = useCallback(
    () =>
      addDangerToast(
        t('The screenshot could not be downloaded. Please, try again later.'),
      ),
    [addDangerToast],
  );

  const printSuccessToast = useCallback(
    () => addSuccessToast(t('The screenshot has been downloaded.')),
    [addSuccessToast],
  );

  const stopIntervals = useCallback(
    (message?: 'success' | 'failure') => {
      currentIntervalIds.current.forEach(clearInterval);

      if (message === 'failure') {
        printFailureToast();
      }
      if (message === 'success') {
        printSuccessToast();
      }
    },
    [printFailureToast, printSuccessToast],
  );

  const onDownloadScreenshot = () => {
    let retries = 0;

    const toastIntervalId = setInterval(
      () => printLoadingToast(),
      RETRY_INTERVAL,
    );

    currentIntervalIds.current = [
      ...(currentIntervalIds.current || []),
      toastIntervalId,
    ];

    printLoadingToast();

    // this function checks if the image is ready
    const checkImageReady = (cacheKey: string) =>
      SupersetClient.get({
        endpoint: `/api/v1/dashboard/${dashboardId}/screenshot/${cacheKey}/?download_format=${format}`,
        headers: { Accept: 'application/pdf, image/png' },
        parseMethod: 'raw',
      })
        .then((response: Response) => response.blob())
        .then(blob => {
          const url = window.URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = url;
          a.download = `screenshot.${format}`;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          window.URL.revokeObjectURL(url);
          stopIntervals('success');
        })
        .catch(err => {
          if ((err as SupersetApiError).status === 404) {
            throw new Error('Image not ready');
          }
        });

    const fetchImageWithRetry = (cacheKey: string) => {
      if (retries >= MAX_RETRIES) {
        stopIntervals('failure');
        logging.error('Max retries reached');
        return;
      }
      checkImageReady(cacheKey).catch(() => {
        retries += 1;
      });
    };

    SupersetClient.post({
      endpoint: `/api/v1/dashboard/${dashboardId}/cache_dashboard_screenshot/`,
      jsonPayload: {
        anchor,
        activeTabs,
        dataMask,
        urlParams: getDashboardUrlParams(),
      },
    })
      .then(({ json }) => {
        const cacheKey = json?.cache_key;
        if (!cacheKey) {
          throw new Error('No image URL in response');
        }
        const retryIntervalId = setInterval(() => {
          fetchImageWithRetry(cacheKey);
        }, RETRY_INTERVAL);
        currentIntervalIds.current.push(retryIntervalId);
        fetchImageWithRetry(cacheKey);
      })
      .catch(error => {
        logging.error(error);
        stopIntervals('failure');
      })
      .finally(() => {
        logEvent?.(
          format === DownloadScreenshotFormat.PNG
            ? LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE
            : LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
        );
      });
  };

  useEffect(
    () => () => {
      if (currentIntervalIds.current.length > 0) {
        stopIntervals();
      }
      currentIntervalIds.current = [];
    },
    [stopIntervals],
  );

  return (
    <Menu.Item key={format} {...rest}>
      <div onClick={onDownloadScreenshot} role="button" tabIndex={0}>
        {text}
      </div>
    </Menu.Item>
  );
}