This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new eab888c63a perf: Optimize dashboard chart-related components (#31241)
eab888c63a is described below
commit eab888c63a3a6a68c1ea7ec24d12bdf55ab0751b
Author: Kamil Gabryjelski <[email protected]>
AuthorDate: Mon Dec 2 15:04:29 2024 +0100
perf: Optimize dashboard chart-related components (#31241)
---
.../src/dashboard/components/SliceHeader/index.tsx | 342 ++++----
.../components/URLShortLinkButton/index.tsx | 13 +-
.../dashboard/components/gridComponents/Chart.jsx | 907 +++++++++++----------
.../components/gridComponents/Chart.test.jsx | 112 ++-
.../components/gridComponents/ChartHolder.tsx | 225 ++---
.../src/dashboard/containers/Chart.jsx | 135 ---
6 files changed, 850 insertions(+), 884 deletions(-)
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index b281ff320d..fb84bf398f 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -16,7 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { FC, ReactNode, useContext, useEffect, useRef, useState } from 'react';
+import {
+ forwardRef,
+ ReactNode,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { css, getExtensionsRegistry, styled, t } from '@superset-ui/core';
import { useUiConfig } from 'src/components/UiConfigContext';
import { Tooltip } from 'src/components/Tooltip';
@@ -34,7 +41,6 @@ import { DashboardPageIdContext } from
'src/dashboard/containers/DashboardPage';
const extensionsRegistry = getExtensionsRegistry();
type SliceHeaderProps = SliceHeaderControlsProps & {
- innerRef?: string;
updateSliceName?: (arg0: string) => void;
editMode?: boolean;
annotationQuery?: object;
@@ -122,176 +128,182 @@ const ChartHeaderStyles = styled.div`
`}
`;
-const SliceHeader: FC<SliceHeaderProps> = ({
- innerRef = null,
- forceRefresh = () => ({}),
- updateSliceName = () => ({}),
- toggleExpandSlice = () => ({}),
- logExploreChart = () => ({}),
- logEvent,
- exportCSV = () => ({}),
- exportXLSX = () => ({}),
- editMode = false,
- annotationQuery = {},
- annotationError = {},
- cachedDttm = null,
- updatedDttm = null,
- isCached = [],
- isExpanded = false,
- sliceName = '',
- supersetCanExplore = false,
- supersetCanShare = false,
- supersetCanCSV = false,
- exportPivotCSV,
- exportFullCSV,
- exportFullXLSX,
- slice,
- componentId,
- dashboardId,
- addSuccessToast,
- addDangerToast,
- handleToggleFullSize,
- isFullSize,
- chartStatus,
- formData,
- width,
- height,
-}) => {
- const SliceHeaderExtension =
extensionsRegistry.get('dashboard.slice.header');
- const uiConfig = useUiConfig();
- const dashboardPageId = useContext(DashboardPageIdContext);
- const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
- const headerRef = useRef<HTMLDivElement>(null);
- // TODO: change to indicator field after it will be implemented
- const crossFilterValue = useSelector<RootState, any>(
- state => state.dataMask[slice?.slice_id]?.filterState?.value,
- );
- const isCrossFiltersEnabled = useSelector<RootState, boolean>(
- ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
- );
+const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
+ (
+ {
+ forceRefresh = () => ({}),
+ updateSliceName = () => ({}),
+ toggleExpandSlice = () => ({}),
+ logExploreChart = () => ({}),
+ logEvent,
+ exportCSV = () => ({}),
+ exportXLSX = () => ({}),
+ editMode = false,
+ annotationQuery = {},
+ annotationError = {},
+ cachedDttm = null,
+ updatedDttm = null,
+ isCached = [],
+ isExpanded = false,
+ sliceName = '',
+ supersetCanExplore = false,
+ supersetCanShare = false,
+ supersetCanCSV = false,
+ exportPivotCSV,
+ exportFullCSV,
+ exportFullXLSX,
+ slice,
+ componentId,
+ dashboardId,
+ addSuccessToast,
+ addDangerToast,
+ handleToggleFullSize,
+ isFullSize,
+ chartStatus,
+ formData,
+ width,
+ height,
+ },
+ ref,
+ ) => {
+ const SliceHeaderExtension = extensionsRegistry.get(
+ 'dashboard.slice.header',
+ );
+ const uiConfig = useUiConfig();
+ const dashboardPageId = useContext(DashboardPageIdContext);
+ const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
+ const headerRef = useRef<HTMLDivElement>(null);
+ // TODO: change to indicator field after it will be implemented
+ const crossFilterValue = useSelector<RootState, any>(
+ state => state.dataMask[slice?.slice_id]?.filterState?.value,
+ );
+ const isCrossFiltersEnabled = useSelector<RootState, boolean>(
+ ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled,
+ );
- const canExplore = !editMode && supersetCanExplore;
+ const canExplore = !editMode && supersetCanExplore;
- useEffect(() => {
- const headerElement = headerRef.current;
- if (canExplore) {
- setHeaderTooltip(getSliceHeaderTooltip(sliceName));
- } else if (
- headerElement &&
- (headerElement.scrollWidth > headerElement.offsetWidth ||
- headerElement.scrollHeight > headerElement.offsetHeight)
- ) {
- setHeaderTooltip(sliceName ?? null);
- } else {
- setHeaderTooltip(null);
- }
- }, [sliceName, width, height, canExplore]);
+ useEffect(() => {
+ const headerElement = headerRef.current;
+ if (canExplore) {
+ setHeaderTooltip(getSliceHeaderTooltip(sliceName));
+ } else if (
+ headerElement &&
+ (headerElement.scrollWidth > headerElement.offsetWidth ||
+ headerElement.scrollHeight > headerElement.offsetHeight)
+ ) {
+ setHeaderTooltip(sliceName ?? null);
+ } else {
+ setHeaderTooltip(null);
+ }
+ }, [sliceName, width, height, canExplore]);
- const exploreUrl =
`/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`;
+ const exploreUrl =
`/explore/?dashboard_page_id=${dashboardPageId}&slice_id=${slice.slice_id}`;
- return (
- <ChartHeaderStyles data-test="slice-header" ref={innerRef}>
- <div className="header-title" ref={headerRef}>
- <Tooltip title={headerTooltip}>
- <EditableTitle
- title={
- sliceName ||
- (editMode
- ? '---' // this makes an empty title clickable
- : '')
- }
- canEdit={editMode}
- onSaveTitle={updateSliceName}
- showTooltip={false}
- url={canExplore ? exploreUrl : undefined}
- />
- </Tooltip>
- {!!Object.values(annotationQuery).length && (
- <Tooltip
- id="annotations-loading-tooltip"
- placement="top"
- title={annotationsLoading}
- >
- <i
- role="img"
- aria-label={annotationsLoading}
- className="fa fa-refresh warning"
+ return (
+ <ChartHeaderStyles data-test="slice-header" ref={ref}>
+ <div className="header-title" ref={headerRef}>
+ <Tooltip title={headerTooltip}>
+ <EditableTitle
+ title={
+ sliceName ||
+ (editMode
+ ? '---' // this makes an empty title clickable
+ : '')
+ }
+ canEdit={editMode}
+ onSaveTitle={updateSliceName}
+ showTooltip={false}
+ url={canExplore ? exploreUrl : undefined}
/>
</Tooltip>
- )}
- {!!Object.values(annotationError).length && (
- <Tooltip
- id="annotation-errors-tooltip"
- placement="top"
- title={annotationsError}
- >
- <i
- role="img"
- aria-label={annotationsError}
- className="fa fa-exclamation-circle danger"
- />
- </Tooltip>
- )}
- </div>
- <div className="header-controls">
- {!editMode && (
- <>
- {SliceHeaderExtension && (
- <SliceHeaderExtension
- sliceId={slice.slice_id}
- dashboardId={dashboardId}
+ {!!Object.values(annotationQuery).length && (
+ <Tooltip
+ id="annotations-loading-tooltip"
+ placement="top"
+ title={annotationsLoading}
+ >
+ <i
+ role="img"
+ aria-label={annotationsLoading}
+ className="fa fa-refresh warning"
/>
- )}
- {crossFilterValue && (
- <Tooltip
- placement="top"
- title={t(
- 'This chart applies cross-filters to charts whose datasets
contain columns with the same name.',
- )}
- >
- <CrossFilterIcon iconSize="m" />
- </Tooltip>
- )}
- {!uiConfig.hideChartControls && (
- <FiltersBadge chartId={slice.slice_id} />
- )}
- {!uiConfig.hideChartControls && (
- <SliceHeaderControls
- slice={slice}
- isCached={isCached}
- isExpanded={isExpanded}
- cachedDttm={cachedDttm}
- updatedDttm={updatedDttm}
- toggleExpandSlice={toggleExpandSlice}
- forceRefresh={forceRefresh}
- logExploreChart={logExploreChart}
- logEvent={logEvent}
- exportCSV={exportCSV}
- exportPivotCSV={exportPivotCSV}
- exportFullCSV={exportFullCSV}
- exportXLSX={exportXLSX}
- exportFullXLSX={exportFullXLSX}
- supersetCanExplore={supersetCanExplore}
- supersetCanShare={supersetCanShare}
- supersetCanCSV={supersetCanCSV}
- componentId={componentId}
- dashboardId={dashboardId}
- addSuccessToast={addSuccessToast}
- addDangerToast={addDangerToast}
- handleToggleFullSize={handleToggleFullSize}
- isFullSize={isFullSize}
- isDescriptionExpanded={isExpanded}
- chartStatus={chartStatus}
- formData={formData}
- exploreUrl={exploreUrl}
- crossFiltersEnabled={isCrossFiltersEnabled}
+ </Tooltip>
+ )}
+ {!!Object.values(annotationError).length && (
+ <Tooltip
+ id="annotation-errors-tooltip"
+ placement="top"
+ title={annotationsError}
+ >
+ <i
+ role="img"
+ aria-label={annotationsError}
+ className="fa fa-exclamation-circle danger"
/>
- )}
- </>
- )}
- </div>
- </ChartHeaderStyles>
- );
-};
+ </Tooltip>
+ )}
+ </div>
+ <div className="header-controls">
+ {!editMode && (
+ <>
+ {SliceHeaderExtension && (
+ <SliceHeaderExtension
+ sliceId={slice.slice_id}
+ dashboardId={dashboardId}
+ />
+ )}
+ {crossFilterValue && (
+ <Tooltip
+ placement="top"
+ title={t(
+ 'This chart applies cross-filters to charts whose datasets
contain columns with the same name.',
+ )}
+ >
+ <CrossFilterIcon iconSize="m" />
+ </Tooltip>
+ )}
+ {!uiConfig.hideChartControls && (
+ <FiltersBadge chartId={slice.slice_id} />
+ )}
+ {!uiConfig.hideChartControls && (
+ <SliceHeaderControls
+ slice={slice}
+ isCached={isCached}
+ isExpanded={isExpanded}
+ cachedDttm={cachedDttm}
+ updatedDttm={updatedDttm}
+ toggleExpandSlice={toggleExpandSlice}
+ forceRefresh={forceRefresh}
+ logExploreChart={logExploreChart}
+ logEvent={logEvent}
+ exportCSV={exportCSV}
+ exportPivotCSV={exportPivotCSV}
+ exportFullCSV={exportFullCSV}
+ exportXLSX={exportXLSX}
+ exportFullXLSX={exportFullXLSX}
+ supersetCanExplore={supersetCanExplore}
+ supersetCanShare={supersetCanShare}
+ supersetCanCSV={supersetCanCSV}
+ componentId={componentId}
+ dashboardId={dashboardId}
+ addSuccessToast={addSuccessToast}
+ addDangerToast={addDangerToast}
+ handleToggleFullSize={handleToggleFullSize}
+ isFullSize={isFullSize}
+ isDescriptionExpanded={isExpanded}
+ chartStatus={chartStatus}
+ formData={formData}
+ exploreUrl={exploreUrl}
+ crossFiltersEnabled={isCrossFiltersEnabled}
+ />
+ )}
+ </>
+ )}
+ </div>
+ </ChartHeaderStyles>
+ );
+ },
+);
export default SliceHeader;
diff --git
a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
index 849b1cd444..f227a9e845 100644
--- a/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
+++ b/superset-frontend/src/dashboard/components/URLShortLinkButton/index.tsx
@@ -22,7 +22,7 @@ import Popover, { PopoverProps } from
'src/components/Popover';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
-import { useSelector } from 'react-redux';
+import { shallowEqual, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
export type URLShortLinkButtonProps = {
@@ -42,10 +42,13 @@ export default function URLShortLinkButton({
}: URLShortLinkButtonProps) {
const [shortUrl, setShortUrl] = useState('');
const { addDangerToast } = useToasts();
- const { dataMask, activeTabs } = useSelector((state: RootState) => ({
- dataMask: state.dataMask,
- activeTabs: state.dashboardState.activeTabs,
- }));
+ const { dataMask, activeTabs } = useSelector(
+ (state: RootState) => ({
+ dataMask: state.dataMask,
+ activeTabs: state.dashboardState.activeTabs,
+ }),
+ shallowEqual,
+ );
const getCopyUrl = async () => {
try {
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
index d125ccb626..5cc8ba6d37 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
@@ -17,11 +17,13 @@
* under the License.
*/
import cx from 'classnames';
-import { Component } from 'react';
+import { useCallback, useEffect, useRef, useMemo, useState, memo } from
'react';
import PropTypes from 'prop-types';
import { styled, t, logging } from '@superset-ui/core';
-import { debounce, isEqual } from 'lodash';
-import { withRouter } from 'react-router-dom';
+import { debounce } from 'lodash';
+import { useHistory } from 'react-router-dom';
+import { bindActionCreators } from 'redux';
+import { useDispatch, useSelector } from 'react-redux';
import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils';
import ChartContainer from 'src/components/Chart/ChartContainer';
@@ -32,13 +34,30 @@ import {
LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART,
LOG_ACTIONS_FORCE_REFRESH_CHART,
} from 'src/logger/LogUtils';
-import { areObjectsEqual } from 'src/reduxUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants';
+import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart';
-import { slicePropShape, chartPropShape } from '../../util/propShapes';
+import {
+ addDangerToast,
+ addSuccessToast,
+} from '../../../components/MessageToasts/actions';
+import {
+ setFocusedFilterField,
+ toggleExpandSlice,
+ unsetFocusedFilterField,
+} from '../../actions/dashboardState';
+import { changeFilter } from '../../actions/dashboardFilters';
+import { refreshChart } from '../../../components/Chart/chartAction';
+import { logEvent } from '../../../logger/actions';
+import {
+ getActiveFilters,
+ getAppliedFilterValues,
+} from '../../util/activeDashboardFilters';
+import getFormDataWithExtraFilters from
'../../util/charts/getFormDataWithExtraFilters';
+import { PLACEHOLDER_DATASOURCE } from '../../constants';
const propTypes = {
id: PropTypes.number.isRequired,
@@ -50,53 +69,15 @@ const propTypes = {
isComponentVisible: PropTypes.bool,
handleToggleFullSize: PropTypes.func.isRequired,
setControlValue: PropTypes.func,
-
- // from redux
- chart: chartPropShape.isRequired,
- formData: PropTypes.object.isRequired,
- labelsColor: PropTypes.object,
- labelsColorMap: PropTypes.object,
- datasource: PropTypes.object,
- slice: slicePropShape.isRequired,
sliceName: PropTypes.string.isRequired,
- timeout: PropTypes.number.isRequired,
- maxRows: PropTypes.number.isRequired,
- // all active filter fields in dashboard
- filters: PropTypes.object.isRequired,
- refreshChart: PropTypes.func.isRequired,
- logEvent: PropTypes.func.isRequired,
- toggleExpandSlice: PropTypes.func.isRequired,
- changeFilter: PropTypes.func.isRequired,
- setFocusedFilterField: PropTypes.func.isRequired,
- unsetFocusedFilterField: PropTypes.func.isRequired,
- editMode: PropTypes.bool.isRequired,
- isExpanded: PropTypes.bool.isRequired,
- isCached: PropTypes.bool,
- supersetCanExplore: PropTypes.bool.isRequired,
- supersetCanShare: PropTypes.bool.isRequired,
- supersetCanCSV: PropTypes.bool.isRequired,
- addSuccessToast: PropTypes.func.isRequired,
- addDangerToast: PropTypes.func.isRequired,
- ownState: PropTypes.object,
- filterState: PropTypes.object,
- postTransformProps: PropTypes.func,
- datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']),
+ isFullSize: PropTypes.bool,
+ extraControls: PropTypes.object,
isInView: PropTypes.bool,
- emitCrossFilters: PropTypes.bool,
-};
-
-const defaultProps = {
- isCached: false,
- isComponentVisible: true,
};
// we use state + shouldComponentUpdate() logic to prevent perf-wrecking
// resizing across all slices on a dashboard on every update
const RESIZE_TIMEOUT = 500;
-const SHOULD_UPDATE_ON_PROP_CHANGES = Object.keys(propTypes).filter(
- prop =>
- prop !== 'width' && prop !== 'height' && prop !== 'isComponentVisible',
-);
const DEFAULT_HEADER_HEIGHT = 22;
const ChartWrapper = styled.div`
@@ -121,429 +102,457 @@ const SliceContainer = styled.div`
max-height: 100%;
`;
-class Chart extends Component {
- constructor(props) {
- super(props);
- this.state = {
- width: props.width,
- height: props.height,
- descriptionHeight: 0,
- };
-
- this.changeFilter = this.changeFilter.bind(this);
- this.handleFilterMenuOpen = this.handleFilterMenuOpen.bind(this);
- this.handleFilterMenuClose = this.handleFilterMenuClose.bind(this);
- this.exportCSV = this.exportCSV.bind(this);
- this.exportPivotCSV = this.exportPivotCSV.bind(this);
- this.exportFullCSV = this.exportFullCSV.bind(this);
- this.exportXLSX = this.exportXLSX.bind(this);
- this.exportFullXLSX = this.exportFullXLSX.bind(this);
- this.forceRefresh = this.forceRefresh.bind(this);
- this.resize = debounce(this.resize.bind(this), RESIZE_TIMEOUT);
- this.setDescriptionRef = this.setDescriptionRef.bind(this);
- this.setHeaderRef = this.setHeaderRef.bind(this);
- this.getChartHeight = this.getChartHeight.bind(this);
- this.getDescriptionHeight = this.getDescriptionHeight.bind(this);
- }
-
- shouldComponentUpdate(nextProps, nextState) {
- // this logic mostly pertains to chart resizing. we keep a copy of the
dimensions in
- // state so that we can buffer component size updates and only update on
the final call
- // which improves performance significantly
- if (
- nextState.width !== this.state.width ||
- nextState.height !== this.state.height ||
- nextState.descriptionHeight !== this.state.descriptionHeight ||
- !isEqual(nextProps.datasource, this.props.datasource)
- ) {
- return true;
- }
-
- // allow chart to update if the status changed and the previous status was
loading.
- if (
- this.props?.chart?.chartStatus !== nextProps?.chart?.chartStatus &&
- this.props?.chart?.chartStatus === 'loading'
- ) {
- return true;
- }
-
- // allow chart update/re-render only if visible:
- // under selected tab or no tab layout
- if (nextProps.isComponentVisible) {
- if (nextProps.chart.triggerQuery) {
- return true;
- }
-
- if (nextProps.isFullSize !== this.props.isFullSize) {
- this.resize();
- return false;
- }
-
- if (
- nextProps.width !== this.props.width ||
- nextProps.height !== this.props.height ||
- nextProps.width !== this.state.width ||
- nextProps.height !== this.state.height
- ) {
- this.resize();
- }
-
- for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
- const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
- // use deep objects equality comparison to prevent
- // unnecessary updates when objects references change
- if (!areObjectsEqual(nextProps[prop], this.props[prop])) {
- return true;
- }
- }
- } else if (
- // chart should re-render if color scheme or label colors were changed
- nextProps.formData?.color_scheme !== this.props.formData?.color_scheme ||
- !areObjectsEqual(
- nextProps.formData?.label_colors || {},
- this.props.formData?.label_colors || {},
- ) ||
- !areObjectsEqual(
- nextProps.formData?.map_label_colors || {},
- this.props.formData?.map_label_colors || {},
- ) ||
- !isEqual(
- nextProps.formData?.shared_label_colors || [],
- this.props.formData?.shared_label_colors || [],
- )
- ) {
- return true;
- }
-
- // `cacheBusterProp` is injected by react-hot-loader
- return this.props.cacheBusterProp !== nextProps.cacheBusterProp;
- }
-
- componentDidMount() {
- if (this.props.isExpanded) {
- const descriptionHeight = this.getDescriptionHeight();
- this.setState({ descriptionHeight });
- }
- }
-
- componentWillUnmount() {
- this.resize.cancel();
- }
-
- componentDidUpdate(prevProps) {
- if (this.props.isExpanded !== prevProps.isExpanded) {
- const descriptionHeight = this.getDescriptionHeight();
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState({ descriptionHeight });
+const EMPTY_OBJECT = {};
+
+const Chart = props => {
+ const dispatch = useDispatch();
+ const descriptionRef = useRef(null);
+ const headerRef = useRef(null);
+
+ const boundActionCreators = useMemo(
+ () =>
+ bindActionCreators(
+ {
+ addSuccessToast,
+ addDangerToast,
+ toggleExpandSlice,
+ changeFilter,
+ setFocusedFilterField,
+ unsetFocusedFilterField,
+ refreshChart,
+ logEvent,
+ },
+ dispatch,
+ ),
+ [dispatch],
+ );
+
+ const chart = useSelector(state => state.charts[props.id] || EMPTY_OBJECT);
+ const slice = useSelector(
+ state => state.sliceEntities.slices[props.id] || EMPTY_OBJECT,
+ );
+ const editMode = useSelector(state => state.dashboardState.editMode);
+ const isExpanded = useSelector(
+ state => !!state.dashboardState.expandedSlices[props.id],
+ );
+ const supersetCanExplore = useSelector(
+ state => !!state.dashboardInfo.superset_can_explore,
+ );
+ const supersetCanShare = useSelector(
+ state => !!state.dashboardInfo.superset_can_share,
+ );
+ const supersetCanCSV = useSelector(
+ state => !!state.dashboardInfo.superset_can_csv,
+ );
+ const timeout = useSelector(
+ state => state.dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+ );
+ const emitCrossFilters = useSelector(
+ state => !!state.dashboardInfo.crossFiltersEnabled,
+ );
+ const datasource = useSelector(
+ state =>
+ (chart &&
+ chart.form_data &&
+ state.datasources[chart.form_data.datasource]) ||
+ PLACEHOLDER_DATASOURCE,
+ );
+
+ const [descriptionHeight, setDescriptionHeight] = useState(0);
+ const [height, setHeight] = useState(props.height);
+ const [width, setWidth] = useState(props.width);
+ const history = useHistory();
+ const resize = useCallback(
+ debounce(() => {
+ const { width, height } = props;
+ setHeight(height);
+ setWidth(width);
+ }, RESIZE_TIMEOUT),
+ [props.width, props.height],
+ );
+
+ const ownColorScheme = chart.form_data?.color_scheme;
+
+ const addFilter = useCallback(
+ (newSelectedValues = {}) => {
+ boundActionCreators.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
+ id: chart.id,
+ columns: Object.keys(newSelectedValues).filter(
+ key => newSelectedValues[key] !== null,
+ ),
+ });
+ boundActionCreators.changeFilter(chart.id, newSelectedValues);
+ },
+ [boundActionCreators.logEvent, boundActionCreators.changeFilter, chart.id],
+ );
+
+ useEffect(() => {
+ if (isExpanded) {
+ const descriptionHeight =
+ isExpanded && descriptionRef.current
+ ? descriptionRef.current?.offsetHeight
+ : 0;
+ setDescriptionHeight(descriptionHeight);
}
- }
-
- getDescriptionHeight() {
- return this.props.isExpanded && this.descriptionRef
- ? this.descriptionRef.offsetHeight
- : 0;
- }
-
- getChartHeight() {
- const headerHeight = this.getHeaderHeight();
- return Math.max(
- this.state.height - headerHeight - this.state.descriptionHeight,
- 20,
- );
- }
-
- getHeaderHeight() {
- if (this.headerRef) {
- const computedStyle = getComputedStyle(this.headerRef).getPropertyValue(
- 'margin-bottom',
- );
+ }, [isExpanded]);
+
+ useEffect(
+ () => () => {
+ resize.cancel();
+ },
+ [resize],
+ );
+
+ useEffect(() => {
+ resize();
+ }, [resize, props.isFullSize]);
+
+ const getHeaderHeight = useCallback(() => {
+ if (headerRef.current) {
+ const computedStyle = getComputedStyle(
+ headerRef.current,
+ ).getPropertyValue('margin-bottom');
const marginBottom = parseInt(computedStyle, 10) || 0;
- return this.headerRef.offsetHeight + marginBottom;
+ return headerRef.current.offsetHeight + marginBottom;
}
return DEFAULT_HEADER_HEIGHT;
- }
-
- setDescriptionRef(ref) {
- this.descriptionRef = ref;
- }
-
- setHeaderRef(ref) {
- this.headerRef = ref;
- }
-
- resize() {
- const { width, height } = this.props;
- this.setState(() => ({ width, height }));
- }
-
- changeFilter(newSelectedValues = {}) {
- this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
- id: this.props.chart.id,
- columns: Object.keys(newSelectedValues).filter(
- key => newSelectedValues[key] !== null,
- ),
+ }, [headerRef]);
+
+ const getChartHeight = useCallback(() => {
+ const headerHeight = getHeaderHeight();
+ return Math.max(height - headerHeight - descriptionHeight, 20);
+ }, [getHeaderHeight, height, descriptionHeight]);
+
+ const handleFilterMenuOpen = useCallback(
+ (chartId, column) => {
+ boundActionCreators.setFocusedFilterField(chartId, column);
+ },
+ [boundActionCreators.setFocusedFilterField],
+ );
+
+ const handleFilterMenuClose = useCallback(
+ (chartId, column) => {
+ boundActionCreators.unsetFocusedFilterField(chartId, column);
+ },
+ [boundActionCreators.unsetFocusedFilterField],
+ );
+
+ const logExploreChart = useCallback(() => {
+ boundActionCreators.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
+ slice_id: slice.slice_id,
+ is_cached: props.isCached,
});
- this.props.changeFilter(this.props.chart.id, newSelectedValues);
- }
-
- handleFilterMenuOpen(chartId, column) {
- this.props.setFocusedFilterField(chartId, column);
- }
-
- handleFilterMenuClose(chartId, column) {
- this.props.unsetFocusedFilterField(chartId, column);
- }
-
- logExploreChart = () => {
- this.props.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
- slice_id: this.props.slice.slice_id,
- is_cached: this.props.isCached,
- });
- };
-
- onExploreChart = async clickEvent => {
- const isOpenInNewTab =
- clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey;
- try {
- const lastTabId = window.localStorage.getItem('last_tab_id');
- const nextTabId = lastTabId
- ? String(Number.parseInt(lastTabId, 10) + 1)
- : undefined;
- const key = await postFormData(
- this.props.datasource.id,
- this.props.datasource.type,
- this.props.formData,
- this.props.slice.slice_id,
- nextTabId,
- );
- const url = mountExploreUrl(null, {
- [URL_PARAMS.formDataKey.name]: key,
- [URL_PARAMS.sliceId.name]: this.props.slice.slice_id,
- });
- if (isOpenInNewTab) {
- window.open(url, '_blank', 'noreferrer');
- } else {
- this.props.history.push(url);
- }
- } catch (error) {
- logging.error(error);
- this.props.addDangerToast(t('An error occurred while opening Explore'));
- }
- };
-
- exportFullCSV() {
- this.exportCSV(true);
- }
-
- exportCSV(isFullCSV = false) {
- this.exportTable('csv', isFullCSV);
- }
-
- exportPivotCSV() {
- this.exportTable('csv', false, true);
- }
-
- exportXLSX() {
- this.exportTable('xlsx', false);
- }
-
- exportFullXLSX() {
- this.exportTable('xlsx', true);
- }
-
- exportTable(format, isFullCSV, isPivot = false) {
- const logAction =
- format === 'csv'
- ? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
- : LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART;
- this.props.logEvent(logAction, {
- slice_id: this.props.slice.slice_id,
- is_cached: this.props.isCached,
- });
- exportChart({
- formData: isFullCSV
- ? { ...this.props.formData, row_limit: this.props.maxRows }
- : this.props.formData,
- resultType: isPivot ? 'post_processed' : 'full',
- resultFormat: format,
- force: true,
- ownState: this.props.ownState,
- });
- }
-
- forceRefresh() {
- this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
- slice_id: this.props.slice.slice_id,
- is_cached: this.props.isCached,
- });
- return this.props.refreshChart(
- this.props.chart.id,
- true,
- this.props.dashboardId,
- );
- }
-
- render() {
- const {
- id,
- componentId,
- dashboardId,
+ }, [boundActionCreators.logEvent, slice.slice_id, props.isCached]);
+
+ const chartConfiguration = useSelector(
+ state => state.dashboardInfo.metadata?.chart_configuration,
+ );
+ const colorScheme = useSelector(state => state.dashboardState.colorScheme);
+ const colorNamespace = useSelector(
+ state => state.dashboardState.colorNamespace,
+ );
+ const datasetsStatus = useSelector(
+ state => state.dashboardState.datasetsStatus,
+ );
+ const allSliceIds = useSelector(state => state.dashboardState.sliceIds);
+ const nativeFilters = useSelector(state => state.nativeFilters?.filters);
+ const dataMask = useSelector(state => state.dataMask);
+ const labelsColor = useSelector(
+ state => state.dashboardInfo?.metadata?.label_colors || EMPTY_OBJECT,
+ );
+ const labelsColorMap = useSelector(
+ state => state.dashboardInfo?.metadata?.map_label_colors || EMPTY_OBJECT,
+ );
+ const sharedLabelsColors = useSelector(state =>
+ enforceSharedLabelsColorsArray(
+ state.dashboardInfo?.metadata?.shared_label_colors,
+ ),
+ );
+
+ const formData = useMemo(
+ () =>
+ getFormDataWithExtraFilters({
+ chart,
+ chartConfiguration,
+ filters: getAppliedFilterValues(props.id),
+ colorScheme,
+ colorNamespace,
+ sliceId: props.id,
+ nativeFilters,
+ allSliceIds,
+ dataMask,
+ extraControls: props.extraControls,
+ labelsColor,
+ labelsColorMap,
+ sharedLabelsColors,
+ ownColorScheme,
+ }),
+ [
chart,
- slice,
- datasource,
- isExpanded,
- editMode,
- filters,
- formData,
+ chartConfiguration,
+ props.id,
+ props.extraControls,
+ colorScheme,
+ colorNamespace,
+ nativeFilters,
+ allSliceIds,
+ dataMask,
labelsColor,
labelsColorMap,
- updateSliceName,
- sliceName,
- toggleExpandSlice,
- timeout,
- supersetCanExplore,
- supersetCanShare,
- supersetCanCSV,
- addSuccessToast,
- addDangerToast,
- ownState,
- filterState,
- handleToggleFullSize,
- isFullSize,
- setControlValue,
- postTransformProps,
- datasetsStatus,
- isInView,
- emitCrossFilters,
- logEvent,
- } = this.props;
-
- const { width } = this.state;
- // this prevents throwing in the case that a gridComponent
- // references a chart that is not associated with the dashboard
- if (!chart || !slice) {
- return <MissingChart height={this.getChartHeight()} />;
- }
+ sharedLabelsColors,
+ ownColorScheme,
+ ],
+ );
+
+ const onExploreChart = useCallback(
+ async clickEvent => {
+ const isOpenInNewTab =
+ clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey;
+ try {
+ const lastTabId = window.localStorage.getItem('last_tab_id');
+ const nextTabId = lastTabId
+ ? String(Number.parseInt(lastTabId, 10) + 1)
+ : undefined;
+ const key = await postFormData(
+ datasource.id,
+ datasource.type,
+ formData,
+ slice.slice_id,
+ nextTabId,
+ );
+ const url = mountExploreUrl(null, {
+ [URL_PARAMS.formDataKey.name]: key,
+ [URL_PARAMS.sliceId.name]: slice.slice_id,
+ });
+ if (isOpenInNewTab) {
+ window.open(url, '_blank', 'noreferrer');
+ } else {
+ history.push(url);
+ }
+ } catch (error) {
+ logging.error(error);
+ boundActionCreators.addDangerToast(
+ t('An error occurred while opening Explore'),
+ );
+ }
+ },
+ [
+ datasource.id,
+ datasource.type,
+ formData,
+ slice.slice_id,
+ boundActionCreators.addDangerToast,
+ history,
+ ],
+ );
+
+ const exportTable = useCallback(
+ (format, isFullCSV, isPivot = false) => {
+ const logAction =
+ format === 'csv'
+ ? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
+ : LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART;
+ boundActionCreators.logEvent(logAction, {
+ slice_id: slice.slice_id,
+ is_cached: props.isCached,
+ });
+ exportChart({
+ formData: isFullCSV
+ ? { ...formData, row_limit: props.maxRows }
+ : formData,
+ resultType: isPivot ? 'post_processed' : 'full',
+ resultFormat: format,
+ force: true,
+ ownState: props.ownState,
+ });
+ },
+ [
+ slice.slice_id,
+ props.isCached,
+ formData,
+ props.maxRows,
+ props.ownState,
+ boundActionCreators.logEvent,
+ ],
+ );
+
+ const exportCSV = useCallback(
+ (isFullCSV = false) => {
+ exportTable('csv', isFullCSV);
+ },
+ [exportTable],
+ );
+
+ const exportFullCSV = useCallback(() => {
+ exportCSV(true);
+ }, [exportCSV]);
+
+ const exportPivotCSV = useCallback(() => {
+ exportTable('csv', false, true);
+ }, [exportTable]);
+
+ const exportXLSX = useCallback(() => {
+ exportTable('xlsx', false);
+ }, [exportTable]);
+
+ const exportFullXLSX = useCallback(() => {
+ exportTable('xlsx', true);
+ }, [exportTable]);
+
+ const forceRefresh = useCallback(() => {
+ boundActionCreators.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
+ slice_id: slice.slice_id,
+ is_cached: props.isCached,
+ });
+ return boundActionCreators.refreshChart(chart.id, true, props.dashboardId);
+ }, [
+ boundActionCreators.refreshChart,
+ chart.id,
+ props.dashboardId,
+ slice.slice_id,
+ props.isCached,
+ boundActionCreators.logEvent,
+ ]);
+
+ if (chart === EMPTY_OBJECT || slice === EMPTY_OBJECT) {
+ return <MissingChart height={getChartHeight()} />;
+ }
- const { queriesResponse, chartUpdateEndTime, chartStatus } = chart;
- const isLoading = chartStatus === 'loading';
+ const { queriesResponse, chartUpdateEndTime, chartStatus, annotationQuery } =
+ chart;
+ const isLoading = chartStatus === 'loading';
+ // eslint-disable-next-line camelcase
+ const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
+ const cachedDttm =
// eslint-disable-next-line camelcase
- const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
- const cachedDttm =
- // eslint-disable-next-line camelcase
- queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
- const initialValues = {};
-
- return (
- <SliceContainer
- className="chart-slice"
- data-test="chart-grid-component"
- data-test-chart-id={id}
- data-test-viz-type={slice.viz_type}
- data-test-chart-name={slice.slice_name}
- >
- <SliceHeader
- innerRef={this.setHeaderRef}
- slice={slice}
- isExpanded={isExpanded}
- isCached={isCached}
- cachedDttm={cachedDttm}
- updatedDttm={chartUpdateEndTime}
- toggleExpandSlice={toggleExpandSlice}
- forceRefresh={this.forceRefresh}
- editMode={editMode}
- annotationQuery={chart.annotationQuery}
- logExploreChart={this.logExploreChart}
- logEvent={logEvent}
- onExploreChart={this.onExploreChart}
- exportCSV={this.exportCSV}
- exportPivotCSV={this.exportPivotCSV}
- exportXLSX={this.exportXLSX}
- exportFullCSV={this.exportFullCSV}
- exportFullXLSX={this.exportFullXLSX}
- updateSliceName={updateSliceName}
- sliceName={sliceName}
- supersetCanExplore={supersetCanExplore}
- supersetCanShare={supersetCanShare}
- supersetCanCSV={supersetCanCSV}
- componentId={componentId}
- dashboardId={dashboardId}
- filters={filters}
- addSuccessToast={addSuccessToast}
- addDangerToast={addDangerToast}
- handleToggleFullSize={handleToggleFullSize}
- isFullSize={isFullSize}
- chartStatus={chart.chartStatus}
- formData={formData}
- width={width}
- height={this.getHeaderHeight()}
- />
-
- {/*
+ queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
+
+ return (
+ <SliceContainer
+ className="chart-slice"
+ data-test="chart-grid-component"
+ data-test-chart-id={props.id}
+ data-test-viz-type={slice.viz_type}
+ data-test-chart-name={slice.slice_name}
+ >
+ <SliceHeader
+ ref={headerRef}
+ slice={slice}
+ isExpanded={isExpanded}
+ isCached={isCached}
+ cachedDttm={cachedDttm}
+ updatedDttm={chartUpdateEndTime}
+ toggleExpandSlice={boundActionCreators.toggleExpandSlice}
+ forceRefresh={forceRefresh}
+ editMode={editMode}
+ annotationQuery={annotationQuery}
+ logExploreChart={logExploreChart}
+ logEvent={boundActionCreators.logEvent}
+ onExploreChart={onExploreChart}
+ exportCSV={exportCSV}
+ exportPivotCSV={exportPivotCSV}
+ exportXLSX={exportXLSX}
+ exportFullCSV={exportFullCSV}
+ exportFullXLSX={exportFullXLSX}
+ updateSliceName={props.updateSliceName}
+ sliceName={props.sliceName}
+ supersetCanExplore={supersetCanExplore}
+ supersetCanShare={supersetCanShare}
+ supersetCanCSV={supersetCanCSV}
+ componentId={props.componentId}
+ dashboardId={props.dashboardId}
+ filters={getActiveFilters() || EMPTY_OBJECT}
+ addSuccessToast={boundActionCreators.addSuccessToast}
+ addDangerToast={boundActionCreators.addDangerToast}
+ handleToggleFullSize={props.handleToggleFullSize}
+ isFullSize={props.isFullSize}
+ chartStatus={chartStatus}
+ formData={formData}
+ width={width}
+ height={getHeaderHeight()}
+ />
+
+ {/*
This usage of dangerouslySetInnerHTML is safe since it is being used
to render
markdown that is sanitized with nh3. See:
https://github.com/apache/superset/pull/4390
and
https://github.com/apache/superset/pull/23862
*/}
- {isExpanded && slice.description_markeddown && (
- <div
- className="slice_description bs-callout bs-callout-default"
- ref={this.setDescriptionRef}
- // eslint-disable-next-line react/no-danger
- dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
- role="complementary"
+ {isExpanded && slice.description_markeddown && (
+ <div
+ className="slice_description bs-callout bs-callout-default"
+ ref={descriptionRef}
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
+ role="complementary"
+ />
+ )}
+
+ <ChartWrapper
+ className={cx('dashboard-chart')}
+ aria-label={slice.description}
+ >
+ {isLoading && (
+ <ChartOverlay
+ style={{
+ width,
+ height: getChartHeight(),
+ }}
/>
)}
- <ChartWrapper
- className={cx('dashboard-chart')}
- aria-label={slice.description}
- >
- {isLoading && (
- <ChartOverlay
- style={{
- width,
- height: this.getChartHeight(),
- }}
- />
- )}
-
- <ChartContainer
- width={width}
- height={this.getChartHeight()}
- addFilter={this.changeFilter}
- onFilterMenuOpen={this.handleFilterMenuOpen}
- onFilterMenuClose={this.handleFilterMenuClose}
- annotationData={chart.annotationData}
- chartAlert={chart.chartAlert}
- chartId={id}
- chartStatus={chartStatus}
- datasource={datasource}
- dashboardId={dashboardId}
- initialValues={initialValues}
- formData={formData}
- labelsColor={labelsColor}
- labelsColorMap={labelsColorMap}
- ownState={ownState}
- filterState={filterState}
- queriesResponse={chart.queriesResponse}
- timeout={timeout}
- triggerQuery={chart.triggerQuery}
- vizType={slice.viz_type}
- setControlValue={setControlValue}
- postTransformProps={postTransformProps}
- datasetsStatus={datasetsStatus}
- isInView={isInView}
- emitCrossFilters={emitCrossFilters}
- />
- </ChartWrapper>
- </SliceContainer>
- );
- }
-}
+ <ChartContainer
+ width={width}
+ height={getChartHeight()}
+ addFilter={addFilter}
+ onFilterMenuOpen={handleFilterMenuOpen}
+ onFilterMenuClose={handleFilterMenuClose}
+ annotationData={chart.annotationData}
+ chartAlert={chart.chartAlert}
+ chartId={props.id}
+ chartStatus={chartStatus}
+ datasource={datasource}
+ dashboardId={props.dashboardId}
+ initialValues={EMPTY_OBJECT}
+ formData={formData}
+ labelsColor={labelsColor}
+ labelsColorMap={labelsColorMap}
+ ownState={dataMask[props.id]?.ownState}
+ filterState={dataMask[props.id]?.filterState}
+ queriesResponse={chart.queriesResponse}
+ timeout={timeout}
+ triggerQuery={chart.triggerQuery}
+ vizType={slice.viz_type}
+ setControlValue={props.setControlValue}
+ datasetsStatus={datasetsStatus}
+ isInView={props.isInView}
+ emitCrossFilters={emitCrossFilters}
+ />
+ </ChartWrapper>
+ </SliceContainer>
+ );
+};
Chart.propTypes = propTypes;
-Chart.defaultProps = defaultProps;
-export default withRouter(Chart);
+export default memo(Chart, (prevProps, nextProps) => {
+ if (prevProps.cacheBusterProp !== nextProps.cacheBusterProp) {
+ return false;
+ }
+ return (
+ !nextProps.isComponentVisible ||
+ (prevProps.isInView === nextProps.isInView &&
+ prevProps.componentId === nextProps.componentId &&
+ prevProps.id === nextProps.id &&
+ prevProps.dashboardId === nextProps.dashboardId &&
+ prevProps.extraControls === nextProps.extraControls &&
+ prevProps.handleToggleFullSize === nextProps.handleToggleFullSize &&
+ prevProps.isFullSize === nextProps.isFullSize &&
+ prevProps.setControlValue === nextProps.setControlValue &&
+ prevProps.sliceName === nextProps.sliceName &&
+ prevProps.updateSliceName === nextProps.updateSliceName &&
+ prevProps.width === nextProps.width &&
+ prevProps.height === nextProps.height)
+ );
+});
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx
b/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx
index e54dac7570..223b98d578 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.test.jsx
@@ -18,6 +18,7 @@
*/
import { fireEvent, render } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core';
+import * as redux from 'redux';
import Chart from 'src/dashboard/components/gridComponents/Chart';
import * as exploreUtils from 'src/explore/exploreUtils';
@@ -32,18 +33,10 @@ const props = {
width: 100,
height: 100,
updateSliceName() {},
-
// from redux
maxRows: 666,
- chart: chartQueries[queryId],
formData: chartQueries[queryId].form_data,
datasource: mockDatasource[sliceEntities.slices[queryId].datasource],
- slice: {
- ...sliceEntities.slices[queryId],
- description_markeddown: 'markdown',
- owners: [],
- viz_type: VizType.Table,
- },
sliceName: sliceEntities.slices[queryId].slice_name,
timeout: 60,
filters: {},
@@ -63,20 +56,60 @@ const props = {
exportFullXLSX() {},
componentId: 'test',
dashboardId: 111,
- editMode: false,
- isExpanded: false,
- supersetCanExplore: false,
- supersetCanCSV: false,
- supersetCanShare: false,
};
-function setup(overrideProps) {
- return render(<Chart.WrappedComponent {...props} {...overrideProps} />, {
+const defaultState = {
+ charts: chartQueries,
+ sliceEntities: {
+ ...sliceEntities,
+ slices: {
+ [queryId]: {
+ ...sliceEntities.slices[queryId],
+ description_markeddown: 'markdown',
+ owners: [],
+ viz_type: VizType.Table,
+ },
+ },
+ },
+ datasources: mockDatasource,
+ dashboardState: { editMode: false, expandedSlices: {} },
+ dashboardInfo: {
+ superset_can_explore: false,
+ superset_can_share: false,
+ superset_can_csv: false,
+ common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 0 } },
+ },
+};
+
+function setup(overrideProps, overrideState) {
+ return render(<Chart {...props} {...overrideProps} />, {
useRedux: true,
useRouter: true,
+ initialState: { ...defaultState, ...overrideState },
});
}
+const refreshChart = jest.fn();
+const logEvent = jest.fn();
+const changeFilter = jest.fn();
+const addSuccessToast = jest.fn();
+const addDangerToast = jest.fn();
+const toggleExpandSlice = jest.fn();
+const setFocusedFilterField = jest.fn();
+const unsetFocusedFilterField = jest.fn();
+beforeAll(() => {
+ jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
+ refreshChart,
+ logEvent,
+ changeFilter,
+ addSuccessToast,
+ addDangerToast,
+ toggleExpandSlice,
+ setFocusedFilterField,
+ unsetFocusedFilterField,
+ }));
+});
+
test('should render a SliceHeader', () => {
const { getByTestId, container } = setup();
expect(getByTestId('slice-header')).toBeInTheDocument();
@@ -89,23 +122,20 @@ test('should render a ChartContainer', () => {
});
test('should render a description if it has one and isExpanded=true', () => {
- const { container } = setup({ isExpanded: true });
- expect(container.querySelector('.slice_description')).toBeInTheDocument();
-});
-
-test('should calculate the description height if it has one and
isExpanded=true', () => {
- const spy = jest.spyOn(
- Chart.WrappedComponent.prototype,
- 'getDescriptionHeight',
+ const { container } = setup(
+ {},
+ {
+ dashboardState: {
+ ...defaultState.dashboardState,
+ expandedSlices: { [props.id]: true },
+ },
+ },
);
- const { container } = setup({ isExpanded: true });
expect(container.querySelector('.slice_description')).toBeInTheDocument();
- expect(spy).toHaveBeenCalled();
});
test('should call refreshChart when SliceHeader calls forceRefresh', () => {
- const refreshChart = jest.fn();
- const { getByText, getByRole } = setup({ refreshChart });
+ const { getByText, getByRole } = setup({});
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.click(getByText('Force refresh'));
expect(refreshChart).toHaveBeenCalled();
@@ -122,7 +152,12 @@ test('should call exportChart when exportCSV is clicked',
async () => {
const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
- const { findByText, getByRole } = setup({ supersetCanCSV: true });
+ const { findByText, getByRole } = setup(
+ {},
+ {
+ dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
+ },
+ );
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to .CSV');
@@ -145,7 +180,12 @@ test('should call exportChart with row_limit props.maxRows
when exportFullCSV is
const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
- const { findByText, getByRole } = setup({ supersetCanCSV: true });
+ const { findByText, getByRole } = setup(
+ {},
+ {
+ dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
+ },
+ );
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to full .CSV');
@@ -167,7 +207,12 @@ test('should call exportChart when exportXLSX is clicked',
async () => {
const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
- const { findByText, getByRole } = setup({ supersetCanCSV: true });
+ const { findByText, getByRole } = setup(
+ {},
+ {
+ dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
+ },
+ );
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to Excel');
@@ -189,7 +234,12 @@ test('should call exportChart with row_limit props.maxRows
when exportFullXLSX i
const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
- const { findByText, getByRole } = setup({ supersetCanCSV: true });
+ const { findByText, getByRole } = setup(
+ {},
+ {
+ dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
+ },
+ );
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to full Excel');
diff --git
a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
index d31e240e4f..887f1baa73 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useState, useMemo, useCallback, useEffect } from 'react';
+import { useState, useMemo, useCallback, useEffect, memo } from 'react';
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
import cx from 'classnames';
@@ -24,7 +24,7 @@ import { useSelector } from 'react-redux';
import { css, useTheme } from '@superset-ui/core';
import { LayoutItem, RootState } from 'src/dashboard/types';
import AnchorLink from 'src/dashboard/components/AnchorLink';
-import Chart from 'src/dashboard/containers/Chart';
+import Chart from 'src/dashboard/components/gridComponents/Chart';
import DeleteComponentButton from
'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
@@ -70,7 +70,7 @@ interface ChartHolderProps {
isInView: boolean;
}
-const ChartHolder: React.FC<ChartHolderProps> = ({
+const ChartHolder = ({
id,
parentId,
component,
@@ -92,7 +92,7 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
handleComponentDrop,
setFullSizeChartId,
isInView,
-}) => {
+}: ChartHolderProps) => {
const theme = useTheme();
const fullSizeStyle = css`
&& {
@@ -107,9 +107,13 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
const isFullSize = fullSizeChartId === chartId;
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
- const dashboardState = useSelector(
- (state: RootState) => state.dashboardState,
+ const directPathToChild = useSelector(
+ (state: RootState) => state.dashboardState.directPathToChild,
);
+ const directPathLastUpdated = useSelector(
+ (state: RootState) => state.dashboardState.directPathLastUpdated ?? 0,
+ );
+
const [extraControls, setExtraControls] = useState<Record<string, unknown>>(
{},
);
@@ -118,18 +122,8 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
const [currentDirectPathLastUpdated, setCurrentDirectPathLastUpdated] =
useState(0);
- const directPathToChild = useMemo(
- () => dashboardState?.directPathToChild ?? [],
- [dashboardState],
- );
-
- const directPathLastUpdated = useMemo(
- () => dashboardState?.directPathLastUpdated ?? 0,
- [dashboardState],
- );
-
const infoFromPath = useMemo(
- () => getChartAndLabelComponentIdFromPath(directPathToChild) as any,
+ () => getChartAndLabelComponentIdFromPath(directPathToChild ?? []) as any,
[directPathToChild],
);
@@ -191,26 +185,26 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
]);
const { chartWidth, chartHeight } = useMemo(() => {
- let chartWidth = 0;
- let chartHeight = 0;
+ let width = 0;
+ let height = 0;
if (isFullSize) {
- chartWidth = window.innerWidth - CHART_MARGIN;
- chartHeight = window.innerHeight - CHART_MARGIN;
+ width = window.innerWidth - CHART_MARGIN;
+ height = window.innerHeight - CHART_MARGIN;
} else {
- chartWidth = Math.floor(
+ width = Math.floor(
widthMultiple * columnWidth +
(widthMultiple - 1) * GRID_GUTTER_SIZE -
CHART_MARGIN,
);
- chartHeight = Math.floor(
+ height = Math.floor(
component.meta.height * GRID_BASE_UNIT - CHART_MARGIN,
);
}
return {
- chartWidth,
- chartHeight,
+ chartWidth: width,
+ chartHeight: height,
};
}, [columnWidth, component, isFullSize, widthMultiple]);
@@ -244,6 +238,111 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
}));
}, []);
+ const renderChild = useCallback(
+ ({ dragSourceRef }) => (
+ <ResizableContainer
+ id={component.id}
+ adjustableWidth={parentComponent.type === ROW_TYPE}
+ adjustableHeight
+ widthStep={columnWidth}
+ widthMultiple={widthMultiple}
+ heightStep={GRID_BASE_UNIT}
+ heightMultiple={component.meta.height}
+ minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+ minHeightMultiple={GRID_MIN_ROW_UNITS}
+ maxWidthMultiple={availableColumnCount + widthMultiple}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ editMode={editMode}
+ >
+ <div
+ ref={dragSourceRef}
+ data-test="dashboard-component-chart-holder"
+ style={focusHighlightStyles}
+ css={isFullSize ? fullSizeStyle : undefined}
+ className={cx(
+ 'dashboard-component',
+ 'dashboard-component-chart-holder',
+ // The following class is added to support custom dashboard
styling via the CSS editor
+ `dashboard-chart-id-${chartId}`,
+ outlinedComponentId ? 'fade-in' : 'fade-out',
+ )}
+ >
+ {!editMode && (
+ <AnchorLink
+ id={component.id}
+ scrollIntoView={outlinedComponentId === component.id}
+ />
+ )}
+ {!!outlinedComponentId && (
+ <style>
+ {`label[for=${outlinedColumnName}] + .Select .Select__control {
+ border-color: #00736a;
+ transition: border-color 1s ease-in-out;
+ }`}
+ </style>
+ )}
+ <Chart
+ componentId={component.id}
+ id={component.meta.chartId}
+ dashboardId={dashboardId}
+ width={chartWidth}
+ height={chartHeight}
+ sliceName={
+ component.meta.sliceNameOverride || component.meta.sliceName ||
''
+ }
+ updateSliceName={handleUpdateSliceName}
+ isComponentVisible={isComponentVisible}
+ handleToggleFullSize={handleToggleFullSize}
+ isFullSize={isFullSize}
+ setControlValue={handleExtraControl}
+ extraControls={extraControls}
+ isInView={isInView}
+ />
+ {editMode && (
+ <HoverMenu position="top">
+ <div data-test="dashboard-delete-component-button">
+ <DeleteComponentButton onDelete={handleDeleteComponent} />
+ </div>
+ </HoverMenu>
+ )}
+ </div>
+ </ResizableContainer>
+ ),
+ [
+ component.id,
+ component.meta.height,
+ component.meta.chartId,
+ component.meta.sliceNameOverride,
+ component.meta.sliceName,
+ parentComponent.type,
+ columnWidth,
+ widthMultiple,
+ availableColumnCount,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ editMode,
+ focusHighlightStyles,
+ isFullSize,
+ fullSizeStyle,
+ chartId,
+ outlinedComponentId,
+ outlinedColumnName,
+ dashboardId,
+ chartWidth,
+ chartHeight,
+ handleUpdateSliceName,
+ isComponentVisible,
+ handleToggleFullSize,
+ handleExtraControl,
+ extraControls,
+ isInView,
+ handleDeleteComponent,
+ ],
+ );
+
return (
<Draggable
component={component}
@@ -255,81 +354,9 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
disableDragDrop={false}
editMode={editMode}
>
- {({ dragSourceRef }) => (
- <ResizableContainer
- id={component.id}
- adjustableWidth={parentComponent.type === ROW_TYPE}
- adjustableHeight
- widthStep={columnWidth}
- widthMultiple={widthMultiple}
- heightStep={GRID_BASE_UNIT}
- heightMultiple={component.meta.height}
- minWidthMultiple={GRID_MIN_COLUMN_COUNT}
- minHeightMultiple={GRID_MIN_ROW_UNITS}
- maxWidthMultiple={availableColumnCount + widthMultiple}
- onResizeStart={onResizeStart}
- onResize={onResize}
- onResizeStop={onResizeStop}
- editMode={editMode}
- >
- <div
- ref={dragSourceRef}
- data-test="dashboard-component-chart-holder"
- style={focusHighlightStyles}
- css={isFullSize ? fullSizeStyle : undefined}
- className={cx(
- 'dashboard-component',
- 'dashboard-component-chart-holder',
- // The following class is added to support custom dashboard
styling via the CSS editor
- `dashboard-chart-id-${chartId}`,
- outlinedComponentId ? 'fade-in' : 'fade-out',
- )}
- >
- {!editMode && (
- <AnchorLink
- id={component.id}
- scrollIntoView={outlinedComponentId === component.id}
- />
- )}
- {!!outlinedComponentId && (
- <style>
- {`label[for=${outlinedColumnName}] + .Select .Select__control {
- border-color: #00736a;
- transition: border-color 1s ease-in-out;
- }`}
- </style>
- )}
- <Chart
- componentId={component.id}
- id={component.meta.chartId}
- dashboardId={dashboardId}
- width={chartWidth}
- height={chartHeight}
- sliceName={
- component.meta.sliceNameOverride ||
- component.meta.sliceName ||
- ''
- }
- updateSliceName={handleUpdateSliceName}
- isComponentVisible={isComponentVisible}
- handleToggleFullSize={handleToggleFullSize}
- isFullSize={isFullSize}
- setControlValue={handleExtraControl}
- extraControls={extraControls}
- isInView={isInView}
- />
- {editMode && (
- <HoverMenu position="top">
- <div data-test="dashboard-delete-component-button">
- <DeleteComponentButton onDelete={handleDeleteComponent} />
- </div>
- </HoverMenu>
- )}
- </div>
- </ResizableContainer>
- )}
+ {renderChild}
</Draggable>
);
};
-export default ChartHolder;
+export default memo(ChartHolder);
diff --git a/superset-frontend/src/dashboard/containers/Chart.jsx
b/superset-frontend/src/dashboard/containers/Chart.jsx
deleted file mode 100644
index 9f00bd0bf7..0000000000
--- a/superset-frontend/src/dashboard/containers/Chart.jsx
+++ /dev/null
@@ -1,135 +0,0 @@
-/**
- * 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 { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-import {
- toggleExpandSlice,
- setFocusedFilterField,
- unsetFocusedFilterField,
-} from 'src/dashboard/actions/dashboardState';
-import { updateComponents } from 'src/dashboard/actions/dashboardLayout';
-import { changeFilter } from 'src/dashboard/actions/dashboardFilters';
-import {
- addSuccessToast,
- addDangerToast,
-} from 'src/components/MessageToasts/actions';
-import { refreshChart } from 'src/components/Chart/chartAction';
-import { logEvent } from 'src/logger/actions';
-import {
- getActiveFilters,
- getAppliedFilterValues,
-} from 'src/dashboard/util/activeDashboardFilters';
-import getFormDataWithExtraFilters from
'src/dashboard/util/charts/getFormDataWithExtraFilters';
-import Chart from 'src/dashboard/components/gridComponents/Chart';
-import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
-import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
-
-const EMPTY_OBJECT = {};
-
-function mapStateToProps(
- {
- charts: chartQueries,
- dashboardInfo,
- dashboardState,
- dataMask,
- datasources,
- sliceEntities,
- nativeFilters,
- common,
- },
- ownProps,
-) {
- const { id, extraControls, setControlValue } = ownProps;
- const chart = chartQueries[id] || EMPTY_OBJECT;
- const datasource =
- (chart && chart.form_data && datasources[chart.form_data.datasource]) ||
- PLACEHOLDER_DATASOURCE;
- const {
- colorScheme: appliedColorScheme,
- colorNamespace,
- datasetsStatus,
- } = dashboardState;
- const labelsColor = dashboardInfo?.metadata?.label_colors || {};
- const labelsColorMap = dashboardInfo?.metadata?.map_label_colors || {};
- const sharedLabelsColors = enforceSharedLabelsColorsArray(
- dashboardInfo?.metadata?.shared_label_colors,
- );
- const ownColorScheme = chart.form_data?.color_scheme;
- // note: this method caches filters if possible to prevent render cascades
- const formData = getFormDataWithExtraFilters({
- chart,
- chartConfiguration: dashboardInfo.metadata?.chart_configuration,
- charts: chartQueries,
- filters: getAppliedFilterValues(id),
- colorNamespace,
- colorScheme: appliedColorScheme,
- ownColorScheme,
- sliceId: id,
- nativeFilters: nativeFilters?.filters,
- allSliceIds: dashboardState.sliceIds,
- dataMask,
- extraControls,
- labelsColor,
- labelsColorMap,
- sharedLabelsColors,
- });
-
- formData.dashboardId = dashboardInfo.id;
-
- return {
- chart,
- datasource,
- labelsColor,
- labelsColorMap,
- slice: sliceEntities.slices[id],
- timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
- filters: getActiveFilters() || EMPTY_OBJECT,
- formData,
- editMode: dashboardState.editMode,
- isExpanded: !!dashboardState.expandedSlices[id],
- supersetCanExplore: !!dashboardInfo.superset_can_explore,
- supersetCanShare: !!dashboardInfo.superset_can_share,
- supersetCanCSV: !!dashboardInfo.superset_can_csv,
- ownState: dataMask[id]?.ownState,
- filterState: dataMask[id]?.filterState,
- maxRows: common.conf.SQL_MAX_ROW,
- setControlValue,
- datasetsStatus,
- emitCrossFilters: !!dashboardInfo.crossFiltersEnabled,
- };
-}
-
-function mapDispatchToProps(dispatch) {
- return bindActionCreators(
- {
- updateComponents,
- addSuccessToast,
- addDangerToast,
- toggleExpandSlice,
- changeFilter,
- setFocusedFilterField,
- unsetFocusedFilterField,
- refreshChart,
- logEvent,
- },
- dispatch,
- );
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(Chart);