diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
index e5a94ba97..b3b3afbb8 100644
--- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
+++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx
@@ -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,
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index fb84bf398..8df620022 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -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';
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
index ed9d502d0..edeb1b541 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx
@@ -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();
});
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewResultsModalTrigger.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewResultsModalTrigger.tsx
new file mode 100644
index 000000000..f45a339dc
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/ViewResultsModalTrigger.tsx
@@ -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 (
+ <>
+
+ {triggerNode}
+
+ {(() => (
+
+
+
+ >
+ }
+ responsive
+ resizable
+ resizableConfig={{
+ minHeight: theme.gridUnit * 128,
+ minWidth: theme.gridUnit * 128,
+ defaultSize: {
+ width: 'auto',
+ height: '75vh',
+ },
+ }}
+ draggable
+ destroyOnClose
+ >
+ {modalBody}
+
+ ))()}
+ >
+ );
+};
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
index bb94a756a..2d2ca2b95 100644
--- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx
@@ -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 = () => (
);
-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[] => {
- // check that child has props
- const childProps: Record = 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 }[],
-): { key: string; ref?: RefObject }[] => {
- 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 => {
- const keysMap: Record = {};
- 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,
- 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,
- 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 (
- <>
-
- {triggerNode}
-
- {(() => (
-
-
-
- >
- }
- responsive
- resizable
- resizableConfig={{
- minHeight: theme.gridUnit * 128,
- minWidth: theme.gridUnit * 128,
- defaultSize: {
- width: 'auto',
- height: '75vh',
- },
- }}
- draggable
- destroyOnClose
- >
- {modalBody}
-
- ))()}
- >
- );
-};
-
-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;
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
new file mode 100644
index 000000000..f13929b82
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts
@@ -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;
+}
diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/utils.ts b/superset-frontend/src/dashboard/components/SliceHeaderControls/utils.ts
new file mode 100644
index 000000000..ab2d45076
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/utils.ts
@@ -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[] => {
+ // check that child has props
+ const childProps: Record = 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 }[],
+): { key: string; ref?: RefObject }[] => {
+ 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 => {
+ const keysMap: Record = {};
+ 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,
+ 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,
+ 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;
+ }
+};
diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/usePermissions.ts b/superset-frontend/src/hooks/usePermissions.ts
similarity index 86%
rename from superset-frontend/src/components/Chart/ChartContextMenu/usePermissions.ts
rename to superset-frontend/src/hooks/usePermissions.ts
index ef45cdf0b..1811c0a96 100644
--- a/superset-frontend/src/components/Chart/ChartContextMenu/usePermissions.ts
+++ b/superset-frontend/src/hooks/usePermissions.ts
@@ -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,
};
};