Compare commits

..

1 Commits

Author SHA1 Message Date
Vitor Avila
6ba3b79831 fix(Databricks): Escape catalog and schema names in pre-queries 2024-11-29 17:32:07 -03:00
76 changed files with 2602 additions and 2696 deletions

View File

@@ -153,7 +153,7 @@ geopy==2.4.1
# via apache-superset
google-auth==2.36.0
# via shillelagh
greenlet==3.1.1
greenlet==3.0.3
# via
# shillelagh
# sqlalchemy

View File

@@ -87,7 +87,7 @@ describe('Charts list', () => {
visitChartList();
cy.getBySel('count-crosslinks').should('be.visible');
cy.getBySel('crosslinks').first().trigger('mouseover');
cy.get('.antd5-tooltip')
cy.get('.ant-tooltip')
.contains('3 - Sample dashboard')
.invoke('removeAttr', 'target')
.click();

View File

@@ -99,13 +99,16 @@ describe('Color scheme control', () => {
cy.get('.ant-select-selection-item .color-scheme-label').trigger(
'mouseover',
);
cy.get('.color-scheme-tooltip').should('be.visible');
cy.get('.color-scheme-tooltip').contains('Superset Colors');
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.focused().type('lyftColors');
cy.getBySel('lyftColors').should('exist');
cy.getBySel('lyftColors').trigger('mouseover');
cy.focused().type('lyftColors{enter}');
cy.get(
'.Control[data-test="color_scheme"] .ant-select-selection-item [data-test="lyftColors"]',
).should('exist');
cy.get('.ant-select-selection-item .color-scheme-label').trigger(
'mouseover',
);
cy.get('.color-scheme-tooltip').should('not.exist');
});
});

View File

