From 6eaa7a87214b984c82ed23846123083ad0a4fe18 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Mon, 2 Dec 2024 13:43:29 +0100 Subject: [PATCH] perf: Optimize dashboard grid components --- .../components/gridComponents/Column.jsx | 380 +++++----- .../gridComponents/DynamicComponent.tsx | 3 +- .../components/gridComponents/Row.jsx | 504 +++++++------ .../components/gridComponents/Tab.jsx | 287 +++---- .../components/gridComponents/Tabs.jsx | 700 +++++++++--------- .../components/gridComponents/Tabs.test.jsx | 25 +- .../components/gridComponents/Tabs.test.tsx | 2 +- .../components/gridComponents/index.js | 4 +- .../components/menu/ShareMenuItems/index.tsx | 13 +- superset-frontend/src/utils/colorScheme.ts | 9 +- 10 files changed, 1018 insertions(+), 909 deletions(-) diff --git a/superset-frontend/src/dashboard/components/gridComponents/Column.jsx b/superset-frontend/src/dashboard/components/gridComponents/Column.jsx index d11937ab176ab..71c1892f3b887 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Column.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Column.jsx @@ -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 ( - ( + - {({ dragSourceRef }) => ( - , + ]} + editMode={editMode} + > + {editMode && ( + + + + } + /> + + )} + - , - ]} - editMode={editMode} - > - {editMode && ( - - - - } - /> - - )} - - {editMode && ( - 0 && 'droptarget-edge', - )} - editMode - > - {({ dropIndicatorProps }) => - dropIndicatorProps &&
+ {editMode && ( + + : { + component: columnItems[0], + })} + depth={depth} + index={0} + orientation="column" + onDrop={handleComponentDrop} + className={cx( + 'empty-droptarget', + columnItems.length > 0 && 'droptarget-edge', )} - {columnItems.length === 0 ? ( -
{t('Empty column')}
- ) : ( - columnItems.map((componentId, itemIndex) => ( - - - {editMode && ( - - {({ dropIndicatorProps }) => - dropIndicatorProps && ( -
- ) - } - + editMode + > + {({ dropIndicatorProps }) => + dropIndicatorProps &&
+ } + + )} + {columnItems.length === 0 ? ( +
{t('Empty column')}
+ ) : ( + columnItems.map((componentId, itemIndex) => ( + + + {editMode && ( + - )) - )} - - - - )} - - ); - } -} + editMode + > + {({ dropIndicatorProps }) => + dropIndicatorProps &&
+ } + + )} + + )) + )} + + + + ), + [ + availableColumnCount, + backgroundStyle.className, + columnComponent, + columnItems, + columnWidth, + depth, + editMode, + handleChangeBackground, + handleChangeFocus, + handleComponentDrop, + handleDeleteComponent, + isComponentVisible, + isFocused, + minColumnWidth, + onChangeTab, + onResize, + onResizeStart, + onResizeStop, + ], + ); + + return ( + + {renderChild} + + ); +}; Column.propTypes = propTypes; Column.defaultProps = defaultProps; -export default Column; +export default memo(Column); diff --git a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx index e30b4a5455126..3b97c277d9c72 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/DynamicComponent.tsx @@ -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 = ({ nativeFilters, dataMask, }), + shallowEqual, ); return ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx index a22361e1421a9..bd59306ca97fa 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx @@ -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; + + 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); - this.containerRef = createRef(); - this.observerEnabler = null; - this.observerDisabler = null; - } + 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); } } - } - - componentDidUpdate() { - this.setVerticalEmptyContainerHeight(); - } - - 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 }); - } - } + return () => { + observerEnabler?.disconnect(); + observerDisabler?.disconnect(); + }; + }, []); - componentWillUnmount() { - this.observerEnabler?.disconnect(); - this.observerDisabler?.disconnect(); - } + useDebouncedEffect( + () => { + const updatedHeight = containerRef.current?.clientHeight; + if ( + editMode && + containerRef.current && + updatedHeight !== containerHeight + ) { + setContainerHeight(updatedHeight); + } + }, + FAST_DEBOUNCE, + [editMode, containerHeight], + ); - 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 && 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 ( - + opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT), + ); + const remainColumnCount = availableColumnCount - occupiedColumnCount; + const renderChild = useCallback( + ({ dragSourceRef }) => ( + , + ]} editMode={editMode} > - {({ dragSourceRef }) => ( - , - ]} - editMode={editMode} + {editMode && ( + - {editMode && ( - - - - } - /> - - )} - + + } + /> + + )} + + {editMode && ( + 0 && 'droptarget-side', )} - data-test={`grid-row-${backgroundStyle.className}`} - ref={this.containerRef} - editMode={editMode} + editMode + style={{ + height: rowItems.length > 0 ? containerHeight : '100%', + ...(rowItems.length > 0 && { width: 16 }), + }} > - {editMode && ( - 0 && 'droptarget-side', - )} - editMode - style={{ - height: rowItems.length > 0 ? containerHeight : '100%', - ...(rowItems.length > 0 && { width: 16 }), - }} - > - {({ dropIndicatorProps }) => - dropIndicatorProps &&
- } - - )} - {rowItems.length === 0 && ( -
{t('Empty row')}
- )} - {rowItems.length > 0 && - rowItems.map((componentId, itemIndex) => ( - - - {editMode && ( - - {({ dropIndicatorProps }) => - dropIndicatorProps &&
- } - + {({ dropIndicatorProps }) => + dropIndicatorProps &&
+ } + + )} + {rowItems.length === 0 && ( +
{t('Empty row')}
+ )} + {rowItems.length > 0 && + rowItems.map((componentId, itemIndex) => ( + + + {editMode && ( + - ))} - - - )} - - ); - } -} + editMode + style={{ + height: containerHeight, + ...(remainColumnCount === 0 && + itemIndex === rowItems.length - 1 && { width: 16 }), + }} + > + {({ dropIndicatorProps }) => + dropIndicatorProps &&
+ } + + )} + + ))} + + + ), + [ + 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 ( + + {renderChild} + + ); +}; Row.propTypes = propTypes; -export default Row; +export default memo(Row); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx index 4d75f8edafd95..2c4695f8c7285 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab.jsx @@ -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 &&
; -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); - } - - handleChangeTab({ pathToTabIndex }) { - this.props.setDirectPathToChild(pathToTabIndex); - } +const Tab = props => { + const dispatch = useDispatch(); + const canEdit = useSelector(state => state.dashboardInfo.dash_edit_perm); + const handleChangeTab = useCallback( + ({ pathToTabIndex }) => { + props.setDirectPathToChild(pathToTabIndex); + }, + [props.setDirectPathToChild], + ); - 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 { setEditMode(true)} + onClick={() => dispatch(setEditMode(true))} > {t('edit mode')} @@ -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 { ))}
); - } + }, [ + 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 ( + + + {!editMode && !embeddedMode && ( + = 5 ? 'left' : 'right'} + /> + )} + + {dropIndicatorProps && !draggingTabOnTab && ( + + )} + + ); + }, + [ + 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 ( - {({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => ( - - - {!editMode && !embeddedMode && ( - = 5 ? 'left' : 'right'} - /> - )} - {dropIndicatorProps && !draggingTabOnTab && ( - - )} - - )} + {renderTabChild} ); - } + }, [ + 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); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx index 901d8729b1158..19d49254da09f 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.jsx @@ -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,58 +126,76 @@ 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 = { + const { tabIndex: initTabIndex, activeKey: initActiveKey } = useMemo(() => { + let tabIndex = Math.max( + 0, + findTabIndexByComponentId({ + currentComponent: props.component, + directPathToChild, + }), + ); + if (tabIndex === 0 && activeTabs?.length) { + props.component.children.forEach((tabId, index) => { + if (tabIndex === 0 && activeTabs?.includes(tabId)) { + tabIndex = index; + } + }); + } + const { children: tabIds } = props.component; + const activeKey = tabIds[tabIndex]; + + return { 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); + }, [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); + + useEffect(() => { + if (prevActiveKey) { + props.setActiveTab(activeKey, prevActiveKey); + } else { + props.setActiveTab(activeKey); } - } + }, [props.setActiveTab, prevActiveKey, 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 })); + useEffect(() => { + if (prevDashboardId && props.dashboardId !== prevDashboardId) { + setSelectedTabIndex(initTabIndex); + setActiveKey(initActiveKey); } + }, [props.dashboardId, prevDashboardId, initTabIndex, initActiveKey]); - // reset tab index if dashboard was changed - if (nextProps.dashboardId !== this.props.dashboardId) { - const { tabIndex, activeKey } = this.getTabInfo(nextProps); - this.setState(() => ({ - tabIndex, - activeKey, - })); + useEffect(() => { + const maxIndex = Math.max(0, props.component.children.length - 1); + if (selectedTabIndex > maxIndex) { + setSelectedTabIndex(maxIndex); } + }, [selectedTabIndex, props.component.children.length, setSelectedTabIndex]); - if (nextProps.isComponentVisible) { - const nextFocusComponent = getLeafComponentIdFromPath( - nextProps.directPathToChild, - ); + useEffect(() => { + const currTabsIds = props.component.children; + + if (props.isComponentVisible) { + const nextFocusComponent = getLeafComponentIdFromPath(directPathToChild); const currentFocusComponent = getLeafComponentIdFromPath( - this.props.directPathToChild, + prevDirectPathToChild, ); // If the currently selected component is different than the new one, @@ -193,328 +204,349 @@ export class Tabs extends PureComponent { if ( nextFocusComponent !== currentFocusComponent || (nextFocusComponent === currentFocusComponent && - currTabsIds !== nextTabsIds) + currTabsIds !== prevTabIds) ) { const nextTabIndex = findTabIndexByComponentId({ - currentComponent: nextProps.component, - directPathToChild: nextProps.directPathToChild, + currentComponent: props.component, + directPathToChild, }); // make sure nextFocusComponent is under this tabs component - if (nextTabIndex > -1 && nextTabIndex !== this.state.tabIndex) { - this.setState(() => ({ - tabIndex: nextTabIndex, - activeKey: nextTabsIds[nextTabIndex], - })); + 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, + }); - getTabInfo = props => { - let tabIndex = Math.max( - 0, - findTabIndexByComponentId({ - currentComponent: props.component, - directPathToChild: props.directPathToChild, - }), - ); - if (tabIndex === 0 && props.activeTabs?.length) { - props.component.children.forEach((tabId, index) => { - if (tabIndex === 0 && props.activeTabs.includes(tabId)) { - tabIndex = index; + 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); } - }); - } - const { children: tabIds } = props.component; - const activeKey = tabIds[tabIndex]; - - return { - tabIndex, - activeKey, - }; - }; - - showDeleteConfirmModal = key => { - const { component, deleteComponent } = this.props; - AntdModal.confirm({ - title: t('Delete dashboard tab?'), - content: ( - - {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', - )}{' '} - {t('undo')}{' '} - {t('button (cmd + z) until you save your changes.')} - - ), - onOk: () => { - deleteComponent(key, component.id); - const tabIndex = component.children.indexOf(key); - this.handleDeleteTab(tabIndex); - }, - okType: 'danger', - okText: t('DELETE'), - cancelText: t('CANCEL'), - icon: null, - }); - }; - - 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, + } + }, + [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: ( + + {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', + )}{' '} + {t('undo')}{' '} + {t('button (cmd + z) until you save your changes.')} + + ), + onOk: () => { + deleteComponent(key, component.id); + const tabIndex = component.children.indexOf(key); + handleDeleteTab(tabIndex); }, + okType: 'danger', + okText: t('DELETE'), + cancelText: t('CANCEL'), + icon: null, }); - } else if (action === 'remove') { - this.showDeleteConfirmModal(event); - } - }; - - 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 }); - } - this.setState(() => ({ activeKey: tabIds[tabIndex] })); - } + }, + [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], + ); - handleDeleteComponent() { - const { deleteComponent, id, parentId } = this.props; + 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 })); - } - } - - 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); - } + setDropPosition(null); } - } - - 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 { children: tabIds } = tabsComponent; - const { - tabIndex: selectedTabIndex, - activeKey, - dropPosition, - dragOverTabIndex, - } = this.state; - - const showDropIndicators = currentDropTabIndex => + }, []); + + 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, - }; - - const removeDraggedTab = tabID => this.state.draggingTabId === tabID; + }, + [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; + } - let tabsToHighlight; - const highlightedFilterId = - nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId; - if (highlightedFilterId) { - tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope; - } - return ( - ( + - {({ dragSourceRef: tabsDragSourceRef }) => ( - - {editMode && renderHoverMenu && ( - - - - - )} - - { - this.handleClickTab(tabIds.indexOf(key)); - }} - onEdit={this.handleEdit} - data-test="nav-list" - type={editMode ? 'editable-card' : 'card'} - > - {tabIds.map((tabId, tabIndex) => ( - - ) : ( - <> - {showDropIndicators(tabIndex).left && ( - - )} - this.handleClickTab(tabIndex)} - isFocused={activeKey === tabId} - isHighlighted={ - activeKey !== tabId && - tabsToHighlight?.includes(tabId) - } - /> - - ) - } - closeIcon={ - removeDraggedTab(tabId) ? ( - <> - ) : ( - + + + + )} + + { + handleClickTab(tabIds.indexOf(key)); + }} + onEdit={handleEdit} + data-test="nav-list" + type={editMode ? 'editable-card' : 'card'} + > + {tabIds.map((tabId, tabIndex) => ( + + ) : ( + <> + {showDropIndicators(tabIndex).left && ( + - ) - } - > - {renderTabContent && ( + )} handleClickTab(tabIndex)} + isFocused={activeKey === tabId} + isHighlighted={ + activeKey !== tabId && tabsToHighlight?.includes(tabId) } /> - )} - - ))} - - - )} - - ); - } -} + + ) + } + closeIcon={ + removeDraggedTab(tabId) ? ( + <> + ) : ( + + ) + } + > + {renderTabContent && ( + + )} + + ))} + + + ), + [ + 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 ( + + {renderChild} + + ); +}; 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); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx index 6d33091d8e40f..a477420249455 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.jsx @@ -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(, { 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'); }); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx index 98ae968fd697b..90a23c03583ef 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tabs.test.tsx @@ -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 => ( diff --git a/superset-frontend/src/dashboard/components/gridComponents/index.js b/superset-frontend/src/dashboard/components/gridComponents/index.js index 95c524f5f7a64..38f3558864923 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/index.js +++ b/superset-frontend/src/dashboard/components/gridComponents/index.js @@ -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, }; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index 96a00dd24cfaf..0d7211ba8ebe1 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -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({ diff --git a/superset-frontend/src/utils/colorScheme.ts b/superset-frontend/src/utils/colorScheme.ts index 1b9f2d12ecef2..35f23d29f4d4d 100644 --- a/superset-frontend/src/utils/colorScheme.ts +++ b/superset-frontend/src/utils/colorScheme.ts @@ -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 | 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 = (