From 1d44662b1d16dfbc43a2957eb524f25aee4dfd24 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Tue, 3 Dec 2024 16:36:39 +0100 Subject: [PATCH] refactor: Split SliceHeaderControls into smaller files (#31270) --- .../ChartContextMenu/ChartContextMenu.tsx | 2 +- .../components/SliceHeader/index.tsx | 5 +- .../SliceHeaderControls.test.tsx | 17 +- .../ViewResultsModalTrigger.tsx | 117 +++++ .../components/SliceHeaderControls/index.tsx | 456 +----------------- .../components/SliceHeaderControls/types.ts | 62 +++ .../components/SliceHeaderControls/utils.ts | 293 +++++++++++ .../usePermissions.ts | 8 + 8 files changed, 501 insertions(+), 459 deletions(-) create mode 100644 superset-frontend/src/dashboard/components/SliceHeaderControls/ViewResultsModalTrigger.tsx create mode 100644 superset-frontend/src/dashboard/components/SliceHeaderControls/types.ts create mode 100644 superset-frontend/src/dashboard/components/SliceHeaderControls/utils.ts rename superset-frontend/src/{components/Chart/ChartContextMenu => hooks}/usePermissions.ts (86%) 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, }; };