perf: Optimize dashboard chart-related components (#31241)

This commit is contained in:
Kamil Gabryjelski 2024-12-02 15:04:29 +01:00 committed by GitHub
parent 3d3c09d299
commit eab888c63a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 836 additions and 870 deletions

View File

@ -16,7 +16,14 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 { css, getExtensionsRegistry, styled, t } from '@superset-ui/core';
import { useUiConfig } from 'src/components/UiConfigContext'; import { useUiConfig } from 'src/components/UiConfigContext';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
@ -34,7 +41,6 @@ import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
const extensionsRegistry = getExtensionsRegistry(); const extensionsRegistry = getExtensionsRegistry();
type SliceHeaderProps = SliceHeaderControlsProps & { type SliceHeaderProps = SliceHeaderControlsProps & {
innerRef?: string;
updateSliceName?: (arg0: string) => void; updateSliceName?: (arg0: string) => void;
editMode?: boolean; editMode?: boolean;
annotationQuery?: object; annotationQuery?: object;
@ -122,176 +128,182 @@ const ChartHeaderStyles = styled.div`
`} `}
`; `;
const SliceHeader: FC<SliceHeaderProps> = ({ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
innerRef = null, (
forceRefresh = () => ({}), {
updateSliceName = () => ({}), forceRefresh = () => ({}),
toggleExpandSlice = () => ({}), updateSliceName = () => ({}),
logExploreChart = () => ({}), toggleExpandSlice = () => ({}),
logEvent, logExploreChart = () => ({}),
exportCSV = () => ({}), logEvent,
exportXLSX = () => ({}), exportCSV = () => ({}),
editMode = false, exportXLSX = () => ({}),
annotationQuery = {}, editMode = false,
annotationError = {}, annotationQuery = {},
cachedDttm = null, annotationError = {},
updatedDttm = null, cachedDttm = null,
isCached = [], updatedDttm = null,
isExpanded = false, isCached = [],
sliceName = '', isExpanded = false,
supersetCanExplore = false, sliceName = '',
supersetCanShare = false, supersetCanExplore = false,
supersetCanCSV = false, supersetCanShare = false,
exportPivotCSV, supersetCanCSV = false,
exportFullCSV, exportPivotCSV,
exportFullXLSX, exportFullCSV,
slice, exportFullXLSX,
componentId, slice,
dashboardId, componentId,
addSuccessToast, dashboardId,
addDangerToast, addSuccessToast,
handleToggleFullSize, addDangerToast,
isFullSize, handleToggleFullSize,
chartStatus, isFullSize,
formData, chartStatus,
width, formData,
height, width,
}) => { height,
const SliceHeaderExtension = extensionsRegistry.get('dashboard.slice.header'); },
const uiConfig = useUiConfig(); ref,
const dashboardPageId = useContext(DashboardPageIdContext); ) => {
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null); const SliceHeaderExtension = extensionsRegistry.get(
const headerRef = useRef<HTMLDivElement>(null); 'dashboard.slice.header',
// TODO: change to indicator field after it will be implemented );
const crossFilterValue = useSelector<RootState, any>( const uiConfig = useUiConfig();
state => state.dataMask[slice?.slice_id]?.filterState?.value, const dashboardPageId = useContext(DashboardPageIdContext);
); const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
const isCrossFiltersEnabled = useSelector<RootState, boolean>( const headerRef = useRef<HTMLDivElement>(null);
({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, // 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(() => { useEffect(() => {
const headerElement = headerRef.current; const headerElement = headerRef.current;
if (canExplore) { if (canExplore) {
setHeaderTooltip(getSliceHeaderTooltip(sliceName)); setHeaderTooltip(getSliceHeaderTooltip(sliceName));
} else if ( } else if (
headerElement && headerElement &&
(headerElement.scrollWidth > headerElement.offsetWidth || (headerElement.scrollWidth > headerElement.offsetWidth ||
headerElement.scrollHeight > headerElement.offsetHeight) headerElement.scrollHeight > headerElement.offsetHeight)
) { ) {
setHeaderTooltip(sliceName ?? null); setHeaderTooltip(sliceName ?? null);
} else { } else {
setHeaderTooltip(null); setHeaderTooltip(null);
} }
}, [sliceName, width, height, canExplore]); }, [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 ( return (
<ChartHeaderStyles data-test="slice-header" ref={innerRef}> <ChartHeaderStyles data-test="slice-header" ref={ref}>
<div className="header-title" ref={headerRef}> <div className="header-title" ref={headerRef}>
<Tooltip title={headerTooltip}> <Tooltip title={headerTooltip}>
<EditableTitle <EditableTitle
title={ title={
sliceName || sliceName ||
(editMode (editMode
? '---' // this makes an empty title clickable ? '---' // this makes an empty title clickable
: '') : '')
} }
canEdit={editMode} canEdit={editMode}
onSaveTitle={updateSliceName} onSaveTitle={updateSliceName}
showTooltip={false} showTooltip={false}
url={canExplore ? exploreUrl : undefined} 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"
/> />
</Tooltip> </Tooltip>
)} {!!Object.values(annotationQuery).length && (
{!!Object.values(annotationError).length && ( <Tooltip
<Tooltip id="annotations-loading-tooltip"
id="annotation-errors-tooltip" placement="top"
placement="top" title={annotationsLoading}
title={annotationsError} >
> <i
<i role="img"
role="img" aria-label={annotationsLoading}
aria-label={annotationsError} className="fa fa-refresh warning"
className="fa fa-exclamation-circle danger"
/>
</Tooltip>
)}
</div>
<div className="header-controls">
{!editMode && (
<>
{SliceHeaderExtension && (
<SliceHeaderExtension
sliceId={slice.slice_id}
dashboardId={dashboardId}
/> />
)} </Tooltip>
{crossFilterValue && ( )}
<Tooltip {!!Object.values(annotationError).length && (
placement="top" <Tooltip
title={t( id="annotation-errors-tooltip"
'This chart applies cross-filters to charts whose datasets contain columns with the same name.', placement="top"
)} title={annotationsError}
> >
<CrossFilterIcon iconSize="m" /> <i
</Tooltip> role="img"
)} aria-label={annotationsError}
{!uiConfig.hideChartControls && ( className="fa fa-exclamation-circle danger"
<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>
</> )}
)} </div>
</div> <div className="header-controls">
</ChartHeaderStyles> {!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; export default SliceHeader;

View File

@ -22,7 +22,7 @@ import Popover, { PopoverProps } from 'src/components/Popover';
import CopyToClipboard from 'src/components/CopyToClipboard'; import CopyToClipboard from 'src/components/CopyToClipboard';
import { getDashboardPermalink } from 'src/utils/urlUtils'; import { getDashboardPermalink } from 'src/utils/urlUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts'; import { useToasts } from 'src/components/MessageToasts/withToasts';
import { useSelector } from 'react-redux'; import { shallowEqual, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';
export type URLShortLinkButtonProps = { export type URLShortLinkButtonProps = {
@ -42,10 +42,13 @@ export default function URLShortLinkButton({
}: URLShortLinkButtonProps) { }: URLShortLinkButtonProps) {
const [shortUrl, setShortUrl] = useState(''); const [shortUrl, setShortUrl] = useState('');
const { addDangerToast } = useToasts(); const { addDangerToast } = useToasts();
const { dataMask, activeTabs } = useSelector((state: RootState) => ({ const { dataMask, activeTabs } = useSelector(
dataMask: state.dataMask, (state: RootState) => ({
activeTabs: state.dashboardState.activeTabs, dataMask: state.dataMask,
})); activeTabs: state.dashboardState.activeTabs,
}),
shallowEqual,
);
const getCopyUrl = async () => { const getCopyUrl = async () => {
try { try {

View File

@ -17,11 +17,13 @@
* under the License. * under the License.
*/ */
import cx from 'classnames'; import cx from 'classnames';
import { Component } from 'react'; import { useCallback, useEffect, useRef, useMemo, useState, memo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { styled, t, logging } from '@superset-ui/core'; import { styled, t, logging } from '@superset-ui/core';
import { debounce, isEqual } from 'lodash'; import { debounce } from 'lodash';
import { withRouter } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { bindActionCreators } from 'redux';
import { useDispatch, useSelector } from 'react-redux';
import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils'; import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils';
import ChartContainer from 'src/components/Chart/ChartContainer'; import ChartContainer from 'src/components/Chart/ChartContainer';
@ -32,13 +34,30 @@ import {
LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART, LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART,
LOG_ACTIONS_FORCE_REFRESH_CHART, LOG_ACTIONS_FORCE_REFRESH_CHART,
} from 'src/logger/LogUtils'; } from 'src/logger/LogUtils';
import { areObjectsEqual } from 'src/reduxUtils';
import { postFormData } from 'src/explore/exploreUtils/formData'; import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import SliceHeader from '../SliceHeader'; import SliceHeader from '../SliceHeader';
import MissingChart from '../MissingChart'; 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 = { const propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
@ -50,53 +69,15 @@ const propTypes = {
isComponentVisible: PropTypes.bool, isComponentVisible: PropTypes.bool,
handleToggleFullSize: PropTypes.func.isRequired, handleToggleFullSize: PropTypes.func.isRequired,
setControlValue: PropTypes.func, 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, sliceName: PropTypes.string.isRequired,
timeout: PropTypes.number.isRequired, isFullSize: PropTypes.bool,
maxRows: PropTypes.number.isRequired, extraControls: PropTypes.object,
// 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']),
isInView: PropTypes.bool, isInView: PropTypes.bool,
emitCrossFilters: PropTypes.bool,
};
const defaultProps = {
isCached: false,
isComponentVisible: true,
}; };
// we use state + shouldComponentUpdate() logic to prevent perf-wrecking // we use state + shouldComponentUpdate() logic to prevent perf-wrecking
// resizing across all slices on a dashboard on every update // resizing across all slices on a dashboard on every update
const RESIZE_TIMEOUT = 500; 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 DEFAULT_HEADER_HEIGHT = 22;
const ChartWrapper = styled.div` const ChartWrapper = styled.div`
@ -121,429 +102,457 @@ const SliceContainer = styled.div`
max-height: 100%; max-height: 100%;
`; `;
class Chart extends Component { const EMPTY_OBJECT = {};
constructor(props) {
super(props);
this.state = {
width: props.width,
height: props.height,
descriptionHeight: 0,
};
this.changeFilter = this.changeFilter.bind(this); const Chart = props => {
this.handleFilterMenuOpen = this.handleFilterMenuOpen.bind(this); const dispatch = useDispatch();
this.handleFilterMenuClose = this.handleFilterMenuClose.bind(this); const descriptionRef = useRef(null);
this.exportCSV = this.exportCSV.bind(this); const headerRef = useRef(null);
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) { const boundActionCreators = useMemo(
// 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 bindActionCreators(
// which improves performance significantly {
if ( addSuccessToast,
nextState.width !== this.state.width || addDangerToast,
nextState.height !== this.state.height || toggleExpandSlice,
nextState.descriptionHeight !== this.state.descriptionHeight || changeFilter,
!isEqual(nextProps.datasource, this.props.datasource) setFocusedFilterField,
) { unsetFocusedFilterField,
return true; 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);
} }
}, [isExpanded]);
// allow chart to update if the status changed and the previous status was loading. useEffect(
if ( () => () => {
this.props?.chart?.chartStatus !== nextProps?.chart?.chartStatus && resize.cancel();
this.props?.chart?.chartStatus === 'loading' },
) { [resize],
return true; );
}
// allow chart update/re-render only if visible: useEffect(() => {
// under selected tab or no tab layout resize();
if (nextProps.isComponentVisible) { }, [resize, props.isFullSize]);
if (nextProps.chart.triggerQuery) {
return true;
}
if (nextProps.isFullSize !== this.props.isFullSize) { const getHeaderHeight = useCallback(() => {
this.resize(); if (headerRef.current) {
return false; const computedStyle = getComputedStyle(
} headerRef.current,
).getPropertyValue('margin-bottom');
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 });
}
}
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',
);
const marginBottom = parseInt(computedStyle, 10) || 0; const marginBottom = parseInt(computedStyle, 10) || 0;
return this.headerRef.offsetHeight + marginBottom; return headerRef.current.offsetHeight + marginBottom;
} }
return DEFAULT_HEADER_HEIGHT; return DEFAULT_HEADER_HEIGHT;
} }, [headerRef]);
setDescriptionRef(ref) { const getChartHeight = useCallback(() => {
this.descriptionRef = ref; const headerHeight = getHeaderHeight();
} return Math.max(height - headerHeight - descriptionHeight, 20);
}, [getHeaderHeight, height, descriptionHeight]);
setHeaderRef(ref) { const handleFilterMenuOpen = useCallback(
this.headerRef = ref; (chartId, column) => {
} boundActionCreators.setFocusedFilterField(chartId, column);
},
[boundActionCreators.setFocusedFilterField],
);
resize() { const handleFilterMenuClose = useCallback(
const { width, height } = this.props; (chartId, column) => {
this.setState(() => ({ width, height })); boundActionCreators.unsetFocusedFilterField(chartId, column);
} },
[boundActionCreators.unsetFocusedFilterField],
);
changeFilter(newSelectedValues = {}) { const logExploreChart = useCallback(() => {
this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, { boundActionCreators.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
id: this.props.chart.id, slice_id: slice.slice_id,
columns: Object.keys(newSelectedValues).filter( is_cached: props.isCached,
key => newSelectedValues[key] !== null,
),
}); });
this.props.changeFilter(this.props.chart.id, newSelectedValues); }, [boundActionCreators.logEvent, slice.slice_id, props.isCached]);
}
handleFilterMenuOpen(chartId, column) { const chartConfiguration = useSelector(
this.props.setFocusedFilterField(chartId, column); 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,
),
);
handleFilterMenuClose(chartId, column) { const formData = useMemo(
this.props.unsetFocusedFilterField(chartId, column); () =>
} getFormDataWithExtraFilters({
chart,
logExploreChart = () => { chartConfiguration,
this.props.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, { filters: getAppliedFilterValues(props.id),
slice_id: this.props.slice.slice_id, colorScheme,
is_cached: this.props.isCached, colorNamespace,
}); sliceId: props.id,
}; nativeFilters,
allSliceIds,
onExploreChart = async clickEvent => { dataMask,
const isOpenInNewTab = extraControls: props.extraControls,
clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey; labelsColor,
try { labelsColorMap,
const lastTabId = window.localStorage.getItem('last_tab_id'); sharedLabelsColors,
const nextTabId = lastTabId ownColorScheme,
? 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,
chart, chart,
slice, chartConfiguration,
datasource, props.id,
isExpanded, props.extraControls,
editMode, colorScheme,
filters, colorNamespace,
formData, nativeFilters,
allSliceIds,
dataMask,
labelsColor, labelsColor,
labelsColorMap, labelsColorMap,
updateSliceName, sharedLabelsColors,
sliceName, ownColorScheme,
toggleExpandSlice, ],
timeout, );
supersetCanExplore,
supersetCanShare,
supersetCanCSV,
addSuccessToast,
addDangerToast,
ownState,
filterState,
handleToggleFullSize,
isFullSize,
setControlValue,
postTransformProps,
datasetsStatus,
isInView,
emitCrossFilters,
logEvent,
} = this.props;
const { width } = this.state; const onExploreChart = useCallback(
// this prevents throwing in the case that a gridComponent async clickEvent => {
// references a chart that is not associated with the dashboard const isOpenInNewTab =
if (!chart || !slice) { clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey;
return <MissingChart height={this.getChartHeight()} />; 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 { queriesResponse, chartUpdateEndTime, chartStatus } = chart; const exportTable = useCallback(
const isLoading = chartStatus === 'loading'; (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, 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 // eslint-disable-next-line camelcase
const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || []; queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
const cachedDttm =
// eslint-disable-next-line camelcase
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
const initialValues = {};
return ( return (
<SliceContainer <SliceContainer
className="chart-slice" className="chart-slice"
data-test="chart-grid-component" data-test="chart-grid-component"
data-test-chart-id={id} data-test-chart-id={props.id}
data-test-viz-type={slice.viz_type} data-test-viz-type={slice.viz_type}
data-test-chart-name={slice.slice_name} data-test-chart-name={slice.slice_name}
> >
<SliceHeader <SliceHeader
innerRef={this.setHeaderRef} ref={headerRef}
slice={slice} slice={slice}
isExpanded={isExpanded} isExpanded={isExpanded}
isCached={isCached} isCached={isCached}
cachedDttm={cachedDttm} cachedDttm={cachedDttm}
updatedDttm={chartUpdateEndTime} updatedDttm={chartUpdateEndTime}
toggleExpandSlice={toggleExpandSlice} toggleExpandSlice={boundActionCreators.toggleExpandSlice}
forceRefresh={this.forceRefresh} forceRefresh={forceRefresh}
editMode={editMode} editMode={editMode}
annotationQuery={chart.annotationQuery} annotationQuery={annotationQuery}
logExploreChart={this.logExploreChart} logExploreChart={logExploreChart}
logEvent={logEvent} logEvent={boundActionCreators.logEvent}
onExploreChart={this.onExploreChart} onExploreChart={onExploreChart}
exportCSV={this.exportCSV} exportCSV={exportCSV}
exportPivotCSV={this.exportPivotCSV} exportPivotCSV={exportPivotCSV}
exportXLSX={this.exportXLSX} exportXLSX={exportXLSX}
exportFullCSV={this.exportFullCSV} exportFullCSV={exportFullCSV}
exportFullXLSX={this.exportFullXLSX} exportFullXLSX={exportFullXLSX}
updateSliceName={updateSliceName} updateSliceName={props.updateSliceName}
sliceName={sliceName} sliceName={props.sliceName}
supersetCanExplore={supersetCanExplore} supersetCanExplore={supersetCanExplore}
supersetCanShare={supersetCanShare} supersetCanShare={supersetCanShare}
supersetCanCSV={supersetCanCSV} supersetCanCSV={supersetCanCSV}
componentId={componentId} componentId={props.componentId}
dashboardId={dashboardId} dashboardId={props.dashboardId}
filters={filters} filters={getActiveFilters() || EMPTY_OBJECT}
addSuccessToast={addSuccessToast} addSuccessToast={boundActionCreators.addSuccessToast}
addDangerToast={addDangerToast} addDangerToast={boundActionCreators.addDangerToast}
handleToggleFullSize={handleToggleFullSize} handleToggleFullSize={props.handleToggleFullSize}
isFullSize={isFullSize} isFullSize={props.isFullSize}
chartStatus={chart.chartStatus} chartStatus={chartStatus}
formData={formData} formData={formData}
width={width} width={width}
height={this.getHeaderHeight()} height={getHeaderHeight()}
/> />
{/* {/*
This usage of dangerouslySetInnerHTML is safe since it is being used to render This usage of dangerouslySetInnerHTML is safe since it is being used to render
markdown that is sanitized with nh3. See: markdown that is sanitized with nh3. See:
https://github.com/apache/superset/pull/4390 https://github.com/apache/superset/pull/4390
and and
https://github.com/apache/superset/pull/23862 https://github.com/apache/superset/pull/23862
*/} */}
{isExpanded && slice.description_markeddown && ( {isExpanded && slice.description_markeddown && (
<div <div
className="slice_description bs-callout bs-callout-default" className="slice_description bs-callout bs-callout-default"
ref={this.setDescriptionRef} ref={descriptionRef}
// eslint-disable-next-line react/no-danger // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: slice.description_markeddown }} dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
role="complementary" role="complementary"
/>
)}
<ChartWrapper
className={cx('dashboard-chart')}
aria-label={slice.description}
>
{isLoading && (
<ChartOverlay
style={{
width,
height: getChartHeight(),
}}
/> />
)} )}
<ChartWrapper <ChartContainer
className={cx('dashboard-chart')} width={width}
aria-label={slice.description} height={getChartHeight()}
> addFilter={addFilter}
{isLoading && ( onFilterMenuOpen={handleFilterMenuOpen}
<ChartOverlay onFilterMenuClose={handleFilterMenuClose}
style={{ annotationData={chart.annotationData}
width, chartAlert={chart.chartAlert}
height: this.getChartHeight(), chartId={props.id}
}} chartStatus={chartStatus}
/> datasource={datasource}
)} dashboardId={props.dashboardId}
initialValues={EMPTY_OBJECT}
<ChartContainer formData={formData}
width={width} labelsColor={labelsColor}
height={this.getChartHeight()} labelsColorMap={labelsColorMap}
addFilter={this.changeFilter} ownState={dataMask[props.id]?.ownState}
onFilterMenuOpen={this.handleFilterMenuOpen} filterState={dataMask[props.id]?.filterState}
onFilterMenuClose={this.handleFilterMenuClose} queriesResponse={chart.queriesResponse}
annotationData={chart.annotationData} timeout={timeout}
chartAlert={chart.chartAlert} triggerQuery={chart.triggerQuery}
chartId={id} vizType={slice.viz_type}
chartStatus={chartStatus} setControlValue={props.setControlValue}
datasource={datasource} datasetsStatus={datasetsStatus}
dashboardId={dashboardId} isInView={props.isInView}
initialValues={initialValues} emitCrossFilters={emitCrossFilters}
formData={formData} />
labelsColor={labelsColor} </ChartWrapper>
labelsColorMap={labelsColorMap} </SliceContainer>
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>
);
}
}
Chart.propTypes = propTypes; 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)
);
});

