refactor: Split SliceHeaderControls into smaller files (#31270)
This commit is contained in:
parent
25f4226dbb
commit
1d44662b1d
|
|
@ -40,13 +40,13 @@ import {
|
|||
} from '@superset-ui/core';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { AntdDropdown as Dropdown } from 'src/components/index';
|
||||
import { updateDataMask } from 'src/dataMask/actions';
|
||||
import { DrillDetailMenuItems } from '../DrillDetail';
|
||||
import { getMenuAdjustedY } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
|
||||
import { usePermissions } from './usePermissions';
|
||||
|
||||
export enum ContextMenuItem {
|
||||
CrossFilter,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@ import { useUiConfig } from 'src/components/UiConfigContext';
|
|||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import { useSelector } from 'react-redux';
|
||||
import EditableTitle from 'src/components/EditableTitle';
|
||||
import SliceHeaderControls, {
|
||||
SliceHeaderControlsProps,
|
||||
} from 'src/dashboard/components/SliceHeaderControls';
|
||||
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
|
||||
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
|
||||
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
|
|
|
|||
|
|
@ -23,10 +23,9 @@ import { render, screen } from 'spec/helpers/testing-library';
|
|||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import mockState from 'spec/fixtures/mockState';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import SliceHeaderControls, {
|
||||
SliceHeaderControlsProps,
|
||||
handleDropdownNavigation,
|
||||
} from '.';
|
||||
import SliceHeaderControls from '.';
|
||||
import { SliceHeaderControlsProps } from './types';
|
||||
import { handleDropdownNavigation } from './utils';
|
||||
|
||||
jest.mock('src/components/Dropdown', () => {
|
||||
const original = jest.requireActual('src/components/Dropdown');
|
||||
|
|
@ -310,13 +309,13 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', (
|
|||
(global as any).featureFlags = {
|
||||
[FeatureFlag.DrillToDetail]: true,
|
||||
};
|
||||
const props = {
|
||||
...createProps(),
|
||||
supersetCanExplore: true,
|
||||
};
|
||||
const props = createProps();
|
||||
props.slice.slice_id = 18;
|
||||
renderWrapper(props, {
|
||||
Admin: [['can_samples', 'Datasource']],
|
||||
Admin: [
|
||||
['can_samples', 'Datasource'],
|
||||
['can_explore', 'Superset'],
|
||||
],
|
||||
});
|
||||
expect(screen.getByText('Drill to detail')).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* 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 { ReactChild, useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { css, t, useTheme } from '@superset-ui/core';
|
||||
import Modal from 'src/components/Modal';
|
||||
import Button from 'src/components/Button';
|
||||
|
||||
export const ViewResultsModalTrigger = ({
|
||||
canExplore,
|
||||
exploreUrl,
|
||||
triggerNode,
|
||||
modalTitle,
|
||||
modalBody,
|
||||
showModal = false,
|
||||
setShowModal,
|
||||
}: {
|
||||
canExplore?: boolean;
|
||||
exploreUrl: string;
|
||||
triggerNode: ReactChild;
|
||||
modalTitle: ReactChild;
|
||||
modalBody: ReactChild;
|
||||
showModal: boolean;
|
||||
setShowModal: (showModal: boolean) => void;
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const exploreChart = () => history.push(exploreUrl);
|
||||
const theme = useTheme();
|
||||
const openModal = useCallback(() => setShowModal(true), [setShowModal]);
|
||||
const closeModal = useCallback(() => setShowModal(false), [setShowModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-test="span-modal-trigger"
|
||||
onClick={openModal}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{triggerNode}
|
||||
</span>
|
||||
{(() => (
|
||||
<Modal
|
||||
css={css`
|
||||
.ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`}
|
||||
show={showModal}
|
||||
onHide={closeModal}
|
||||
closable
|
||||
title={modalTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
buttonSize="small"
|
||||
onClick={exploreChart}
|
||||
disabled={!canExplore}
|
||||
tooltip={
|
||||
!canExplore
|
||||
? t(
|
||||
'You do not have sufficient permissions to edit the chart',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t('Edit chart')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
onClick={closeModal}
|
||||
css={css`
|
||||
margin-left: ${theme.gridUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
responsive
|
||||
resizable
|
||||
resizableConfig={{
|
||||
minHeight: theme.gridUnit * 128,
|
||||
minWidth: theme.gridUnit * 128,
|
||||
defaultSize: {
|
||||
width: 'auto',
|
||||
height: '75vh',
|
||||
},
|
||||
}}
|
||||
draggable
|
||||
destroyOnClose
|
||||
>
|
||||
{modalBody}
|
||||
</Modal>
|
||||
))()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -16,19 +16,9 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
MouseEvent,
|
||||
Key,
|
||||
KeyboardEvent,
|
||||
ReactChild,
|
||||
useState,
|
||||
useRef,
|
||||
RefObject,
|
||||
useCallback,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { MouseEvent, Key, useState, useRef, RefObject } from 'react';
|
||||
|
||||
import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
Behavior,
|
||||
|
|
@ -36,23 +26,12 @@ import {
|
|||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
styled,
|
||||
t,
|
||||
useTheme,
|
||||
ensureIsArray,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
MenuItemKeyEnum,
|
||||
Menu,
|
||||
MenuItemChildType,
|
||||
isAntdMenuItem,
|
||||
isAntdMenuItemRef,
|
||||
isSubMenuOrItemType,
|
||||
isAntdMenuSubmenu,
|
||||
} from 'src/components/Menu';
|
||||
import { Menu } from 'src/components/Menu';
|
||||
import { NoAnimationDropdown } from 'src/components/Dropdown';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
|
|
@ -60,28 +39,16 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
|
|||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import Icons from 'src/components/Icons';
|
||||
import ModalTrigger from 'src/components/ModalTrigger';
|
||||
import Button from 'src/components/Button';
|
||||
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
||||
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
||||
import Modal from 'src/components/Modal';
|
||||
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
||||
|
||||
const ACTION_KEYS = {
|
||||
enter: 'Enter',
|
||||
spacebar: 'Spacebar',
|
||||
space: ' ',
|
||||
};
|
||||
|
||||
const NAV_KEYS = {
|
||||
tab: 'Tab',
|
||||
escape: 'Escape',
|
||||
up: 'ArrowUp',
|
||||
down: 'ArrowDown',
|
||||
};
|
||||
import { handleDropdownNavigation } from './utils';
|
||||
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
|
||||
import { SliceHeaderControlsProps } from './types';
|
||||
|
||||
// TODO: replace 3 dots with an icon
|
||||
const VerticalDotsContainer = styled.div`
|
||||
|
|
@ -126,51 +93,6 @@ const VerticalDotsTrigger = () => (
|
|||
</VerticalDotsContainer>
|
||||
);
|
||||
|
||||
export interface SliceHeaderControlsProps {
|
||||
slice: {
|
||||
description: string;
|
||||
viz_type: string;
|
||||
slice_name: string;
|
||||
slice_id: number;
|
||||
slice_description: string;
|
||||
datasource: string;
|
||||
};
|
||||
|
||||
componentId: string;
|
||||
dashboardId: number;
|
||||
chartStatus: string;
|
||||
isCached: boolean[];
|
||||
cachedDttm: string[] | null;
|
||||
isExpanded?: boolean;
|
||||
updatedDttm: number | null;
|
||||
isFullSize?: boolean;
|
||||
isDescriptionExpanded?: boolean;
|
||||
formData: QueryFormData;
|
||||
exploreUrl: string;
|
||||
|
||||
forceRefresh: (sliceId: number, dashboardId: number) => void;
|
||||
logExploreChart?: (sliceId: number) => void;
|
||||
logEvent?: (eventName: string, eventData?: object) => void;
|
||||
toggleExpandSlice?: (sliceId: number) => void;
|
||||
exportCSV?: (sliceId: number) => void;
|
||||
exportPivotCSV?: (sliceId: number) => void;
|
||||
exportFullCSV?: (sliceId: number) => void;
|
||||
exportXLSX?: (sliceId: number) => void;
|
||||
exportFullXLSX?: (sliceId: number) => void;
|
||||
handleToggleFullSize: () => void;
|
||||
|
||||
addDangerToast: (message: string) => void;
|
||||
addSuccessToast: (message: string) => void;
|
||||
|
||||
supersetCanExplore?: boolean;
|
||||
supersetCanShare?: boolean;
|
||||
supersetCanCSV?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
}
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||
RouteComponentProps;
|
||||
|
||||
const dropdownIconsStyles = css`
|
||||
&&.anticon > .anticon:first-child {
|
||||
margin-right: 0;
|
||||
|
|
@ -178,353 +100,7 @@ const dropdownIconsStyles = css`
|
|||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* A MenuItem can be recognized in the tree by the presence of a ref
|
||||
*
|
||||
* @param children
|
||||
* @param currentKeys
|
||||
* @returns an array of keys
|
||||
*/
|
||||
const extractMenuItemRefs = (child: MenuItemChildType): RefObject<any>[] => {
|
||||
// check that child has props
|
||||
const childProps: Record<string, any> = child?.props;
|
||||
// loop through each prop
|
||||
if (childProps) {
|
||||
const arrayProps = Object.values(childProps);
|
||||
// check if any is of type ref MenuItem
|
||||
const refs = arrayProps.filter(ref => isAntdMenuItemRef(ref));
|
||||
return refs;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
/**
|
||||
* Recursively extracts keys from menu items
|
||||
*
|
||||
* @param children
|
||||
* @param currentKeys
|
||||
* @returns an array of keys and their refs
|
||||
*
|
||||
*/
|
||||
const extractMenuItemsKeys = (
|
||||
children: MenuItemChildType[],
|
||||
currentKeys?: { key: string; ref?: RefObject<any> }[],
|
||||
): { key: string; ref?: RefObject<any> }[] => {
|
||||
const allKeys = currentKeys || [];
|
||||
const arrayChildren = ensureIsArray(children);
|
||||
|
||||
arrayChildren.forEach((child: MenuItemChildType) => {
|
||||
const isMenuItem = isAntdMenuItem(child);
|
||||
const refs = extractMenuItemRefs(child);
|
||||
// key is immediately available in a standard MenuItem
|
||||
if (isMenuItem) {
|
||||
const { key } = child;
|
||||
if (key) {
|
||||
allKeys.push({
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
// one or more menu items refs are available
|
||||
if (refs.length) {
|
||||
allKeys.push(
|
||||
...refs.map(ref => ({ key: ref.current.props.eventKey, ref })),
|
||||
);
|
||||
}
|
||||
|
||||
// continue to extract keys from nested children
|
||||
if (child?.props?.children) {
|
||||
const childKeys = extractMenuItemsKeys(child.props.children, allKeys);
|
||||
allKeys.push(...childKeys);
|
||||
}
|
||||
});
|
||||
|
||||
return allKeys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a map of keys and their types for a MenuItem
|
||||
* Individual refs can be given to extract keys from nested items
|
||||
* Refs can be used to control the event handlers of the menu items
|
||||
*
|
||||
* @param itemChildren
|
||||
* @param type
|
||||
* @returns a map of keys and their types
|
||||
*/
|
||||
const extractMenuItemsKeyMap = (
|
||||
children: MenuItemChildType,
|
||||
): Record<string, any> => {
|
||||
const keysMap: Record<string, any> = {};
|
||||
const childrenArray = ensureIsArray(children);
|
||||
|
||||
childrenArray.forEach((child: MenuItemChildType) => {
|
||||
const isMenuItem = isAntdMenuItem(child);
|
||||
const isSubmenu = isAntdMenuSubmenu(child);
|
||||
const menuItemsRefs = extractMenuItemRefs(child);
|
||||
|
||||
// key is immediately available in MenuItem or SubMenu
|
||||
if (isMenuItem || isSubmenu) {
|
||||
const directKey = child?.key;
|
||||
if (directKey) {
|
||||
keysMap[directKey] = {};
|
||||
keysMap[directKey].type = isSubmenu
|
||||
? MenuItemKeyEnum.SubMenu
|
||||
: MenuItemKeyEnum.MenuItem;
|
||||
}
|
||||
}
|
||||
|
||||
// one or more menu items refs are available
|
||||
if (menuItemsRefs.length) {
|
||||
menuItemsRefs.forEach(ref => {
|
||||
const key = ref.current.props.eventKey;
|
||||
keysMap[key] = {};
|
||||
keysMap[key].type = isSubmenu
|
||||
? MenuItemKeyEnum.SubMenu
|
||||
: MenuItemKeyEnum.MenuItem;
|
||||
keysMap[key].parent = child.key;
|
||||
keysMap[key].ref = ref;
|
||||
});
|
||||
}
|
||||
|
||||
// if it has children must check for the presence of menu items
|
||||
if (child?.props?.children) {
|
||||
const theChildren = child?.props?.children;
|
||||
const childKeys = extractMenuItemsKeys(theChildren);
|
||||
childKeys.forEach(keyMap => {
|
||||
const k = keyMap.key;
|
||||
keysMap[k] = {};
|
||||
keysMap[k].type = MenuItemKeyEnum.SubMenuItem;
|
||||
keysMap[k].parent = child.key;
|
||||
if (keyMap.ref) {
|
||||
keysMap[k].ref = keyMap.ref;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return keysMap;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Determines the next key to select based on the current key and direction
|
||||
*
|
||||
* @param keys
|
||||
* @param keysMap
|
||||
* @param currentKeyIndex
|
||||
* @param direction
|
||||
* @returns the selected key and the open key
|
||||
*/
|
||||
const getNavigationKeys = (
|
||||
keys: string[],
|
||||
keysMap: Record<string, any>,
|
||||
currentKeyIndex: number,
|
||||
direction = 'up',
|
||||
) => {
|
||||
const step = direction === 'up' ? -1 : 1;
|
||||
const skipStep = direction === 'up' ? -2 : 2;
|
||||
const keysLen = direction === 'up' ? 0 : keys.length;
|
||||
const mathFn = direction === 'up' ? Math.max : Math.min;
|
||||
let openKey: string | undefined;
|
||||
let selectedKey = keys[mathFn(currentKeyIndex + step, keysLen)];
|
||||
|
||||
// go to first key if current key is the last
|
||||
if (!selectedKey) {
|
||||
return { selectedKey: keys[0], openKey: undefined };
|
||||
}
|
||||
|
||||
const isSubMenu = keysMap[selectedKey]?.type === MenuItemKeyEnum.SubMenu;
|
||||
if (isSubMenu) {
|
||||
// this is a submenu, skip to first submenu item
|
||||
selectedKey = keys[mathFn(currentKeyIndex + skipStep, keysLen)];
|
||||
}
|
||||
// re-evaulate if current selected key is a submenu or submenu item
|
||||
if (!isSubMenuOrItemType(keysMap[selectedKey].type)) {
|
||||
openKey = undefined;
|
||||
} else {
|
||||
const parentKey = keysMap[selectedKey].parent;
|
||||
if (parentKey) {
|
||||
openKey = parentKey;
|
||||
}
|
||||
}
|
||||
return { selectedKey, openKey };
|
||||
};
|
||||
|
||||
export const handleDropdownNavigation = (
|
||||
e: KeyboardEvent<HTMLElement>,
|
||||
dropdownIsOpen: boolean,
|
||||
menu: ReactElement,
|
||||
toggleDropdown: () => void,
|
||||
setSelectedKeys: (keys: string[]) => void,
|
||||
setOpenKeys: (keys: string[]) => void,
|
||||
) => {
|
||||
if (e.key === NAV_KEYS.tab && !dropdownIsOpen) {
|
||||
return; // if tab, continue with system tab navigation
|
||||
}
|
||||
const menuProps = menu.props || {};
|
||||
const keysMap = extractMenuItemsKeyMap(menuProps.children);
|
||||
const keys = Object.keys(keysMap);
|
||||
const { selectedKeys = [] } = menuProps;
|
||||
const currentKeyIndex = keys.indexOf(selectedKeys[0]);
|
||||
|
||||
switch (e.key) {
|
||||
// toggle the dropdown on keypress
|
||||
case ACTION_KEYS.enter:
|
||||
case ACTION_KEYS.spacebar:
|
||||
case ACTION_KEYS.space:
|
||||
if (selectedKeys.length) {
|
||||
const currentKey = selectedKeys[0];
|
||||
const currentKeyConf = keysMap[selectedKeys];
|
||||
// when a menu item is selected, then trigger
|
||||
// the menu item's onClick handler
|
||||
menuProps.onClick?.({ key: currentKey, domEvent: e });
|
||||
// trigger click handle on ref
|
||||
if (currentKeyConf?.ref) {
|
||||
const refMenuItemProps = currentKeyConf.ref.current.props;
|
||||
refMenuItemProps.onClick?.({
|
||||
key: currentKey,
|
||||
domEvent: e,
|
||||
});
|
||||
}
|
||||
// clear out/deselect keys
|
||||
setSelectedKeys([]);
|
||||
// close submenus
|
||||
setOpenKeys([]);
|
||||
// put focus back on menu trigger
|
||||
e.currentTarget.focus();
|
||||
}
|
||||
// if nothing was selected, or after selecting new menu item,
|
||||
toggleDropdown();
|
||||
break;
|
||||
// select the menu items going down
|
||||
case NAV_KEYS.down:
|
||||
case NAV_KEYS.tab && !e.shiftKey: {
|
||||
const { selectedKey, openKey } = getNavigationKeys(
|
||||
keys,
|
||||
keysMap,
|
||||
currentKeyIndex,
|
||||
'down',
|
||||
);
|
||||
setSelectedKeys([selectedKey]);
|
||||
setOpenKeys(openKey ? [openKey] : []);
|
||||
break;
|
||||
}
|
||||
// select the menu items going up
|
||||
case NAV_KEYS.up:
|
||||
case NAV_KEYS.tab && e.shiftKey: {
|
||||
const { selectedKey, openKey } = getNavigationKeys(
|
||||
keys,
|
||||
keysMap,
|
||||
currentKeyIndex,
|
||||
'up',
|
||||
);
|
||||
setSelectedKeys([selectedKey]);
|
||||
setOpenKeys(openKey ? [openKey] : []);
|
||||
break;
|
||||
}
|
||||
case NAV_KEYS.escape:
|
||||
// close dropdown menu
|
||||
toggleDropdown();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const ViewResultsModalTrigger = ({
|
||||
canExplore,
|
||||
exploreUrl,
|
||||
triggerNode,
|
||||
modalTitle,
|
||||
modalBody,
|
||||
showModal = false,
|
||||
setShowModal,
|
||||
}: {
|
||||
canExplore?: boolean;
|
||||
exploreUrl: string;
|
||||
triggerNode: ReactChild;
|
||||
modalTitle: ReactChild;
|
||||
modalBody: ReactChild;
|
||||
showModal: boolean;
|
||||
setShowModal: (showModal: boolean) => void;
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
const exploreChart = () => history.push(exploreUrl);
|
||||
const theme = useTheme();
|
||||
const openModal = useCallback(() => setShowModal(true), []);
|
||||
const closeModal = useCallback(() => setShowModal(false), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
data-test="span-modal-trigger"
|
||||
onClick={openModal}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{triggerNode}
|
||||
</span>
|
||||
{(() => (
|
||||
<Modal
|
||||
css={css`
|
||||
.ant-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
`}
|
||||
show={showModal}
|
||||
onHide={closeModal}
|
||||
closable
|
||||
title={modalTitle}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
buttonSize="small"
|
||||
onClick={exploreChart}
|
||||
disabled={!canExplore}
|
||||
tooltip={
|
||||
!canExplore
|
||||
? t(
|
||||
'You do not have sufficient permissions to edit the chart',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t('Edit chart')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
buttonSize="small"
|
||||
onClick={closeModal}
|
||||
css={css`
|
||||
margin-left: ${theme.gridUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
responsive
|
||||
resizable
|
||||
resizableConfig={{
|
||||
minHeight: theme.gridUnit * 128,
|
||||
minWidth: theme.gridUnit * 128,
|
||||
defaultSize: {
|
||||
width: 'auto',
|
||||
height: '75vh',
|
||||
},
|
||||
}}
|
||||
draggable
|
||||
destroyOnClose
|
||||
>
|
||||
{modalBody}
|
||||
</Modal>
|
||||
))()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
|
||||
const SliceHeaderControls = (props: SliceHeaderControlsProps) => {
|
||||
const [dropdownIsOpen, setDropdownIsOpen] = useState(false);
|
||||
const [tableModalIsOpen, setTableModalIsOpen] = useState(false);
|
||||
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
|
||||
|
|
@ -559,19 +135,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
|
|||
.get(props.slice.viz_type)
|
||||
?.behaviors?.includes(Behavior.InteractiveChart);
|
||||
const canExplore = props.supersetCanExplore;
|
||||
const canDatasourceSamples = useSelector((state: RootState) =>
|
||||
findPermission('can_samples', 'Datasource', state.user?.roles),
|
||||
);
|
||||
const canDrill = useSelector((state: RootState) =>
|
||||
findPermission('can_drill', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples;
|
||||
const canViewQuery = useSelector((state: RootState) =>
|
||||
findPermission('can_view_query', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
const canViewTable = useSelector((state: RootState) =>
|
||||
findPermission('can_view_chart_as_table', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
|
||||
const refreshChart = () => {
|
||||
if (props.updatedDttm) {
|
||||
props.forceRefresh(props.slice.slice_id, props.dashboardId);
|
||||
|
|
@ -965,4 +529,4 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default withRouter(SliceHeaderControls);
|
||||
export default SliceHeaderControls;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* 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 { QueryFormData } from '@superset-ui/core';
|
||||
|
||||
export interface SliceHeaderControlsProps {
|
||||
slice: {
|
||||
description: string;
|
||||
viz_type: string;
|
||||
slice_name: string;
|
||||
slice_id: number;
|
||||
slice_description: string;
|
||||
datasource: string;
|
||||
};
|
||||
|
||||
componentId: string;
|
||||
dashboardId: number;
|
||||
chartStatus: string;
|
||||
isCached: boolean[];
|
||||
cachedDttm: string[] | null;
|
||||
isExpanded?: boolean;
|
||||
updatedDttm: number | null;
|
||||
isFullSize?: boolean;
|
||||
isDescriptionExpanded?: boolean;
|
||||
formData: QueryFormData;
|
||||
exploreUrl: string;
|
||||
|
||||
forceRefresh: (sliceId: number, dashboardId: number) => void;
|
||||
logExploreChart?: (sliceId: number) => void;
|
||||
logEvent?: (eventName: string, eventData?: object) => void;
|
||||
toggleExpandSlice?: (sliceId: number) => void;
|
||||
exportCSV?: (sliceId: number) => void;
|
||||
exportPivotCSV?: (sliceId: number) => void;
|
||||
exportFullCSV?: (sliceId: number) => void;
|
||||
exportXLSX?: (sliceId: number) => void;
|
||||
exportFullXLSX?: (sliceId: number) => void;
|
||||
handleToggleFullSize: () => void;
|
||||
|
||||
addDangerToast: (message: string) => void;
|
||||
addSuccessToast: (message: string) => void;
|
||||
|
||||
supersetCanExplore?: boolean;
|
||||
supersetCanShare?: boolean;
|
||||
supersetCanCSV?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* 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 {
|
||||
isAntdMenuItem,
|
||||
isAntdMenuItemRef,
|
||||
isAntdMenuSubmenu,
|
||||
isSubMenuOrItemType,
|
||||
MenuItemChildType,
|
||||
MenuItemKeyEnum,
|
||||
} from 'src/components/Menu';
|
||||
import { KeyboardEvent, ReactElement, RefObject } from 'react';
|
||||
import { ensureIsArray } from '@superset-ui/core';
|
||||
|
||||
const ACTION_KEYS = {
|
||||
enter: 'Enter',
|
||||
spacebar: 'Spacebar',
|
||||
space: ' ',
|
||||
};
|
||||
|
||||
const NAV_KEYS = {
|
||||
tab: 'Tab',
|
||||
escape: 'Escape',
|
||||
up: 'ArrowUp',
|
||||
down: 'ArrowDown',
|
||||
};
|
||||
|
||||
/**
|
||||
* A MenuItem can be recognized in the tree by the presence of a ref
|
||||
*
|
||||
* @param children
|
||||
* @param currentKeys
|
||||
* @returns an array of keys
|
||||
*/
|
||||
const extractMenuItemRefs = (child: MenuItemChildType): RefObject<any>[] => {
|
||||
// check that child has props
|
||||
const childProps: Record<string, any> = child?.props;
|
||||
// loop through each prop
|
||||
if (childProps) {
|
||||
const arrayProps = Object.values(childProps);
|
||||
// check if any is of type ref MenuItem
|
||||
return arrayProps.filter(ref => isAntdMenuItemRef(ref));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively extracts keys from menu items
|
||||
*
|
||||
* @param children
|
||||
* @param currentKeys
|
||||
* @returns an array of keys and their refs
|
||||
*
|
||||
*/
|
||||
const extractMenuItemsKeys = (
|
||||
children: MenuItemChildType[],
|
||||
currentKeys?: { key: string; ref?: RefObject<any> }[],
|
||||
): { key: string; ref?: RefObject<any> }[] => {
|
||||
const allKeys = currentKeys || [];
|
||||
const arrayChildren = ensureIsArray(children);
|
||||
|
||||
arrayChildren.forEach((child: MenuItemChildType) => {
|
||||
const isMenuItem = isAntdMenuItem(child);
|
||||
const refs = extractMenuItemRefs(child);
|
||||
// key is immediately available in a standard MenuItem
|
||||
if (isMenuItem) {
|
||||
const { key } = child;
|
||||
if (key) {
|
||||
allKeys.push({
|
||||
key,
|
||||
});
|
||||
}
|
||||
}
|
||||
// one or more menu items refs are available
|
||||
if (refs.length) {
|
||||
allKeys.push(
|
||||
...refs.map(ref => ({ key: ref.current.props.eventKey, ref })),
|
||||
);
|
||||
}
|
||||
|
||||
// continue to extract keys from nested children
|
||||
if (child?.props?.children) {
|
||||
const childKeys = extractMenuItemsKeys(child.props.children, allKeys);
|
||||
allKeys.push(...childKeys);
|
||||
}
|
||||
});
|
||||
|
||||
return allKeys;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a map of keys and their types for a MenuItem
|
||||
* Individual refs can be given to extract keys from nested items
|
||||
* Refs can be used to control the event handlers of the menu items
|
||||
*
|
||||
* @param itemChildren
|
||||
* @param type
|
||||
* @returns a map of keys and their types
|
||||
*/
|
||||
const extractMenuItemsKeyMap = (
|
||||
children: MenuItemChildType,
|
||||
): Record<string, any> => {
|
||||
const keysMap: Record<string, any> = {};
|
||||
const childrenArray = ensureIsArray(children);
|
||||
|
||||
childrenArray.forEach((child: MenuItemChildType) => {
|
||||
const isMenuItem = isAntdMenuItem(child);
|
||||
const isSubmenu = isAntdMenuSubmenu(child);
|
||||
const menuItemsRefs = extractMenuItemRefs(child);
|
||||
|
||||
// key is immediately available in MenuItem or SubMenu
|
||||
if (isMenuItem || isSubmenu) {
|
||||
const directKey = child?.key;
|
||||
if (directKey) {
|
||||
keysMap[directKey] = {};
|
||||
keysMap[directKey].type = isSubmenu
|
||||
? MenuItemKeyEnum.SubMenu
|
||||
: MenuItemKeyEnum.MenuItem;
|
||||
}
|
||||
}
|
||||
|
||||
// one or more menu items refs are available
|
||||
if (menuItemsRefs.length) {
|
||||
menuItemsRefs.forEach(ref => {
|
||||
const key = ref.current.props.eventKey;
|
||||
keysMap[key] = {};
|
||||
keysMap[key].type = isSubmenu
|
||||
? MenuItemKeyEnum.SubMenu
|
||||
: MenuItemKeyEnum.MenuItem;
|
||||
keysMap[key].parent = child.key;
|
||||
keysMap[key].ref = ref;
|
||||
});
|
||||
}
|
||||
|
||||
// if it has children must check for the presence of menu items
|
||||
if (child?.props?.children) {
|
||||
const theChildren = child?.props?.children;
|
||||
const childKeys = extractMenuItemsKeys(theChildren);
|
||||
childKeys.forEach(keyMap => {
|
||||
const k = keyMap.key;
|
||||
keysMap[k] = {};
|
||||
keysMap[k].type = MenuItemKeyEnum.SubMenuItem;
|
||||
keysMap[k].parent = child.key;
|
||||
if (keyMap.ref) {
|
||||
keysMap[k].ref = keyMap.ref;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return keysMap;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Determines the next key to select based on the current key and direction
|
||||
*
|
||||
* @param keys
|
||||
* @param keysMap
|
||||
* @param currentKeyIndex
|
||||
* @param direction
|
||||
* @returns the selected key and the open key
|
||||
*/
|
||||
const getNavigationKeys = (
|
||||
keys: string[],
|
||||
keysMap: Record<string, any>,
|
||||
currentKeyIndex: number,
|
||||
direction = 'up',
|
||||
) => {
|
||||
const step = direction === 'up' ? -1 : 1;
|
||||
const skipStep = direction === 'up' ? -2 : 2;
|
||||
const keysLen = direction === 'up' ? 0 : keys.length;
|
||||
const mathFn = direction === 'up' ? Math.max : Math.min;
|
||||
let openKey: string | undefined;
|
||||
let selectedKey = keys[mathFn(currentKeyIndex + step, keysLen)];
|
||||
|
||||
// go to first key if current key is the last
|
||||
if (!selectedKey) {
|
||||
return { selectedKey: keys[0], openKey: undefined };
|
||||
}
|
||||
|
||||
const isSubMenu = keysMap[selectedKey]?.type === MenuItemKeyEnum.SubMenu;
|
||||
if (isSubMenu) {
|
||||
// this is a submenu, skip to first submenu item
|
||||
selectedKey = keys[mathFn(currentKeyIndex + skipStep, keysLen)];
|
||||
}
|
||||
// re-evaulate if current selected key is a submenu or submenu item
|
||||
if (!isSubMenuOrItemType(keysMap[selectedKey].type)) {
|
||||
openKey = undefined;
|
||||
} else {
|
||||
const parentKey = keysMap[selectedKey].parent;
|
||||
if (parentKey) {
|
||||
openKey = parentKey;
|
||||
}
|
||||
}
|
||||
return { selectedKey, openKey };
|
||||
};
|
||||
|
||||
export const handleDropdownNavigation = (
|
||||
e: KeyboardEvent<HTMLElement>,
|
||||
dropdownIsOpen: boolean,
|
||||
menu: ReactElement,
|
||||
toggleDropdown: () => void,
|
||||
setSelectedKeys: (keys: string[]) => void,
|
||||
setOpenKeys: (keys: string[]) => void,
|
||||
) => {
|
||||
if (e.key === NAV_KEYS.tab && !dropdownIsOpen) {
|
||||
return; // if tab, continue with system tab navigation
|
||||
}
|
||||
const menuProps = menu.props || {};
|
||||
const keysMap = extractMenuItemsKeyMap(menuProps.children);
|
||||
const keys = Object.keys(keysMap);
|
||||
const { selectedKeys = [] } = menuProps;
|
||||
const currentKeyIndex = keys.indexOf(selectedKeys[0]);
|
||||
|
||||
switch (e.key) {
|
||||
// toggle the dropdown on keypress
|
||||
case ACTION_KEYS.enter:
|
||||
case ACTION_KEYS.spacebar:
|
||||
case ACTION_KEYS.space:
|
||||
if (selectedKeys.length) {
|
||||
const currentKey = selectedKeys[0];
|
||||
const currentKeyConf = keysMap[selectedKeys];
|
||||
// when a menu item is selected, then trigger
|
||||
// the menu item's onClick handler
|
||||
menuProps.onClick?.({ key: currentKey, domEvent: e });
|
||||
// trigger click handle on ref
|
||||
if (currentKeyConf?.ref) {
|
||||
const refMenuItemProps = currentKeyConf.ref.current.props;
|
||||
refMenuItemProps.onClick?.({
|
||||
key: currentKey,
|
||||
domEvent: e,
|
||||
});
|
||||
}
|
||||
// clear out/deselect keys
|
||||
setSelectedKeys([]);
|
||||
// close submenus
|
||||
setOpenKeys([]);
|
||||
// put focus back on menu trigger
|
||||
e.currentTarget.focus();
|
||||
}
|
||||
// if nothing was selected, or after selecting new menu item,
|
||||
toggleDropdown();
|
||||
break;
|
||||
// select the menu items going down
|
||||
case NAV_KEYS.down:
|
||||
case NAV_KEYS.tab && !e.shiftKey: {
|
||||
const { selectedKey, openKey } = getNavigationKeys(
|
||||
keys,
|
||||
keysMap,
|
||||
currentKeyIndex,
|
||||
'down',
|
||||
);
|
||||
setSelectedKeys([selectedKey]);
|
||||
setOpenKeys(openKey ? [openKey] : []);
|
||||
break;
|
||||
}
|
||||
// select the menu items going up
|
||||
case NAV_KEYS.up:
|
||||
case NAV_KEYS.tab && e.shiftKey: {
|
||||
const { selectedKey, openKey } = getNavigationKeys(
|
||||
keys,
|
||||
keysMap,
|
||||
currentKeyIndex,
|
||||
'up',
|
||||
);
|
||||
setSelectedKeys([selectedKey]);
|
||||
setOpenKeys(openKey ? [openKey] : []);
|
||||
break;
|
||||
}
|
||||
case NAV_KEYS.escape:
|
||||
// close dropdown menu
|
||||
toggleDropdown();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
|
@ -38,6 +38,12 @@ export const usePermissions = () => {
|
|||
);
|
||||
const canDrillBy = (canExplore || canDrill) && canWriteExploreFormData;
|
||||
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples;
|
||||
const canViewQuery = useSelector((state: RootState) =>
|
||||
findPermission('can_view_query', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
const canViewTable = useSelector((state: RootState) =>
|
||||
findPermission('can_view_chart_as_table', 'Dashboard', state.user?.roles),
|
||||
);
|
||||
|
||||
return {
|
||||
canExplore,
|
||||
|
|
@ -47,5 +53,7 @@ export const usePermissions = () => {
|
|||
canDrill,
|
||||
canDrillBy,
|
||||
canDrillToDetail,
|
||||
canViewQuery,
|
||||
canViewTable,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue