refactor: Split SliceHeaderControls into smaller files (#31270)

This commit is contained in:
Kamil Gabryjelski 2024-12-03 16:36:39 +01:00 committed by GitHub
parent 25f4226dbb
commit 1d44662b1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 501 additions and 459 deletions

View File

@ -40,13 +40,13 @@ import {
} from '@superset-ui/core'; } from '@superset-ui/core';
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions';
import { AntdDropdown as Dropdown } from 'src/components/index'; import { AntdDropdown as Dropdown } from 'src/components/index';
import { updateDataMask } from 'src/dataMask/actions'; import { updateDataMask } from 'src/dataMask/actions';
import { DrillDetailMenuItems } from '../DrillDetail'; import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils'; import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems'; import { DrillByMenuItems } from '../DrillBy/DrillByMenuItems';
import { usePermissions } from './usePermissions';
export enum ContextMenuItem { export enum ContextMenuItem {
CrossFilter, CrossFilter,

View File

@ -29,9 +29,8 @@ import { useUiConfig } from 'src/components/UiConfigContext';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import EditableTitle from 'src/components/EditableTitle'; import EditableTitle from 'src/components/EditableTitle';
import SliceHeaderControls, { import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
SliceHeaderControlsProps, import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
} from 'src/dashboard/components/SliceHeaderControls';
import FiltersBadge from 'src/dashboard/components/FiltersBadge'; import FiltersBadge from 'src/dashboard/components/FiltersBadge';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';

View File

@ -23,10 +23,9 @@ import { render, screen } from 'spec/helpers/testing-library';
import { FeatureFlag, VizType } from '@superset-ui/core'; import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState'; import mockState from 'spec/fixtures/mockState';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import SliceHeaderControls, { import SliceHeaderControls from '.';
SliceHeaderControlsProps, import { SliceHeaderControlsProps } from './types';
handleDropdownNavigation, import { handleDropdownNavigation } from './utils';
} from '.';
jest.mock('src/components/Dropdown', () => { jest.mock('src/components/Dropdown', () => {
const original = jest.requireActual('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 = { (global as any).featureFlags = {
[FeatureFlag.DrillToDetail]: true, [FeatureFlag.DrillToDetail]: true,
}; };
const props = { const props = createProps();
...createProps(),
supersetCanExplore: true,
};
props.slice.slice_id = 18; props.slice.slice_id = 18;
renderWrapper(props, { renderWrapper(props, {
Admin: [['can_samples', 'Datasource']], Admin: [
['can_samples', 'Datasource'],
['can_explore', 'Superset'],
],
}); });
expect(screen.getByText('Drill to detail')).toBeInTheDocument(); expect(screen.getByText('Drill to detail')).toBeInTheDocument();
}); });

View File

@ -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>
))()}
</>
);
};

View File

@ -16,19 +16,9 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { import { MouseEvent, Key, useState, useRef, RefObject } from 'react';
MouseEvent,
Key,
KeyboardEvent,
ReactChild,
useState,
useRef,
RefObject,
useCallback,
ReactElement,
} from 'react';
import { RouteComponentProps, useHistory, withRouter } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import moment from 'moment'; import moment from 'moment';
import { import {
Behavior, Behavior,
@ -36,23 +26,12 @@ import {
isFeatureEnabled, isFeatureEnabled,
FeatureFlag, FeatureFlag,
getChartMetadataRegistry, getChartMetadataRegistry,
QueryFormData,
styled, styled,
t, t,
useTheme,
ensureIsArray,
VizType, VizType,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import { Menu } from 'src/components/Menu';
MenuItemKeyEnum,
Menu,
MenuItemChildType,
isAntdMenuItem,
isAntdMenuItemRef,
isSubMenuOrItemType,
isAntdMenuSubmenu,
} from 'src/components/Menu';
import { NoAnimationDropdown } from 'src/components/Dropdown'; import { NoAnimationDropdown } from 'src/components/Dropdown';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage'; import downloadAsImage from 'src/utils/downloadAsImage';
@ -60,28 +39,16 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import ModalTrigger from 'src/components/ModalTrigger'; import ModalTrigger from 'src/components/ModalTrigger';
import Button from 'src/components/Button';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal'; import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane'; import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import Modal from 'src/components/Modal';
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types'; 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'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import { handleDropdownNavigation } from './utils';
const ACTION_KEYS = { import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
enter: 'Enter', import { SliceHeaderControlsProps } from './types';
spacebar: 'Spacebar',
space: ' ',
};
const NAV_KEYS = {
tab: 'Tab',
escape: 'Escape',
up: 'ArrowUp',
down: 'ArrowDown',
};
// TODO: replace 3 dots with an icon // TODO: replace 3 dots with an icon
const VerticalDotsContainer = styled.div` const VerticalDotsContainer = styled.div`
@ -126,51 +93,6 @@ const VerticalDotsTrigger = () => (
</VerticalDotsContainer> </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` const dropdownIconsStyles = css`
&&.anticon > .anticon:first-child { &&.anticon > .anticon:first-child {
margin-right: 0; margin-right: 0;
@ -178,353 +100,7 @@ const dropdownIconsStyles = css`
} }
`; `;
/** const SliceHeaderControls = (props: SliceHeaderControlsProps) => {
* 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 [dropdownIsOpen, setDropdownIsOpen] = useState(false); const [dropdownIsOpen, setDropdownIsOpen] = useState(false);
const [tableModalIsOpen, setTableModalIsOpen] = useState(false); const [tableModalIsOpen, setTableModalIsOpen] = useState(false);
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
@ -559,19 +135,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
.get(props.slice.viz_type) .get(props.slice.viz_type)
?.behaviors?.includes(Behavior.InteractiveChart); ?.behaviors?.includes(Behavior.InteractiveChart);
const canExplore = props.supersetCanExplore; const canExplore = props.supersetCanExplore;
const canDatasourceSamples = useSelector((state: RootState) => const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
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 refreshChart = () => { const refreshChart = () => {
if (props.updatedDttm) { if (props.updatedDttm) {
props.forceRefresh(props.slice.slice_id, props.dashboardId); props.forceRefresh(props.slice.slice_id, props.dashboardId);
@ -965,4 +529,4 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => {
); );
}; };
export default withRouter(SliceHeaderControls); export default SliceHeaderControls;

View File

@ -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;
}

View File

@ -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;
}
};

View File

@ -38,6 +38,12 @@ export const usePermissions = () => {
); );
const canDrillBy = (canExplore || canDrill) && canWriteExploreFormData; const canDrillBy = (canExplore || canDrill) && canWriteExploreFormData;
const canDrillToDetail = (canExplore || canDrill) && canDatasourceSamples; 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 { return {
canExplore, canExplore,
@ -47,5 +53,7 @@ export const usePermissions = () => {
canDrill, canDrill,
canDrillBy, canDrillBy,
canDrillToDetail, canDrillToDetail,
canViewQuery,
canViewTable,
}; };
}; };