perf: Optimize dashboard grid components (#31240)

This commit is contained in:
Kamil Gabryjelski 2024-12-02 15:05:29 +01:00 committed by GitHub
parent 24d001e498
commit 5006f97f70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1023 additions and 914 deletions

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, Fragment } from 'react';
import { Fragment, useCallback, useState, useMemo, memo } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { css, styled, t } from '@superset-ui/core';
@ -119,203 +119,219 @@ const emptyColumnContentStyles = theme => css`
color: ${theme.colors.text.label};
`;
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 Column = props => {
const {
component: columnComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
minColumnWidth,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
deleteComponent,
id,
parentId,
updateComponents,
} = props;
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
const [isFocused, setIsFocused] = useState(false);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
}
}, [deleteComponent, id, parentId]);
handleChangeFocus(nextFocus) {
this.setState(() => ({ isFocused: Boolean(nextFocus) }));
}
const handleChangeFocus = useCallback(nextFocus => {
setIsFocused(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,
const handleChangeBackground = useCallback(
nextValue => {
const metaKey = 'background';
if (nextValue && columnComponent.meta[metaKey] !== nextValue) {
updateComponents({
[columnComponent.id]: {
...columnComponent,
meta: {
...columnComponent.meta,
[metaKey]: nextValue,
},
},
},
});
}
}
});
}
},
[columnComponent, updateComponents],
);
render() {
const {
component: columnComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
minColumnWidth,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
} = this.props;
const columnItems = useMemo(
() => columnComponent.children || [],
[columnComponent.children],
);
const columnItems = columnComponent.children || [];
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value ===
(columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
const backgroundStyle = backgroundStyleOptions.find(
opt =>
opt.value === (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
);
return (
<Draggable
component={columnComponent}
parentComponent={parentComponent}
orientation="column"
index={index}
depth={depth}
onDrop={handleComponentDrop}
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}
editMode={editMode}
>
{({ 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}
<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)}
editMode={editMode}
>
<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}
/>
<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} />
{editMode && (
<Droppable
component={columnComponent}
parentComponent={columnComponent}
{...(columnItems.length === 0
? {
dropToChild: true,
}
</Droppable>
: {
component: columnItems[0],
})}
depth={depth}
index={0}
orientation="column"
onDrop={handleComponentDrop}
className={cx(
'empty-droptarget',
columnItems.length > 0 && 'droptarget-edge',
)}
{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>
)}
{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',
)}
</Fragment>
))
)}
</ColumnStyles>
</WithPopoverMenu>
</ResizableContainer>
)}
</Draggable>
);
}
}
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>
);
};
Column.propTypes = propTypes;
Column.defaultProps = defaultProps;
export default Column;
export default memo(Column);

View File

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

View File

@ -16,10 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, PureComponent, Fragment } from 'react';
import {
Fragment,
useState,
useCallback,
useRef,
useEffect,
useMemo,
memo,
} from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import { debounce } from 'lodash';
import {
css,
FAST_DEBOUNCE,
@ -46,6 +53,7 @@ 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,
@ -126,285 +134,301 @@ const emptyRowContentStyles = theme => css`
color: ${theme.colors.text.label};
`;
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 Row = props => {
const {
component: rowComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
occupiedColumnCount,
depth,
onResizeStart,
onResize,
onResizeStop,
handleComponentDrop,
editMode,
onChangeTab,
isComponentVisible,
updateComponents,
deleteComponent,
parentId,
} = props;
this.containerRef = createRef();
this.observerEnabler = null;
this.observerDisabler = null;
}
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]);
// 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
componentDidMount() {
useEffect(() => {
let observerEnabler;
let observerDisabler;
if (
isFeatureEnabled(FeatureFlag.DashboardVirtualization) &&
!isCurrentUserBot()
) {
this.observerEnabler = new IntersectionObserver(
observerEnabler = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !this.state.isInView) {
this.setState({ isInView: true });
if (entry.isIntersecting && isComponentVisibleRef.current) {
setIsInView(true);
}
},
{
rootMargin: '100% 0px',
},
);
this.observerDisabler = new IntersectionObserver(
observerDisabler = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && this.state.isInView) {
this.setState({ isInView: false });
if (!entry.isIntersecting && isComponentVisibleRef.current) {
setIsInView(false);
}
},
{
rootMargin: '400% 0px',
},
);
const element = this.containerRef.current;
const element = containerRef.current;
if (element) {
this.observerEnabler.observe(element);
this.observerDisabler.observe(element);
this.setVerticalEmptyContainerHeight();
observerEnabler.observe(element);
observerDisabler.observe(element);
}
}
}
return () => {
observerEnabler?.disconnect();
observerDisabler?.disconnect();
};
}, []);
componentDidUpdate() {
this.setVerticalEmptyContainerHeight();
}
useDebouncedEffect(
() => {
const updatedHeight = containerRef.current?.clientHeight;
if (
editMode &&
containerRef.current &&
updatedHeight !== containerHeight
) {
setContainerHeight(updatedHeight);
}
},
FAST_DEBOUNCE,
[editMode, containerHeight],
);
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 handleChangeFocus = useCallback(nextFocus => {
setIsFocused(Boolean(nextFocus));
}, []);
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,
const handleChangeBackground = useCallback(
nextValue => {
const metaKey = 'background';
if (nextValue && rowComponent.meta[metaKey] !== nextValue) {
updateComponents({
[rowComponent.id]: {
...rowComponent,
meta: {
...rowComponent.meta,
[metaKey]: nextValue,
},
},
},
});
}
}
});
}
},
[updateComponents, rowComponent],
);
handleDeleteComponent() {
const { deleteComponent, component, parentId } = this.props;
deleteComponent(component.id, parentId);
}
const handleDeleteComponent = useCallback(() => {
deleteComponent(rowComponent.id, parentId);
}, [deleteComponent, rowComponent, parentId]);
handleMenuHover = hovered => {
const handleMenuHover = useCallback(hovered => {
const { isHovered } = hovered;
this.setState(() => ({ hoverMenuHovered: isHovered }));
};
setHoverMenuHovered(isHovered);
}, []);
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 rowItems = useMemo(
() => rowComponent.children || [],
[rowComponent.children],
);
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}
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}
/>,
]}
editMode={editMode}
>
{({ dragSourceRef }) => (
<WithPopoverMenu
isFocused={this.state.isFocused}
onChangeFocus={this.handleChangeFocus}
disableClick
menuItems={[
<BackgroundStyleDropdown
id={`${rowComponent.id}-background`}
value={backgroundStyle.value}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
{editMode && (
<HoverMenu
onHover={handleMenuHover}
innerRef={dragSourceRef}
position="left"
>
{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" />}
/>
</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>
)}
</Fragment>
))}
</GridRow>
</WithPopoverMenu>
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={handleChangeFocus}
icon={<Icons.Cog iconSize="xl" />}
/>
</HoverMenu>
)}
</Draggable>
);
}
}
<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 && (
<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>
)}
</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>
);
};
Row.propTypes = propTypes;
export default Row;
export default memo(Row);

View File

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

View File

@ -16,10 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
import PropTypes from 'prop-types';
import { styled, t } from '@superset-ui/core';
import { connect } from 'react-redux';
import { styled, t, usePrevious } from '@superset-ui/core';
import { useSelector } 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,7 +48,6 @@ 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)
@ -71,12 +70,6 @@ const propTypes = {
};
const defaultProps = {
renderTabContent: true,
renderHoverMenu: true,
availableColumnCount: 0,
columnWidth: 0,
activeTabs: [],
directPathToChild: [],
setActiveTab() {},
onResizeStart() {},
onResize() {},
@ -133,95 +126,24 @@ const CloseIconWithDropIndicator = props => (
</>
);
export class Tabs extends PureComponent {
constructor(props) {
super(props);
const { tabIndex, activeKey } = this.getTabInfo(props);
const Tabs = props => {
const nativeFilters = useSelector(state => state.nativeFilters);
const activeTabs = useSelector(state => state.dashboardState.activeTabs);
const directPathToChild = useSelector(
state => state.dashboardState.directPathToChild,
);
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 => {
const { tabIndex: initTabIndex, activeKey: initActiveKey } = useMemo(() => {
let tabIndex = Math.max(
0,
findTabIndexByComponentId({
currentComponent: props.component,
directPathToChild: props.directPathToChild,
directPathToChild,
}),
);
if (tabIndex === 0 && props.activeTabs?.length) {
if (tabIndex === 0 && activeTabs?.length) {
props.component.children.forEach((tabId, index) => {
if (tabIndex === 0 && props.activeTabs.includes(tabId)) {
if (tabIndex === 0 && activeTabs?.includes(tabId)) {
tabIndex = index;
}
});
@ -233,288 +155,398 @@ export class Tabs extends PureComponent {
tabIndex,
activeKey,
};
};
}, [activeTabs, props.component, directPathToChild]);
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,
});
};
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);
handleEdit = (event, action) => {
const { component, createComponent } = this.props;
if (action === 'add') {
// Prevent the tab container to be selected
event?.stopPropagation?.();
createComponent({
destination: {
id: component.id,
type: component.type,
index: component.children.length,
},
dragging: {
id: NEW_TAB_ID,
type: TAB_TYPE,
},
});
} else if (action === 'remove') {
this.showDeleteConfirmModal(event);
useEffect(() => {
if (prevActiveKey) {
props.setActiveTab(activeKey, prevActiveKey);
} else {
props.setActiveTab(activeKey);
}
};
}, [props.setActiveTab, prevActiveKey, activeKey]);
handleClickTab(tabIndex) {
const { component } = this.props;
const { children: tabIds } = component;
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,
});
this.props.onChangeTab({ pathToTabIndex });
useEffect(() => {
if (prevDashboardId && props.dashboardId !== prevDashboardId) {
setSelectedTabIndex(initTabIndex);
setActiveKey(initActiveKey);
}
this.setState(() => ({ activeKey: tabIds[tabIndex] }));
}
}, [props.dashboardId, prevDashboardId, initTabIndex, initActiveKey]);
handleDeleteComponent() {
const { deleteComponent, id, parentId } = this.props;
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);
},
okType: 'danger',
okText: t('DELETE'),
cancelText: t('CANCEL'),
icon: null,
});
},
[props.component, props.deleteComponent, handleDeleteTab],
);
const handleEdit = useCallback(
(event, action) => {
const { component, createComponent } = props;
if (action === 'add') {
// Prevent the tab container to be selected
event?.stopPropagation?.();
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],
);
const handleDeleteComponent = useCallback(() => {
const { deleteComponent, id, parentId } = props;
deleteComponent(id, parentId);
}
}, [props.deleteComponent, props.id, props.parentId]);
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 handleGetDropPosition = useCallback(dragObject => {
const { dropIndicator, isDraggingOver, index } = dragObject;
if (isDraggingOver) {
this.setState(() => ({
dropPosition: dropIndicator,
dragOverTabIndex: index,
}));
setDropPosition(dropIndicator);
setDragOverTabIndex(index);
} else {
this.setState(() => ({ dropPosition: null }));
setDropPosition(null);
}
}
}, []);
handleDropOnTab(dropResult) {
const { component } = this.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(() => {
this.handleClickTab(dropTabIndex);
}, 30);
}
}
}
handleDrop(dropResult) {
if (dropResult.dragging.type !== TABS_TYPE) {
this.props.handleComponentDrop(dropResult);
}
}
handleDragggingTab(tabId) {
const handleDragggingTab = useCallback(tabId => {
if (tabId) {
this.setState(() => ({ draggingTabId: tabId }));
setDraggingTabId(tabId);
} else {
this.setState(() => ({ draggingTabId: null }));
setDraggingTabId(null);
}
}
}, []);
render() {
const {
depth,
component: tabsComponent,
parentComponent,
index,
availableColumnCount,
columnWidth,
onResizeStart,
onResize,
onResizeStop,
renderTabContent,
renderHoverMenu,
isComponentVisible: isCurrentTabVisible,
editMode,
nativeFilters,
} = this.props;
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 {
tabIndex: selectedTabIndex,
activeKey,
dropPosition,
dragOverTabIndex,
} = this.state;
const { children: tabIds } = tabsComponent;
const showDropIndicators = currentDropTabIndex =>
const showDropIndicators = useCallback(
currentDropTabIndex =>
currentDropTabIndex === dragOverTabIndex && {
left: editMode && dropPosition === DROP_LEFT,
right: editMode && dropPosition === DROP_RIGHT,
};
},
[dragOverTabIndex, dropPosition, editMode],
);
const removeDraggedTab = tabID => this.state.draggingTabId === tabID;
const removeDraggedTab = useCallback(
tabID => draggingTabId === tabID,
[draggingTabId],
);
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}
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"
>
{({ 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>
)}
{editMode && renderHoverMenu && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={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)}
<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"
/>
)
}
>
{renderTabContent && (
)}
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth} // see isValidChild.js for why tabs don't increment child depth
depth={depth}
index={tabIndex}
renderType={RENDER_TAB_CONTENT}
renderType={RENDER_TAB}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
onDropOnTab={this.handleDropOnTab}
isComponentVisible={
selectedTabIndex === tabIndex && isCurrentTabVisible
onDropOnTab={handleDropOnTab}
onDropPositionChange={handleGetDropPosition}
onDragTab={handleDragggingTab}
onHoverTab={() => handleClickTab(tabIndex)}
isFocused={activeKey === tabId}
isHighlighted={
activeKey !== tabId && tabsToHighlight?.includes(tabId)
}
/>
)}
</LineEditableTabs.TabPane>
))}
</LineEditableTabs>
</StyledTabsContainer>
)}
</Draggable>
);
}
}
</>
)
}
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,
depth,
availableColumnCount,
columnWidth,
handleDropOnTab,
handleGetDropPosition,
handleDragggingTab,
tabsToHighlight,
renderTabContent,
onResizeStart,
onResize,
onResizeStop,
selectedTabIndex,
isCurrentTabVisible,
],
);
return (
<Draggable
component={tabsComponent}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleDrop}
editMode={editMode}
>
{renderChild}
</Draggable>
);
};
Tabs.propTypes = propTypes;
Tabs.defaultProps = defaultProps;
function mapStateToProps(state) {
return {
nativeFilters: state.nativeFilters,
activeTabs: state.dashboardState.activeTabs,
directPathToChild: state.dashboardState.directPathToChild,
};
}
export default connect(mapStateToProps)(Tabs);
export default memo(Tabs);