@@ -140,7 +140,7 @@ export const sqlLabView = {
tabsNavList: "[class='ant-tabs-nav-list']",
tab: "[class='ant-tabs-tab-btn']",
addTabButton: dataTestLocator('add-tab-icon'),
tooltip: '.antd5-tooltip-content',
tooltip: '.ant-tooltip-content',
tabName: '.css-1suejie',
schemaInput: '[data-test=DatabaseSelector] > :nth-child(2)',
loadingIndicator: '.Select__loading-indicator',

View File

@@ -18,8 +18,9 @@
*/
import { CSSProperties } from 'react';
import { kebabCase } from 'lodash';
import { TooltipPlacement } from 'antd/lib/tooltip';
import { t } from '@superset-ui/core';
import { Tooltip, TooltipProps, TooltipPlacement } from './Tooltip';
import { Tooltip, TooltipProps } from './Tooltip';
export interface InfoTooltipWithTriggerProps {
label?: string;

View File

@@ -17,41 +17,48 @@
* under the License.
*/
import { useTheme } from '@superset-ui/core';
import { Tooltip as BaseTooltip } from 'antd-v5';
import {
TooltipProps as BaseTooltipProps,
TooltipPlacement as BaseTooltipPlacement,
} from 'antd-v5/lib/tooltip';
import { useTheme, css } from '@superset-ui/core';
import { Tooltip as BaseTooltip } from 'antd';
import type { TooltipProps } from 'antd/lib/tooltip';
import { Global } from '@emotion/react';
export type TooltipProps = BaseTooltipProps;
export type TooltipPlacement = BaseTooltipPlacement;
export type { TooltipProps } from 'antd/lib/tooltip';
export const Tooltip = ({
overlayStyle,
color,
...props
}: BaseTooltipProps) => {
export const Tooltip = ({ overlayStyle, color, ...props }: TooltipProps) => {
const theme = useTheme();
const defaultColor = `${theme.colors.grayscale.dark2}e6`;
return (
<BaseTooltip
overlayStyle={{
fontSize: theme.typography.sizes.s,
lineHeight: '1.6',
maxWidth: theme.gridUnit * 62,
minWidth: theme.gridUnit * 30,
...overlayStyle,
}}
// make the tooltip display closer to the label
align={{ offset: [0, 1] }}
color={defaultColor || color}
trigger="hover"
placement="bottom"
// don't allow hovering over the tooltip
mouseLeaveDelay={0}
{...props}
/>
<>
{/* Safari hack to hide browser default tooltips */}
<Global
styles={css`
.ant-tooltip-open {
display: inline-block;
&::after {
content: '';
display: block;
}
}
`}
/>
<BaseTooltip
overlayStyle={{
fontSize: theme.typography.sizes.s,
lineHeight: '1.6',
maxWidth: theme.gridUnit * 62,
minWidth: theme.gridUnit * 30,
...overlayStyle,
}}
// make the tooltip display closer to the label
align={{ offset: [0, 1] }}
color={defaultColor || color}
trigger="hover"
placement="bottom"
// don't allow hovering over the tooltip
mouseLeaveDelay={0}
{...props}
/>
</>
);
};

View File

@@ -53,6 +53,7 @@ describe('OptionDescription', () => {
// Perform delayed mouse hovering so tooltip could pop out
fireEvent.mouseOver(tooltipTrigger);
act(() => jest.runAllTimers());
fireEvent.mouseOut(tooltipTrigger);
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toBeInTheDocument();

View File

@@ -28,7 +28,7 @@ const StyledTooltip = (props: any) => {
{({ css }) => (
<Tooltip
overlayClassName={css`
.antd5-tooltip-inner {
.ant-tooltip-inner {
max-width: ${theme.gridUnit * 125}px;
word-wrap: break-word;
text-align: center;

View File

@@ -42,7 +42,6 @@ import { IconTooltip } from 'src/components/IconTooltip';
import ModalTrigger from 'src/components/ModalTrigger';
import Loading from 'src/components/Loading';
import useEffectEvent from 'src/hooks/useEffectEvent';
import { ActionType } from 'src/types/Action';
import ColumnElement, { ColumnKeyTypeType } from '../ColumnElement';
import ShowSQL from '../ShowSQL';
@@ -327,7 +326,7 @@ const TableElement = ({ table, ...props }: TableElementProps) => {
const renderHeader = () => {
const element: HTMLInputElement | null = tableNameRef.current;
let trigger = [] as ActionType[];
let trigger: string[] = [];
if (element && element.offsetWidth < element.scrollWidth) {
trigger = ['hover'];
}

View File

@@ -28,8 +28,9 @@ import { mix } from 'polished';
import cx from 'classnames';
import { Button as AntdButton } from 'antd';
import { useTheme } from '@superset-ui/core';
import { Tooltip, TooltipProps } from 'src/components/Tooltip';
import { Tooltip } from 'src/components/Tooltip';
import { ButtonProps as AntdButtonProps } from 'antd/lib/button';
import { TooltipProps } from 'antd/lib/tooltip';
export type OnClickHandler = MouseEventHandler<HTMLElement>;

View File

@@ -20,7 +20,6 @@
import { styled, useTheme } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import { ActionType } from 'src/types/Action';
export interface InfoTooltipProps {
iconStyle?: React.CSSProperties;
@@ -39,7 +38,7 @@ export interface InfoTooltipProps {
| 'rightTop'
| 'rightBottom'
| undefined;
trigger?: ActionType | ActionType[];
trigger?: string | Array<string>;
overlayStyle?: any;
bgColor?: string;
viewBox?: string;

View File

@@ -18,8 +18,9 @@
*/
import { ReactElement } from 'react';
import { styled } from '@superset-ui/core';
import { Tooltip, TooltipPlacement } from 'src/components/Tooltip';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import { TooltipPlacement } from 'antd/lib/tooltip';
export type ActionProps = {
label: string;

View File

@@ -38,7 +38,7 @@ const StyledCrossLinks = styled.div`
width: 100%;
display: flex;
.antd5-tooltip-open {
.ant-tooltip-open {
display: inline;
}

View File

@@ -17,7 +17,8 @@
* under the License.
*/
import Button from 'src/components/Button';
import { Tooltip, TooltipPlacement, TooltipProps } from './index';
import { TooltipProps, TooltipPlacement } from 'antd/lib/tooltip';
import { Tooltip } from './index';
export default {
title: 'Tooltip',

View File

@@ -44,13 +44,17 @@ test('renders on hover', async () => {
test('renders with theme', () => {
render(
<Tooltip title="Simple tooltip" defaultOpen>
<Tooltip title="Simple tooltip" defaultVisible>
<Button>Hover me</Button>
</Tooltip>,
);
const tooltip = screen.getByRole('tooltip');
expect(tooltip).toHaveStyle({
'background-color': `${supersetTheme.colors.grayscale.dark2}e6`,
background: `${supersetTheme.colors.grayscale.dark2}e6`,
});
expect(tooltip.parentNode?.parentNode).toHaveStyle({
lineHeight: 1.6,
fontSize: 12,
});
});

View File

@@ -16,25 +16,31 @@
* specific language governing permissions and limitations
* under the License.
*/
import { supersetTheme } from '@superset-ui/core';
import { Tooltip as AntdTooltip } from 'antd-v5';
import { useTheme } from '@superset-ui/core';
import { Tooltip as AntdTooltip } from 'antd';
import {
TooltipProps as AntdTooltipProps,
TooltipProps,
TooltipPlacement as AntdTooltipPlacement,
} from 'antd-v5/lib/tooltip';
} from 'antd/lib/tooltip';
export type TooltipPlacement = AntdTooltipPlacement;
export type TooltipProps = AntdTooltipProps;
export const Tooltip = (props: TooltipProps) => (
<>
<AntdTooltip
overlayInnerStyle={{
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
color={`${supersetTheme.colors.grayscale.dark2}e6`}
{...props}
/>
</>
);
export const Tooltip = (props: TooltipProps) => {
const theme = useTheme();
return (
<>
<AntdTooltip
overlayStyle={{ fontSize: theme.typography.sizes.s, lineHeight: '1.6' }}
overlayInnerStyle={{
display: '-webkit-box',
overflow: 'hidden',
WebkitLineClamp: 40,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
}}
color={`${theme.colors.grayscale.dark2}e6`}
{...props}
/>
</>
);
};

View File

@@ -17,9 +17,9 @@
* under the License.
*/
import { useState, FC } from 'react';
import { Typography } from 'antd';
import { Tooltip, Typography } from 'antd';
import { ParagraphProps } from 'antd/es/typography/Paragraph';
import { Tooltip } from '../Tooltip';
const TooltipParagraph: FC<ParagraphProps> = ({
children,

View File

@@ -56,7 +56,7 @@ const StyledTruncatedList = styled.div`
width: 100%;
display: flex;
.antd5-tooltip-open {
.ant-tooltip-open {
display: inline;
}
}

View File

@@ -26,7 +26,11 @@ import getBootstrapData from 'src/utils/getBootstrapData';
import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
import getLayoutComponentFromChartId from '../util/getLayoutComponentFromChartId';
import { slicePropShape } from '../util/propShapes';
import {
slicePropShape,
dashboardInfoPropShape,
dashboardStatePropShape,
} from '../util/propShapes';
import {
LOG_ACTIONS_HIDE_BROWSER_TAB,
LOG_ACTIONS_MOUNT_DASHBOARD,
@@ -47,10 +51,8 @@ const propTypes = {
logEvent: PropTypes.func.isRequired,
clearDataMaskState: PropTypes.func.isRequired,
}).isRequired,
dashboardId: PropTypes.number.isRequired,
editMode: PropTypes.bool,
isPublished: PropTypes.bool,
hasUnsavedChanges: PropTypes.bool,
dashboardInfo: dashboardInfoPropShape.isRequired,
dashboardState: dashboardStatePropShape.isRequired,
slices: PropTypes.objectOf(slicePropShape).isRequired,
activeFilters: PropTypes.object.isRequired,
chartConfiguration: PropTypes.object,
@@ -94,13 +96,13 @@ class Dashboard extends PureComponent {
componentDidMount() {
const bootstrapData = getBootstrapData();
const { editMode, isPublished, layout } = this.props;
const { dashboardState, layout } = this.props;
const eventData = {
is_soft_navigation: Logger.timeOriginOffset > 0,
is_edit_mode: editMode,
is_edit_mode: dashboardState.editMode,
mount_duration: Logger.getTimestamp(),
is_empty: isDashboardEmpty(layout),
is_published: isPublished,
is_published: dashboardState.isPublished,
bootstrap_data_length: bootstrapData.length,
};
const directLinkComponentId = getLocationHash();
@@ -128,7 +130,7 @@ class Dashboard extends PureComponent {
const currentChartIds = getChartIdsFromLayout(this.props.layout);
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
if (this.props.dashboardId !== nextProps.dashboardId) {
if (this.props.dashboardInfo.id !== nextProps.dashboardInfo.id) {
// single-page-app navigation check
return;
}
@@ -155,14 +157,10 @@ class Dashboard extends PureComponent {
}
applyCharts() {
const {
activeFilters,
ownDataCharts,
chartConfiguration,
hasUnsavedChanges,
editMode,
} = this.props;
const { hasUnsavedChanges, editMode } = this.props.dashboardState;
const { appliedFilters, appliedOwnDataCharts } = this;
const { activeFilters, ownDataCharts, chartConfiguration } = this.props;
if (
isFeatureEnabled(FeatureFlag.DashboardCrossFilters) &&
!chartConfiguration

View File

@@ -208,9 +208,7 @@ describe('DashboardBuilder', () => {
});
it('should render a BuilderComponentPane if editMode=true and user selects "Insert Components" pane', () => {
const { queryAllByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
});
const { queryAllByTestId } = setup({ dashboardState: { editMode: true } });
const builderComponents = queryAllByTestId('mock-builder-component-pane');
expect(builderComponents.length).toBeGreaterThanOrEqual(1);
});
@@ -243,7 +241,7 @@ describe('DashboardBuilder', () => {
it('should display a loading spinner when saving is in progress', async () => {
const { findByAltText } = setup({
dashboardState: { ...mockState.dashboardState, dashboardIsSaving: true },
dashboardState: { dashboardIsSaving: true },
});
expect(await findByAltText('Loading...')).toBeVisible();

View File

@@ -18,7 +18,7 @@
*/
/* eslint-env browser */
import cx from 'classnames';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
addAlpha,
css,
@@ -82,6 +82,8 @@ import DashboardContainer from './DashboardContainer';
import { useNativeFilters } from './state';
import DashboardWrapper from './DashboardWrapper';
type DashboardBuilderProps = {};
// @z-index-above-dashboard-charts + 1 = 11
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
grid-column: 1;
@@ -368,11 +370,7 @@ const StyledDashboardContent = styled.div<{
`}
`;
const ELEMENT_ON_SCREEN_OPTIONS = {
threshold: [1],
};
const DashboardBuilder = () => {
const DashboardBuilder: FC<DashboardBuilderProps> = () => {
const dispatch = useDispatch();
const uiConfig = useUiConfig();
const theme = useTheme();
@@ -471,9 +469,9 @@ const DashboardBuilder = () => {
nativeFiltersEnabled,
} = useNativeFilters();
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
ELEMENT_ON_SCREEN_OPTIONS,
);
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>({
threshold: [1],
});
const showFilterBar =
(crossFiltersEnabled || nativeFiltersEnabled) && !editMode;
@@ -583,43 +581,6 @@ const DashboardBuilder = () => {
? 0
: theme.gridUnit * 8;
const renderChild = useCallback(
adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
? adjustedWidth
: CLOSED_FILTER_BAR_WIDTH;
return (
<FiltersPanel
width={filterBarWidth}
hidden={isReport}
data-test="dashboard-filters-panel"
>
<StickyPanel ref={containerRef} width={filterBarWidth}>
<ErrorBoundary>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: dashboardFiltersOpen,
toggleFiltersBar: toggleDashboardFiltersOpen,
width: filterBarWidth,
height: filterBarHeight,
offset: filterBarOffset,
}}
/>
</ErrorBoundary>
</StickyPanel>
</FiltersPanel>
);
},
[
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
filterBarHeight,
filterBarOffset,
isReport,
],
);
return (
<DashboardWrapper>
{showFilterBar &&
@@ -632,7 +593,33 @@ const DashboardBuilder = () => {
maxWidth={OPEN_FILTER_BAR_MAX_WIDTH}
initialWidth={OPEN_FILTER_BAR_WIDTH}
>
{renderChild}
{adjustedWidth => {
const filterBarWidth = dashboardFiltersOpen
? adjustedWidth
: CLOSED_FILTER_BAR_WIDTH;
return (
<FiltersPanel
width={filterBarWidth}
hidden={isReport}
data-test="dashboard-filters-panel"
>
<StickyPanel ref={containerRef} width={filterBarWidth}>
<ErrorBoundary>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: dashboardFiltersOpen,
toggleFiltersBar: toggleDashboardFiltersOpen,
width: filterBarWidth,
height: filterBarHeight,
offset: filterBarOffset,
}}
/>
</ErrorBoundary>
</StickyPanel>
</FiltersPanel>
);
}}
</ResizableSidebar>
</>
)}
@@ -735,4 +722,4 @@ const DashboardBuilder = () => {
);
};
export default memo(DashboardBuilder);
export default DashboardBuilder;

View File

@@ -18,17 +18,8 @@
*/
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import {
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import {
Filter,
Filters,
@@ -52,7 +43,6 @@ import {
import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilterScope';
import findTabIndexByComponentId from 'src/dashboard/util/findTabIndexByComponentId';
import { setInScopeStatusOfFilters } from 'src/dashboard/actions/nativeFilters';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import {
applyDashboardLabelsColorOnLoad,
updateDashboardLabelsColor,
@@ -60,7 +50,6 @@ import {
ensureSyncedSharedLabelsColors,
ensureSyncedLabelsColorMap,
} from 'src/dashboard/actions/dashboardState';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { getColorNamespace, resetColors } from 'src/utils/colorScheme';
import { NATIVE_FILTER_DIVIDER_PREFIX } from '../nativeFilters/FiltersConfigModal/utils';
import { findTabsWithChartsInScope } from '../nativeFilters/utils';
@@ -70,21 +59,6 @@ type DashboardContainerProps = {
topLevelTabs?: LayoutItem;
};
export const renderedChartIdsSelector = createSelector(
[(state: RootState) => state.charts],
charts =>
Object.values(charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
const useRenderedChartIds = () => {
const renderedChartIds = useSelector<RootState, number[]>(
renderedChartIdsSelector,
);
return useMemo(() => renderedChartIds, [JSON.stringify(renderedChartIds)]);
};
const useNativeFilterScopes = () => {
const nativeFilters = useSelector<RootState, Filters>(
state => state.nativeFilters?.filters,
@@ -96,12 +70,10 @@ const useNativeFilterScopes = () => {
pick(filter, ['id', 'scope', 'type']),
)
: [],
[nativeFilters],
[JSON.stringify(nativeFilters)],
);
};
const TOP_OF_PAGE_RANGE = 220;
const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const nativeFilterScopes = useNativeFilterScopes();
const dispatch = useDispatch();
@@ -115,10 +87,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
const directPathToChild = useSelector<RootState, string[]>(
state => state.dashboardState.directPathToChild,
);
const chartIds = useChartIds();
const renderedChartIds = useRenderedChartIds();
const chartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts).map(chart => chart.id),
);
const renderedChartIds = useSelector<RootState, number[]>(state =>
Object.values(state.charts)
.filter(chart => chart.chartStatus === 'rendered')
.map(chart => chart.id),
);
const [dashboardLabelsColorInitiated, setDashboardLabelsColorInitiated] =
useState(false);
const prevRenderedChartIds = useRef<number[]>([]);
@@ -160,19 +136,13 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
chartsInScope: [],
};
}
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item?.type === CHART_TYPE,
);
const chartsInScope: number[] = getChartIdsInFilterScope(
filterScope.scope,
chartIds,
chartLayoutItems,
dashboardLayout,
);
const tabsInScope = findTabsWithChartsInScope(
chartLayoutItems,
dashboardLayout,
chartsInScope,
);
return {
@@ -182,14 +152,14 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
});
dispatch(setInScopeStatusOfFilters(scopes));
}, [chartIds, JSON.stringify(nativeFilterScopes), dashboardLayout, dispatch]);
}, [nativeFilterScopes, dashboardLayout, dispatch]);
const childIds: string[] = useMemo(
() => (topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID]),
[topLevelTabs],
);
const childIds: string[] = topLevelTabs
? topLevelTabs.children
: [DASHBOARD_GRID_ID];
const min = Math.min(tabIndex, childIds.length - 1);
const activeKey = min === 0 ? DASHBOARD_GRID_ID : min.toString();
const TOP_OF_PAGE_RANGE = 220;
useEffect(() => {
if (shouldForceFreshSharedLabelsColors) {
@@ -259,63 +229,57 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
};
}, [onBeforeUnload]);
const renderTabBar = useCallback(() => <></>, []);
const handleFocus = useCallback(e => {
if (
// prevent scrolling when tabbing to the tab pane
e.target.classList.contains('ant-tabs-tabpane') &&
window.scrollY < TOP_OF_PAGE_RANGE
) {
// prevent window from jumping down when tabbing
// if already at the top of the page
// to help with accessibility when using keyboard navigation
window.scrollTo(window.scrollX, 0);
}
}, []);
const renderParentSizeChildren = useCallback(
({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<Tabs
id={DASHBOARD_GRID_ID}
activeKey={activeKey}
renderTabBar={renderTabBar}
fullWidth={false}
animated={false}
allowOverflow
onFocus={handleFocus}
>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<Tabs.TabPane
key={index === 0 ? DASHBOARD_GRID_ID : index.toString()}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</Tabs.TabPane>
))}
</Tabs>
),
[activeKey, childIds, dashboardLayout, handleFocus, renderTabBar, tabIndex],
);
return (
<div className="grid-container" data-test="grid-container">
<ParentSize>{renderParentSizeChildren}</ParentSize>
<ParentSize>
{({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<Tabs
id={DASHBOARD_GRID_ID}
activeKey={activeKey}
renderTabBar={() => <></>}
fullWidth={false}
animated={false}
allowOverflow
onFocus={e => {
if (
// prevent scrolling when tabbing to the tab pane
e.target.classList.contains('ant-tabs-tabpane') &&
window.scrollY < TOP_OF_PAGE_RANGE
) {
// prevent window from jumping down when tabbing
// if already at the top of the page
// to help with accessibility when using keyboard navigation
window.scrollTo(window.scrollX, 0);
}
}}
>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<Tabs.TabPane
key={index === 0 ? DASHBOARD_GRID_ID : index.toString()}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
isComponentVisible={index === tabIndex}
/>
</Tabs.TabPane>
))}
</Tabs>
)}
</ParentSize>
</div>
);
};
export default memo(DashboardContainer);
export default DashboardContainer;

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
import { RootState } from 'src/dashboard/types';
@@ -34,7 +34,7 @@ export const useNativeFilters = () => {
);
const filters = useFilters();
const filterValues = useMemo(() => Object.values(filters), [filters]);
const filterValues = Object.values(filters);
const expandFilters = getUrlParam(URL_PARAMS.expandFilters);
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
expandFilters ?? !!filterValues.length,
@@ -43,28 +43,24 @@ export const useNativeFilters = () => {
const nativeFiltersEnabled =
canEdit || (!canEdit && filterValues.length !== 0);
const requiredFirstFilter = useMemo(
() => filterValues.filter(filter => filter.requiredFirst),
[filterValues],
const requiredFirstFilter = filterValues.filter(
filter => filter.requiredFirst,
);
const dataMask = useNativeFiltersDataMask();
const missingInitialFilters = useMemo(
() =>
requiredFirstFilter
.filter(({ id }) => dataMask[id]?.filterState?.value === undefined)
.map(({ name }) => name),
[requiredFirstFilter, dataMask],
);
const missingInitialFilters = requiredFirstFilter
.filter(({ id }) => dataMask[id]?.filterState?.value === undefined)
.map(({ name }) => name);
const showDashboard =
isInitialized ||
!nativeFiltersEnabled ||
missingInitialFilters.length === 0;
const toggleDashboardFiltersOpen = useCallback((visible?: boolean) => {
setDashboardFiltersOpen(prevState => visible ?? !prevState);
}, []);
const toggleDashboardFiltersOpen = useCallback(
(visible?: boolean) => {
setDashboardFiltersOpen(visible ?? !dashboardFiltersOpen);
},
[dashboardFiltersOpen],
);
useEffect(() => {
if (

View File

@@ -39,7 +39,6 @@ import {
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import Badge from 'src/components/Badge';
import DetailsPanelPopover from './DetailsPanel';
import {
@@ -48,7 +47,7 @@ import {
selectIndicatorsForChart,
selectNativeIndicatorsForChart,
} from '../nativeFilters/selectors';
import { Chart, RootState } from '../../types';
import { Chart, DashboardLayout, RootState } from '../../types';
export interface FiltersBadgeProps {
chartId: number;
@@ -127,7 +126,9 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
state => state.dashboardInfo.metadata?.chart_configuration,
);
const chart = useSelector<RootState, Chart>(state => state.charts[chartId]);
const chartLayoutItems = useChartLayoutItems();
const present = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
@@ -206,7 +207,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
]);
const prevNativeFilters = usePrevious(nativeFilters);
const prevChartLayoutItems = usePrevious(chartLayoutItems);
const prevDashboardLayout = usePrevious(present);
const prevDataMask = usePrevious(dataMask);
const prevChartConfig = usePrevious(chartConfiguration);
@@ -220,7 +221,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
chart?.queriesResponse?.[0]?.applied_filters !==
prevChart?.queriesResponse?.[0]?.applied_filters ||
nativeFilters !== prevNativeFilters ||
chartLayoutItems !== prevChartLayoutItems ||
present !== prevDashboardLayout ||
dataMask !== prevDataMask ||
prevChartConfig !== chartConfiguration
) {
@@ -230,7 +231,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
dataMask,
chartId,
chart,
chartLayoutItems,
present,
chartConfiguration,
),
);
@@ -243,14 +244,14 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => {
dataMask,
nativeFilters,
nativeIndicators.length,
present,
prevChart?.queriesResponse,
prevChartConfig,
prevChartStatus,
prevDashboardLayout,
prevDataMask,
prevNativeFilters,
showIndicators,
chartLayoutItems,
prevChartLayoutItems,
]);
const indicators = useMemo(

View File

@@ -16,14 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { FC, 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';
@@ -41,6 +34,7 @@ import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
const extensionsRegistry = getExtensionsRegistry();
type SliceHeaderProps = SliceHeaderControlsProps & {
innerRef?: string;
updateSliceName?: (arg0: string) => void;
editMode?: boolean;
annotationQuery?: object;
@@ -82,7 +76,7 @@ const ChartHeaderStyles = styled.div`
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
& > span.antd5-tooltip-open {
& > span.ant-tooltip-open {
display: inline;
}
}
@@ -128,182 +122,176 @@ const ChartHeaderStyles = styled.div`
`}
`;
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 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 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={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}
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"
/>
</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"
)}
{!!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}
/>
</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"
)}
{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>
)}
</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>
);
},
);
)}
</>
)}
</div>
</ChartHeaderStyles>
);
};
export default SliceHeader;

View File

@@ -18,9 +18,8 @@
*/
import { FC, useEffect } from 'react';
import { pick, pickBy } from 'lodash';
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { pick } from 'lodash';
import { shallowEqual, useSelector } from 'react-redux';
import { DashboardContextForExplore } from 'src/types/DashboardContextForExplore';
import {
getItem,
@@ -43,7 +42,11 @@ export const getDashboardContextLocalStorage = () => {
// A new dashboard tab id is generated on each dashboard page opening.
// We mark ids as redundant when user leaves the dashboard, because they won't be reused.
// Then we remove redundant dashboard contexts from local storage in order not to clutter it
return pickBy(dashboardsContexts, value => !value.isRedundant);
return Object.fromEntries(
Object.entries(dashboardsContexts).filter(
([, value]) => !value.isRedundant,
),
);
};
const updateDashboardTabLocalStorage = (
@@ -53,45 +56,38 @@ const updateDashboardTabLocalStorage = (
const dashboardsContexts = getDashboardContextLocalStorage();
setItem(LocalStorageKeys.DashboardExploreContext, {
...dashboardsContexts,
[dashboardPageId]: { ...dashboardContext, dashboardPageId },
[dashboardPageId]: dashboardContext,
});
};
const selectDashboardContextForExplore = createSelector(
[
(state: RootState) => state.dashboardInfo.metadata,
(state: RootState) => state.dashboardInfo.id,
(state: RootState) => state.dashboardState?.colorScheme,
(state: RootState) => state.nativeFilters?.filters,
(state: RootState) => state.dataMask,
],
(metadata, dashboardId, colorScheme, filters, dataMask) => {
const nativeFilters = Object.keys(filters).reduce((acc, key) => {
acc[key] = pick(filters[key], ['chartsInScope']);
return acc;
}, {});
return {
labelsColor: metadata?.label_colors || EMPTY_OBJECT,
labelsColorMap: metadata?.map_label_colors || EMPTY_OBJECT,
sharedLabelsColors: enforceSharedLabelsColorsArray(
metadata?.shared_label_colors,
),
colorScheme,
chartConfiguration: metadata?.chart_configuration || EMPTY_OBJECT,
nativeFilters,
dataMask,
dashboardId,
filterBoxFilters: getActiveFilters(),
};
},
);
const SyncDashboardState: FC<Props> = ({ dashboardPageId }) => {
const dashboardContextForExplore = useSelector<
RootState,
DashboardContextForExplore
>(selectDashboardContextForExplore);
>(
({ dashboardInfo, dashboardState, nativeFilters, dataMask }) => ({
labelsColor: dashboardInfo.metadata?.label_colors || EMPTY_OBJECT,
labelsColorMap: dashboardInfo.metadata?.map_label_colors || EMPTY_OBJECT,
sharedLabelsColors: enforceSharedLabelsColorsArray(
dashboardInfo.metadata?.shared_label_colors,
),
colorScheme: dashboardState?.colorScheme,
chartConfiguration:
dashboardInfo.metadata?.chart_configuration || EMPTY_OBJECT,
nativeFilters: Object.entries(nativeFilters.filters).reduce(
(acc, [key, filterValue]) => ({
...acc,
[key]: pick(filterValue, ['chartsInScope']),
}),
{},
),
dataMask,
dashboardId: dashboardInfo.id,
filterBoxFilters: getActiveFilters(),
dashboardPageId,
}),
shallowEqual,
);
useEffect(() => {
updateDashboardTabLocalStorage(dashboardPageId, dashboardContextForExplore);

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 { shallowEqual, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
export type URLShortLinkButtonProps = {
@@ -42,13 +42,10 @@ 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,
}),
shallowEqual,
);
const { dataMask, activeTabs } = useSelector((state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
}));
const getCopyUrl = async () => {
try {

View File

@@ -17,13 +17,11 @@
* under the License.
*/
import cx from 'classnames';
import { useCallback, useEffect, useRef, useMemo, useState, memo } from 'react';
import { Component } from 'react';
import PropTypes from 'prop-types';
import { styled, t, logging } from '@superset-ui/core';
import { debounce } from 'lodash';
import { useHistory } from 'react-router-dom';
import { bindActionCreators } from 'redux';
import { useDispatch, useSelector } from 'react-redux';
import { debounce, isEqual } from 'lodash';
import { withRouter } from 'react-router-dom';
import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils';
import ChartContainer from 'src/components/Chart/ChartContainer';
@@ -34,30 +32,13 @@ 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 {
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';
import { slicePropShape, chartPropShape } from '../../util/propShapes';
const propTypes = {
id: PropTypes.number.isRequired,
@@ -69,15 +50,53 @@ 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,
isFullSize: PropTypes.bool,
extraControls: PropTypes.object,
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']),
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`
@@ -102,457 +121,429 @@ const SliceContainer = styled.div`
max-height: 100%;
`;
const EMPTY_OBJECT = {};
class Chart extends Component {
constructor(props) {
super(props);
this.state = {
width: props.width,
height: props.height,
descriptionHeight: 0,
};
const Chart = props => {
const dispatch = useDispatch();
const descriptionRef = useRef(null);
const headerRef = useRef(null);
const boundActionCreators = useMemo(
() =>
bindActionCreators(
{
addSuccessToast,
addDangerToast,
toggleExpandSlice,
changeFilter,
setFocusedFilterField,
unsetFocusedFilterField,
refreshChart,
logEvent,
},
dispatch,
),
[dispatch],
);
const chart = useSelector(state => state.charts[props.id] || EMPTY_OBJECT);
const slice = useSelector(
state => state.sliceEntities.slices[props.id] || EMPTY_OBJECT,
);
const editMode = useSelector(state => state.dashboardState.editMode);
const isExpanded = useSelector(
state => !!state.dashboardState.expandedSlices[props.id],
);
const supersetCanExplore = useSelector(
state => !!state.dashboardInfo.superset_can_explore,
);
const supersetCanShare = useSelector(
state => !!state.dashboardInfo.superset_can_share,
);
const supersetCanCSV = useSelector(
state => !!state.dashboardInfo.superset_can_csv,
);
const timeout = useSelector(
state => state.dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
);
const emitCrossFilters = useSelector(
state => !!state.dashboardInfo.crossFiltersEnabled,
);
const datasource = useSelector(
state =>
(chart &&
chart.form_data &&
state.datasources[chart.form_data.datasource]) ||
PLACEHOLDER_DATASOURCE,
);
const [descriptionHeight, setDescriptionHeight] = useState(0);
const [height, setHeight] = useState(props.height);
const [width, setWidth] = useState(props.width);
const history = useHistory();
const resize = useCallback(
debounce(() => {
const { width, height } = props;
setHeight(height);
setWidth(width);
}, RESIZE_TIMEOUT),
[props.width, props.height],
);
const ownColorScheme = chart.form_data?.color_scheme;
const addFilter = useCallback(
(newSelectedValues = {}) => {
boundActionCreators.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: chart.id,
columns: Object.keys(newSelectedValues).filter(
key => newSelectedValues[key] !== null,
),
});
boundActionCreators.changeFilter(chart.id, newSelectedValues);
},
[boundActionCreators.logEvent, boundActionCreators.changeFilter, chart.id],
);
useEffect(() => {
if (isExpanded) {
const descriptionHeight =
isExpanded && descriptionRef.current
? descriptionRef.current?.offsetHeight
: 0;
setDescriptionHeight(descriptionHeight);
}
}, [isExpanded]);
useEffect(
() => () => {
resize.cancel();
},
[resize],
);
useEffect(() => {
resize();
}, [resize, props.isFullSize]);
const getHeaderHeight = useCallback(() => {
if (headerRef.current) {
const computedStyle = getComputedStyle(
headerRef.current,
).getPropertyValue('margin-bottom');
const marginBottom = parseInt(computedStyle, 10) || 0;
return headerRef.current.offsetHeight + marginBottom;
}
return DEFAULT_HEADER_HEIGHT;
}, [headerRef]);
const getChartHeight = useCallback(() => {
const headerHeight = getHeaderHeight();
return Math.max(height - headerHeight - descriptionHeight, 20);
}, [getHeaderHeight, height, descriptionHeight]);
const handleFilterMenuOpen = useCallback(
(chartId, column) => {
boundActionCreators.setFocusedFilterField(chartId, column);
},
[boundActionCreators.setFocusedFilterField],
);
const handleFilterMenuClose = useCallback(
(chartId, column) => {
boundActionCreators.unsetFocusedFilterField(chartId, column);
},
[boundActionCreators.unsetFocusedFilterField],
);
const logExploreChart = useCallback(() => {
boundActionCreators.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
slice_id: slice.slice_id,
is_cached: props.isCached,
});
}, [boundActionCreators.logEvent, slice.slice_id, props.isCached]);
const chartConfiguration = useSelector(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const colorScheme = useSelector(state => state.dashboardState.colorScheme);
const colorNamespace = useSelector(
state => state.dashboardState.colorNamespace,
);
const datasetsStatus = useSelector(
state => state.dashboardState.datasetsStatus,
);
const allSliceIds = useSelector(state => state.dashboardState.sliceIds);
const nativeFilters = useSelector(state => state.nativeFilters?.filters);
const dataMask = useSelector(state => state.dataMask);
const labelsColor = useSelector(
state => state.dashboardInfo?.metadata?.label_colors || EMPTY_OBJECT,
);
const labelsColorMap = useSelector(
state => state.dashboardInfo?.metadata?.map_label_colors || EMPTY_OBJECT,
);
const sharedLabelsColors = useSelector(state =>
enforceSharedLabelsColorsArray(
state.dashboardInfo?.metadata?.shared_label_colors,
),
);
const formData = useMemo(
() =>
getFormDataWithExtraFilters({
chart,
chartConfiguration,
filters: getAppliedFilterValues(props.id),
colorScheme,
colorNamespace,
sliceId: props.id,
nativeFilters,
allSliceIds,
dataMask,
extraControls: props.extraControls,
labelsColor,
labelsColorMap,
sharedLabelsColors,
ownColorScheme,
}),
[
chart,
chartConfiguration,
props.id,
props.extraControls,
colorScheme,
colorNamespace,
nativeFilters,
allSliceIds,
dataMask,
labelsColor,
labelsColorMap,
sharedLabelsColors,
ownColorScheme,
],
);
const onExploreChart = useCallback(
async clickEvent => {
const isOpenInNewTab =
clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey;
try {
const lastTabId = window.localStorage.getItem('last_tab_id');
const nextTabId = lastTabId
? String(Number.parseInt(lastTabId, 10) + 1)
: undefined;
const key = await postFormData(
datasource.id,
datasource.type,
formData,
slice.slice_id,
nextTabId,
);
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
[URL_PARAMS.sliceId.name]: slice.slice_id,
});
if (isOpenInNewTab) {
window.open(url, '_blank', 'noreferrer');
} else {
history.push(url);
}
} catch (error) {
logging.error(error);
boundActionCreators.addDangerToast(
t('An error occurred while opening Explore'),
);
}
},
[
datasource.id,
datasource.type,
formData,
slice.slice_id,
boundActionCreators.addDangerToast,
history,
],
);
const exportTable = useCallback(
(format, isFullCSV, isPivot = false) => {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
: LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART;
boundActionCreators.logEvent(logAction, {
slice_id: slice.slice_id,
is_cached: props.isCached,
});
exportChart({
formData: isFullCSV
? { ...formData, row_limit: props.maxRows }
: formData,
resultType: isPivot ? 'post_processed' : 'full',
resultFormat: format,
force: true,
ownState: props.ownState,
});
},
[
slice.slice_id,
props.isCached,
formData,
props.maxRows,
props.ownState,
boundActionCreators.logEvent,
],
);
const exportCSV = useCallback(
(isFullCSV = false) => {
exportTable('csv', isFullCSV);
},
[exportTable],
);
const exportFullCSV = useCallback(() => {
exportCSV(true);
}, [exportCSV]);
const exportPivotCSV = useCallback(() => {
exportTable('csv', false, true);
}, [exportTable]);
const exportXLSX = useCallback(() => {
exportTable('xlsx', false);
}, [exportTable]);
const exportFullXLSX = useCallback(() => {
exportTable('xlsx', true);
}, [exportTable]);
const forceRefresh = useCallback(() => {
boundActionCreators.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
slice_id: slice.slice_id,
is_cached: props.isCached,
});
return boundActionCreators.refreshChart(chart.id, true, props.dashboardId);
}, [
boundActionCreators.refreshChart,
chart.id,
props.dashboardId,
slice.slice_id,
props.isCached,
boundActionCreators.logEvent,
]);
if (chart === EMPTY_OBJECT || slice === EMPTY_OBJECT) {
return <MissingChart height={getChartHeight()} />;
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 { queriesResponse, chartUpdateEndTime, chartStatus, annotationQuery } =
chart;
const isLoading = chartStatus === 'loading';
// eslint-disable-next-line camelcase
const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
const cachedDttm =
shouldComponentUpdate(nextProps, nextState) {
// this logic mostly pertains to chart resizing. we keep a copy of the dimensions in
// state so that we can buffer component size updates and only update on the final call
// which improves performance significantly
if (
nextState.width !== this.state.width ||
nextState.height !== this.state.height ||
nextState.descriptionHeight !== this.state.descriptionHeight ||
!isEqual(nextProps.datasource, this.props.datasource)
) {
return true;
}
// allow chart to update if the status changed and the previous status was loading.
if (
this.props?.chart?.chartStatus !== nextProps?.chart?.chartStatus &&
this.props?.chart?.chartStatus === 'loading'
) {
return true;
}
// allow chart update/re-render only if visible:
// under selected tab or no tab layout
if (nextProps.isComponentVisible) {
if (nextProps.chart.triggerQuery) {
return true;
}
if (nextProps.isFullSize !== this.props.isFullSize) {
this.resize();
return false;
}
if (
nextProps.width !== this.props.width ||
nextProps.height !== this.props.height ||
nextProps.width !== this.state.width ||
nextProps.height !== this.state.height
) {
this.resize();
}
for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
// use deep objects equality comparison to prevent
// unnecessary updates when objects references change
if (!areObjectsEqual(nextProps[prop], this.props[prop])) {
return true;
}
}
} else if (
// chart should re-render if color scheme or label colors were changed
nextProps.formData?.color_scheme !== this.props.formData?.color_scheme ||
!areObjectsEqual(
nextProps.formData?.label_colors || {},
this.props.formData?.label_colors || {},
) ||
!areObjectsEqual(
nextProps.formData?.map_label_colors || {},
this.props.formData?.map_label_colors || {},
) ||
!isEqual(
nextProps.formData?.shared_label_colors || [],
this.props.formData?.shared_label_colors || [],
)
) {
return true;
}
// `cacheBusterProp` is injected by react-hot-loader
return this.props.cacheBusterProp !== nextProps.cacheBusterProp;
}
componentDidMount() {
if (this.props.isExpanded) {
const descriptionHeight = this.getDescriptionHeight();
this.setState({ descriptionHeight });
}
}
componentWillUnmount() {
this.resize.cancel();
}
componentDidUpdate(prevProps) {
if (this.props.isExpanded !== prevProps.isExpanded) {
const descriptionHeight = this.getDescriptionHeight();
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ descriptionHeight });
}
}
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;
return this.headerRef.offsetHeight + marginBottom;
}
return DEFAULT_HEADER_HEIGHT;
}
setDescriptionRef(ref) {
this.descriptionRef = ref;
}
setHeaderRef(ref) {
this.headerRef = ref;
}
resize() {
const { width, height } = this.props;
this.setState(() => ({ width, height }));
}
changeFilter(newSelectedValues = {}) {
this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: this.props.chart.id,
columns: Object.keys(newSelectedValues).filter(
key => newSelectedValues[key] !== null,
),
});
this.props.changeFilter(this.props.chart.id, newSelectedValues);
}
handleFilterMenuOpen(chartId, column) {
this.props.setFocusedFilterField(chartId, column);
}
handleFilterMenuClose(chartId, column) {
this.props.unsetFocusedFilterField(chartId, column);
}
logExploreChart = () => {
this.props.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
};
onExploreChart = async clickEvent => {
const isOpenInNewTab =
clickEvent.shiftKey || clickEvent.ctrlKey || clickEvent.metaKey;
try {
const lastTabId = window.localStorage.getItem('last_tab_id');
const nextTabId = lastTabId
? String(Number.parseInt(lastTabId, 10) + 1)
: undefined;
const key = await postFormData(
this.props.datasource.id,
this.props.datasource.type,
this.props.formData,
this.props.slice.slice_id,
nextTabId,
);
const url = mountExploreUrl(null, {
[URL_PARAMS.formDataKey.name]: key,
[URL_PARAMS.sliceId.name]: this.props.slice.slice_id,
});
if (isOpenInNewTab) {
window.open(url, '_blank', 'noreferrer');
} else {
this.props.history.push(url);
}
} catch (error) {
logging.error(error);
this.props.addDangerToast(t('An error occurred while opening Explore'));
}
};
exportFullCSV() {
this.exportCSV(true);
}
exportCSV(isFullCSV = false) {
this.exportTable('csv', isFullCSV);
}
exportPivotCSV() {
this.exportTable('csv', false, true);
}
exportXLSX() {
this.exportTable('xlsx', false);
}
exportFullXLSX() {
this.exportTable('xlsx', true);
}
exportTable(format, isFullCSV, isPivot = false) {
const logAction =
format === 'csv'
? LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART
: LOG_ACTIONS_EXPORT_XLSX_DASHBOARD_CHART;
this.props.logEvent(logAction, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
exportChart({
formData: isFullCSV
? { ...this.props.formData, row_limit: this.props.maxRows }
: this.props.formData,
resultType: isPivot ? 'post_processed' : 'full',
resultFormat: format,
force: true,
ownState: this.props.ownState,
});
}
forceRefresh() {
this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
return this.props.refreshChart(
this.props.chart.id,
true,
this.props.dashboardId,
);
}
render() {
const {
id,
componentId,
dashboardId,
chart,
slice,
datasource,
isExpanded,
editMode,
filters,
formData,
labelsColor,
labelsColorMap,
updateSliceName,
sliceName,
toggleExpandSlice,
timeout,
supersetCanExplore,
supersetCanShare,
supersetCanCSV,
addSuccessToast,
addDangerToast,
ownState,
filterState,
handleToggleFullSize,
isFullSize,
setControlValue,
postTransformProps,
datasetsStatus,
isInView,
emitCrossFilters,
logEvent,
} = this.props;
const { width } = this.state;
// this prevents throwing in the case that a gridComponent
// references a chart that is not associated with the dashboard
if (!chart || !slice) {
return <MissingChart height={this.getChartHeight()} />;
}
const { queriesResponse, chartUpdateEndTime, chartStatus } = chart;
const isLoading = chartStatus === 'loading';
// eslint-disable-next-line camelcase
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
const isCached = queriesResponse?.map(({ is_cached }) => is_cached) || [];
const cachedDttm =
// eslint-disable-next-line camelcase
queriesResponse?.map(({ cached_dttm }) => cached_dttm) || [];
const initialValues = {};
return (
<SliceContainer
className="chart-slice"
data-test="chart-grid-component"
data-test-chart-id={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()}
/>
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()}
/>
{/*
{/*
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={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(),
}}
{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"
/>
)}
<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>
);
};
<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>
);
}
}
Chart.propTypes = propTypes;
Chart.defaultProps = defaultProps;
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)
);
});
export default withRouter(Chart);

View File

@@ -18,7 +18,6 @@
*/
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';
@@ -33,10 +32,18 @@ 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: {},
@@ -56,60 +63,20 @@ const props = {
exportFullXLSX() {},
componentId: 'test',
dashboardId: 111,
editMode: false,
isExpanded: false,
supersetCanExplore: false,
supersetCanCSV: false,
supersetCanShare: false,
};
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} />, {
function setup(overrideProps) {
return render(<Chart.WrappedComponent {...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();
@@ -122,20 +89,23 @@ test('should render a ChartContainer', () => {
});
test('should render a description if it has one and isExpanded=true', () => {
const { container } = setup(
{},
{
dashboardState: {
...defaultState.dashboardState,
expandedSlices: { [props.id]: 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({ isExpanded: true });
expect(container.querySelector('.slice_description')).toBeInTheDocument();
expect(spy).toHaveBeenCalled();
});
test('should call refreshChart when SliceHeader calls forceRefresh', () => {
const { getByText, getByRole } = setup({});
const refreshChart = jest.fn();
const { getByText, getByRole } = setup({ refreshChart });
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.click(getByText('Force refresh'));
expect(refreshChart).toHaveBeenCalled();
@@ -152,12 +122,7 @@ test('should call exportChart when exportCSV is clicked', async () => {
const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
const { findByText, getByRole } = setup(
{},
{
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
},
);
const { findByText, getByRole } = setup({ supersetCanCSV: true });
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to .CSV');
@@ -180,12 +145,7 @@ test('should call exportChart with row_limit props.maxRows when exportFullCSV is
const stubbedExportCSV = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
const { findByText, getByRole } = setup(
{},
{
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
},
);
const { findByText, getByRole } = setup({ supersetCanCSV: true });
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to full .CSV');
@@ -207,12 +167,7 @@ test('should call exportChart when exportXLSX is clicked', async () => {
const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
const { findByText, getByRole } = setup(
{},
{
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
},
);
const { findByText, getByRole } = setup({ supersetCanCSV: true });
fireEvent.click(getByRole('button', { name: 'More Options' }));
fireEvent.mouseOver(getByRole('button', { name: 'Download right' }));
const exportAction = await findByText('Export to Excel');
@@ -234,12 +189,7 @@ test('should call exportChart with row_limit props.maxRows when exportFullXLSX i
const stubbedExportXLSX = jest
.spyOn(exploreUtils, 'exportChart')
.mockImplementation(() => {});
const { findByText, getByRole } = setup(
{},
{
dashboardInfo: { ...defaultState.dashboardInfo, superset_can_csv: true },
},
);
const { findByText, getByRole } = setup({ supersetCanCSV: 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, memo } from 'react';
import { useState, useMemo, useCallback, useEffect } 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/components/gridComponents/Chart';
import Chart from 'src/dashboard/containers/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 = ({
const ChartHolder: React.FC<ChartHolderProps> = ({
id,
parentId,
component,
@@ -92,7 +92,7 @@ const ChartHolder = ({
handleComponentDrop,
setFullSizeChartId,
isInView,
}: ChartHolderProps) => {
}) => {
const theme = useTheme();
const fullSizeStyle = css`
&& {
@@ -107,13 +107,9 @@ const ChartHolder = ({
const isFullSize = fullSizeChartId === chartId;
const focusHighlightStyles = useFilterFocusHighlightStyles(chartId);
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
const dashboardState = useSelector(
(state: RootState) => state.dashboardState,
);
const directPathLastUpdated = useSelector(
(state: RootState) => state.dashboardState.directPathLastUpdated ?? 0,
);
const [extraControls, setExtraControls] = useState<Record<string, unknown>>(
{},
);
@@ -122,8 +118,18 @@ const ChartHolder = ({
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],
);
@@ -185,26 +191,26 @@ const ChartHolder = ({
]);
const { chartWidth, chartHeight } = useMemo(() => {
let width = 0;
let height = 0;
let chartWidth = 0;
let chartHeight = 0;
if (isFullSize) {
width = window.innerWidth - CHART_MARGIN;
height = window.innerHeight - CHART_MARGIN;
chartWidth = window.innerWidth - CHART_MARGIN;
chartHeight = window.innerHeight - CHART_MARGIN;
} else {
width = Math.floor(
chartWidth = Math.floor(
widthMultiple * columnWidth +
(widthMultiple - 1) * GRID_GUTTER_SIZE -
CHART_MARGIN,
);
height = Math.floor(
chartHeight = Math.floor(
component.meta.height * GRID_BASE_UNIT - CHART_MARGIN,
);
}
return {
chartWidth: width,
chartHeight: height,
chartWidth,
chartHeight,
};
}, [columnWidth, component, isFullSize, widthMultiple]);
@@ -238,111 +244,6 @@ const ChartHolder = ({
}));
}, []);
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}
@@ -354,9 +255,81 @@ const ChartHolder = ({
disableDragDrop={false}
editMode={editMode}
>
{renderChild}
{({ 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>
)}
</Draggable>
);
};
export default memo(ChartHolder);
export default ChartHolder;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Fragment, useCallback, useState, useMemo, memo } from 'react';
import { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { css, styled, t } from '@superset-ui/core';
@@ -119,219 +119,203 @@ const emptyColumnContentStyles = theme => css`
color: ${theme.colors.text.label};
`;
const Column = props => {
const {
component: columnComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
minColumnWidth,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
deleteComponent,
id,
parentId,
updateComponents,
} = props;
class Column extends PureComponent {
constructor(props) {
super(props);
this.state = {
isFocused: false,
};
this.handleChangeBackground = this.handleUpdateMeta.bind(
this,
'background',
);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
}
const [isFocused, setIsFocused] = useState(false);
const handleDeleteComponent = useCallback(() => {
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}, [deleteComponent, id, parentId]);
}
const handleChangeFocus = useCallback(nextFocus => {
setIsFocused(Boolean(nextFocus));
}, []);
handleChangeFocus(nextFocus) {
this.setState(() => ({ isFocused: Boolean(nextFocus) }));
}
const handleChangeBackground = useCallback(
nextValue => {
const metaKey = 'background';
if (nextValue && columnComponent.meta[metaKey] !== nextValue) {
updateComponents({
[columnComponent.id]: {
...columnComponent,
meta: {
...columnComponent.meta,
[metaKey]: nextValue,
},
handleUpdateMeta(metaKey, nextValue) {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
});
}
},
[columnComponent, updateComponents],
);
},
});
}
}
const columnItems = useMemo(
() => columnComponent.children || [],
[columnComponent.children],
);
render() {
const {
component: columnComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
minColumnWidth,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
} = this.props;
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const columnItems = columnComponent.children || [];
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value ===
(columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const renderChild = useCallback(
({ dragSourceRef }) => (
<ResizableContainer
id={columnComponent.id}
adjustableWidth
adjustableHeight={false}
widthStep={columnWidth}
widthMultiple={columnComponent.meta.width}
minWidthMultiple={minColumnWidth}
maxWidthMultiple={
availableColumnCount + (columnComponent.meta.width || 0)
}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
return (
<Draggable
component={columnComponent}
parentComponent={parentComponent}
orientation="column"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${columnComponent.id}-background`}
value={columnComponent.meta.background}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu innerRef={dragSourceRef} position="top">
<DragHandle position="top" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={handleChangeFocus}
icon={<Icons.Cog iconSize="xl" />}
/>
</HoverMenu>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle.className)}
{({ dragSourceRef }) => (
<ResizableContainer
id={columnComponent.id}
adjustableWidth
adjustableHeight={false}
widthStep={columnWidth}
widthMultiple={columnComponent.meta.width}
minWidthMultiple={minColumnWidth}
maxWidthMultiple={
availableColumnCount + (columnComponent.meta.width || 0)
}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
<WithPopoverMenu
isFocused={this.state.isFocused}
onChangeFocus={this.handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${columnComponent.id}-background`}
value={columnComponent.meta.background}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
{editMode && (
<HoverMenu innerRef={dragSourceRef} position="top">
<DragHandle position="top" />
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
<IconButton
onClick={this.handleChangeFocus}
icon={<Icons.Cog iconSize="xl" />}
/>
</HoverMenu>
)}
<ColumnStyles
className={cx('grid-column', backgroundStyle.className)}
editMode={editMode}
>
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{columnItems.length === 0 ? (
<div css={emptyColumnContentStyles}>{t('Empty column')}</div>
) : (
columnItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
id={componentId}
parentId={columnComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={columnComponent.meta.width}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
/>
{editMode && (
<Droppable
component={columnItems}
parentComponent={columnComponent}
depth={depth}
index={itemIndex + 1}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
itemIndex === columnItems.length - 1 &&
'droptarget-edge',
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && (
<div {...dropIndicatorProps} />
)
}
</Droppable>
)}
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ResizableContainer>
),
[
availableColumnCount,
backgroundStyle.className,
columnComponent,
columnItems,
columnWidth,
depth,
editMode,
handleChangeBackground,
handleChangeFocus,
handleComponentDrop,
handleDeleteComponent,
isComponentVisible,
isFocused,
minColumnWidth,
onChangeTab,
onResize,
onResizeStart,
onResizeStop,
],
);
return (
<Draggable
component={columnComponent}
parentComponent={parentComponent}
orientation="column"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
);
};
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ResizableContainer>
)}
</Draggable>
);
}
}
Column.propTypes = propTypes;
Column.defaultProps = defaultProps;
export default memo(Column);
export default Column;

View File

@@ -20,7 +20,7 @@ import { FC, Suspense } from 'react';
import { DashboardComponentMetadata, JsonObject, t } from '@superset-ui/core';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import cx from 'classnames';
import { shallowEqual, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
import { Draggable } from '../dnd/DragDroppable';
import { COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes';
import WithPopoverMenu from '../menu/WithPopoverMenu';
@@ -103,7 +103,6 @@ const DynamicComponent: FC<FilterSummaryType> = ({
nativeFilters,
dataMask,
}),
shallowEqual,
);
return (

View File

@@ -16,17 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
Fragment,
useState,
useCallback,
useRef,
useEffect,
useMemo,
memo,
} from 'react';
import { createRef, PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { debounce } from 'lodash';
import {
css,
FAST_DEBOUNCE,
@@ -53,7 +46,6 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
import { isCurrentUserBot } from 'src/utils/isBot';
import { useDebouncedEffect } from '../../../explore/exploreUtils';
const propTypes = {
id: PropTypes.string.isRequired,
@@ -134,301 +126,285 @@ const emptyRowContentStyles = theme => css`
color: ${theme.colors.text.label};
`;
const Row = props => {
const {
component: rowComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
occupiedColumnCount,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
updateComponents,
deleteComponent,
parentId,
} = props;
class Row extends PureComponent {
constructor(props) {
super(props);
this.state = {
isFocused: false,
isInView: false,
hoverMenuHovered: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
this.handleChangeBackground = this.handleUpdateMeta.bind(
this,
'background',
);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleMenuHover = this.handleMenuHover.bind(this);
this.setVerticalEmptyContainerHeight = debounce(
this.setVerticalEmptyContainerHeight.bind(this),
FAST_DEBOUNCE,
);
const [isFocused, setIsFocused] = useState(false);
const [isInView, setIsInView] = useState(false);
const [hoverMenuHovered, setHoverMenuHovered] = useState(false);
const [containerHeight, setContainerHeight] = useState(null);
const containerRef = useRef();
const isComponentVisibleRef = useRef(isComponentVisible);
useEffect(() => {
isComponentVisibleRef.current = isComponentVisible;
}, [isComponentVisible]);
this.containerRef = createRef();
this.observerEnabler = null;
this.observerDisabler = null;
}
// if chart not rendered - render it if it's less than 1 view height away from current viewport
// if chart rendered - remove it if it's more than 4 view heights away from current viewport
useEffect(() => {
let observerEnabler;
let observerDisabler;
componentDidMount() {
if (
isFeatureEnabled(FeatureFlag.DashboardVirtualization) &&
!isCurrentUserBot()
) {
observerEnabler = new IntersectionObserver(
this.observerEnabler = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && isComponentVisibleRef.current) {
setIsInView(true);
if (entry.isIntersecting && !this.state.isInView) {
this.setState({ isInView: true });
}
},
{
rootMargin: '100% 0px',
},
);
observerDisabler = new IntersectionObserver(
this.observerDisabler = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && isComponentVisibleRef.current) {
setIsInView(false);
if (!entry.isIntersecting && this.state.isInView) {
this.setState({ isInView: false });
}
},
{
rootMargin: '400% 0px',
},
);
const element = containerRef.current;
const element = this.containerRef.current;
if (element) {
observerEnabler.observe(element);
observerDisabler.observe(element);
this.observerEnabler.observe(element);
this.observerDisabler.observe(element);
this.setVerticalEmptyContainerHeight();
}
}
return () => {
observerEnabler?.disconnect();
observerDisabler?.disconnect();
};
}, []);
}
useDebouncedEffect(
() => {
const updatedHeight = containerRef.current?.clientHeight;
if (
editMode &&
containerRef.current &&
updatedHeight !== containerHeight
) {
setContainerHeight(updatedHeight);
}
},
FAST_DEBOUNCE,
[editMode, containerHeight],
);
componentDidUpdate() {
this.setVerticalEmptyContainerHeight();
}
const handleChangeFocus = useCallback(nextFocus => {
setIsFocused(Boolean(nextFocus));
}, []);
setVerticalEmptyContainerHeight() {
const { containerHeight } = this.state;
const { editMode } = this.props;
const updatedHeight = this.containerRef.current?.clientHeight;
if (
editMode &&
this.containerRef.current &&
updatedHeight !== containerHeight
) {
this.setState({ containerHeight: updatedHeight });
}
}
const handleChangeBackground = useCallback(
nextValue => {
const metaKey = 'background';
if (nextValue && rowComponent.meta[metaKey] !== nextValue) {
updateComponents({
[rowComponent.id]: {
...rowComponent,
meta: {
...rowComponent.meta,
[metaKey]: nextValue,
},
componentWillUnmount() {
this.observerEnabler?.disconnect();
this.observerDisabler?.disconnect();
}
handleChangeFocus(nextFocus) {
this.setState(() => ({ isFocused: Boolean(nextFocus) }));
}
handleUpdateMeta(metaKey, nextValue) {
const { updateComponents, component } = this.props;
if (nextValue && component.meta[metaKey] !== nextValue) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
[metaKey]: nextValue,
},
});
}
},
[updateComponents, rowComponent],
);
},
});
}
}
const handleDeleteComponent = useCallback(() => {
deleteComponent(rowComponent.id, parentId);
}, [deleteComponent, rowComponent, parentId]);
handleDeleteComponent() {
const { deleteComponent, component, parentId } = this.props;
deleteComponent(component.id, parentId);
}
const handleMenuHover = useCallback(hovered => {
handleMenuHover = hovered => {
const { isHovered } = hovered;
setHoverMenuHovered(isHovered);
}, []);
this.setState(() => ({ hoverMenuHovered: isHovered }));
};
const rowItems = useMemo(
() => rowComponent.children || [],
[rowComponent.children],
);
render() {
const {
component: rowComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
occupiedColumnCount,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
} = this.props;
const { containerHeight, hoverMenuHovered } = this.state;
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const remainColumnCount = availableColumnCount - occupiedColumnCount;
const renderChild = useCallback(
({ dragSourceRef }) => (
<WithPopoverMenu
isFocused={isFocused}
onChangeFocus={handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={handleChangeBackground}
/>,
]}
const rowItems = rowComponent.children || [];
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const remainColumnCount = availableColumnCount - occupiedColumnCount;
return (
<Draggable
component={rowComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{editMode && (
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
{({ dragSourceRef }) => (
<WithPopoverMenu
isFocused={this.state.isFocused}
onChangeFocus={this.handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={handleChangeFocus}
icon={<Icons.Cog iconSize="xl" />}
/>
</HoverMenu>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={containerRef}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={isInView}
{editMode && (
<HoverMenu
onHover={this.handleMenuHover}
innerRef={dragSourceRef}
position="left"
>
<DragHandle position="left" />
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
<IconButton
onClick={this.handleChangeFocus}
icon={<Icons.Cog iconSize="xl" />}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
</HoverMenu>
)}
<GridRow
className={cx(
'grid-row',
rowItems.length === 0 && 'grid-row--empty',
hoverMenuHovered && 'grid-row--hovered',
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
ref={this.containerRef}
editMode={editMode}
>
{editMode && (
<Droppable
{...(rowItems.length === 0
? {
component: rowComponent,
parentComponent: rowComponent,
dropToChild: true,
}
: {
component: rowItems[0],
parentComponent: rowComponent,
})}
depth={depth}
index={0}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
rowItems.length > 0 && 'droptarget-side',
)}
editMode
style={{
height: rowItems.length > 0 ? containerHeight : '100%',
...(rowItems.length > 0 && { width: 16 }),
}}
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
{rowItems.length === 0 && (
<div css={emptyRowContentStyles}>{t('Empty row')}</div>
)}
{rowItems.length > 0 &&
rowItems.map((componentId, itemIndex) => (
<Fragment key={componentId}>
<DashboardComponent
key={componentId}
id={componentId}
parentId={rowComponent.id}
depth={depth + 1}
index={itemIndex}
availableColumnCount={remainColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={onChangeTab}
isInView={this.state.isInView}
/>
{editMode && (
<Droppable
component={rowItems}
parentComponent={rowComponent}
depth={depth}
index={itemIndex + 1}
orientation="row"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
'empty-droptarget--vertical',
remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 &&
'droptarget-side',
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
editMode
style={{
height: containerHeight,
...(remainColumnCount === 0 &&
itemIndex === rowItems.length - 1 && { width: 16 }),
}}
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && <div {...dropIndicatorProps} />
}
</Droppable>
)}
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
),
[
backgroundStyle.className,
backgroundStyle.value,
columnWidth,
containerHeight,
depth,
editMode,
handleChangeBackground,
handleChangeFocus,
handleComponentDrop,
handleDeleteComponent,
handleMenuHover,
hoverMenuHovered,
isComponentVisible,
isFocused,
isInView,
onChangeTab,
onResize,
onResizeStart,
onResizeStop,
remainColumnCount,
rowComponent,
rowItems,
],
);
return (
<Draggable
component={rowComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
);
};
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
)}
</Draggable>
);
}
}
Row.propTypes = propTypes;
export default memo(Row);
export default Row;

View File

@@ -16,10 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Fragment, useCallback, memo } from 'react';
import { PureComponent, Fragment } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import { styled, t } from '@superset-ui/core';
import { EmptyStateMedium } from 'src/components/EmptyState';
@@ -50,6 +51,7 @@ const propTypes = {
onDragTab: PropTypes.func,
onHoverTab: PropTypes.func,
editMode: PropTypes.bool.isRequired,
canEdit: PropTypes.bool.isRequired,
embeddedMode: PropTypes.bool,
// grid related
@@ -63,6 +65,7 @@ const propTypes = {
handleComponentDrop: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
setDirectPathToChild: PropTypes.func.isRequired,
setEditMode: PropTypes.func.isRequired,
};
const defaultProps = {
@@ -99,65 +102,62 @@ const TitleDropIndicator = styled.div`
const renderDraggableContent = dropProps =>
dropProps.dropIndicatorProps && <div {...dropProps.dropIndicatorProps} />;
const Tab = props => {
const dispatch = useDispatch();
const canEdit = useSelector(state => state.dashboardInfo.dash_edit_perm);
const handleChangeTab = useCallback(
({ pathToTabIndex }) => {
props.setDirectPathToChild(pathToTabIndex);
},
[props.setDirectPathToChild],
);
class Tab extends PureComponent {
constructor(props) {
super(props);
this.handleChangeText = this.handleChangeText.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleOnHover = this.handleOnHover.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.handleChangeTab = this.handleChangeTab.bind(this);
}
const handleChangeText = useCallback(
nextTabText => {
const { updateComponents, component } = props;
if (nextTabText && nextTabText !== component.meta.text) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
text: nextTabText,
},
handleChangeTab({ pathToTabIndex }) {
this.props.setDirectPathToChild(pathToTabIndex);
}
handleChangeText(nextTabText) {
const { updateComponents, component } = this.props;
if (nextTabText && nextTabText !== component.meta.text) {
updateComponents({
[component.id]: {
...component,
meta: {
...component.meta,
text: nextTabText,
},
});
}
},
[props.updateComponents, props.component],
);
},
});
}
}
const handleDrop = useCallback(
dropResult => {
props.handleComponentDrop(dropResult);
props.onDropOnTab(dropResult);
},
[props.handleComponentDrop, props.onDropOnTab],
);
handleDrop(dropResult) {
this.props.handleComponentDrop(dropResult);
this.props.onDropOnTab(dropResult);
}
const handleHoverTab = useCallback(() => {
props.onHoverTab?.();
}, [props.onHoverTab]);
handleOnHover() {
this.props.onHoverTab();
}
const handleTopDropTargetDrop = useCallback(
dropResult => {
if (dropResult) {
props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
},
[props.handleComponentDrop],
);
handleTopDropTargetDrop(dropResult) {
if (dropResult) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
}
const shouldDropToChild = useCallback(item => item.type !== TAB_TYPE, []);
shouldDropToChild(item) {
return item.type !== TAB_TYPE;
}
const renderTabContent = useCallback(() => {
renderTabContent() {
const {
component: tabComponent,
depth,
@@ -168,8 +168,10 @@ const Tab = props => {
onResizeStop,
editMode,
isComponentVisible,
canEdit,
setEditMode,
dashboardId,
} = props;
} = this.props;
const shouldDisplayEmptyState = tabComponent.children.length === 0;
return (
@@ -183,8 +185,8 @@ const Tab = props => {
depth={depth}
onDrop={
tabComponent.children.length === 0
? handleTopDropTargetDrop
: handleDrop
? this.handleTopDropTargetDrop
: this.handleDrop
}
editMode
className={classNames({
@@ -223,7 +225,7 @@ const Tab = props => {
<span
role="button"
tabIndex={0}
onClick={() => dispatch(setEditMode(true))}
onClick={() => setEditMode(true)}
>
{t('edit mode')}
</span>
@@ -240,15 +242,15 @@ const Tab = props => {
parentId={tabComponent.id}
depth={depth} // see isValidChild.js for why tabs don't increment child depth
index={componentIndex}
onDrop={handleDrop}
onHover={handleHoverTab}
onDrop={this.handleDrop}
onHover={this.handleOnHover}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
isComponentVisible={isComponentVisible}
onChangeTab={handleChangeTab}
onChangeTab={this.handleChangeTab}
/>
{/* Make bottom of tab droppable */}
{editMode && (
@@ -257,7 +259,7 @@ const Tab = props => {
orientation="column"
index={componentIndex + 1}
depth={depth}
onDrop={handleDrop}
onDrop={this.handleDrop}
editMode
className="empty-droptarget"
>
@@ -268,95 +270,21 @@ const Tab = props => {
))}
</div>
);
}, [
dispatch,
props.component,
props.depth,
props.availableColumnCount,
props.columnWidth,
props.onResizeStart,
props.onResize,
props.onResizeStop,
props.editMode,
props.isComponentVisible,
props.dashboardId,
props.handleComponentDrop,
props.onDropOnTab,
props.setDirectPathToChild,
props.updateComponents,
handleHoverTab,
canEdit,
handleChangeTab,
handleChangeText,
handleDrop,
handleTopDropTargetDrop,
shouldDropToChild,
]);
}
const renderTabChild = useCallback(
({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => {
const {
component,
index,
editMode,
isFocused,
isHighlighted,
dashboardId,
embeddedMode,
} = props;
return (
<TabTitleContainer
isHighlighted={isHighlighted}
className="dragdroppable-tab"
ref={dragSourceRef}
>
<EditableTitle
title={component.meta.text}
defaultTitle={component.meta.defaultText}
placeholder={component.meta.placeholder}
canEdit={editMode && isFocused}
onSaveTitle={handleChangeText}
showTooltip={false}
editing={editMode && isFocused}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={dashboardId}
placement={index >= 5 ? 'left' : 'right'}
/>
)}
{dropIndicatorProps && !draggingTabOnTab && (
<TitleDropIndicator
className={dropIndicatorProps.className}
data-test="title-drop-indicator"
/>
)}
</TabTitleContainer>
);
},
[
props.component,
props.index,
props.editMode,
props.isFocused,
props.isHighlighted,
props.dashboardId,
handleChangeText,
],
);
const renderTab = useCallback(() => {
renderTab() {
const {
component,
parentComponent,
index,
depth,
editMode,
isFocused,
isHighlighted,
onDropPositionChange,
onDragTab,
} = props;
embeddedMode,
} = this.props;
return (
<DragDroppable
@@ -365,32 +293,71 @@ const Tab = props => {
orientation="column"
index={index}
depth={depth}
onDrop={handleDrop}
onHover={handleHoverTab}
onDrop={this.handleDrop}
onHover={this.handleOnHover}
onDropIndicatorChange={onDropPositionChange}
onDragTab={onDragTab}
editMode={editMode}
dropToChild={shouldDropToChild}
dropToChild={this.shouldDropToChild}
>
{renderTabChild}
{({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => (
<TabTitleContainer
isHighlighted={isHighlighted}
className="dragdroppable-tab"
ref={dragSourceRef}
>
<EditableTitle
title={component.meta.text}
defaultTitle={component.meta.defaultText}
placeholder={component.meta.placeholder}
canEdit={editMode && isFocused}
onSaveTitle={this.handleChangeText}
showTooltip={false}
editing={editMode && isFocused}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={this.props.dashboardId}
placement={index >= 5 ? 'left' : 'right'}
/>
)}
{dropIndicatorProps && !draggingTabOnTab && (
<TitleDropIndicator
className={dropIndicatorProps.className}
data-test="title-drop-indicator"
/>
)}
</TabTitleContainer>
)}
</DragDroppable>
);
}, [
props.component,
props.parentComponent,
props.index,
props.depth,
props.editMode,
handleDrop,
handleHoverTab,
shouldDropToChild,
renderTabChild,
]);
}
return props.renderType === RENDER_TAB ? renderTab() : renderTabContent();
};
render() {
const { renderType } = this.props;
return renderType === RENDER_TAB
? this.renderTab()
: this.renderTabContent();
}
}
Tab.propTypes = propTypes;
Tab.defaultProps = defaultProps;
export default memo(Tab);
function mapStateToProps(state) {
return {
canEdit: state.dashboardInfo.dash_edit_perm,
};
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
setEditMode,
},
dispatch,
);
}
export default connect(mapStateToProps, mapDispatchToProps)(Tab);

View File

@@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { styled, t, usePrevious } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { styled, t } from '@superset-ui/core';
import { connect } from 'react-redux';
import { LineEditableTabs } from 'src/components/Tabs';
import Icons from 'src/components/Icons';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
@@ -48,6 +48,7 @@ const propTypes = {
renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
activeTabs: PropTypes.arrayOf(PropTypes.string),
// actions (from DashboardComponent.jsx)
@@ -70,6 +71,12 @@ const propTypes = {
};
const defaultProps = {
renderTabContent: true,
renderHoverMenu: true,
availableColumnCount: 0,
columnWidth: 0,
activeTabs: [],
directPathToChild: [],
setActiveTab() {},
onResizeStart() {},
onResize() {},
@@ -126,24 +133,95 @@ const CloseIconWithDropIndicator = props => (
</>
);
const Tabs = props => {
const nativeFilters = useSelector(state => state.nativeFilters);
const activeTabs = useSelector(state => state.dashboardState.activeTabs);
const directPathToChild = useSelector(
state => state.dashboardState.directPathToChild,
);
export class Tabs extends PureComponent {
constructor(props) {
super(props);
const { tabIndex, activeKey } = this.getTabInfo(props);
const { tabIndex: initTabIndex, activeKey: initActiveKey } = useMemo(() => {
this.state = {
tabIndex,
activeKey,
};
this.handleClickTab = this.handleClickTab.bind(this);
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleDeleteTab = this.handleDeleteTab.bind(this);
this.handleDropOnTab = this.handleDropOnTab.bind(this);
this.handleDrop = this.handleDrop.bind(this);
this.handleGetDropPosition = this.handleGetDropPosition.bind(this);
this.handleDragggingTab = this.handleDragggingTab.bind(this);
}
componentDidMount() {
this.props.setActiveTab(this.state.activeKey);
}
componentDidUpdate(prevProps, prevState) {
if (prevState.activeKey !== this.state.activeKey) {
this.props.setActiveTab(this.state.activeKey, prevState.activeKey);
}
}
UNSAFE_componentWillReceiveProps(nextProps) {
const maxIndex = Math.max(0, nextProps.component.children.length - 1);
const currTabsIds = this.props.component.children;
const nextTabsIds = nextProps.component.children;
if (this.state.tabIndex > maxIndex) {
this.setState(() => ({ tabIndex: maxIndex }));
}
// reset tab index if dashboard was changed
if (nextProps.dashboardId !== this.props.dashboardId) {
const { tabIndex, activeKey } = this.getTabInfo(nextProps);
this.setState(() => ({
tabIndex,
activeKey,
}));
}
if (nextProps.isComponentVisible) {
const nextFocusComponent = getLeafComponentIdFromPath(
nextProps.directPathToChild,
);
const currentFocusComponent = getLeafComponentIdFromPath(
this.props.directPathToChild,
);
// If the currently selected component is different than the new one,
// or the tab length/order changed, calculate the new tab index and
// replace it if it's different than the current one
if (
nextFocusComponent !== currentFocusComponent ||
(nextFocusComponent === currentFocusComponent &&
currTabsIds !== nextTabsIds)
) {
const nextTabIndex = findTabIndexByComponentId({
currentComponent: nextProps.component,
directPathToChild: nextProps.directPathToChild,
});
// make sure nextFocusComponent is under this tabs component
if (nextTabIndex > -1 && nextTabIndex !== this.state.tabIndex) {
this.setState(() => ({
tabIndex: nextTabIndex,
activeKey: nextTabsIds[nextTabIndex],
}));
}
}
}
}
getTabInfo = props => {
let tabIndex = Math.max(
0,
findTabIndexByComponentId({
currentComponent: props.component,
directPathToChild,
directPathToChild: props.directPathToChild,
}),
);
if (tabIndex === 0 && activeTabs?.length) {
if (tabIndex === 0 && props.activeTabs?.length) {
props.component.children.forEach((tabId, index) => {
if (tabIndex === 0 && activeTabs?.includes(tabId)) {
if (tabIndex === 0 && props.activeTabs.includes(tabId)) {
tabIndex = index;
}
});
@@ -155,398 +233,288 @@ const Tabs = props => {
tabIndex,
activeKey,
};
}, [activeTabs, props.component, directPathToChild]);
};
const [activeKey, setActiveKey] = useState(initActiveKey);
const [selectedTabIndex, setSelectedTabIndex] = useState(initTabIndex);
const [dropPosition, setDropPosition] = useState(null);
const [dragOverTabIndex, setDragOverTabIndex] = useState(null);
const [draggingTabId, setDraggingTabId] = useState(null);
const prevActiveKey = usePrevious(activeKey);
const prevDashboardId = usePrevious(props.dashboardId);
const prevDirectPathToChild = usePrevious(directPathToChild);
const prevTabIds = usePrevious(props.component.children);
showDeleteConfirmModal = key => {
const { component, deleteComponent } = this.props;
AntdModal.confirm({
title: t('Delete dashboard tab?'),
content: (
<span>
{t(
'Deleting a tab will remove all content within it and will deactivate any related alerts or reports. You may still ' +
'reverse this action with the',
)}{' '}
<b>{t('undo')}</b>{' '}
{t('button (cmd + z) until you save your changes.')}
</span>
),
onOk: () => {
deleteComponent(key, component.id);
const tabIndex = component.children.indexOf(key);
this.handleDeleteTab(tabIndex);
},
okType: 'danger',
okText: t('DELETE'),
cancelText: t('CANCEL'),
icon: null,
});
};
useEffect(() => {
if (prevActiveKey) {
props.setActiveTab(activeKey, prevActiveKey);
} else {
props.setActiveTab(activeKey);
}
}, [props.setActiveTab, prevActiveKey, activeKey]);
handleEdit = (event, action) => {
const { component, createComponent } = this.props;
if (action === 'add') {
// Prevent the tab container to be selected
event?.stopPropagation?.();
useEffect(() => {
if (prevDashboardId && props.dashboardId !== prevDashboardId) {
setSelectedTabIndex(initTabIndex);
setActiveKey(initActiveKey);
}
}, [props.dashboardId, prevDashboardId, initTabIndex, initActiveKey]);
useEffect(() => {
const maxIndex = Math.max(0, props.component.children.length - 1);
if (selectedTabIndex > maxIndex) {
setSelectedTabIndex(maxIndex);
}
}, [selectedTabIndex, props.component.children.length, setSelectedTabIndex]);
useEffect(() => {
const currTabsIds = props.component.children;
if (props.isComponentVisible) {
const nextFocusComponent = getLeafComponentIdFromPath(directPathToChild);
const currentFocusComponent = getLeafComponentIdFromPath(
prevDirectPathToChild,
);
// If the currently selected component is different than the new one,
// or the tab length/order changed, calculate the new tab index and
// replace it if it's different than the current one
if (
nextFocusComponent !== currentFocusComponent ||
(nextFocusComponent === currentFocusComponent &&
currTabsIds !== prevTabIds)
) {
const nextTabIndex = findTabIndexByComponentId({
currentComponent: props.component,
directPathToChild,
});
// make sure nextFocusComponent is under this tabs component
if (nextTabIndex > -1 && nextTabIndex !== selectedTabIndex) {
setSelectedTabIndex(nextTabIndex);
setActiveKey(currTabsIds[nextTabIndex]);
}
}
}
}, [
props.component,
directPathToChild,
props.isComponentVisible,
selectedTabIndex,
prevDirectPathToChild,
prevTabIds,
]);
const handleClickTab = useCallback(
tabIndex => {
const { component } = props;
const { children: tabIds } = component;
if (tabIndex !== selectedTabIndex) {
const pathToTabIndex = getDirectPathToTabIndex(component, tabIndex);
const targetTabId = pathToTabIndex[pathToTabIndex.length - 1];
props.logEvent(LOG_ACTIONS_SELECT_DASHBOARD_TAB, {
target_id: targetTabId,
index: tabIndex,
});
props.onChangeTab({ pathToTabIndex });
}
setActiveKey(tabIds[tabIndex]);
},
[
props.component,
props.logEvent,
props.onChangeTab,
selectedTabIndex,
setActiveKey,
],
);
const handleDropOnTab = useCallback(
dropResult => {
const { component } = props;
// Ensure dropped tab is visible
const { destination } = dropResult;
if (destination) {
const dropTabIndex =
destination.id === component.id
? destination.index // dropped ON tabs
: component.children.indexOf(destination.id); // dropped IN tab
if (dropTabIndex > -1) {
setTimeout(() => {
handleClickTab(dropTabIndex);
}, 30);
}
}
},
[props.component, handleClickTab],
);
const handleDrop = useCallback(
dropResult => {
if (dropResult.dragging.type !== TABS_TYPE) {
props.handleComponentDrop(dropResult);
}
},
[props.handleComponentDrop],
);
const handleDeleteTab = useCallback(
tabIndex => {
// If we're removing the currently selected tab,
// select the previous one (if any)
if (selectedTabIndex === tabIndex) {
handleClickTab(Math.max(0, tabIndex - 1));
}
},
[selectedTabIndex, handleClickTab],
);
const showDeleteConfirmModal = useCallback(
key => {
const { component, deleteComponent } = props;
AntdModal.confirm({
title: t('Delete dashboard tab?'),
content: (
<span>
{t(
'Deleting a tab will remove all content within it and will deactivate any related alerts or reports. You may still ' +
'reverse this action with the',
)}{' '}
<b>{t('undo')}</b>{' '}
{t('button (cmd + z) until you save your changes.')}
</span>
),
onOk: () => {
deleteComponent(key, component.id);
const tabIndex = component.children.indexOf(key);
handleDeleteTab(tabIndex);
createComponent({
destination: {
id: component.id,
type: component.type,
index: component.children.length,
},
dragging: {
id: NEW_TAB_ID,
type: TAB_TYPE,
},
okType: 'danger',
okText: t('DELETE'),
cancelText: t('CANCEL'),
icon: null,
});
},
[props.component, props.deleteComponent, handleDeleteTab],
);
} else if (action === 'remove') {
this.showDeleteConfirmModal(event);
}
};
const handleEdit = useCallback(
(event, action) => {
const { component, createComponent } = props;
if (action === 'add') {
// Prevent the tab container to be selected
event?.stopPropagation?.();
handleClickTab(tabIndex) {
const { component } = this.props;
const { children: tabIds } = component;
createComponent({
destination: {
id: component.id,
type: component.type,
index: component.children.length,
},
dragging: {
id: NEW_TAB_ID,
type: TAB_TYPE,
},
});
} else if (action === 'remove') {
showDeleteConfirmModal(event);
}
},
[props.component, props.createComponent, showDeleteConfirmModal],
);
if (tabIndex !== this.state.tabIndex) {
const pathToTabIndex = getDirectPathToTabIndex(component, tabIndex);
const targetTabId = pathToTabIndex[pathToTabIndex.length - 1];
this.props.logEvent(LOG_ACTIONS_SELECT_DASHBOARD_TAB, {
target_id: targetTabId,
index: tabIndex,
});
const handleDeleteComponent = useCallback(() => {
const { deleteComponent, id, parentId } = props;
this.props.onChangeTab({ pathToTabIndex });
}
this.setState(() => ({ activeKey: tabIds[tabIndex] }));
}
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
deleteComponent(id, parentId);
}, [props.deleteComponent, props.id, props.parentId]);
}
const handleGetDropPosition = useCallback(dragObject => {
handleDeleteTab(tabIndex) {
// If we're removing the currently selected tab,
// select the previous one (if any)
if (this.state.tabIndex === tabIndex) {
this.handleClickTab(Math.max(0, tabIndex - 1));
}
}
handleGetDropPosition(dragObject) {
const { dropIndicator, isDraggingOver, index } = dragObject;
if (isDraggingOver) {
setDropPosition(dropIndicator);
setDragOverTabIndex(index);
this.setState(() => ({
dropPosition: dropIndicator,
dragOverTabIndex: index,
}));
} else {
setDropPosition(null);
this.setState(() => ({ dropPosition: null }));
}
}, []);
const handleDragggingTab = useCallback(tabId => {
if (tabId) {
setDraggingTabId(tabId);
} else {
setDraggingTabId(null);
}
}, []);
const {
depth,
component: tabsComponent,
parentComponent,
index,
availableColumnCount = 0,
columnWidth = 0,
onResizeStart,
onResize,
onResizeStop,
renderTabContent = true,
renderHoverMenu = true,
isComponentVisible: isCurrentTabVisible,
editMode,
} = props;
const { children: tabIds } = tabsComponent;
const showDropIndicators = useCallback(
currentDropTabIndex =>
currentDropTabIndex === dragOverTabIndex && {
left: editMode && dropPosition === DROP_LEFT,
right: editMode && dropPosition === DROP_RIGHT,
},
[dragOverTabIndex, dropPosition, editMode],
);
const removeDraggedTab = useCallback(
tabID => draggingTabId === tabID,
[draggingTabId],
);
let tabsToHighlight;
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (highlightedFilterId) {
tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope;
}
const renderChild = useCallback(
({ dragSourceRef: tabsDragSourceRef }) => (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
handleDropOnTab(dropResult) {
const { component } = this.props;
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
handleClickTab(tabIds.indexOf(key));
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
>
{tabIds.map((tabId, tabIndex) => (
<LineEditableTabs.TabPane
key={tabId}
tab={
removeDraggedTab(tabId) ? (
<></>
) : (
<>
{showDropIndicators(tabIndex).left && (
<DropIndicator
className="drop-indicator-left"
pos="left"
/>
)}
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth}
index={tabIndex}
renderType={RENDER_TAB}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onDropOnTab={handleDropOnTab}
onDropPositionChange={handleGetDropPosition}
onDragTab={handleDragggingTab}
onHoverTab={() => handleClickTab(tabIndex)}
isFocused={activeKey === tabId}
isHighlighted={
activeKey !== tabId && tabsToHighlight?.includes(tabId)
}
/>
</>
)
}
closeIcon={
removeDraggedTab(tabId) ? (
<></>
) : (
<CloseIconWithDropIndicator
role="button"
tabIndex={tabIndex}
showDropIndicators={showDropIndicators(tabIndex)}
/>
)
}
>
{renderTabContent && (
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth} // see isValidChild.js for why tabs don't increment child depth
index={tabIndex}
renderType={RENDER_TAB_CONTENT}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
onDropOnTab={handleDropOnTab}
isComponentVisible={
selectedTabIndex === tabIndex && isCurrentTabVisible
}
/>
)}
</LineEditableTabs.TabPane>
))}
</LineEditableTabs>
</StyledTabsContainer>
),
[
editMode,
renderHoverMenu,
handleDeleteComponent,
tabsComponent.id,
activeKey,
handleEdit,
tabIds,
handleClickTab,
removeDraggedTab,
showDropIndicators,
// Ensure dropped tab is visible
const { destination } = dropResult;
if (destination) {
const dropTabIndex =
destination.id === component.id
? destination.index // dropped ON tabs
: component.children.indexOf(destination.id); // dropped IN tab
if (dropTabIndex > -1) {
setTimeout(() => {
this.handleClickTab(dropTabIndex);
}, 30);
}
}
}
handleDrop(dropResult) {
if (dropResult.dragging.type !== TABS_TYPE) {
this.props.handleComponentDrop(dropResult);
}
}
handleDragggingTab(tabId) {
if (tabId) {
this.setState(() => ({ draggingTabId: tabId }));
} else {
this.setState(() => ({ draggingTabId: null }));
}
}
render() {
const {
depth,
component: tabsComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
handleDropOnTab,
handleGetDropPosition,
handleDragggingTab,
tabsToHighlight,
renderTabContent,
onResizeStart,
onResize,
onResizeStop,
selectedTabIndex,
isCurrentTabVisible,
],
);
renderTabContent,
renderHoverMenu,
isComponentVisible: isCurrentTabVisible,
editMode,
nativeFilters,
} = this.props;
return (
<Draggable
component={tabsComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
);
};
const { children: tabIds } = tabsComponent;
const {
tabIndex: selectedTabIndex,
activeKey,
dropPosition,
dragOverTabIndex,
} = this.state;
const showDropIndicators = currentDropTabIndex =>
currentDropTabIndex === dragOverTabIndex && {
left: editMode && dropPosition === DROP_LEFT,
right: editMode && dropPosition === DROP_RIGHT,
};
const removeDraggedTab = tabID => this.state.draggingTabId === tabID;
let tabsToHighlight;
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (highlightedFilterId) {
tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope;
}
return (
<Draggable
component={tabsComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={this.handleDrop}
editMode={editMode}
>
{({ dragSourceRef: tabsDragSourceRef }) => (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
this.handleClickTab(tabIds.indexOf(key));
}}
onEdit={this.handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
>
{tabIds.map((tabId, tabIndex) => (
<LineEditableTabs.TabPane
key={tabId}
tab={
removeDraggedTab(tabId) ? (
<></>
) : (
<>
{showDropIndicators(tabIndex).left && (
<DropIndicator
className="drop-indicator-left"
pos="left"
/>
)}
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth}
index={tabIndex}
renderType={RENDER_TAB}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onDropOnTab={this.handleDropOnTab}
onDropPositionChange={this.handleGetDropPosition}
onDragTab={this.handleDragggingTab}
onHoverTab={() => this.handleClickTab(tabIndex)}
isFocused={activeKey === tabId}
isHighlighted={
activeKey !== tabId &&
tabsToHighlight?.includes(tabId)
}
/>
</>
)
}
closeIcon={
removeDraggedTab(tabId) ? (
<></>
) : (
<CloseIconWithDropIndicator
role="button"
tabIndex={tabIndex}
showDropIndicators={showDropIndicators(tabIndex)}
/>
)
}
>
{renderTabContent && (
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth} // see isValidChild.js for why tabs don't increment child depth
index={tabIndex}
renderType={RENDER_TAB_CONTENT}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
onDropOnTab={this.handleDropOnTab}
isComponentVisible={
selectedTabIndex === tabIndex && isCurrentTabVisible
}
/>
)}
</LineEditableTabs.TabPane>
))}
</LineEditableTabs>
</StyledTabsContainer>
)}
</Draggable>
);
}
}
Tabs.propTypes = propTypes;
Tabs.defaultProps = defaultProps;
export default memo(Tabs);
function mapStateToProps(state) {
return {
nativeFilters: state.nativeFilters,
activeTabs: state.dashboardState.activeTabs,
directPathToChild: state.dashboardState.directPathToChild,
};
}
export default connect(mapStateToProps)(Tabs);

View File

@@ -20,10 +20,11 @@ import { fireEvent, render } from 'spec/helpers/testing-library';
import { AntdModal } from 'src/components';
import fetchMock from 'fetch-mock';
import Tabs from 'src/dashboard/components/gridComponents/Tabs';
import { Tabs } from 'src/dashboard/components/gridComponents/Tabs';
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
import { getMockStore } from 'spec/fixtures/mockStore';
import { nativeFilters } from 'spec/fixtures/mockNativeFilters';
import { initialState } from 'src/SqlLab/fixtures';
@@ -80,17 +81,17 @@ const props = {
nativeFilters: nativeFilters.filters,
};
function setup(overrideProps, overrideState = {}) {
const mockStore = getMockStore({
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
});
function setup(overrideProps) {
return render(<Tabs {...props} {...overrideProps} />, {
useDnd: true,
useRouter: true,
useRedux: true,
initialState: {
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
...overrideState,
},
store: mockStore,
});
}
@@ -173,7 +174,11 @@ test('should direct display direct-link tab', () => {
// display child in directPathToChild list
const directPathToChild =
dashboardLayoutWithTabs.present.ROW_ID2.parents.slice();
const { getByRole } = setup({}, { dashboardState: { directPathToChild } });
const directLinkProps = {
...props,
directPathToChild,
};
const { getByRole } = setup(directLinkProps);
expect(getByRole('tab', { selected: true })).toHaveTextContent('TAB_ID2');
});

View File

@@ -25,7 +25,7 @@ import { Draggable } from 'src/dashboard/components/dnd/DragDroppable';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import getLeafComponentIdFromPath from 'src/dashboard/util/getLeafComponentIdFromPath';
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
import Tabs from './Tabs';
import { Tabs } from './Tabs';
jest.mock('src/dashboard/containers/DashboardComponent', () =>
jest.fn(props => (

View File

@@ -35,7 +35,7 @@ import Divider from './Divider';
import Header from './Header';
import Row from './Row';
import Tab from './Tab';
import Tabs from './Tabs';
import TabsConnected from './Tabs';
import DynamicComponent from './DynamicComponent';
export { default as ChartHolder } from './ChartHolder';
@@ -56,6 +56,6 @@ export const componentLookup = {
[HEADER_TYPE]: Header,
[ROW_TYPE]: Row,
[TAB_TYPE]: Tab,
[TABS_TYPE]: Tabs,
[TABS_TYPE]: TabsConnected,
[DYNAMIC_TYPE]: DynamicComponent,
};

View File

@@ -22,7 +22,7 @@ import { t, logging } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';
interface ShareMenuItemProps {
url?: string;
@@ -54,13 +54,10 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
selectedKeys,
...rest
} = props;
const { dataMask, activeTabs } = useSelector(
(state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
}),
shallowEqual,
);
const { dataMask, activeTabs } = useSelector((state: RootState) => ({
dataMask: state.dataMask,
activeTabs: state.dashboardState.activeTabs,
}));
async function generateUrl() {
return getDashboardPermalink({

View File

@@ -39,9 +39,6 @@ const INITIAL_STATE = {
3: { id: 3 },
4: { id: 4 },
},
dashboardState: {
sliceIds: [1, 2, 3, 4],
},
dashboardInfo: {
id: 1,
metadata: {

View File

@@ -22,6 +22,7 @@ import { isDefined, NativeFilterScope, t } from '@superset-ui/core';
import Modal from 'src/components/Modal';
import {
ChartConfiguration,
Layout,
RootState,
isCrossFilterScopeGlobal,
GlobalChartCrossFilterConfig,
@@ -31,7 +32,6 @@ import { getChartIdsInFilterScope } from 'src/dashboard/util/getChartIdsInFilter
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { saveChartConfiguration } from 'src/dashboard/actions/dashboardInfo';
import { DEFAULT_CROSS_FILTER_SCOPING } from 'src/dashboard/constants';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { ScopingModalContent } from './ScopingModalContent';
import { NEW_CHART_SCOPING_ID } from './constants';
@@ -76,7 +76,9 @@ export const ScopingModal = ({
closeModal,
}: ScopingModalProps) => {
const dispatch = useDispatch();
const chartLayoutItems = useChartLayoutItems();
const layout = useSelector<RootState, Layout>(
state => state.dashboardLayout.present,
);
const chartIds = useChartIds();
const [currentChartId, setCurrentChartId] = useState(initialChartId);
const initialChartConfig = useSelector<RootState, ChartConfiguration>(
@@ -152,11 +154,7 @@ export const ScopingModal = ({
id: currentChartId,
crossFilters: {
scope,
chartsInScope: getChartIdsInFilterScope(
scope,
chartIds,
chartLayoutItems,
),
chartsInScope: getChartIdsInFilterScope(scope, chartIds, layout),
},
},
}));
@@ -164,7 +162,7 @@ export const ScopingModal = ({
const globalChartsInScope = getChartIdsInFilterScope(
scope,
chartIds,
chartLayoutItems,
layout,
);
setGlobalChartConfig({
scope,
@@ -178,7 +176,7 @@ export const ScopingModal = ({
);
}
},
[currentChartId, chartIds, chartLayoutItems],
[currentChartId, chartIds, layout],
);
const removeCustomScope = useCallback(
@@ -243,11 +241,7 @@ export const ScopingModal = ({
id: newChartId,
crossFilters: {
scope: newScope,
chartsInScope: getChartIdsInFilterScope(
newScope,
chartIds,
chartLayoutItems,
),
chartsInScope: getChartIdsInFilterScope(newScope, chartIds, layout),
},
};
@@ -281,7 +275,7 @@ export const ScopingModal = ({
currentChartId,
globalChartConfig.chartsInScope,
globalChartConfig.scope,
chartLayoutItems,
layout,
],
);

View File

@@ -17,11 +17,9 @@
* under the License.
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { DashboardLayout, RootState } from 'src/dashboard/types';
import crossFiltersSelector from './selectors';
import VerticalCollapse from './VerticalCollapse';
import { useChartsVerboseMaps } from '../utils';
@@ -30,13 +28,17 @@ const CrossFiltersVertical = () => {
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const verboseMaps = useChartsVerboseMaps();
const selectedCrossFilters = crossFiltersSelector({
dataMask,
chartIds,
chartLayoutItems,
chartConfiguration,
dashboardLayout,
verboseMaps,
});

View File

@@ -21,37 +21,36 @@ import {
DataMaskStateWithId,
getColumnLabel,
isDefined,
JsonObject,
} from '@superset-ui/core';
import { LayoutItem } from 'src/dashboard/types';
import { DashboardLayout } from 'src/dashboard/types';
import { CrossFilterIndicator, getCrossFilterIndicator } from '../../selectors';
export const crossFiltersSelector = (props: {
dataMask: DataMaskStateWithId;
chartIds: number[];
chartLayoutItems: LayoutItem[];
chartConfiguration: JsonObject;
dashboardLayout: DashboardLayout;
verboseMaps: { [key: string]: Record<string, string> };
}): CrossFilterIndicator[] => {
const { dataMask, chartIds, chartLayoutItems, verboseMaps } = props;
const { dataMask, chartConfiguration, dashboardLayout, verboseMaps } = props;
const chartsIds = Object.keys(chartConfiguration || {});
return chartIds
return chartsIds
.map(chartId => {
const id = Number(chartId);
const filterIndicator = getCrossFilterIndicator(
chartId,
dataMask[chartId],
chartLayoutItems,
id,
dataMask[id],
dashboardLayout,
);
if (
isDefined(filterIndicator.column) &&
isDefined(filterIndicator.value)
) {
const verboseColName =
verboseMaps[chartId]?.[getColumnLabel(filterIndicator.column)] ||
verboseMaps[id]?.[getColumnLabel(filterIndicator.column)] ||
filterIndicator.column;
return {
...filterIndicator,
column: verboseColName,
emitterId: chartId,
};
return { ...filterIndicator, column: verboseColName, emitterId: id };
}
return null;
})

View File

@@ -37,6 +37,7 @@ import {
isFeatureEnabled,
FeatureFlag,
isNativeFilterWithDataMask,
JsonObject,
} from '@superset-ui/core';
import {
createHtmlPortalNode,
@@ -48,13 +49,15 @@ import {
useDashboardHasTabs,
useSelectFiltersInScope,
} from 'src/dashboard/components/nativeFilters/state';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import {
DashboardLayout,
FilterBarOrientation,
RootState,
} from 'src/dashboard/types';
import DropdownContainer, {
Ref as DropdownContainerRef,
} from 'src/components/DropdownContainer';
import Icons from 'src/components/Icons';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent';
@@ -62,15 +65,12 @@ import crossFiltersSelector from '../CrossFilters/selectors';
import CrossFilter from '../CrossFilters/CrossFilter';
import { useFilterOutlined } from '../useFilterOutlined';
import { useChartsVerboseMaps } from '../utils';
import { CrossFilterIndicator } from '../../selectors';
type FilterControlsProps = {
dataMaskSelected: DataMaskStateWithId;
onFilterSelectionChange: (filter: Filter, dataMask: DataMask) => void;
};
const EMPTY_ARRAY: CrossFilterIndicator[] = [];
const FilterControls: FC<FilterControlsProps> = ({
dataMaskSelected,
onFilterSelectionChange,
@@ -90,8 +90,12 @@ const FilterControls: FC<FilterControlsProps> = ({
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const verboseMaps = useChartsVerboseMaps();
const isCrossFiltersEnabled = isFeatureEnabled(
@@ -102,12 +106,12 @@ const FilterControls: FC<FilterControlsProps> = ({
isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartIds,
chartLayoutItems,
chartConfiguration,
dashboardLayout,
verboseMaps,
})
: EMPTY_ARRAY,
[chartIds, chartLayoutItems, dataMask, isCrossFiltersEnabled, verboseMaps],
: [],
[chartConfiguration, dashboardLayout, dataMask, isCrossFiltersEnabled],
);
const { filterControlFactory, filtersWithValues } = useFilterControlFactory(
dataMaskSelected,
@@ -150,27 +154,18 @@ const FilterControls: FC<FilterControlsProps> = ({
[filtersWithValues, portalNodes],
);
const renderVerticalContent = useCallback(
() => (
<>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
forceRender={hasRequiredFirst}
hasTopMargin={filtersInScope.length > 0}
renderer={renderer}
/>
)}
</>
),
[
filtersInScope,
renderer,
showCollapsePanel,
filtersOutOfScope,
hasRequiredFirst,
],
const renderVerticalContent = () => (
<>
{filtersInScope.map(renderer)}
{showCollapsePanel && (
<FiltersOutOfScopeCollapsible
filtersOutOfScope={filtersOutOfScope}
forceRender={hasRequiredFirst}
hasTopMargin={filtersInScope.length > 0}
renderer={renderer}
/>
)}
</>
);
const overflowedFiltersInScope = useMemo(
@@ -235,84 +230,70 @@ const FilterControls: FC<FilterControlsProps> = ({
return [...crossFilters, ...nativeFiltersInScope];
}, [filtersInScope, renderer, rendererCrossFilter, selectedCrossFilters]);
const renderHorizontalContent = useCallback(
() => (
<div
css={(theme: SupersetTheme) => css`
padding: 0 ${theme.gridUnit * 4}px;
min-width: 0;
flex: 1;
`}
>
<DropdownContainer
items={items}
dropdownTriggerIcon={
<Icons.FilterSmall
css={css`
&& {
margin-right: -4px;
display: flex;
}
`}
/>
}
dropdownTriggerText={t('More filters')}
dropdownTriggerCount={activeOverflowedFiltersInScope.length}
dropdownTriggerTooltip={
activeOverflowedFiltersInScope.length === 0
? t('No applied filters')
: t(
'Applied filters: %s',
activeOverflowedFiltersInScope
.map(filter => filter.name)
.join(', '),
)
}
dropdownContent={
overflowedFiltersInScope.length ||
overflowedCrossFilters.length ||
(filtersOutOfScope.length && showCollapsePanel)
? () => (
<FiltersDropdownContent
overflowedCrossFilters={overflowedCrossFilters}
filtersInScope={overflowedFiltersInScope}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
rendererCrossFilter={rendererCrossFilter}
showCollapsePanel={showCollapsePanel}
forceRenderOutOfScope={hasRequiredFirst}
/>
)
: undefined
}
forceRender={hasRequiredFirst}
ref={popoverRef}
onOverflowingStateChange={({ overflowed: nextOverflowedIds }) => {
if (
nextOverflowedIds.length !== overflowedIds.length ||
overflowedIds.reduce(
(a, b, i) => a || b !== nextOverflowedIds[i],
false,
const renderHorizontalContent = () => (
<div
css={(theme: SupersetTheme) => css`
padding: 0 ${theme.gridUnit * 4}px;
min-width: 0;
flex: 1;
`}
>
<DropdownContainer
items={items}
dropdownTriggerIcon={
<Icons.FilterSmall
css={css`
&& {
margin-right: -4px;
display: flex;
}
`}
/>
}
dropdownTriggerText={t('More filters')}
dropdownTriggerCount={activeOverflowedFiltersInScope.length}
dropdownTriggerTooltip={
activeOverflowedFiltersInScope.length === 0
? t('No applied filters')
: t(
'Applied filters: %s',
activeOverflowedFiltersInScope
.map(filter => filter.name)
.join(', '),
)
) {
setOverflowedIds(nextOverflowedIds);
}
}}
/>
</div>
),
[
items,
activeOverflowedFiltersInScope,
overflowedFiltersInScope,
overflowedCrossFilters,
filtersOutOfScope,
showCollapsePanel,
renderer,
rendererCrossFilter,
hasRequiredFirst,
overflowedIds,
],
}
dropdownContent={
overflowedFiltersInScope.length ||
overflowedCrossFilters.length ||
(filtersOutOfScope.length && showCollapsePanel)
? () => (
<FiltersDropdownContent
overflowedCrossFilters={overflowedCrossFilters}
filtersInScope={overflowedFiltersInScope}
filtersOutOfScope={filtersOutOfScope}
renderer={renderer}
rendererCrossFilter={rendererCrossFilter}
showCollapsePanel={showCollapsePanel}
forceRenderOutOfScope={hasRequiredFirst}
/>
)
: undefined
}
forceRender={hasRequiredFirst}
ref={popoverRef}
onOverflowingStateChange={({ overflowed: nextOverflowedIds }) => {
if (
nextOverflowedIds.length !== overflowedIds.length ||
overflowedIds.reduce(
(a, b, i) => a || b !== nextOverflowedIds[i],
false,
)
) {
setOverflowedIds(nextOverflowedIds);
}
}}
/>
</div>
);
const overflowedByIndex = useMemo(() => {

View File

@@ -221,7 +221,7 @@ const FilterValue: FC<FilterControlProps> = ({
datasetId,
groupby,
handleFilterLoadFinish,
filter,
JSON.stringify(filter),
hasDataSource,
isRefreshing,
shouldRefresh,

View File

@@ -17,19 +17,18 @@
* under the License.
*/
import { FC, memo, useMemo } from 'react';
import { FC, memo } from 'react';
import {
DataMaskStateWithId,
FeatureFlag,
isFeatureEnabled,
JsonObject,
styled,
t,
} from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Loading from 'src/components/Loading';
import { RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { DashboardLayout, RootState } from 'src/dashboard/types';
import { useSelector } from 'react-redux';
import FilterControls from './FilterControls/FilterControls';
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
@@ -37,7 +36,6 @@ import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import FilterConfigurationLink from './FilterConfigurationLink';
import crossFiltersSelector from './CrossFilters/selectors';
import { CrossFilterIndicator } from '../selectors';
const HorizontalBar = styled.div`
${({ theme }) => `
@@ -98,7 +96,6 @@ const FiltersLinkContainer = styled.div<{ hasFilters: boolean }>`
`}
`;
const EMPTY_ARRAY: CrossFilterIndicator[] = [];
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
actions,
canEdit,
@@ -111,26 +108,25 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const chartConfiguration = useSelector<RootState, JsonObject>(
state => state.dashboardInfo.metadata?.chart_configuration,
);
const dashboardLayout = useSelector<RootState, DashboardLayout>(
state => state.dashboardLayout.present,
);
const isCrossFiltersEnabled = isFeatureEnabled(
FeatureFlag.DashboardCrossFilters,
);
const verboseMaps = useChartsVerboseMaps();
const selectedCrossFilters = useMemo(
() =>
isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartIds,
chartLayoutItems,
verboseMaps,
})
: EMPTY_ARRAY,
[chartIds, chartLayoutItems, dataMask, isCrossFiltersEnabled, verboseMaps],
);
const selectedCrossFilters = isCrossFiltersEnabled
? crossFiltersSelector({
dataMask,
chartConfiguration,
dashboardLayout,
verboseMaps,
})
: [];
const hasFilters = filterValues.length > 0 || selectedCrossFilters.length > 0;
return (

View File

@@ -24,8 +24,8 @@ import {
useEffect,
useState,
useCallback,
createContext,
useRef,
useMemo,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -129,6 +129,7 @@ const publishDataMask = debounce(
SLOW_DEBOUNCE,
);
export const FilterBarScrollContext = createContext(false);
const FilterBar: FC<FiltersBarProps> = ({
orientation = FilterBarOrientation.Vertical,
verticalConfig,
@@ -143,11 +144,8 @@ const FilterBar: FC<FiltersBarProps> = ({
const tabId = useTabId();
const filters = useFilters();
const previousFilters = usePrevious(filters);
const filterValues = useMemo(() => Object.values(filters), [filters]);
const nativeFilterValues = useMemo(
() => filterValues.filter(isNativeFilter),
[filterValues],
);
const filterValues = Object.values(filters);
const nativeFilterValues = filterValues.filter(isNativeFilter);
const dashboardId = useSelector<any, number>(
({ dashboardInfo }) => dashboardInfo?.id,
);
@@ -214,9 +212,14 @@ const FilterBar: FC<FiltersBarProps> = ({
if (!isEmpty(updates)) {
setDataMaskSelected(draft => ({ ...draft, ...updates }));
Object.keys(updates).forEach(key => dispatch(clearDataMask(key)));
}
}
}, [dashboardId, filters, previousDashboardId, setDataMaskSelected]);
}, [
JSON.stringify(filters),
JSON.stringify(previousFilters),
previousDashboardId,
]);
const dataMaskAppliedText = JSON.stringify(dataMaskApplied);
@@ -273,27 +276,16 @@ const FilterBar: FC<FiltersBarProps> = ({
);
const isInitialized = useInitialization();
const actions = useMemo(
() => (
<ActionButtons
filterBarOrientation={orientation}
width={verticalConfig?.width}
onApply={handleApply}
onClearAll={handleClearAll}
dataMaskSelected={dataMaskSelected}
dataMaskApplied={dataMaskApplied}
isApplyDisabled={isApplyDisabled}
/>
),
[
orientation,
verticalConfig?.width,
handleApply,
handleClearAll,
dataMaskSelected,
dataMaskAppliedText,
isApplyDisabled,
],
const actions = (
<ActionButtons
filterBarOrientation={orientation}
width={verticalConfig?.width}
onApply={handleApply}
onClearAll={handleClearAll}
dataMaskSelected={dataMaskSelected}
dataMaskApplied={dataMaskApplied}
isApplyDisabled={isApplyDisabled}
/>
);
const filterBarComponent =

View File

@@ -18,26 +18,17 @@
*/
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
import getChartAndLabelComponentIdFromPath from 'src/dashboard/util/getChartAndLabelComponentIdFromPath';
const filterOutlinedSelector = createSelector(
[
(state: RootState) => state.dashboardState.directPathToChild,
(state: RootState) => state.dashboardState.directPathLastUpdated,
],
(directPathToChild, directPathLastUpdated) => ({
outlinedFilterId: (
getChartAndLabelComponentIdFromPath(directPathToChild || []) as Record<
string,
string
>
)?.native_filter,
lastUpdated: directPathLastUpdated,
}),
);
export const useFilterOutlined = () =>
useSelector<RootState, { outlinedFilterId: string; lastUpdated: number }>(
filterOutlinedSelector,
state => ({
outlinedFilterId: (
getChartAndLabelComponentIdFromPath(
state.dashboardState.directPathToChild || [],
) as Record<string, string>
)?.native_filter,
lastUpdated: state.dashboardState.directPathLastUpdated,
}),
);

View File

@@ -22,7 +22,6 @@ import { DataMaskStateWithId, Filter, FilterState } from '@superset-ui/core';
import { testWithId } from 'src/utils/testUtils';
import { RootState } from 'src/dashboard/types';
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
export const getOnlyExtraFormData = (data: DataMaskStateWithId) =>
Object.values(data).reduce(
@@ -65,26 +64,20 @@ export const checkIsApplyDisabled = (
);
};
const chartsVerboseMapSelector = createSelector(
[
(state: RootState) => state.sliceEntities.slices,
(state: RootState) => state.datasources,
],
(slices, datasources) =>
Object.keys(slices).reduce((chartsVerboseMaps, chartId) => {
const chartDatasource = slices[chartId]?.datasource
? datasources[slices[chartId].datasource]
: undefined;
return {
...chartsVerboseMaps,
[chartId]: chartDatasource ? chartDatasource.verbose_map : {},
};
}, {}),
);
export const useChartsVerboseMaps = () =>
useSelector<RootState, { [chartId: string]: Record<string, string> }>(
chartsVerboseMapSelector,
state => {
const { charts, datasources } = state;
return Object.keys(state.charts).reduce((chartsVerboseMaps, chartId) => {
const chartDatasource =
datasources[charts[chartId]?.form_data?.datasource];
return {
...chartsVerboseMaps,
[chartId]: chartDatasource ? chartDatasource.verbose_map : {},
};
}, {});
},
);
export const FILTER_BAR_TEST_ID = 'filter-bar';

View File

@@ -86,9 +86,6 @@ const baseInitialState = {
id: 3,
},
},
dashboardState: {
sliceIds: [1, 2, 3],
},
dashboardLayout: {
past: [],
future: [],

View File

@@ -33,7 +33,7 @@ export const Row = styled.div`
margin-bottom: 0;
}
& .antd5-tooltip-open {
& .ant-tooltip-open {
display: inline-flex;
}
`};

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Tooltip, TooltipProps } from 'src/components/Tooltip';
import { TooltipProps } from 'antd/lib/tooltip';
import { Tooltip } from 'src/components/Tooltip';
import { TooltipTrigger } from './Styles';
export const TooltipWithTruncation = ({

View File

@@ -32,7 +32,11 @@ import {
} from '@superset-ui/core';
import { TIME_FILTER_MAP } from 'src/explore/constants';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { ChartConfiguration, LayoutItem } from 'src/dashboard/types';
import {
ChartConfiguration,
DashboardLayout,
Layout,
} from 'src/dashboard/types';
import { areObjectsEqual } from 'src/reduxUtils';
export enum IndicatorStatus {
@@ -166,7 +170,7 @@ export type CrossFilterIndicator = Indicator & { emitterId: number };
export const getCrossFilterIndicator = (
chartId: number,
dataMask: DataMask,
chartLayoutItems: LayoutItem[],
dashboardLayout: DashboardLayout,
) => {
const filterState = dataMask?.filterState;
const filters = dataMask?.extraFormData?.filters;
@@ -175,17 +179,19 @@ export const getCrossFilterIndicator = (
const column =
filters?.[0]?.col || (filtersState && Object.keys(filtersState)[0]);
const chartLayoutItem = chartLayoutItems.find(
const dashboardLayoutItem = Object.values(dashboardLayout).find(
layoutItem => layoutItem?.meta?.chartId === chartId,
);
const filterObject: Indicator = {
column,
name:
chartLayoutItem?.meta?.sliceNameOverride ||
chartLayoutItem?.meta?.sliceName ||
dashboardLayoutItem?.meta?.sliceNameOverride ||
dashboardLayoutItem?.meta?.sliceName ||
'',
path: [...(chartLayoutItem?.parents ?? []), chartLayoutItem?.id || ''],
path: [
...(dashboardLayoutItem?.parents ?? []),
dashboardLayoutItem?.id || '',
],
value: label,
};
return filterObject;
@@ -282,7 +288,7 @@ const defaultChartConfig = {};
export const selectChartCrossFilters = (
dataMask: DataMaskStateWithId,
chartId: number,
chartLayoutItems: LayoutItem[],
dashboardLayout: Layout,
chartConfiguration: ChartConfiguration = defaultChartConfig,
appliedColumns: Set<string>,
rejectedColumns: Set<string>,
@@ -306,7 +312,7 @@ export const selectChartCrossFilters = (
const filterIndicator = getCrossFilterIndicator(
Number(chartConfig.id),
dataMask[chartConfig.id],
chartLayoutItems,
dashboardLayout,
);
const filterStatus = getStatus({
label: filterIndicator.value,
@@ -333,7 +339,7 @@ export const selectNativeIndicatorsForChart = (
dataMask: DataMaskStateWithId,
chartId: number,
chart: any,
chartLayoutItems: LayoutItem[],
dashboardLayout: Layout,
chartConfiguration: ChartConfiguration = defaultChartConfig,
): Indicator[] => {
const appliedColumns = getAppliedColumns(chart);
@@ -345,7 +351,7 @@ export const selectNativeIndicatorsForChart = (
areObjectsEqual(cachedFilterData?.appliedColumns, appliedColumns) &&
areObjectsEqual(cachedFilterData?.rejectedColumns, rejectedColumns) &&
cachedFilterData?.nativeFilters === nativeFilters &&
cachedFilterData?.chartLayoutItems === chartLayoutItems &&
cachedFilterData?.dashboardLayout === dashboardLayout &&
cachedFilterData?.chartConfiguration === chartConfiguration &&
cachedFilterData?.dataMask === dataMask
) {
@@ -383,7 +389,7 @@ export const selectNativeIndicatorsForChart = (
crossFilterIndicators = selectChartCrossFilters(
dataMask,
chartId,
chartLayoutItems,
dashboardLayout,
chartConfiguration,
appliedColumns,
rejectedColumns,
@@ -393,7 +399,7 @@ export const selectNativeIndicatorsForChart = (
cachedNativeIndicatorsForChart[chartId] = indicators;
cachedNativeFilterDataForChart[chartId] = {
nativeFilters,
chartLayoutItems,
dashboardLayout,
chartConfiguration,
dataMask,
appliedColumns,

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import {
Filter,
FilterConfiguration,
@@ -25,7 +25,7 @@ import {
isFilterDivider,
} from '@superset-ui/core';
import { ActiveTabs, DashboardLayout, RootState } from '../../types';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { TAB_TYPE } from '../../util/componentTypes';
const defaultFilterConfiguration: Filter[] = [];
@@ -79,45 +79,34 @@ function useActiveDashboardTabs() {
function useSelectChartTabParents() {
const dashboardLayout = useDashboardLayout();
const layoutChartItems = useMemo(
() =>
Object.values(dashboardLayout).filter(item => item.type === CHART_TYPE),
[dashboardLayout],
);
return useCallback(
(chartId: number) => {
const chartLayoutItem = layoutChartItems.find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
},
[dashboardLayout, layoutChartItems],
);
return (chartId: number) => {
const chartLayoutItem = Object.values(dashboardLayout).find(
layoutItem => layoutItem.meta?.chartId === chartId,
);
return chartLayoutItem?.parents?.filter(
(parent: string) => dashboardLayout[parent]?.type === TAB_TYPE,
);
};
}
export function useIsFilterInScope() {
const activeTabs = useActiveDashboardTabs();
const selectChartTabParents = useSelectChartTabParents();
// Filter is in scope if any of its charts is visible.
// Filter is in scope if any of it's charts is visible.
// Chart is visible if it's placed in an active tab tree or if it's not attached to any tab.
// Chart is in an active tab tree if all of its ancestors of type TAB are active
// Chart is in an active tab tree if all of it's ancestors of type TAB are active
// Dividers are always in scope
return useCallback(
(filter: Filter | Divider) =>
isFilterDivider(filter) ||
('chartsInScope' in filter &&
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
})),
[selectChartTabParents, activeTabs],
);
return (filter: Filter | Divider) =>
isFilterDivider(filter) ||
('chartsInScope' in filter &&
filter.chartsInScope?.some((chartId: number) => {
const tabParents = selectChartTabParents(chartId);
return (
tabParents?.length === 0 ||
tabParents?.every(tab => activeTabs.includes(tab))
);
}));
}
export function useSelectFiltersInScope(filters: (Filter | Divider)[]) {

View File

@@ -19,7 +19,6 @@
import { Behavior, FeatureFlag } from '@superset-ui/core';
import * as uiCore from '@superset-ui/core';
import { DashboardLayout } from 'src/dashboard/types';
import { CHART_TYPE } from 'src/dashboard/util/componentTypes';
import { nativeFilterGate, findTabsWithChartsInScope } from './utils';
let isFeatureEnabledMock: jest.MockInstance<boolean, [feature: FeatureFlag]>;
@@ -120,10 +119,7 @@ test('findTabsWithChartsInScope should handle a recursive layout structure', ()
},
} as any as DashboardLayout;
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item.type === CHART_TYPE,
);
expect(Array.from(findTabsWithChartsInScope(chartLayoutItems, []))).toEqual(
expect(Array.from(findTabsWithChartsInScope(dashboardLayout, []))).toEqual(
[],
);
});

View File

@@ -30,9 +30,10 @@ import {
QueryFormData,
t,
} from '@superset-ui/core';
import { LayoutItem } from 'src/dashboard/types';
import { DashboardLayout } from 'src/dashboard/types';
import extractUrlParams from 'src/dashboard/util/extractUrlParams';
import { TAB_TYPE } from '../../util/componentTypes';
import { CHART_TYPE, TAB_TYPE } from '../../util/componentTypes';
import { DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
import getBootstrapData from '../../../utils/getBootstrapData';
const getDefaultRowLimit = (): number => {
@@ -155,20 +156,84 @@ export function nativeFilterGate(behaviors: Behavior[]): boolean {
);
}
export const findTabsWithChartsInScope = (
chartLayoutItems: LayoutItem[],
const isComponentATab = (
dashboardLayout: DashboardLayout,
componentId: string,
) => dashboardLayout?.[componentId]?.type === TAB_TYPE;
const findTabsWithChartsInScopeHelper = (
dashboardLayout: DashboardLayout,
chartsInScope: number[],
) =>
new Set<string>(
chartsInScope
.map(chartId =>
chartLayoutItems
.find(item => item?.meta?.chartId === chartId)
?.parents?.filter(parent => parent.startsWith(`${TAB_TYPE}-`)),
)
.filter(id => id !== undefined)
.flat() as string[],
componentId: string,
tabIds: string[],
tabsToHighlight: Set<string>,
visited: Set<string>,
) => {
if (visited.has(componentId)) {
return;
}
visited.add(componentId);
if (
dashboardLayout?.[componentId]?.type === CHART_TYPE &&
chartsInScope.includes(dashboardLayout[componentId]?.meta?.chartId)
) {
tabIds.forEach(tabsToHighlight.add, tabsToHighlight);
}
if (
dashboardLayout?.[componentId]?.children?.length === 0 ||
(isComponentATab(dashboardLayout, componentId) &&
tabsToHighlight.has(componentId))
) {
return;
}
dashboardLayout[componentId]?.children.forEach(childId =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
childId,
isComponentATab(dashboardLayout, childId) ? [...tabIds, childId] : tabIds,
tabsToHighlight,
visited,
),
);
};
export const findTabsWithChartsInScope = (
dashboardLayout: DashboardLayout,
chartsInScope: number[],
) => {
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
const hasTopLevelTabs = rootChildId !== DASHBOARD_GRID_ID;
const tabsInScope = new Set<string>();
const visited = new Set<string>();
if (hasTopLevelTabs) {
dashboardLayout[rootChildId]?.children?.forEach(tabId =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
tabId,
[tabId],
tabsInScope,
visited,
),
);
} else {
Object.values(dashboardLayout)
.filter(element => element?.type === TAB_TYPE)
.forEach(element =>
findTabsWithChartsInScopeHelper(
dashboardLayout,
chartsInScope,
element.id,
[element.id],
tabsInScope,
visited,
),
);
}
return tabsInScope;
};
export const getFilterValueForDisplay = (
value?: string[] | null | string | number | object,

View File

@@ -0,0 +1,135 @@
/**
* 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);

View File

@@ -43,10 +43,8 @@ function mapStateToProps(state: RootState) {
return {
timeout: dashboardInfo.common?.conf?.SUPERSET_WEBSERVER_TIMEOUT,
userId: dashboardInfo.userId,
dashboardId: dashboardInfo.id,
editMode: dashboardState.editMode,
isPublished: dashboardState.isPublished,
hasUnsavedChanges: dashboardState.hasUnsavedChanges,
dashboardInfo,
dashboardState,
datasources,
chartConfiguration: dashboardInfo.metadata?.chart_configuration,
slices: sliceEntities.slices,

View File

@@ -16,15 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, memo, useMemo } from 'react';
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { useSelector, useDispatch } from 'react-redux';
import { connect } from 'react-redux';
import { logEvent } from 'src/logger/actions';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { componentLookup } from 'src/dashboard/components/gridComponents';
import getDetailedComponentWidth from 'src/dashboard/util/getDetailedComponentWidth';
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { componentShape } from 'src/dashboard/util/propShapes';
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
import {
createComponent,
@@ -46,93 +47,86 @@ const propTypes = {
renderHoverMenu: PropTypes.bool,
renderTabContent: PropTypes.bool,
onChangeTab: PropTypes.func,
component: componentShape.isRequired,
parentComponent: componentShape.isRequired,
createComponent: PropTypes.func.isRequired,
deleteComponent: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
directPathToChild: PropTypes.arrayOf(PropTypes.string),
directPathLastUpdated: PropTypes.number,
dashboardId: PropTypes.number.isRequired,
isComponentVisible: PropTypes.bool,
};
const DashboardComponent = props => {
const dispatch = useDispatch();
const dashboardLayout = useSelector(state => state.dashboardLayout.present);
const dashboardInfo = useSelector(state => state.dashboardInfo);
const editMode = useSelector(state => state.dashboardState.editMode);
const fullSizeChartId = useSelector(
state => state.dashboardState.fullSizeChartId,
);
const dashboardId = dashboardInfo.id;
const component = dashboardLayout[props.id];
const parentComponent = dashboardLayout[props.parentId];
const getComponentById = useCallback(
id => dashboardLayout[id],
[dashboardLayout],
);
const { isComponentVisible = true } = props;
const filters = getActiveFilters();
const embeddedMode = !dashboardInfo.userId;
const defaultProps = {
isComponentVisible: true,
};
const boundActionCreators = useMemo(
() =>
bindActionCreators(
{
addDangerToast,
createComponent,
deleteComponent,
updateComponents,
handleComponentDrop,
setDirectPathToChild,
setFullSizeChartId,
setActiveTab,
logEvent,
},
dispatch,
),
[dispatch],
);
function mapStateToProps(
{ dashboardLayout: undoableLayout, dashboardState, dashboardInfo },
ownProps,
) {
const dashboardLayout = undoableLayout.present;
const { id, parentId } = ownProps;
const component = dashboardLayout[id];
const props = {
component,
getComponentById: id => dashboardLayout[id],
parentComponent: dashboardLayout[parentId],
editMode: dashboardState.editMode,
filters: getActiveFilters(),
dashboardId: dashboardInfo.id,
dashboardInfo,
fullSizeChartId: dashboardState.fullSizeChartId,
embeddedMode: !dashboardInfo?.userId,
};
// rows and columns need more data about their child dimensions
// doing this allows us to not pass the entire component lookup to all Components
const { occupiedColumnCount, minColumnWidth } = useMemo(() => {
if (component) {
const componentType = component.type;
if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
id: props.id,
components: dashboardLayout,
});
if (component) {
const componentType = component.type;
if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
id,
components: dashboardLayout,
});
if (componentType === ROW_TYPE) {
return { occupiedColumnCount: occupiedWidth };
}
if (componentType === COLUMN_TYPE) {
return { minColumnWidth: minimumWidth };
}
}
return {};
if (componentType === ROW_TYPE) props.occupiedColumnCount = occupiedWidth;
if (componentType === COLUMN_TYPE) props.minColumnWidth = minimumWidth;
}
return {};
}, [component, dashboardLayout, props.id]);
}
const Component = component ? componentLookup[component.type] : null;
return Component ? (
<Component
{...props}
{...boundActionCreators}
component={component}
getComponentById={getComponentById}
parentComponent={parentComponent}
editMode={editMode}
filters={filters}
dashboardId={dashboardId}
dashboardInfo={dashboardInfo}
fullSizeChartId={fullSizeChartId}
occupiedColumnCount={occupiedColumnCount}
minColumnWidth={minColumnWidth}
isComponentVisible={isComponentVisible}
embeddedMode={embeddedMode}
/>
) : null;
};
return props;
}
function mapDispatchToProps(dispatch) {
return bindActionCreators(
{
addDangerToast,
createComponent,
deleteComponent,
updateComponents,
handleComponentDrop,
setDirectPathToChild,
setFullSizeChartId,
setActiveTab,
logEvent,
},
dispatch,
);
}
class DashboardComponent extends PureComponent {
render() {
const { component } = this.props;
const Component = component ? componentLookup[component.type] : null;
return Component ? <Component {...this.props} /> : null;
}
}
DashboardComponent.propTypes = propTypes;
DashboardComponent.defaultProps = defaultProps;
export default memo(DashboardComponent);
export default connect(mapStateToProps, mapDispatchToProps)(DashboardComponent);

View File

@@ -83,20 +83,16 @@ const selectRelevantDatamask = createSelector(
dataMask => getRelevantDataMask(dataMask, 'ownState'), // the second parameter conducts the transformation
);
const selectChartConfiguration = (state: RootState) =>
state.dashboardInfo.metadata?.chart_configuration;
const selectNativeFilters = (state: RootState) => state.nativeFilters.filters;
const selectDataMask = (state: RootState) => state.dataMask;
const selectAllSliceIds = (state: RootState) => state.dashboardState.sliceIds;
// TODO: move to Dashboard.jsx when it's refactored to functional component
const selectActiveFilters = createSelector(
[
selectChartConfiguration,
selectNativeFilters,
selectDataMask,
selectAllSliceIds,
],
(chartConfiguration, nativeFilters, dataMask, allSliceIds) => ({
(state: RootState) => ({
// eslint-disable-next-line camelcase
chartConfiguration: state.dashboardInfo.metadata?.chart_configuration,
nativeFilters: state.nativeFilters.filters,
dataMask: state.dataMask,
allSliceIds: state.dashboardState.sliceIds,
}),
({ chartConfiguration, nativeFilters, dataMask, allSliceIds }) => ({
...getActiveFilters(),
...getAllActiveFilters({
// eslint-disable-next-line camelcase
@@ -232,23 +228,17 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
if (error) throw error; // caught in error boundary
const globalStyles = useMemo(
() => [
filterCardPopoverStyle(theme),
headerStyles(theme),
chartContextMenuStyles(theme),
focusStyle(theme),
chartHeaderStyles(theme),
],
[theme],
);
if (error) throw error; // caught in error boundary
const DashboardBuilderComponent = useMemo(() => <DashboardBuilder />, []);
return (
<>
<Global styles={globalStyles} />
<Global
styles={[
filterCardPopoverStyle(theme),
headerStyles(theme),
chartContextMenuStyles(theme),
focusStyle(theme),
chartHeaderStyles(theme),
]}
/>
{readyToRender && hasDashboardInfoInitiated ? (
<>
<SyncDashboardState dashboardPageId={dashboardPageId} />
@@ -257,7 +247,7 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
activeFilters={activeFilters}
ownDataCharts={relevantDataMask}
>
{DashboardBuilderComponent}
<DashboardBuilder />
</DashboardContainer>
</DashboardPageIdContext.Provider>
</>

View File

@@ -93,9 +93,9 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css`
}
.filter-card-tooltip {
&.antd5-tooltip-placement-bottom {
&.ant-tooltip-placement-bottom {
padding-top: 0;
& .antd5-tooltip-arrow {
& .ant-tooltip-arrow {
top: -13px;
}
}

View File

@@ -17,7 +17,20 @@
* under the License.
*/
import { useSelector } from 'react-redux';
import { isEqual } from 'lodash';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
import { useMemoCompare } from 'src/hooks/useMemoCompare';
export const useChartIds = () =>
useSelector<RootState, number[]>(state => state.dashboardState.sliceIds);
const chartIdsSelector = createSelector(
(state: RootState) => state.charts,
charts => Object.values(charts).map(chart => chart.id),
);
export const useChartIds = () => {
const chartIds = useSelector<RootState, number[]>(chartIdsSelector);
return useMemoCompare(
chartIds,
(prev, next) => prev === next || isEqual(prev, next),
);
};

View File

@@ -33,7 +33,6 @@ import {
isCrossFilterScopeGlobal,
} from '../types';
import { DEFAULT_CROSS_FILTER_SCOPING } from '../constants';
import { CHART_TYPE } from './componentTypes';
export const isCrossFiltersEnabled = (
metadataCrossFiltersEnabled: boolean | undefined,
@@ -53,17 +52,13 @@ export const getCrossFiltersConfiguration = (
return undefined;
}
const chartLayoutItems = Object.values(dashboardLayout).filter(
item => item?.type === CHART_TYPE,
);
const globalChartConfiguration = metadata.global_chart_configuration?.scope
? {
scope: metadata.global_chart_configuration.scope,
chartsInScope: getChartIdsInFilterScope(
metadata.global_chart_configuration.scope,
Object.values(charts).map(chart => chart.id),
chartLayoutItems,
dashboardLayout,
),
}
: {
@@ -74,7 +69,7 @@ export const getCrossFiltersConfiguration = (
// If user just added cross filter to dashboard it's not saving its scope on server,
// so we tweak it until user will update scope and will save it in server
const chartConfiguration = {};
chartLayoutItems.forEach(layoutItem => {
Object.values(dashboardLayout).forEach(layoutItem => {
const chartId = layoutItem.meta?.chartId;
if (!isDefined(chartId)) {
@@ -110,7 +105,7 @@ export const getCrossFiltersConfiguration = (
: getChartIdsInFilterScope(
chartConfiguration[chartId].crossFilters.scope,
Object.values(charts).map(chart => chart.id),
chartLayoutItems,
dashboardLayout,
);
}
});

View File

@@ -18,13 +18,14 @@
*/
import { NativeFilterScope } from '@superset-ui/core';
import { CHART_TYPE } from './componentTypes';
import { LayoutItem } from '../types';
import { Layout } from '../types';
export function getChartIdsInFilterScope(
filterScope: NativeFilterScope,
chartIds: number[],
layoutItems: LayoutItem[],
layout: Layout,
) {
const layoutItems = Object.values(layout);
return chartIds.filter(
chartId =>
!filterScope.excluded.includes(chartId) &&

View File

@@ -1,29 +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 { createSelector } from '@reduxjs/toolkit';
import { useSelector } from 'react-redux';
import { RootState } from '../types';
import { CHART_TYPE } from './componentTypes';
const chartLayoutItemsSelector = createSelector(
(state: RootState) => state.dashboardLayout.present,
layout => Object.values(layout).filter(item => item?.type === CHART_TYPE),
);
export const useChartLayoutItems = () => useSelector(chartLayoutItemsSelector);

View File

@@ -141,4 +141,54 @@ describe('useFilterFocusHighlightStyles', () => {
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
it('should return unfocused styles if focusedFilterField is targeting a different chart', async () => {
const chartId = 18;
mockGetRelatedCharts.mockReturnValue([]);
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId: 10,
column: 'test',
},
},
dashboardFilters: {
10: {
scopes: {},
},
},
});
renderWrapper(chartId, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(0.3);
});
it('should return focused styles if focusedFilterField chart equals our own', async () => {
const chartId = 18;
mockGetRelatedCharts.mockReturnValue([chartId]);
const store = createMockStore({
dashboardState: {
focusedFilterField: {
chartId,
column: 'test',
},
},
dashboardFilters: {
[chartId]: {
scopes: {
otherColumn: {},
},
},
},
});
renderWrapper(chartId, store);
const container = screen.getByTestId('test-component');
const styles = getComputedStyle(container);
expect(parseFloat(styles.opacity)).toBe(1);
});
});

View File

@@ -16,30 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { Filter, useTheme } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { getChartIdsInFilterScope } from 'src/dashboard/util/activeDashboardFilters';
import { DashboardState, RootState } from 'src/dashboard/types';
import { getRelatedCharts } from './getRelatedCharts';
const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' };
const EMPTY = {};
const selectFocusedFilterScope = (
dashboardState: DashboardState,
dashboardFilters: any,
) => {
if (!dashboardState.focusedFilterField) return null;
const { chartId, column } = dashboardState.focusedFilterField;
return {
chartId,
scope: dashboardFilters[chartId].scopes[column],
};
};
const useFilterFocusHighlightStyles = (chartId: number) => {
const theme = useTheme();
const focusedChartStyles = useMemo(
() => ({
borderColor: theme.colors.primary.light2,
opacity: 1,
boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`,
pointerEvents: 'auto',
}),
[theme],
const nativeFilters = useSelector((state: RootState) => state.nativeFilters);
const dashboardState = useSelector(
(state: RootState) => state.dashboardState,
);
const nativeFilters = useSelector((state: RootState) => state.nativeFilters);
const dashboardFilters = useSelector(
(state: RootState) => state.dashboardFilters,
);
const focusedFilterScope = selectFocusedFilterScope(
dashboardState,
dashboardFilters,
);
const slices =
useSelector((state: RootState) => state.sliceEntities.slices) || {};
@@ -47,8 +57,8 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (!highlightedFilterId) {
return EMPTY;
if (!(focusedFilterScope || highlightedFilterId)) {
return {};
}
const relatedCharts = getRelatedCharts(
@@ -57,7 +67,29 @@ const useFilterFocusHighlightStyles = (chartId: number) => {
slices,
);
if (highlightedFilterId && relatedCharts.includes(chartId)) {
// we use local styles here instead of a conditionally-applied class,
// because adding any conditional class to this container
// causes performance issues in Chrome.
// default to the "de-emphasized" state
const unfocusedChartStyles = { opacity: 0.3, pointerEvents: 'none' };
const focusedChartStyles = {
borderColor: theme.colors.primary.light2,
opacity: 1,
boxShadow: `0px 0px ${theme.gridUnit * 2}px ${theme.colors.primary.base}`,
pointerEvents: 'auto',
};
if (highlightedFilterId) {
if (relatedCharts.includes(chartId)) {
return focusedChartStyles;
}
} else if (
chartId === focusedFilterScope?.chartId ||
getChartIdsInFilterScope({
filterScope: focusedFilterScope?.scope,
}).includes(chartId)
) {
return focusedChartStyles;
}

View File

@@ -82,7 +82,7 @@ export default function ColorSchemeLabel(props: ColorSchemeLabelProps) {
overlayClassName="color-scheme-tooltip"
title={tooltipContent}
key={id}
open={showTooltip}
visible={showTooltip}
>
<span
className="color-scheme-option"

View File

@@ -110,12 +110,12 @@ const StyledTooltip = (props: any) => {
{({ css }) => (
<Tooltip
overlayClassName={css`
.antd5-tooltip-content {
.ant-tooltip-content {
min-width: ${theme.gridUnit * 125}px;
max-height: 410px;
overflow-y: scroll;
.antd5-tooltip-inner {
.ant-tooltip-inner {
max-width: ${theme.gridUnit * 125}px;
h3 {
font-size: ${theme.typography.sizes.m}px;

View File

@@ -80,8 +80,8 @@ export const VizTile = ({
return (
<Tooltip
title={tooltipTitle}
onOpenChange={visible => setTooltipVisible(visible)}
open={tooltipVisible && !isTransitioning}
onVisibleChange={visible => setTooltipVisible(visible)}
visible={tooltipVisible && !isTransitioning}
placement="top"
mouseEnterDelay={0.4}
>

View File

@@ -149,7 +149,7 @@ const StyledContainer = styled.div`
}
}
&&&& .antd5-tooltip-open {
&&&& .ant-tooltip-open {
display: inline;
}

View File

@@ -340,13 +340,13 @@ describe('RTL', () => {
it('renders an "Import Saved Query" tooltip under import button', async () => {
const importButton = await screen.findByTestId('import-button');
userEvent.hover(importButton);
waitFor(() => {
expect(importButton).toHaveClass('ant-tooltip-open');
screen.findByTestId('import-tooltip-test');
const importTooltip = screen.getByRole('tooltip', {
name: 'Import queries',
});
expect(importTooltip).toBeVisible();
expect(importTooltip).toBeInTheDocument();
});
});

View File

@@ -109,10 +109,6 @@ const baseConfig: ThemeConfig = {
colorPrimaryHover: supersetTheme.colors.primary.base,
colorTextTertiary: supersetTheme.colors.grayscale.light1,
},
Tooltip: {
fontSize: supersetTheme.typography.sizes.s,
lineHeight: 1.6,
},
},
};

View File

@@ -1,20 +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.
*/
export type ActionType = 'hover' | 'focus' | 'click' | 'contextMenu';

View File

@@ -41,5 +41,4 @@ export interface DashboardContextForExplore {
}
| {};
isRedundant?: boolean;
dashboardPageId?: string;
}

View File

@@ -19,13 +19,10 @@
import {
CategoricalColorNamespace,
ensureIsArray,
getCategoricalSchemeRegistry,
getLabelsColorMap,
} from '@superset-ui/core';
const EMPTY_ARRAY: string[] = [];
/**
* Force falsy namespace values to undefined to default to GLOBAL
*
@@ -44,7 +41,7 @@ export const getColorNamespace = (namespace?: string) => namespace || undefined;
*/
export const enforceSharedLabelsColorsArray = (
sharedLabelsColors: string[] | Record<string, string> | undefined,
) => (Array.isArray(sharedLabelsColors) ? sharedLabelsColors : EMPTY_ARRAY);
) => (Array.isArray(sharedLabelsColors) ? sharedLabelsColors : []);
/**
* Get labels shared across all charts in a dashboard.
@@ -70,9 +67,7 @@ export const getFreshSharedLabels = (
.filter(([, count]) => count > 1)
.map(([label]) => label);
return Array.from(
new Set([...ensureIsArray(currentSharedLabels), ...duplicates]),
);
return Array.from(new Set([...currentSharedLabels, ...duplicates]));
};
export const getSharedLabelsColorMapEntries = (