airbnb/superset

View on GitHub
superset-frontend/src/dashboard/components/Header/index.jsx

Summary

Maintainability
F
5 days
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.
 */
/* eslint-env browser */
import moment from 'moment';
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import {
  styled,
  css,
  isFeatureEnabled,
  FeatureFlag,
  t,
  getExtensionsRegistry,
} from '@superset-ui/core';
import { Global } from '@emotion/react';
import {
  LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD,
  LOG_ACTIONS_FORCE_REFRESH_DASHBOARD,
  LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
} from 'src/logger/LogUtils';
import Icons from 'src/components/Icons';
import Button from 'src/components/Button';
import { AntdButton } from 'src/components/';
import { findPermission } from 'src/utils/findPermission';
import { Tooltip } from 'src/components/Tooltip';
import { safeStringify } from 'src/utils/safeStringify';
import ConnectedHeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActionsDropdown';
import PublishedStatus from 'src/dashboard/components/PublishedStatus';
import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import getOwnerName from 'src/utils/getOwnerName';
import {
  UNDO_LIMIT,
  SAVE_TYPE_OVERWRITE,
  DASHBOARD_POSITION_DATA_LIMIT,
} from 'src/dashboard/util/constants';
import setPeriodicRunner, {
  stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner';
import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions';
import MetadataBar, { MetadataType } from 'src/components/MetadataBar';
import DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm';

const extensionsRegistry = getExtensionsRegistry();

const propTypes = {
  addSuccessToast: PropTypes.func.isRequired,
  addDangerToast: PropTypes.func.isRequired,
  addWarningToast: PropTypes.func.isRequired,
  user: PropTypes.object, // UserWithPermissionsAndRoles,
  dashboardInfo: PropTypes.object.isRequired,
  dashboardTitle: PropTypes.string,
  dataMask: PropTypes.object.isRequired,
  charts: PropTypes.objectOf(chartPropShape).isRequired,
  layout: PropTypes.object.isRequired,
  expandedSlices: PropTypes.object,
  customCss: PropTypes.string,
  colorNamespace: PropTypes.string,
  colorScheme: PropTypes.string,
  setColorScheme: PropTypes.func.isRequired,
  setUnsavedChanges: PropTypes.func.isRequired,
  isStarred: PropTypes.bool.isRequired,
  isPublished: PropTypes.bool.isRequired,
  isLoading: PropTypes.bool.isRequired,
  onSave: PropTypes.func.isRequired,
  onChange: PropTypes.func.isRequired,
  fetchFaveStar: PropTypes.func.isRequired,
  fetchCharts: PropTypes.func.isRequired,
  saveFaveStar: PropTypes.func.isRequired,
  savePublished: PropTypes.func.isRequired,
  updateDashboardTitle: PropTypes.func.isRequired,
  editMode: PropTypes.bool.isRequired,
  setEditMode: PropTypes.func.isRequired,
  showBuilderPane: PropTypes.func.isRequired,
  updateCss: PropTypes.func.isRequired,
  logEvent: PropTypes.func.isRequired,
  hasUnsavedChanges: PropTypes.bool.isRequired,
  maxUndoHistoryExceeded: PropTypes.bool.isRequired,
  lastModifiedTime: PropTypes.number.isRequired,

  // redux
  onRefresh: PropTypes.func.isRequired,
  onUndo: PropTypes.func.isRequired,
  onRedo: PropTypes.func.isRequired,
  undoLength: PropTypes.number.isRequired,
  redoLength: PropTypes.number.isRequired,
  setMaxUndoHistoryExceeded: PropTypes.func.isRequired,
  maxUndoHistoryToast: PropTypes.func.isRequired,
  refreshFrequency: PropTypes.number,
  shouldPersistRefreshFrequency: PropTypes.bool.isRequired,
  setRefreshFrequency: PropTypes.func.isRequired,
  dashboardInfoChanged: PropTypes.func.isRequired,
  dashboardTitleChanged: PropTypes.func.isRequired,
};

const defaultProps = {
  colorNamespace: undefined,
  colorScheme: undefined,
};

const headerContainerStyle = theme => css`
  border-bottom: 1px solid ${theme.colors.grayscale.light2};
`;

const editButtonStyle = theme => css`
  color: ${theme.colors.primary.dark2};
`;

const actionButtonsStyle = theme => css`
  display: flex;
  align-items: center;

  .action-schedule-report {
    margin-left: ${theme.gridUnit * 2}px;
  }

  .undoRedo {
    display: flex;
    margin-right: ${theme.gridUnit * 2}px;
  }
`;

const StyledUndoRedoButton = styled(AntdButton)`
  padding: 0;
  &:hover {
    background: transparent;
  }
`;

const undoRedoStyle = theme => css`
  color: ${theme.colors.grayscale.light1};
  &:hover {
    color: ${theme.colors.grayscale.base};
  }
`;

const undoRedoEmphasized = theme => css`
  color: ${theme.colors.grayscale.base};
`;

const undoRedoDisabled = theme => css`
  color: ${theme.colors.grayscale.light2};
`;

const saveBtnStyle = theme => css`
  min-width: ${theme.gridUnit * 17}px;
  height: ${theme.gridUnit * 8}px;
`;

const discardBtnStyle = theme => css`
  min-width: ${theme.gridUnit * 22}px;
  height: ${theme.gridUnit * 8}px;
`;

class Header extends PureComponent {
  static discardChanges() {
    const url = new URL(window.location.href);

    url.searchParams.delete('edit');
    window.location.assign(url);
  }

  constructor(props) {
    super(props);
    this.state = {
      didNotifyMaxUndoHistoryToast: false,
      emphasizeUndo: false,
      emphasizeRedo: false,
      showingPropertiesModal: false,
      isDropdownVisible: false,
    };

    this.handleChangeText = this.handleChangeText.bind(this);
    this.handleCtrlZ = this.handleCtrlZ.bind(this);
    this.handleCtrlY = this.handleCtrlY.bind(this);
    this.toggleEditMode = this.toggleEditMode.bind(this);
    this.forceRefresh = this.forceRefresh.bind(this);
    this.startPeriodicRender = this.startPeriodicRender.bind(this);
    this.overwriteDashboard = this.overwriteDashboard.bind(this);
    this.showPropertiesModal = this.showPropertiesModal.bind(this);
    this.hidePropertiesModal = this.hidePropertiesModal.bind(this);
    this.setIsDropdownVisible = this.setIsDropdownVisible.bind(this);
  }

  componentDidMount() {
    const { refreshFrequency } = this.props;
    this.startPeriodicRender(refreshFrequency * 1000);
  }

  componentDidUpdate(prevProps) {
    if (this.props.refreshFrequency !== prevProps.refreshFrequency) {
      const { refreshFrequency } = this.props;
      this.startPeriodicRender(refreshFrequency * 1000);
    }
  }

  UNSAFE_componentWillReceiveProps(nextProps) {
    if (
      UNDO_LIMIT - nextProps.undoLength <= 0 &&
      !this.state.didNotifyMaxUndoHistoryToast
    ) {
      this.setState(() => ({ didNotifyMaxUndoHistoryToast: true }));
      this.props.maxUndoHistoryToast();
    }
    if (
      nextProps.undoLength > UNDO_LIMIT &&
      !this.props.maxUndoHistoryExceeded
    ) {
      this.props.setMaxUndoHistoryExceeded();
    }
  }

  componentWillUnmount() {
    stopPeriodicRender(this.refreshTimer);
    this.props.setRefreshFrequency(0);
    clearTimeout(this.ctrlYTimeout);
    clearTimeout(this.ctrlZTimeout);
  }

  handleChangeText(nextText) {
    const { updateDashboardTitle, onChange } = this.props;
    if (nextText && this.props.dashboardTitle !== nextText) {
      updateDashboardTitle(nextText);
      onChange();
    }
  }

  setIsDropdownVisible(visible) {
    this.setState({
      isDropdownVisible: visible,
    });
  }

  handleCtrlY() {
    this.props.onRedo();
    this.setState({ emphasizeRedo: true }, () => {
      if (this.ctrlYTimeout) clearTimeout(this.ctrlYTimeout);
      this.ctrlYTimeout = setTimeout(() => {
        this.setState({ emphasizeRedo: false });
      }, 100);
    });
  }

  handleCtrlZ() {
    this.props.onUndo();
    this.setState({ emphasizeUndo: true }, () => {
      if (this.ctrlZTimeout) clearTimeout(this.ctrlZTimeout);
      this.ctrlZTimeout = setTimeout(() => {
        this.setState({ emphasizeUndo: false });
      }, 100);
    });
  }

  forceRefresh() {
    if (!this.props.isLoading) {
      const chartList = Object.keys(this.props.charts);
      this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, {
        force: true,
        interval: 0,
        chartCount: chartList.length,
      });
      return this.props.onRefresh(
        chartList,
        true,
        0,
        this.props.dashboardInfo.id,
      );
    }
    return false;
  }

  startPeriodicRender(interval) {
    let intervalMessage;

    if (interval) {
      const { dashboardInfo } = this.props;
      const periodicRefreshOptions =
        dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
      const predefinedValue = periodicRefreshOptions.find(
        option => Number(option[0]) === interval / 1000,
      );

      if (predefinedValue) {
        intervalMessage = t(predefinedValue[1]);
      } else {
        intervalMessage = moment.duration(interval, 'millisecond').humanize();
      }
    }

    const periodicRender = () => {
      const { fetchCharts, logEvent, charts, dashboardInfo } = this.props;
      const { metadata } = dashboardInfo;
      const immune = metadata.timed_refresh_immune_slices || [];
      const affectedCharts = Object.values(charts)
        .filter(chart => immune.indexOf(chart.id) === -1)
        .map(chart => chart.id);

      logEvent(LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, {
        interval,
        chartCount: affectedCharts.length,
      });
      this.props.addWarningToast(
        t(
          `This dashboard is currently auto refreshing; the next auto refresh will be in %s.`,
          intervalMessage,
        ),
      );
      if (dashboardInfo.common.conf.DASHBOARD_AUTO_REFRESH_MODE === 'fetch') {
        // force-refresh while auto-refresh in dashboard
        return fetchCharts(
          affectedCharts,
          false,
          interval * 0.2,
          dashboardInfo.id,
        );
      }
      return fetchCharts(
        affectedCharts,
        true,
        interval * 0.2,
        dashboardInfo.id,
      );
    };

    this.refreshTimer = setPeriodicRunner({
      interval,
      periodicRender,
      refreshTimer: this.refreshTimer,
    });
  }

  toggleEditMode() {
    this.props.logEvent(LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD, {
      edit_mode: !this.props.editMode,
    });
    this.props.setEditMode(!this.props.editMode);
  }

  overwriteDashboard() {
    const {
      dashboardTitle,
      layout: positions,
      colorScheme,
      colorNamespace,
      customCss,
      dashboardInfo,
      refreshFrequency: currentRefreshFrequency,
      shouldPersistRefreshFrequency,
      lastModifiedTime,
      slug,
    } = this.props;

    // check refresh frequency is for current session or persist
    const refreshFrequency = shouldPersistRefreshFrequency
      ? currentRefreshFrequency
      : dashboardInfo.metadata?.refresh_frequency;

    const currentColorNamespace =
      dashboardInfo?.metadata?.color_namespace || colorNamespace;
    const currentColorScheme =
      dashboardInfo?.metadata?.color_scheme || colorScheme;

    const data = {
      certified_by: dashboardInfo.certified_by,
      certification_details: dashboardInfo.certification_details,
      css: customCss,
      dashboard_title: dashboardTitle,
      last_modified_time: lastModifiedTime,
      owners: dashboardInfo.owners,
      roles: dashboardInfo.roles,
      slug,
      metadata: {
        ...dashboardInfo?.metadata,
        color_namespace: currentColorNamespace,
        color_scheme: currentColorScheme,
        positions,
        refresh_frequency: refreshFrequency,
      },
    };

    // make sure positions data less than DB storage limitation:
    const positionJSONLength = safeStringify(positions).length;
    const limit =
      dashboardInfo.common.conf.SUPERSET_DASHBOARD_POSITION_DATA_LIMIT ||
      DASHBOARD_POSITION_DATA_LIMIT;
    if (positionJSONLength >= limit) {
      this.props.addDangerToast(
        t(
          'Your dashboard is too large. Please reduce its size before saving it.',
        ),
      );
    } else {
      if (positionJSONLength >= limit * 0.9) {
        this.props.addWarningToast('Your dashboard is near the size limit.');
      }

      this.props.onSave(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE);
    }
  }

  showPropertiesModal() {
    this.setState({ showingPropertiesModal: true });
  }

  hidePropertiesModal() {
    this.setState({ showingPropertiesModal: false });
  }

  showEmbedModal = () => {
    this.setState({ showingEmbedModal: true });
  };

  hideEmbedModal = () => {
    this.setState({ showingEmbedModal: false });
  };

  getMetadataItems = () => {
    const { dashboardInfo } = this.props;
    return [
      {
        type: MetadataType.LastModified,
        value: dashboardInfo.changed_on_delta_humanized,
        modifiedBy:
          getOwnerName(dashboardInfo.changed_by) || t('Not available'),
      },
      {
        type: MetadataType.Owner,
        createdBy: getOwnerName(dashboardInfo.created_by) || t('Not available'),
        owners:
          dashboardInfo.owners.length > 0
            ? dashboardInfo.owners.map(getOwnerName)
            : t('None'),
        createdOn: dashboardInfo.created_on_delta_humanized,
      },
    ];
  };

  render() {
    const {
      dashboardTitle,
      layout,
      expandedSlices,
      customCss,
      colorNamespace,
      dataMask,
      setColorScheme,
      setUnsavedChanges,
      colorScheme,
      onUndo,
      onRedo,
      undoLength,
      redoLength,
      onChange,
      onSave,
      updateCss,
      editMode,
      isPublished,
      user,
      dashboardInfo,
      hasUnsavedChanges,
      isLoading,
      refreshFrequency,
      shouldPersistRefreshFrequency,
      setRefreshFrequency,
      lastModifiedTime,
      logEvent,
    } = this.props;

    const userCanEdit =
      dashboardInfo.dash_edit_perm && !dashboardInfo.is_managed_externally;
    const userCanShare = dashboardInfo.dash_share_perm;
    const userCanSaveAs = dashboardInfo.dash_save_perm;
    const userCanCurate =
      isFeatureEnabled(FeatureFlag.EmbeddedSuperset) &&
      findPermission('can_set_embedded', 'Dashboard', user.roles);
    const refreshLimit =
      dashboardInfo.common?.conf?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT;
    const refreshWarning =
      dashboardInfo.common?.conf
        ?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE;

    const handleOnPropertiesChange = updates => {
      const { dashboardInfoChanged, dashboardTitleChanged } = this.props;

      setColorScheme(updates.colorScheme);
      dashboardInfoChanged({
        slug: updates.slug,
        metadata: JSON.parse(updates.jsonMetadata || '{}'),
        certified_by: updates.certifiedBy,
        certification_details: updates.certificationDetails,
        owners: updates.owners,
        roles: updates.roles,
      });
      setUnsavedChanges(true);
      dashboardTitleChanged(updates.title);
    };

    const NavExtension = extensionsRegistry.get('dashboard.nav.right');

    return (
      <div
        css={headerContainerStyle}
        data-test="dashboard-header-container"
        data-test-id={dashboardInfo.id}
        className="dashboard-header-container"
      >
        <PageHeaderWithActions
          editableTitleProps={{
            title: dashboardTitle,
            canEdit: userCanEdit && editMode,
            onSave: this.handleChangeText,
            placeholder: t('Add the name of the dashboard'),
            label: t('Dashboard title'),
            showTooltip: false,
          }}
          certificatiedBadgeProps={{
            certifiedBy: dashboardInfo.certified_by,
            details: dashboardInfo.certification_details,
          }}
          faveStarProps={{
            itemId: dashboardInfo.id,
            fetchFaveStar: this.props.fetchFaveStar,
            saveFaveStar: this.props.saveFaveStar,
            isStarred: this.props.isStarred,
            showTooltip: true,
          }}
          titlePanelAdditionalItems={[
            !editMode && (
              <PublishedStatus
                dashboardId={dashboardInfo.id}
                isPublished={isPublished}
                savePublished={this.props.savePublished}
                canEdit={userCanEdit}
                canSave={userCanSaveAs}
                visible={!editMode}
              />
            ),
            !editMode && (
              <MetadataBar
                items={this.getMetadataItems()}
                tooltipPlacement="bottom"
              />
            ),
          ]}
          rightPanelAdditionalItems={
            <div className="button-container">
              {userCanSaveAs && (
                <div
                  className="button-container"
                  data-test="dashboard-edit-actions"
                >
                  {editMode && (
                    <div css={actionButtonsStyle}>
                      <div className="undoRedo">
                        <Tooltip
                          id="dashboard-undo-tooltip"
                          title={t('Undo the action')}
                        >
                          <StyledUndoRedoButton
                            type="text"
                            disabled={undoLength < 1}
                            onClick={undoLength && onUndo}
                          >
                            <Icons.Undo
                              css={[
                                undoRedoStyle,
                                this.state.emphasizeUndo && undoRedoEmphasized,
                                undoLength < 1 && undoRedoDisabled,
                              ]}
                              data-test="undo-action"
                              iconSize="xl"
                            />
                          </StyledUndoRedoButton>
                        </Tooltip>
                        <Tooltip
                          id="dashboard-redo-tooltip"
                          title={t('Redo the action')}
                        >
                          <StyledUndoRedoButton
                            type="text"
                            disabled={redoLength < 1}
                            onClick={redoLength && onRedo}
                          >
                            <Icons.Redo
                              css={[
                                undoRedoStyle,
                                this.state.emphasizeRedo && undoRedoEmphasized,
                                redoLength < 1 && undoRedoDisabled,
                              ]}
                              data-test="redo-action"
                              iconSize="xl"
                            />
                          </StyledUndoRedoButton>
                        </Tooltip>
                      </div>
                      <Button
                        css={discardBtnStyle}
                        buttonSize="small"
                        onClick={this.constructor.discardChanges}
                        buttonStyle="default"
                        data-test="discard-changes-button"
                        aria-label={t('Discard')}
                      >
                        {t('Discard')}
                      </Button>
                      <Button
                        css={saveBtnStyle}
                        buttonSize="small"
                        disabled={!hasUnsavedChanges}
                        buttonStyle="primary"
                        onClick={this.overwriteDashboard}
                        data-test="header-save-button"
                        aria-label={t('Save')}
                      >
                        {t('Save')}
                      </Button>
                    </div>
                  )}
                </div>
              )}
              {editMode ? (
                <UndoRedoKeyListeners
                  onUndo={this.handleCtrlZ}
                  onRedo={this.handleCtrlY}
                />
              ) : (
                <div css={actionButtonsStyle}>
                  {NavExtension && <NavExtension />}
                  {userCanEdit && (
                    <Button
                      buttonStyle="secondary"
                      onClick={this.toggleEditMode}
                      data-test="edit-dashboard-button"
                      className="action-button"
                      css={editButtonStyle}
                      aria-label={t('Edit dashboard')}
                    >
                      {t('Edit dashboard')}
                    </Button>
                  )}
                </div>
              )}
            </div>
          }
          menuDropdownProps={{
            getPopupContainer: triggerNode =>
              triggerNode.closest('.header-with-actions'),
            visible: this.state.isDropdownVisible,
            onVisibleChange: this.setIsDropdownVisible,
          }}
          additionalActionsMenu={
            <ConnectedHeaderActionsDropdown
              addSuccessToast={this.props.addSuccessToast}
              addDangerToast={this.props.addDangerToast}
              dashboardId={dashboardInfo.id}
              dashboardTitle={dashboardTitle}
              dashboardInfo={dashboardInfo}
              dataMask={dataMask}
              layout={layout}
              expandedSlices={expandedSlices}
              customCss={customCss}
              colorNamespace={colorNamespace}
              colorScheme={colorScheme}
              onSave={onSave}
              onChange={onChange}
              forceRefreshAllCharts={this.forceRefresh}
              startPeriodicRender={this.startPeriodicRender}
              refreshFrequency={refreshFrequency}
              shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
              setRefreshFrequency={setRefreshFrequency}
              updateCss={updateCss}
              editMode={editMode}
              hasUnsavedChanges={hasUnsavedChanges}
              userCanEdit={userCanEdit}
              userCanShare={userCanShare}
              userCanSave={userCanSaveAs}
              userCanCurate={userCanCurate}
              isLoading={isLoading}
              showPropertiesModal={this.showPropertiesModal}
              manageEmbedded={this.showEmbedModal}
              refreshLimit={refreshLimit}
              refreshWarning={refreshWarning}
              lastModifiedTime={lastModifiedTime}
              isDropdownVisible={this.state.isDropdownVisible}
              setIsDropdownVisible={this.setIsDropdownVisible}
              logEvent={logEvent}
            />
          }
          showFaveStar={user?.userId && dashboardInfo?.id}
          showTitlePanelItems
        />
        {this.state.showingPropertiesModal && (
          <PropertiesModal
            dashboardId={dashboardInfo.id}
            dashboardInfo={dashboardInfo}
            dashboardTitle={dashboardTitle}
            show={this.state.showingPropertiesModal}
            onHide={this.hidePropertiesModal}
            colorScheme={this.props.colorScheme}
            onSubmit={handleOnPropertiesChange}
            onlyApply
          />
        )}

        <OverwriteConfirm />

        {userCanCurate && (
          <DashboardEmbedModal
            show={this.state.showingEmbedModal}
            onHide={this.hideEmbedModal}
            dashboardId={dashboardInfo.id}
          />
        )}
        <Global
          styles={css`
            .ant-menu-vertical {
              border-right: none;
            }
          `}
        />
      </div>
    );
  }
}

Header.propTypes = propTypes;
Header.defaultProps = defaultProps;

export default Header;