mirror of
https://github.com/apache/superset.git
synced 2026-07-12 01:35:36 +00:00
Compare commits
1 Commits
supersetbo
...
fix/databr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba3b79831 |
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,7 +38,7 @@ const StyledCrossLinks = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.antd5-tooltip-open {
|
||||
.ant-tooltip-open {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -56,7 +56,7 @@ const StyledTruncatedList = styled.div`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.antd5-tooltip-open {
|
||||
.ant-tooltip-open {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -39,9 +39,6 @@ const INITIAL_STATE = {
|
||||
3: { id: 3 },
|
||||
4: { id: 4 },
|
||||
},
|
||||
dashboardState: {
|
||||
sliceIds: [1, 2, 3, 4],
|
||||
},
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
metadata: {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -221,7 +221,7 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
datasetId,
|
||||
groupby,
|
||||
handleFilterLoadFinish,
|
||||
filter,
|
||||
JSON.stringify(filter),
|
||||
hasDataSource,
|
||||
isRefreshing,
|
||||
shouldRefresh,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -86,9 +86,6 @@ const baseInitialState = {
|
||||
id: 3,
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
sliceIds: [1, 2, 3],
|
||||
},
|
||||
dashboardLayout: {
|
||||
past: [],
|
||||
future: [],
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Row = styled.div`
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
& .antd5-tooltip-open {
|
||||
& .ant-tooltip-open {
|
||||
display: inline-flex;
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
135
superset-frontend/src/dashboard/containers/Chart.jsx
Normal file
135
superset-frontend/src/dashboard/containers/Chart.jsx
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -149,7 +149,7 @@ const StyledContainer = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&&&& .antd5-tooltip-open {
|
||||
&&&& .ant-tooltip-open {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -41,5 +41,4 @@ export interface DashboardContextForExplore {
|
||||
}
|
||||
| {};
|
||||
isRedundant?: boolean;
|
||||
dashboardPageId?: string;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user