perf: Optimize dashboard chart-related components (#31241)
This commit is contained in:
parent
3d3c09d299
commit
eab888c63a
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
Loading…
Reference in New Issue