View File

@ -20,11 +20,10 @@ 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';
@ -81,17 +80,17 @@ const props = {
nativeFilters: nativeFilters.filters,
};
const mockStore = getMockStore({
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
});
function setup(overrideProps) {
function setup(overrideProps, overrideState = {}) {
return render(<Tabs {...props} {...overrideProps} />, {
useDnd: true,
useRouter: true,
store: mockStore,
useRedux: true,
initialState: {
...initialState,
dashboardLayout: dashboardLayoutWithTabs,
dashboardFilters: {},
...overrideState,
},
});
}
@ -174,11 +173,7 @@ test('should direct display direct-link tab', () => {
// display child in directPathToChild list
const directPathToChild =
dashboardLayoutWithTabs.present.ROW_ID2.parents.slice();
const directLinkProps = {
...props,
directPathToChild,
};
const { getByRole } = setup(directLinkProps);
const { getByRole } = setup({}, { dashboardState: { directPathToChild } });
expect(getByRole('tab', { selected: true })).toHaveTextContent('TAB_ID2');
});

View File

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

View File

@ -35,7 +35,7 @@ import Divider from './Divider';
import Header from './Header';
import Row from './Row';
import Tab from './Tab';
import TabsConnected from './Tabs';
import Tabs 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]: TabsConnected,
[TABS_TYPE]: Tabs,
[DYNAMIC_TYPE]: DynamicComponent,
};

View File

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

View File

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