blob: 9fbf49345bd3ad7997b327ec30ed37452bf6eb98 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
import PropTypes from 'prop-types';
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';
import Modal from 'src/components/Modal';
import { DROP_LEFT, DROP_RIGHT } from 'src/dashboard/util/getDropPosition';
import { Draggable } from '../dnd/DragDroppable';
import DragHandle from '../dnd/DragHandle';
import DashboardComponent from '../../containers/DashboardComponent';
import DeleteComponentButton from '../DeleteComponentButton';
import HoverMenu from '../menu/HoverMenu';
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex';
import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath';
import { componentShape } from '../../util/propShapes';
import { NEW_TAB_ID } from '../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
import { TABS_TYPE, TAB_TYPE } from '../../util/componentTypes';
const propTypes = {
id: PropTypes.string.isRequired,
parentId: PropTypes.string.isRequired,
component: componentShape.isRequired,
parentComponent: componentShape.isRequired,
index: PropTypes.number.isRequired,
depth: PropTypes.number.isRequired,
renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
activeTabs: PropTypes.arrayOf(PropTypes.string),
// actions (from DashboardComponent.jsx)
logEvent: PropTypes.func.isRequired,
setActiveTab: PropTypes.func,
// grid related
availableColumnCount: PropTypes.number,
columnWidth: PropTypes.number,
onResizeStart: PropTypes.func,
onResize: PropTypes.func,
onResizeStop: PropTypes.func,
// dnd
createComponent: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
onChangeTab: PropTypes.func.isRequired,
deleteComponent: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
};
const defaultProps = {
setActiveTab() {},
onResizeStart() {},
onResize() {},
onResizeStop() {},
};
const StyledTabsContainer = styled.div`
width: 100%;
background-color: ${({ theme }) => theme.colors.grayscale.light5};
.dashboard-component-tabs-content {
min-height: ${({ theme }) => theme.gridUnit * 12}px;
margin-top: ${({ theme }) => theme.gridUnit / 4}px;
position: relative;
}
.ant-tabs {
overflow: visible;
.ant-tabs-nav-wrap {
min-height: ${({ theme }) => theme.gridUnit * 12.5}px;
}
.ant-tabs-content-holder {
overflow: visible;
}
}
div .ant-tabs-tab-btn {
text-transform: none;
}
`;
const StyledCancelXIcon = styled(Icons.CancelX)`
color: ${({ theme }) => theme.colors.grayscale.base};
`;
const DropIndicator = styled.div`
border: 2px solid ${({ theme }) => theme.colors.primary.base};
width: 5px;
height: 100%;
position: absolute;
top: 0;
${({ pos }) => (pos === 'left' ? 'left: -4px' : 'right: -4px')};
border-radius: 2px;
`;
const CloseIconWithDropIndicator = props => (
<>
<StyledCancelXIcon />
{props.showDropIndicators.right && (
<DropIndicator className="drop-indicator-right" pos="right" />
)}
</>
);
const Tabs = props => {
const nativeFilters = useSelector(state => state.nativeFilters);
const activeTabs = useSelector(state => state.dashboardState.activeTabs);
const directPathToChild = useSelector(
state => state.dashboardState.directPathToChild,
);
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,
};
}, [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]);
useEffect(() => {
if (prevDashboardId && props.dashboardId !== prevDashboardId) {
setSelectedTabIndex(initTabIndex);
setActiveKey(initActiveKey);
}
}, [props.dashboardId, prevDashboardId, initTabIndex, initActiveKey]);
useEffect(() => {
const maxIndex = Math.max(0, props.component.children.length - 1);
if (selectedTabIndex > maxIndex) {
setSelectedTabIndex(maxIndex);
}
}, [selectedTabIndex, props.component.children.length, setSelectedTabIndex]);
useEffect(() => {
const currTabsIds = props.component.children;
if (props.isComponentVisible) {
const nextFocusComponent = getLeafComponentIdFromPath(directPathToChild);
const currentFocusComponent = getLeafComponentIdFromPath(
prevDirectPathToChild,
);
// If the currently selected component is different than the new one,
// or the tab length/order changed, calculate the new tab index and
// replace it if it's different than the current one
if (
nextFocusComponent !== currentFocusComponent ||
(nextFocusComponent === currentFocusComponent &&
currTabsIds !== prevTabIds)
) {
const nextTabIndex = findTabIndexByComponentId({
currentComponent: props.component,
directPathToChild,
});
// make sure nextFocusComponent is under this tabs component
if (nextTabIndex > -1 && nextTabIndex !== selectedTabIndex) {
setSelectedTabIndex(nextTabIndex);
setActiveKey(currTabsIds[nextTabIndex]);
}
}
}
}, [
props.component,
directPathToChild,
props.isComponentVisible,
selectedTabIndex,
prevDirectPathToChild,
prevTabIds,
]);
const handleClickTab = useCallback(
tabIndex => {
const { component } = props;
const { children: tabIds } = component;
if (tabIndex !== selectedTabIndex) {
const pathToTabIndex = getDirectPathToTabIndex(component, tabIndex);
const targetTabId = pathToTabIndex[pathToTabIndex.length - 1];
props.logEvent(LOG_ACTIONS_SELECT_DASHBOARD_TAB, {
target_id: targetTabId,
index: tabIndex,
});
props.onChangeTab({ pathToTabIndex });
}
setActiveKey(tabIds[tabIndex]);
},
[
props.component,
props.logEvent,
props.onChangeTab,
selectedTabIndex,
setActiveKey,
],
);
const handleDropOnTab = useCallback(
dropResult => {
const { component } = props;
// Ensure dropped tab is visible
const { destination } = dropResult;
if (destination) {
const dropTabIndex =
destination.id === component.id
? destination.index // dropped ON tabs
: component.children.indexOf(destination.id); // dropped IN tab
if (dropTabIndex > -1) {
setTimeout(() => {
handleClickTab(dropTabIndex);
}, 30);
}
}
},
[props.component, handleClickTab],
);
const handleDrop = useCallback(
dropResult => {
if (dropResult.dragging.type !== TABS_TYPE) {
props.handleComponentDrop(dropResult);
}
},
[props.handleComponentDrop],
);
const handleDeleteTab = useCallback(
tabIndex => {
// If we're removing the currently selected tab,
// select the previous one (if any)
if (selectedTabIndex === tabIndex) {
handleClickTab(Math.max(0, tabIndex - 1));
}
},
[selectedTabIndex, handleClickTab],
);
const showDeleteConfirmModal = useCallback(
key => {
const { component, deleteComponent } = props;
Modal.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]);
const handleGetDropPosition = useCallback(dragObject => {
const { dropIndicator, isDraggingOver, index } = dragObject;
if (isDraggingOver) {
setDropPosition(dropIndicator);
setDragOverTabIndex(index);
} else {
setDropPosition(null);
}
}, []);
const handleDragggingTab = useCallback(tabId => {
if (tabId) {
setDraggingTabId(tabId);
} else {
setDraggingTabId(null);
}
}, []);
const {
depth,
component: tabsComponent,
parentComponent,
index,
availableColumnCount = 0,
columnWidth = 0,
onResizeStart,
onResize,
onResizeStop,
renderTabContent = true,
renderHoverMenu = true,
isComponentVisible: isCurrentTabVisible,
editMode,
} = props;
const { children: tabIds } = tabsComponent;
const showDropIndicators = useCallback(
currentDropTabIndex =>
currentDropTabIndex === dragOverTabIndex && {
left: editMode && dropPosition === DROP_LEFT,
right: editMode && dropPosition === DROP_RIGHT,
},
[dragOverTabIndex, dropPosition, editMode],
);
const removeDraggedTab = useCallback(
tabID => draggingTabId === tabID,
[draggingTabId],
);
let tabsToHighlight;
const highlightedFilterId =
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
if (highlightedFilterId) {
tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope;
}
const renderChild = useCallback(
({ dragSourceRef: tabsDragSourceRef }) => (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
>
{editMode && renderHoverMenu && (
<HoverMenu innerRef={tabsDragSourceRef} position="left">
<DragHandle position="left" />
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<LineEditableTabs
id={tabsComponent.id}
activeKey={activeKey}
onChange={key => {
handleClickTab(tabIds.indexOf(key));
}}
onEdit={handleEdit}
data-test="nav-list"
type={editMode ? 'editable-card' : 'card'}
>
{tabIds.map((tabId, tabIndex) => (
<LineEditableTabs.TabPane
key={tabId}
tab={
removeDraggedTab(tabId) ? (
<></>
) : (
<>
{showDropIndicators(tabIndex).left && (
<DropIndicator
className="drop-indicator-left"
pos="left"
/>
)}
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth}
index={tabIndex}
renderType={RENDER_TAB}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onDropOnTab={handleDropOnTab}
onDropPositionChange={handleGetDropPosition}
onDragTab={handleDragggingTab}
onHoverTab={() => handleClickTab(tabIndex)}
isFocused={activeKey === tabId}
isHighlighted={
activeKey !== tabId && tabsToHighlight?.includes(tabId)
}
/>
</>
)
}
closeIcon={
removeDraggedTab(tabId) ? (
<></>
) : (
<CloseIconWithDropIndicator
role="button"
tabIndex={tabIndex}
showDropIndicators={showDropIndicators(tabIndex)}
/>
)
}
>
{renderTabContent && (
<DashboardComponent
id={tabId}
parentId={tabsComponent.id}
depth={depth} // see isValidChild.js for why tabs don't increment child depth
index={tabIndex}
renderType={RENDER_TAB_CONTENT}
availableColumnCount={availableColumnCount}
columnWidth={columnWidth}
onResizeStart={onResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
onDropOnTab={handleDropOnTab}
isComponentVisible={
selectedTabIndex === tabIndex && isCurrentTabVisible
}
/>
)}
</LineEditableTabs.TabPane>
))}
</LineEditableTabs>
</StyledTabsContainer>
),
[
editMode,
renderHoverMenu,
handleDeleteComponent,
tabsComponent.id,
activeKey,
handleEdit,
tabIds,
handleClickTab,
removeDraggedTab,
showDropIndicators,
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;
export default memo(Tabs);