This is an automated email from the ASF dual-hosted git repository. msyavuz pushed a commit to branch msyavuz/fix/console-errors in repository https://gitbox.apache.org/repos/asf/superset.git
commit 8a298b0439d490cd690c16b92695743b9902b45f Author: Mehmet Salih Yavuz <salih.ya...@proton.me> AuthorDate: Wed Jul 16 00:05:30 2025 +0300 fix(SliceHeaderControls): Menu items --- .../src/components/Chart/DrillDetail/index.ts | 1 + .../Chart/DrillDetail/useDrillDetailMenuItems.tsx | 269 ++++++++++++++++ .../components/SliceHeaderControls/index.tsx | 356 +++++++++++---------- .../components/menu/ShareMenuItems/index.tsx | 40 +-- 4 files changed, 484 insertions(+), 182 deletions(-) diff --git a/superset-frontend/src/components/Chart/DrillDetail/index.ts b/superset-frontend/src/components/Chart/DrillDetail/index.ts index cf154680be..7911551479 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/index.ts +++ b/superset-frontend/src/components/Chart/DrillDetail/index.ts @@ -18,3 +18,4 @@ */ export { default as DrillDetailMenuItems } from './DrillDetailMenuItems'; +export { useDrillDetailMenuItems } from './useDrillDetailMenuItems'; diff --git a/superset-frontend/src/components/Chart/DrillDetail/useDrillDetailMenuItems.tsx b/superset-frontend/src/components/Chart/DrillDetail/useDrillDetailMenuItems.tsx new file mode 100644 index 0000000000..f89d949770 --- /dev/null +++ b/superset-frontend/src/components/Chart/DrillDetail/useDrillDetailMenuItems.tsx @@ -0,0 +1,269 @@ +/** + * 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 { + Dispatch, + ReactNode, + SetStateAction, + useCallback, + useMemo, +} from 'react'; +import { isEmpty } from 'lodash'; +import { + Behavior, + BinaryQueryObjectFilterClause, + css, + extractQueryFields, + getChartMetadataRegistry, + QueryFormData, + removeHTMLTags, + styled, + t, +} from '@superset-ui/core'; +import { useSelector } from 'react-redux'; +import { MenuItem } from '@superset-ui/core/components/Menu'; +import { RootState } from 'src/dashboard/types'; +import { getSubmenuYOffset } from '../utils'; +import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; +import { useMenuItemWithTruncation } from '../MenuItemWithTruncation'; + +const DRILL_TO_DETAIL = t('Drill to detail'); +const DRILL_TO_DETAIL_BY = t('Drill to detail by'); +const DISABLED_REASONS = { + DATABASE: t( + 'Drill to detail is disabled for this database. Change the database settings to enable it.', + ), + NO_AGGREGATIONS: t( + 'Drill to detail is disabled because this chart does not group data by dimension value.', + ), + NO_FILTERS: t( + 'Right-click on a dimension value to drill to detail by that value.', + ), + NOT_SUPPORTED: t( + 'Drill to detail by value is not yet supported for this chart type.', + ), +}; + +function getDisabledMenuItem( + children: ReactNode, + menuKey: string, + ...rest: unknown[] +): MenuItem { + return { + disabled: true, + key: menuKey, + label: ( + <div + css={css` + white-space: normal; + max-width: 160px; + `} + > + {children} + </div> + ), + ...rest, + }; +} + +const Filter = ({ + children, + stripHTML = false, +}: { + children: ReactNode; + stripHTML: boolean; +}) => { + const content = + stripHTML && typeof children === 'string' + ? removeHTMLTags(children) + : children; + return <span>{content}</span>; +}; + +const StyledFilter = styled(Filter)` + ${({ theme }) => ` + font-weight: ${theme.fontWeightStrong}; + color: ${theme.colorPrimary}; + `} +`; + +export type DrillDetailMenuItemsArgs = { + formData: QueryFormData; + filters?: BinaryQueryObjectFilterClause[]; + setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>; + isContextMenu?: boolean; + contextMenuY?: number; + onSelection?: () => void; + onClick?: (event: MouseEvent) => void; + submenuIndex?: number; + setShowModal: (show: boolean) => void; + key?: string; + forceSubmenuRender?: boolean; +}; + +export const useDrillDetailMenuItems = ({ + formData, + filters = [], + isContextMenu = false, + contextMenuY = 0, + onSelection = () => null, + onClick = () => null, + submenuIndex = 0, + setFilters, + setShowModal, + key, + ...props +}: DrillDetailMenuItemsArgs) => { + const drillToDetailDisabled = useSelector<RootState, boolean | undefined>( + ({ datasources }) => + datasources[formData.datasource]?.database?.disable_drill_to_detail, + ); + + const openModal = useCallback( + (filters, event) => { + onClick(event); + onSelection(); + setFilters(filters); + setShowModal(true); + }, + [onClick, onSelection], + ); + + // Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu` + // event for dimensions. If it doesn't, tell the user that drill to detail by + // dimension is not supported. If it does, and the `contextmenu` handler didn't + // pass any filters, tell the user that they didn't select a dimension. + const handlesDimensionContextMenu = useMemo( + () => + getChartMetadataRegistry() + .get(formData.viz_type) + ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail), + [formData.viz_type], + ); + + // Check metrics to see if chart's current configuration lacks + // aggregations, in which case Drill to Detail should be disabled. + const noAggregations = useMemo(() => { + const { metrics } = extractQueryFields(formData); + return isEmpty(metrics); + }, [formData]); + + // Ensure submenu doesn't appear offscreen + const submenuYOffset = useMemo( + () => + getSubmenuYOffset( + contextMenuY, + filters.length > 1 ? filters.length + 1 : filters.length, + submenuIndex, + ), + [contextMenuY, filters.length, submenuIndex], + ); + + let drillDisabled; + let drillByDisabled; + if (drillToDetailDisabled) { + drillDisabled = DISABLED_REASONS.DATABASE; + drillByDisabled = DISABLED_REASONS.DATABASE; + } else if (handlesDimensionContextMenu) { + if (noAggregations) { + drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS; + drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS; + } else if (!filters?.length) { + drillByDisabled = DISABLED_REASONS.NO_FILTERS; + } + } else { + drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED; + } + + const drillToDetailMenuItem: MenuItem = drillDisabled + ? getDisabledMenuItem( + <> + {DRILL_TO_DETAIL} + <MenuItemTooltip title={drillDisabled} /> + </>, + 'drill-to-detail-disabled', + props, + ) + : { + key: 'drill-to-detail', + label: DRILL_TO_DETAIL, + onClick: openModal.bind(null, []), + ...props, + }; + + const getMenuItemWithTruncation = useMenuItemWithTruncation(); + + const drillToDetailByMenuItem: MenuItem = drillByDisabled + ? getDisabledMenuItem( + <> + {DRILL_TO_DETAIL_BY} + <MenuItemTooltip title={drillByDisabled} /> + </>, + 'drill-to-detail-by-disabled', + props, + ) + : { + key: key || 'drill-to-detail-by', + label: DRILL_TO_DETAIL_BY, + children: [ + ...filters.map((filter, i) => ({ + key: `drill-detail-filter-${i}`, + label: getMenuItemWithTruncation({ + tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`, + onClick: openModal.bind(null, [filter]), + key: `drill-detail-filter-${i}`, + children: ( + <> + {`${DRILL_TO_DETAIL_BY} `} + <StyledFilter stripHTML>{filter.formattedVal}</StyledFilter> + </> + ), + }), + })), + filters.length > 1 && { + key: 'drill-detail-filter-all', + label: getMenuItemWithTruncation({ + tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`, + onClick: openModal.bind(null, filters), + key: 'drill-detail-filter-all', + children: ( + <> + {`${DRILL_TO_DETAIL_BY} `} + <StyledFilter stripHTML={false}>{t('all')}</StyledFilter> + </> + ), + }), + }, + ].filter(Boolean) as MenuItem[], + onClick: openModal.bind(null, filters), + forceSubmenuRender: true, + popupOffset: [0, submenuYOffset], + popupClassName: 'chart-context-submenu', + ...props, + }; + if (isContextMenu) { + return { + drillToDetailMenuItem, + drillToDetailByMenuItem, + }; + } + return { + drillToDetailMenuItem, + }; +}; diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 5d9293a1c4..6af7069fe2 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -41,20 +41,20 @@ import { QueryFormData, } from '@superset-ui/core'; import { useSelector } from 'react-redux'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; import { NoAnimationDropdown, Tooltip, Button, ModalTrigger, } from '@superset-ui/core/components'; -import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; +import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems'; import downloadAsImage from 'src/utils/downloadAsImage'; import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'; import { Icons } from '@superset-ui/core/components/Icons'; import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal'; import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane'; -import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; +import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal'; @@ -329,174 +329,194 @@ const SliceHeaderControls = ( animationDuration: '0s', }; - const menu = ( - <Menu - onClick={handleMenuClick} - data-test={`slice_${slice.slice_id}-menu`} - id={`slice_${slice.slice_id}-menu`} - selectable={false} - > - <Menu.Item - key={MenuKeys.ForceRefresh} - disabled={props.chartStatus === 'loading'} - style={{ height: 'auto', lineHeight: 'initial' }} - data-test="refresh-chart-menu-item" - > - {t('Force refresh')} - <RefreshTooltip data-test="dashboard-slice-refresh-tooltip"> - {refreshTooltip} - </RefreshTooltip> - </Menu.Item> - - <Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item> - - <Menu.Divider /> - - {slice.description && ( - <Menu.Item key={MenuKeys.ToggleChartDescription}> - {props.isDescriptionExpanded - ? t('Hide chart description') - : t('Show chart description')} - </Menu.Item> - )} - - {canExplore && ( - <Menu.Item - key={MenuKeys.ExploreChart} - data-test-edit-chart-name={slice.slice_name} - > - <Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}> - {t('Edit chart')} - </Tooltip> - </Menu.Item> - )} + const newMenuItems: MenuItem[] = [ + { + key: MenuKeys.ForceRefresh, + label: ( + <> + {t('Force refresh')} + <RefreshTooltip data-test="dashboard-slice-refresh-tooltip"> + {refreshTooltip} + </RefreshTooltip> + </> + ), + disabled: props.chartStatus === 'loading', + style: { height: 'auto', lineHeight: 'initial' }, + ...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type + }, + { + key: MenuKeys.Fullscreen, + label: fullscreenLabel, + }, + { + type: 'divider', + }, + ]; + + if (slice.description) { + newMenuItems.push({ + key: MenuKeys.ToggleChartDescription, + label: props.isDescriptionExpanded + ? t('Hide chart description') + : t('Show chart description'), + }); + } - {canEditCrossFilters && ( - <Menu.Item key={MenuKeys.CrossFilterScoping}> - {t('Cross-filtering scoping')} - </Menu.Item> - )} + if (canExplore) { + newMenuItems.push({ + key: MenuKeys.ExploreChart, + label: ( + <Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}> + {t('Edit chart')} + </Tooltip> + ), + ...{ 'data-test-edit-chart-name': slice.slice_name }, + }); + } - {(canExplore || canEditCrossFilters) && <Menu.Divider />} - - {(canExplore || canViewQuery) && ( - <Menu.Item key={MenuKeys.ViewQuery}> - <ModalTrigger - triggerNode={ - <div data-test="view-query-menu-item">{t('View query')}</div> - } - modalTitle={t('View query')} - modalBody={<ViewQueryModal latestQueryFormData={props.formData} />} - draggable - resizable - responsive - ref={queryMenuRef} - /> - </Menu.Item> - )} + if (canEditCrossFilters) { + newMenuItems.push({ + key: MenuKeys.CrossFilterScoping, + label: t('Cross-filtering scoping'), + }); + } - {(canExplore || canViewTable) && ( - <Menu.Item key={MenuKeys.ViewResults}> - <ViewResultsModalTrigger - canExplore={props.supersetCanExplore} - exploreUrl={props.exploreUrl} - triggerNode={ - <div data-test="view-query-menu-item">{t('View as table')}</div> - } - modalRef={resultsMenuRef} - modalTitle={t('Chart Data: %s', slice.slice_name)} - modalBody={ - <ResultsPaneOnDashboard - queryFormData={props.formData} - queryForce={false} - dataSize={20} - isRequest - isVisible - canDownload={!!props.supersetCanCSV} - /> - } - /> - </Menu.Item> - )} + if (canExplore || canEditCrossFilters) { + newMenuItems.push({ type: 'divider' }); + } - {isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && ( - <DrillDetailMenuItems - setFilters={setFilters} - filters={modalFilters} - formData={props.formData} - key={MenuKeys.DrillToDetail} - setShowModal={setDrillModalIsOpen} + if (canExplore || canViewQuery) { + newMenuItems.push({ + key: MenuKeys.ViewQuery, + label: ( + <ModalTrigger + triggerNode={ + <div data-test="view-query-menu-item">{t('View query')}</div> + } + modalTitle={t('View query')} + modalBody={<ViewQueryModal latestQueryFormData={props.formData} />} + draggable + resizable + responsive + ref={queryMenuRef} /> - )} + ), + }); + } - {(slice.description || canExplore) && <Menu.Divider />} - - {supersetCanShare && ( - <ShareMenuItems - dashboardId={dashboardId} - dashboardComponentId={componentId} - copyMenuItemTitle={t('Copy permalink to clipboard')} - emailMenuItemTitle={t('Share chart by email')} - emailSubject={t('Superset chart')} - emailBody={t('Check out this chart: ')} - addSuccessToast={addSuccessToast} - addDangerToast={addDangerToast} - title={t('Share')} + if (canExplore || canViewTable) { + newMenuItems.push({ + key: MenuKeys.ViewResults, + label: ( + <ViewResultsModalTrigger + canExplore={props.supersetCanExplore} + exploreUrl={props.exploreUrl} + triggerNode={ + <div data-test="view-query-menu-item">{t('View as table')}</div> + } + modalRef={resultsMenuRef} + modalTitle={t('Chart Data: %s', slice.slice_name)} + modalBody={ + <ResultsPaneOnDashboard + queryFormData={props.formData} + queryForce={false} + dataSize={20} + isRequest + isVisible + canDownload={!!props.supersetCanCSV} + /> + } /> - )} + ), + }); + } + + const { drillToDetailMenuItem, drillToDetailByMenuItem } = + useDrillDetailMenuItems({ + formData: props.formData, + filters: modalFilters, + setFilters, + setShowModal: setDrillModalIsOpen, + key: MenuKeys.DrillToDetail, + }); + + const shareMenuItems = useShareMenuItems({ + dashboardId, + dashboardComponentId: componentId, + copyMenuItemTitle: t('Copy permalink to clipboard'), + emailMenuItemTitle: t('Share chart by email'), + emailSubject: t('Superset chart'), + emailBody: t('Check out this chart: '), + addSuccessToast, + addDangerToast, + title: t('Share'), + }); + + if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) { + newMenuItems.push(drillToDetailMenuItem); + if (drillToDetailByMenuItem) { + newMenuItems.push(drillToDetailByMenuItem); + } + } + + if (slice.description || canExplore) { + newMenuItems.push({ type: 'divider' }); + } + + if (supersetCanShare) { + newMenuItems.push(shareMenuItems); + } + + if (props.supersetCanCSV) { + newMenuItems.push({ + type: 'submenu', + key: MenuKeys.Download, + label: t('Download'), + children: [ + { + key: MenuKeys.ExportCsv, + label: t('Export to .CSV'), + icon: <Icons.FileOutlined css={dropdownIconsStyles} />, + }, + ...(isPivotTable + ? [ + { + key: MenuKeys.ExportPivotCsv, + label: t('Export to Pivoted .CSV'), + icon: <Icons.FileOutlined css={dropdownIconsStyles} />, + }, + ] + : []), + { + key: MenuKeys.ExportXlsx, + label: t('Export to Excel'), + icon: <Icons.FileOutlined css={dropdownIconsStyles} />, + }, + ...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) && + props.supersetCanCSV && + isTable + ? [ + { + key: MenuKeys.ExportFullCsv, + label: t('Export to full .CSV'), + icon: <Icons.FileOutlined css={dropdownIconsStyles} />, + }, + { + key: MenuKeys.ExportFullXlsx, + label: t('Export to full Excel'), + icon: <Icons.FileOutlined css={dropdownIconsStyles} />, + }, + ] + : []), + { + key: MenuKeys.DownloadAsImage, + label: t('Download as image'), + icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />, + }, + ], + }); + } - {props.supersetCanCSV && ( - <Menu.SubMenu title={t('Download')} key={MenuKeys.Download}> - <Menu.Item - key={MenuKeys.ExportCsv} - icon={<Icons.FileOutlined css={dropdownIconsStyles} />} - > - {t('Export to .CSV')} - </Menu.Item> - {isPivotTable && ( - <Menu.Item - key={MenuKeys.ExportPivotCsv} - icon={<Icons.FileOutlined css={dropdownIconsStyles} />} - > - {t('Export to Pivoted .CSV')} - </Menu.Item> - )} - <Menu.Item - key={MenuKeys.ExportXlsx} - icon={<Icons.FileOutlined css={dropdownIconsStyles} />} - > - {t('Export to Excel')} - </Menu.Item> - - {isFeatureEnabled(FeatureFlag.AllowFullCsvExport) && - props.supersetCanCSV && - isTable && ( - <> - <Menu.Item - key={MenuKeys.ExportFullCsv} - icon={<Icons.FileOutlined css={dropdownIconsStyles} />} - > - {t('Export to full .CSV')} - </Menu.Item> - <Menu.Item - key={MenuKeys.ExportFullXlsx} - icon={<Icons.FileOutlined css={dropdownIconsStyles} />} - > - {t('Export to full Excel')} - </Menu.Item> - </> - )} - - <Menu.Item - key={MenuKeys.DownloadAsImage} - icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />} - > - {t('Download as image')} - </Menu.Item> - </Menu.SubMenu> - )} - </Menu> - ); return ( <> {isFullSize && ( @@ -508,7 +528,15 @@ const SliceHeaderControls = ( /> )} <NoAnimationDropdown - popupRender={() => menu} + popupRender={() => ( + <Menu + onClick={handleMenuClick} + data-test={`slice_${slice.slice_id}-menu`} + id={`slice_${slice.slice_id}-menu`} + selectable={false} + items={newMenuItems} + /> + )} overlayStyle={dropdownOverlayStyle} trigger={['click']} placement="bottomRight" diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index 9d663958e3..d42a733284 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -19,7 +19,7 @@ import { ComponentProps, RefObject } from 'react'; import copyTextToClipboard from 'src/utils/copy'; import { t, logging } from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; import { getDashboardPermalink } from 'src/utils/urlUtils'; import { MenuKeys, RootState } from 'src/dashboard/types'; import { shallowEqual, useSelector } from 'react-redux'; @@ -42,7 +42,7 @@ interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> { disabled?: boolean; } -const ShareMenuItems = (props: ShareMenuItemProps) => { +export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => { const { copyMenuItemTitle, emailMenuItemTitle, @@ -54,6 +54,7 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { dashboardComponentId, title, disabled, + children, ...rest } = props; const { dataMask, activeTabs } = useSelector( @@ -96,20 +97,23 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { } } - return ( - <Menu.SubMenu - title={title} - key={MenuKeys.Share} - disabled={disabled} - {...rest} - > - <Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}> - {copyMenuItemTitle} - </Menu.Item> - <Menu.Item key={MenuKeys.ShareByEmail} onClick={() => onShareByEmail()}> - {emailMenuItemTitle} - </Menu.Item> - </Menu.SubMenu> - ); + return { + type: 'submenu', + label: title, + key: MenuKeys.Share, + disabled, + children: [ + { + key: MenuKeys.CopyLink, + label: copyMenuItemTitle, + onClick: () => onCopyLink, + }, + { + key: MenuKeys.ShareByEmail, + label: emailMenuItemTitle, + onClick: () => onShareByEmail, + }, + ], + ...rest, + }; }; -export default ShareMenuItems;