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
* 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;

View File

@ -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 {

View File

@ -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,
};
const EMPTY_OBJECT = {};
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);
}
const Chart = props => {
const dispatch = useDispatch();
const descriptionRef = useRef(null);
const headerRef = useRef(null);
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;
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);
}
}, [isExpanded]);
// 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;
}
useEffect(
() => () => {
resize.cancel();
},
[resize],
);
// allow chart update/re-render only if visible:
// under selected tab or no tab layout
if (nextProps.isComponentVisible) {
if (nextProps.chart.triggerQuery) {
return true;
}
useEffect(() => {
resize();
}, [resize, props.isFullSize]);
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 });
}
}
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 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;
}
}, [headerRef]);
setDescriptionRef(ref) {
this.descriptionRef = ref;
}
const getChartHeight = useCallback(() => {
const headerHeight = getHeaderHeight();
return Math.max(height - headerHeight - descriptionHeight, 20);
}, [getHeaderHeight, height, descriptionHeight]);
setHeaderRef(ref) {
this.headerRef = ref;
}
const handleFilterMenuOpen = useCallback(
(chartId, column) => {
boundActionCreators.setFocusedFilterField(chartId, column);
},
[boundActionCreators.setFocusedFilterField],
);
resize() {
const { width, height } = this.props;
this.setState(() => ({ width, height }));
}
const handleFilterMenuClose = useCallback(
(chartId, column) => {
boundActionCreators.unsetFocusedFilterField(chartId, column);
},
[boundActionCreators.unsetFocusedFilterField],
);
changeFilter(newSelectedValues = {}) {
this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: this.props.chart.id,
columns: Object.keys(newSelectedValues).filter(
key => newSelectedValues[key] !== null,
),
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);
}
}, [boundActionCreators.logEvent, slice.slice_id, props.isCached]);
handleFilterMenuOpen(chartId, column) {
this.props.setFocusedFilterField(chartId, column);
}
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,
),
);
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,
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;
sharedLabelsColors,
ownColorScheme,
],
);
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()} />;
}
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 { queriesResponse, chartUpdateEndTime, chartStatus } = chart;
const isLoading = chartStatus === 'loading';
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, 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 = {};
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
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()}
/>
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)
);
});

View File

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

View File

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

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