View File

@ -18,6 +18,7 @@
*/ */
import { fireEvent, render } from 'spec/helpers/testing-library'; import { fireEvent, render } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core'; import { FeatureFlag, VizType } from '@superset-ui/core';
import * as redux from 'redux';
import Chart from 'src/dashboard/components/gridComponents/Chart'; import Chart from 'src/dashboard/components/gridComponents/Chart';
import * as exploreUtils from 'src/explore/exploreUtils'; import * as exploreUtils from 'src/explore/exploreUtils';
@ -32,18 +33,10 @@ const props = {
width: 100, width: 100,
height: 100, height: 100,
updateSliceName() {}, updateSliceName() {},
// from redux // from redux
maxRows: 666, maxRows: 666,
chart: chartQueries[queryId],
formData: chartQueries[queryId].form_data, formData: chartQueries[queryId].form_data,
datasource: mockDatasource[sliceEntities.slices[queryId].datasource], datasource: mockDatasource[sliceEntities.slices[queryId].datasource],
slice: {
...sliceEntities.slices[queryId],
description_markeddown: 'markdown',
owners: [],
viz_type: VizType.Table,
},
sliceName: sliceEntities.slices[queryId].slice_name, sliceName: sliceEntities.slices[queryId].slice_name,
timeout: 60, timeout: 60,
filters: {}, filters: {},
@ -63,20 +56,60 @@ const props = {
exportFullXLSX() {}, exportFullXLSX() {},
componentId: 'test', componentId: 'test',
dashboardId: 111, dashboardId: 111,
editMode: false,
isExpanded: false,
supersetCanExplore: false,
supersetCanCSV: false,
supersetCanShare: false,
}; };
function setup(overrideProps) { const defaultState = {
return render(<Chart.WrappedComponent {...props} {...overrideProps} />, { 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, useRedux: true,
useRouter: 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', () => { test('should render a SliceHeader', () => {
const { getByTestId, container } = setup(); const { getByTestId, container } = setup();
expect(getByTestId('slice-header')).toBeInTheDocument(); 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', () => { test('should render a description if it has one and isExpanded=true', () => {
const { container } = setup({ isExpanded: true }); const { container } = setup(
expect(container.querySelector('.slice_description')).toBeInTheDocument(); {},
}); {
dashboardState: {
test('should calculate the description height if it has one and isExpanded=true', () => { ...defaultState.dashboardState,
const spy = jest.spyOn( expandedSlices: { [props.id]: true },
Chart.WrappedComponent.prototype, },
'getDescriptionHeight', },
); );
const { container } = setup({ isExpanded: true });
expect(container.querySelector('.slice_description')).toBeInTheDocument(); expect(container.querySelector('.slice_description')).toBeInTheDocument();
expect(spy).toHaveBeenCalled();
}); });
test('should call refreshChart when SliceHeader calls forceRefresh', () => { test('should call refreshChart when SliceHeader calls forceRefresh', () => {
const refreshChart = jest.fn(); const { getByText, getByRole } = setup({});
const { getByText, getByRole } = setup({ refreshChart });
fireEvent.click(getByRole('button', { name: 'More Options' })); fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.click(getByText('Force refresh')); fireEvent.click(getByText('Force refresh'));
expect(refreshChart).toHaveBeenCalled(); expect(refreshChart).toHaveBeenCalled();
@ -122,7 +152,12 @@ test('should call exportChart when exportCSV is clicked', async () => {
const stubbedExportCSV = jest const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart') .spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {}); .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.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' })); fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to .CSV'); 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 const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart') .spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {}); .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.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' })); fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to full .CSV'); const exportAction = await findByText('Export to full .CSV');
@ -167,7 +207,12 @@ test('should call exportChart when exportXLSX is clicked', async () => {
const stubbedExportXLSX = jest const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart') .spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {}); .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.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' })); fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to Excel'); 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 const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart') .spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {}); .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.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' })); fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to full Excel'); const exportAction = await findByText('Export to full Excel');

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * 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 { ResizeCallback, ResizeStartCallback } from 're-resizable';
import cx from 'classnames'; import cx from 'classnames';
@ -24,7 +24,7 @@ import { useSelector } from 'react-redux';
import { css, useTheme } from '@superset-ui/core'; import { css, useTheme } from '@superset-ui/core';
import { LayoutItem, RootState } from 'src/dashboard/types'; import { LayoutItem, RootState } from 'src/dashboard/types';
import AnchorLink from 'src/dashboard/components/AnchorLink'; 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 DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import { Draggable } from 'src/dashboard/components/dnd/DragDroppable'; import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu'; import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
@ -70,7 +70,7 @@ interface ChartHolderProps {
isInView: boolean; isInView: boolean;
} }
const ChartHolder: React.FC<ChartHolderProps> = ({ const ChartHolder = ({
id, id,
parentId, parentId,
component, component,
@ -92,7 +92,7 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
handleComponentDrop, handleComponentDrop,
setFullSizeChartId, setFullSizeChartId,
isInView, isInView,
}) => { }: ChartHolderProps) => {
const theme = useTheme(); const theme = useTheme();
const fullSizeStyle = css` const fullSizeStyle = css`
&& { && {
@ -107,9 +107,13 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
const isFullSize = fullSizeChartId === chartId; const isFullSize = fullSizeChartId === chartId;
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId); const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
const dashboardState = useSelector( const directPathToChild = useSelector(
(state: RootState) => state.dashboardState, (state: RootState) => state.dashboardState.directPathToChild,
); );
const directPathLastUpdated = useSelector(
(state: RootState) => state.dashboardState.directPathLastUpdated ?? 0,
);
const [extraControls, setExtraControls] = useState<Record<string, unknown>>( const [extraControls, setExtraControls] = useState<Record<string, unknown>>(
{}, {},
); );
@ -118,18 +122,8 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
const [currentDirectPathLastUpdated, setCurrentDirectPathLastUpdated] = const [currentDirectPathLastUpdated, setCurrentDirectPathLastUpdated] =
useState(0); useState(0);
const directPathToChild = useMemo(
() => dashboardState?.directPathToChild ?? [],
[dashboardState],
);
const directPathLastUpdated = useMemo(
() => dashboardState?.directPathLastUpdated ?? 0,
[dashboardState],
);
const infoFromPath = useMemo( const infoFromPath = useMemo(
() => getChartAndLabelComponentIdFromPath(directPathToChild) as any, () => getChartAndLabelComponentIdFromPath(directPathToChild ?? []) as any,
[directPathToChild], [directPathToChild],
); );
@ -191,26 +185,26 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
]); ]);
const { chartWidth, chartHeight } = useMemo(() => { const { chartWidth, chartHeight } = useMemo(() => {
let chartWidth = 0; let width = 0;
let chartHeight = 0; let height = 0;
if (isFullSize) { if (isFullSize) {
chartWidth = window.innerWidth - CHART_MARGIN; width = window.innerWidth - CHART_MARGIN;
chartHeight = window.innerHeight - CHART_MARGIN; height = window.innerHeight - CHART_MARGIN;
} else { } else {
chartWidth = Math.floor( width = Math.floor(
widthMultiple * columnWidth + widthMultiple * columnWidth +
(widthMultiple - 1) * GRID_GUTTER_SIZE - (widthMultiple - 1) * GRID_GUTTER_SIZE -
CHART_MARGIN, CHART_MARGIN,
); );
chartHeight = Math.floor( height = Math.floor(
component.meta.height * GRID_BASE_UNIT - CHART_MARGIN, component.meta.height * GRID_BASE_UNIT - CHART_MARGIN,
); );
} }
return { return {
chartWidth, chartWidth: width,
chartHeight, chartHeight: height,
}; };
}, [columnWidth, component, isFullSize, widthMultiple]); }, [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 ( return (
<Draggable <Draggable
component={component} component={component}
@ -255,81 +354,9 @@ const ChartHolder: React.FC<ChartHolderProps> = ({
disableDragDrop={false} disableDragDrop={false}
editMode={editMode} editMode={editMode}
> >
{({ dragSourceRef }) => ( {renderChild}
<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>
)}
</Draggable> </Draggable>
); );
}; };
export default ChartHolder; export default memo(ChartHolder);

View File

@ -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);