airbnb/caravel

View on GitHub
superset-frontend/src/explore/components/ExploreChartPanel/index.jsx

Summary

Maintainability
C
1 day
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 { useState, useEffect, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import Split from 'react-split';
import {
  css,
  DatasourceType,
  ensureIsArray,
  isFeatureEnabled,
  FeatureFlag,
  getChartMetadataRegistry,
  styled,
  SupersetClient,
  t,
  useTheme,
} from '@superset-ui/core';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import ChartContainer from 'src/components/Chart/ChartContainer';
import {
  getItem,
  setItem,
  LocalStorageKeys,
} from 'src/utils/localStorageHelpers';
import Alert from 'src/components/Alert';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { buildV1ChartDataPayload } from 'src/explore/exploreUtils';
import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage';
import { DataTablesPane } from '../DataTablesPane';
import { ChartPills } from '../ChartPills';
import { ExploreAlert } from '../ExploreAlert';
import useResizeDetectorByObserver from './useResizeDetectorByObserver';

const propTypes = {
  actions: PropTypes.object.isRequired,
  onQuery: PropTypes.func,
  can_overwrite: PropTypes.bool.isRequired,
  can_download: PropTypes.bool.isRequired,
  datasource: PropTypes.object,
  dashboardId: PropTypes.number,
  column_formats: PropTypes.object,
  containerId: PropTypes.string.isRequired,
  isStarred: PropTypes.bool.isRequired,
  slice: PropTypes.object,
  sliceName: PropTypes.string,
  table_name: PropTypes.string,
  vizType: PropTypes.string.isRequired,
  form_data: PropTypes.object,
  ownState: PropTypes.object,
  standalone: PropTypes.bool,
  force: PropTypes.bool,
  timeout: PropTypes.number,
  chartIsStale: PropTypes.bool,
  chart: chartPropShape,
  errorMessage: PropTypes.node,
  triggerRender: PropTypes.bool,
};

const GUTTER_SIZE_FACTOR = 1.25;

const INITIAL_SIZES = [100, 0];
const MIN_SIZES = [300, 65];
const DEFAULT_SOUTH_PANE_HEIGHT_PERCENT = 40;

const Styles = styled.div`
  display: flex;
  flex-direction: column;
  align-items: stretch;
  align-content: stretch;
  overflow: auto;
  box-shadow: none;
  height: 100%;

  & > div {
    height: 100%;
  }

  .gutter {
    border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
    border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
    width: ${({ theme }) => theme.gridUnit * 9}px;
    margin: ${({ theme }) => theme.gridUnit * GUTTER_SIZE_FACTOR}px auto;
  }

  .gutter.gutter-vertical {
    display: ${({ showSplite }) => (showSplite ? 'block' : 'none')};
    cursor: row-resize;
  }

  .ant-collapse {
    .ant-tabs {
      height: 100%;
      .ant-tabs-nav {
        padding-left: ${({ theme }) => theme.gridUnit * 5}px;
        margin: 0;
      }
      .ant-tabs-content-holder {
        overflow: hidden;
        .ant-tabs-content {
          height: 100%;
        }
      }
    }
  }
`;

const ExploreChartPanel = ({
  chart,
  slice,
  vizType,
  ownState,
  triggerRender,
  force,
  datasource,
  errorMessage,
  form_data: formData,
  onQuery,
  actions,
  timeout,
  standalone,
  chartIsStale,
  chartAlert,
  can_download: canDownload,
}) => {
  const theme = useTheme();
  const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR;
  const gutterHeight = theme.gridUnit * GUTTER_SIZE_FACTOR;
  const {
    ref: chartPanelRef,
    observerRef: resizeObserverRef,
    width: chartPanelWidth,
    height: chartPanelHeight,
  } = useResizeDetectorByObserver();
  const [splitSizes, setSplitSizes] = useState(
    isFeatureEnabled(FeatureFlag.DatapanelClosedByDefault)
      ? INITIAL_SIZES
      : getItem(LocalStorageKeys.ChartSplitSizes, INITIAL_SIZES),
  );
  const [showSplite, setShowSplit] = useState(
    isFeatureEnabled(FeatureFlag.DatapanelClosedByDefault)
      ? false
      : getItem(LocalStorageKeys.IsDatapanelOpen, false),
  );

  const [showDatasetModal, setShowDatasetModal] = useState(false);

  const metaDataRegistry = getChartMetadataRegistry();
  const { useLegacyApi } = metaDataRegistry.get(vizType) ?? {};
  const vizTypeNeedsDataset =
    useLegacyApi && datasource.type !== DatasourceType.Table;
  // added boolean column to below show boolean so that the errors aren't overlapping
  const showAlertBanner =
    !chartAlert &&
    chartIsStale &&
    !vizTypeNeedsDataset &&
    chart.chartStatus !== 'failed' &&
    ensureIsArray(chart.queriesResponse).length > 0;

  const updateQueryContext = useCallback(
    async function fetchChartData() {
      if (slice && slice.query_context === null) {
        const queryContext = buildV1ChartDataPayload({
          formData: slice.form_data,
          force,
          resultFormat: 'json',
          resultType: 'full',
          setDataMask: null,
          ownState: null,
        });

        await SupersetClient.put({
          endpoint: `/api/v1/chart/${slice.slice_id}`,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            query_context: JSON.stringify(queryContext),
            query_context_generation: true,
          }),
        });
      }
    },
    [slice],
  );

  useEffect(() => {
    updateQueryContext();
  }, [updateQueryContext]);

  useEffect(() => {
    setItem(LocalStorageKeys.ChartSplitSizes, splitSizes);
  }, [splitSizes]);

  const onDragEnd = useCallback(sizes => {
    setSplitSizes(sizes);
  }, []);

  const refreshCachedQuery = useCallback(() => {
    actions.setForceQuery(true);
    actions.postChartFormData(
      formData,
      true,
      timeout,
      chart.id,
      undefined,
      ownState,
    );
    actions.updateQueryFormData(formData, chart.id);
  }, [actions, chart.id, formData, ownState, timeout]);

  const onCollapseChange = useCallback(isOpen => {
    let splitSizes;
    if (!isOpen) {
      splitSizes = INITIAL_SIZES;
    } else {
      splitSizes = [
        100 - DEFAULT_SOUTH_PANE_HEIGHT_PERCENT,
        DEFAULT_SOUTH_PANE_HEIGHT_PERCENT,
      ];
    }
    setSplitSizes(splitSizes);
    setShowSplit(isOpen);
  }, []);

  const renderChart = useCallback(
    () => (
      <div
        css={css`
          min-height: 0;
          flex: 1;
          overflow: auto;
        `}
        ref={chartPanelRef}
      >
        {chartPanelWidth && chartPanelHeight && (
          <ChartContainer
            width={Math.floor(chartPanelWidth)}
            height={chartPanelHeight}
            ownState={ownState}
            annotationData={chart.annotationData}
            chartAlert={chart.chartAlert}
            chartStackTrace={chart.chartStackTrace}
            chartId={chart.id}
            chartStatus={chart.chartStatus}
            triggerRender={triggerRender}
            force={force}
            datasource={datasource}
            errorMessage={errorMessage}
            formData={formData}
            latestQueryFormData={chart.latestQueryFormData}
            onQuery={onQuery}
            queriesResponse={chart.queriesResponse}
            chartIsStale={chartIsStale}
            setControlValue={actions.setControlValue}
            timeout={timeout}
            triggerQuery={chart.triggerQuery}
            vizType={vizType}
          />
        )}
      </div>
    ),
    [
      actions.setControlValue,
      chart.annotationData,
      chart.chartAlert,
      chart.chartStackTrace,
      chart.chartStatus,
      chart.id,
      chart.latestQueryFormData,
      chart.queriesResponse,
      chart.triggerQuery,
      chartIsStale,
      chartPanelHeight,
      chartPanelRef,
      chartPanelWidth,
      datasource,
      errorMessage,
      force,
      formData,
      onQuery,
      ownState,
      timeout,
      triggerRender,
      vizType,
    ],
  );

  const panelBody = useMemo(
    () => (
      <div
        className="panel-body"
        css={css`
          display: flex;
          flex-direction: column;
        `}
        ref={resizeObserverRef}
      >
        {vizTypeNeedsDataset && (
          <Alert
            message={t('Chart type requires a dataset')}
            type="error"
            css={theme => css`
              margin: 0 0 ${theme.gridUnit * 4}px 0;
            `}
            description={
              <>
                {t(
                  'This chart type is not supported when using an unsaved query as a chart source. ',
                )}
                <span
                  role="button"
                  tabIndex={0}
                  onClick={() => setShowDatasetModal(true)}
                  css={{ textDecoration: 'underline' }}
                >
                  {t('Create a dataset')}
                </span>
                {t(' to visualize your data.')}
              </>
            }
          />
        )}
        {showAlertBanner && (
          <ExploreAlert
            title={
              errorMessage
                ? t('Required control values have been removed')
                : t('Your chart is not up to date')
            }
            bodyText={
              errorMessage ? (
                getChartRequiredFieldsMissingMessage(false)
              ) : (
                <span>
                  {t(
                    'You updated the values in the control panel, but the chart was not updated automatically. Run the query by clicking on the "Update chart" button or',
                  )}{' '}
                  <span role="button" tabIndex={0} onClick={onQuery}>
                    {t('click here')}
                  </span>
                  .
                </span>
              )
            }
            type="warning"
            css={theme => css`
              margin: 0 0 ${theme.gridUnit * 4}px 0;
            `}
          />
        )}
        <ChartPills
          queriesResponse={chart.queriesResponse}
          chartStatus={chart.chartStatus}
          chartUpdateStartTime={chart.chartUpdateStartTime}
          chartUpdateEndTime={chart.chartUpdateEndTime}
          refreshCachedQuery={refreshCachedQuery}
          rowLimit={formData?.row_limit}
        />
        {renderChart()}
      </div>
    ),
    [
      resizeObserverRef,
      showAlertBanner,
      errorMessage,
      onQuery,
      chart.queriesResponse,
      chart.chartStatus,
      chart.chartUpdateStartTime,
      chart.chartUpdateEndTime,
      refreshCachedQuery,
      formData?.row_limit,
      renderChart,
    ],
  );

  const standaloneChartBody = useMemo(() => renderChart(), [renderChart]);

  const [queryFormData, setQueryFormData] = useState(chart.latestQueryFormData);

  useEffect(() => {
    // only update when `latestQueryFormData` changes AND `triggerRender`
    // is false. No update should be done when only `triggerRender` changes,
    // as this can trigger a query downstream based on incomplete form data.
    // (`latestQueryFormData` is only updated when a a valid request has been
    // triggered).
    if (!triggerRender) {
      setQueryFormData(chart.latestQueryFormData);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [chart.latestQueryFormData]);

  const elementStyle = useCallback(
    (dimension, elementSize, gutterSize) => ({
      [dimension]: `calc(${elementSize}% - ${gutterSize + gutterMargin}px)`,
    }),
    [gutterMargin],
  );

  if (standalone) {
    // dom manipulation hack to get rid of the bootstrap theme's body background
    const standaloneClass = 'background-transparent';
    const bodyClasses = document.body.className.split(' ');
    if (!bodyClasses.includes(standaloneClass)) {
      document.body.className += ` ${standaloneClass}`;
    }
    return (
      <div id="app" data-test="standalone-app" ref={resizeObserverRef}>
        {standaloneChartBody}
      </div>
    );
  }

  return (
    <Styles
      className="panel panel-default chart-container"
      showSplite={showSplite}
    >
      <Split
        sizes={splitSizes}
        minSize={MIN_SIZES}
        direction="vertical"
        gutterSize={gutterHeight}
        onDragEnd={onDragEnd}
        elementStyle={elementStyle}
        expandToMin
      >
        {panelBody}
        <DataTablesPane
          ownState={ownState}
          queryFormData={queryFormData}
          datasource={datasource}
          queryForce={force}
          onCollapseChange={onCollapseChange}
          chartStatus={chart.chartStatus}
          errorMessage={errorMessage}
          actions={actions}
          canDownload={canDownload}
        />
      </Split>
      {showDatasetModal && (
        <SaveDatasetModal
          visible={showDatasetModal}
          onHide={() => setShowDatasetModal(false)}
          buttonTextOnSave={t('Save')}
          buttonTextOnOverwrite={t('Overwrite')}
          datasource={getDatasourceAsSaveableDataset(datasource)}
          openWindow={false}
          formData={formData}
        />
      )}
    </Styles>
  );
};

ExploreChartPanel.propTypes = propTypes;

export default ExploreChartPanel;