diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js index b4cc177deff..72fb1b3dfe8 100644 --- a/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js +++ b/superset-frontend/cypress-base/cypress/e2e/explore/chart.test.js @@ -68,11 +68,13 @@ function verifyDashboardSearch() { function verifyDashboardLink() { interceptDashboardGet(); openDashboardsAddedTo(); - cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); + cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', { + force: true, + }); cy.get('.ant-dropdown-menu-submenu-popup a') .first() .invoke('removeAttr', 'target') - .click(); + .click({ force: true }); cy.wait('@get'); } diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/TimeComparisonVisibility.tsx b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/TimeComparisonVisibility.tsx index 8e3d0fbe8e2..4546c543913 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/TimeComparisonVisibility.tsx +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/AgGridTable/components/TimeComparisonVisibility.tsx @@ -18,7 +18,7 @@ */ /* eslint-disable import/no-extraneous-dependencies */ import { useState } from 'react'; -import { Dropdown, Menu } from 'antd'; +import { Dropdown } from 'antd'; import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons'; import { t } from '@superset-ui/core'; import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles'; @@ -69,34 +69,42 @@ const TimeComparisonVisibility: React.FC = ({ return ( { + open={showComparisonDropdown} + onOpenChange={(flag: boolean) => { setShowComparisonDropdown(flag); }} - overlay={ - - - {t( - 'Select columns that will be displayed in the table. You can multiselect columns.', - )} - - {comparisonColumns.map((column: ComparisonColumn) => ( - - {column.label} - - {selectedComparisonColumns.includes(column.key) && ( - + menu={{ + multiple: true, + onClick: handleOnClick, + onBlur: handleOnBlur, + selectedKeys: selectedComparisonColumns, + items: [ + { + key: 'all', + label: ( + + {t( + 'Select columns that will be displayed in the table. You can multiselect columns.', )} - - - ))} - - } + + ), + type: 'group', + children: comparisonColumns.map((column: ComparisonColumn) => ({ + key: column.key, + label: ( + <> + {column.label} + + {selectedComparisonColumns.includes(column.key) && ( + + )} + + + ), + })), + }, + ], + }} trigger={['click']} > diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index b701e2a3566..ced1e23232d 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -59,7 +59,6 @@ import { Space, RawAntdSelect as Select, Dropdown, - Menu, Tooltip, } from '@superset-ui/core/components'; import { @@ -564,52 +563,62 @@ export default function TableChart( return ( { + open={showComparisonDropdown} + onOpenChange={(flag: boolean) => { setShowComparisonDropdown(flag); }} - overlay={ - -
- {t( - 'Select columns that will be displayed in the table. You can multiselect columns.', - )} -
- {comparisonColumns.map(column => ( - - - {column.label} - - - {selectedComparisonColumns.includes(column.key) && ( - + {t( + 'Select columns that will be displayed in the table. You can multiselect columns.', )} - - - ))} -
- } + + ), + type: 'group', + children: comparisonColumns.map( + (column: { key: string; label: string }) => ({ + key: column.key, + label: ( + <> + + {column.label} + + + {selectedComparisonColumns.includes(column.key) && ( + + )} + + + ), + }), + ), + }, + ], + }} trigger={['click']} > diff --git a/superset-frontend/src/components/Chart/DrillDetail/index.ts b/superset-frontend/src/components/Chart/DrillDetail/index.ts index cf154680bec..79115514797 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 00000000000..f89d9497701 --- /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: ( +
+ {children} +
+ ), + ...rest, + }; +} + +const Filter = ({ + children, + stripHTML = false, +}: { + children: ReactNode; + stripHTML: boolean; +}) => { + const content = + stripHTML && typeof children === 'string' + ? removeHTMLTags(children) + : children; + return {content}; +}; + +const StyledFilter = styled(Filter)` + ${({ theme }) => ` + font-weight: ${theme.fontWeightStrong}; + color: ${theme.colorPrimary}; + `} +`; + +export type DrillDetailMenuItemsArgs = { + formData: QueryFormData; + filters?: BinaryQueryObjectFilterClause[]; + setFilters: Dispatch>; + 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( + ({ 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} + + , + '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} + + , + '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} `} + {filter.formattedVal} + + ), + }), + })), + 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} `} + {t('all')} + + ), + }), + }, + ].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/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 511e70a91e7..9900d29b0a3 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -18,16 +18,16 @@ */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; import { t } from '@superset-ui/core'; import { isEmpty } from 'lodash'; import { URL_PARAMS } from 'src/constants'; -import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; -import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems'; +import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems'; +import { useDownloadMenuItems } from 'src/dashboard/components/menu/DownloadMenuItems'; +import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown'; import CssEditor from 'src/dashboard/components/CssEditor'; import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal'; import SaveModal from 'src/dashboard/components/SaveModal'; -import HeaderReportDropdown from 'src/features/reports/ReportModal/HeaderReportDropdown'; import injectCustomCss from 'src/dashboard/util/injectCustomCss'; import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants'; import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal'; @@ -74,9 +74,6 @@ export const useHeaderActionsMenu = ({ }: HeaderDropdownProps) => { const dispatch = useDispatch(); const [css, setCss] = useState(customCss || ''); - const [showReportSubMenu, setShowReportSubMenu] = useState( - null, - ); const [isDropdownVisible, setIsDropdownVisible] = useState(false); const directPathToChild = useSelector( (state: RootState) => state.dashboardState.directPathToChild, @@ -172,163 +169,220 @@ export const useHeaderActionsMenu = ({ [directPathToChild], ); + const shareMenuItems = useShareMenuItems({ + title: t('Share'), + disabled: isLoading, + url, + dashboardId, + dashboardComponentId, + copyMenuItemTitle: t('Copy permalink to clipboard'), + emailMenuItemTitle: t('Share permalink by email'), + emailSubject, + emailBody: t('Check out this dashboard: '), + addSuccessToast, + addDangerToast, + }); + + const downloadMenuItem = useDownloadMenuItems({ + pdfMenuItemTitle: t('Export to PDF'), + imageMenuItemTitle: t('Download as Image'), + dashboardTitle, + dashboardId, + title: t('Download'), + disabled: isLoading, + logEvent, + }); + + const reportMenuItem = useHeaderReportMenuItems({ + dashboardId: dashboardInfo?.id, + showReportModal, + setCurrentReportDeleting, + }); + + // Helper function to create menu items for components with triggerNode + const createModalMenuItem = ( + key: string, + modalComponent: React.ReactElement, + ): MenuItem => ({ + key, + label: modalComponent, + }); + const menu = useMemo(() => { const isEmbedded = !dashboardInfo?.userId; const refreshIntervalOptions = - dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS; + dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS; + + const menuItems: MenuItem[] = []; + + // Refresh dashboard + if (!editMode) { + menuItems.push({ + key: MenuKeys.RefreshDashboard, + label: t('Refresh dashboard'), + disabled: isLoading, + }); + } + + // Toggle fullscreen + if (!editMode && !isEmbedded) { + menuItems.push({ + key: MenuKeys.ToggleFullscreen, + label: getUrlParam(URL_PARAMS.standalone) + ? t('Exit fullscreen') + : t('Enter fullscreen'), + }); + } + + // Edit properties + if (editMode) { + menuItems.push({ + key: MenuKeys.EditProperties, + label: t('Edit properties'), + }); + } + + // Edit CSS + if (editMode) { + menuItems.push( + createModalMenuItem( + MenuKeys.EditCss, + {t('Theme & CSS')}} + initialCss={css} + onChange={changeCss} + addDangerToast={addDangerToast} + currentThemeId={dashboardInfo.theme?.id || null} + onThemeChange={handleThemeChange} + />, + ), + ); + } + + // Divider + menuItems.push({ type: 'divider' }); + + // Save as + if (userCanSave) { + menuItems.push( + createModalMenuItem( + MenuKeys.SaveModal, + {t('Save as')} + } + canOverwrite={userCanEdit} + />, + ), + ); + } + + // Download submenu + menuItems.push(downloadMenuItem); + + // Share submenu + if (userCanShare) { + menuItems.push(shareMenuItems); + } + + // Embed dashboard + if (!editMode && userCanCurate) { + menuItems.push({ + key: MenuKeys.ManageEmbedded, + label: t('Embed dashboard'), + }); + } + + // Divider + menuItems.push({ type: 'divider' }); + + // Report dropdown + if (!editMode && reportMenuItem) { + menuItems.push(reportMenuItem); + } + + // Set filter mapping + if (editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes)) { + menuItems.push( + createModalMenuItem( + MenuKeys.SetFilterMapping, + {t('Set filter mapping')}} + />, + ), + ); + } + + // Auto-refresh interval + menuItems.push( + createModalMenuItem( + MenuKeys.AutorefreshModal, + {t('Set auto-refresh interval')}} + />, + ), + ); + return ( - {!editMode && ( - - {t('Refresh dashboard')} - - )} - {!editMode && !isEmbedded && ( - - {getUrlParam(URL_PARAMS.standalone) - ? t('Exit fullscreen') - : t('Enter fullscreen')} - - )} - {editMode && ( - - {t('Edit properties')} - - )} - {editMode && ( - - {t('Theme & CSS')}} - initialCss={css} - onChange={changeCss} - addDangerToast={addDangerToast} - currentThemeId={dashboardInfo.theme?.id || null} - onThemeChange={handleThemeChange} - /> - - )} - - {userCanSave && ( - - {t('Save as')} - } - canOverwrite={userCanEdit} - /> - - )} - - {userCanShare && ( - - )} - {!editMode && userCanCurate && ( - - {t('Embed dashboard')} - - )} - - {!editMode ? ( - showReportSubMenu ? ( - <> - - - - ) : ( - - ) - ) : null} - {editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && ( - - {t('Set filter mapping')}} - /> - - )} - - {t('Set auto-refresh interval')}} - /> - - + items={menuItems} + /> ); }, [ - css, - showReportSubMenu, - isDropdownVisible, - directPathToChild, - handleMenuClick, - changeCss, + addDangerToast, + addSuccessToast, changeRefreshInterval, - emailSubject, - url, - dashboardComponentId, + changeCss, + colorNamespace, + colorScheme, + css, + customCss, + dashboardId, + dashboardInfo, + dashboardTitle, + downloadMenuItem, + editMode, + expandedSlices, + handleMenuClick, + isLoading, + lastModifiedTime, + layout, + onSave, + refreshFrequency, + refreshLimit, + refreshWarning, + reportMenuItem, + shareMenuItems, + shouldPersistRefreshFrequency, + userCanCurate, + userCanEdit, + userCanSave, + userCanShare, ]); return [menu, isDropdownVisible, setIsDropdownVisible]; diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx index 3e31277b278..599d271d36d 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/PropertiesModal.test.tsx @@ -438,10 +438,9 @@ describe('PropertiesModal', () => { const props = createProps(); const propsWithDashboardInfo = { ...props, dashboardInfo }; - const open = () => waitFor(() => userEvent.click(getSelect())); const getSelect = () => screen.getByRole('combobox', { name: SupersetCore.t('Owners') }); - + const open = () => waitFor(() => userEvent.click(getSelect())); const getElementsByClassName = (className: string) => document.querySelectorAll(className)! as NodeListOf; diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 312b20ec869..b43941ad821 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'; @@ -334,183 +334,199 @@ const SliceHeaderControls = ( animationDuration: '0s', }; - const menu = ( - - - {t('Force refresh')} - - {refreshTooltip} - - + const newMenuItems: MenuItem[] = [ + { + key: MenuKeys.ForceRefresh, + label: ( + <> + {t('Force refresh')} + + {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', + }, + ]; - {fullscreenLabel} + if (slice.description) { + newMenuItems.push({ + key: MenuKeys.ToggleChartDescription, + label: props.isDescriptionExpanded + ? t('Hide chart description') + : t('Show chart description'), + }); + } - + if (canExplore) { + newMenuItems.push({ + key: MenuKeys.ExploreChart, + label: ( + + {t('Edit chart')} + + ), + ...{ 'data-test-edit-chart-name': slice.slice_name }, + }); + } - {slice.description && ( - - {props.isDescriptionExpanded - ? t('Hide chart description') - : t('Show chart description')} - - )} + if (canEditCrossFilters) { + newMenuItems.push({ + key: MenuKeys.CrossFilterScoping, + label: t('Cross-filtering scoping'), + }); + } - {canExplore && ( - - - {t('Edit chart')} - - - )} + if (canExplore || canEditCrossFilters) { + newMenuItems.push({ type: 'divider' }); + } - {canEditCrossFilters && ( - - {t('Cross-filtering scoping')} - - )} - - {(canExplore || canEditCrossFilters) && } - - {(canExplore || canViewQuery) && ( - - {t('View query')} - } - modalTitle={t('View query')} - modalBody={} - draggable - resizable - responsive - ref={queryMenuRef} - /> - - )} - - {(canExplore || canViewTable) && ( - - {t('View as table')} - } - modalRef={resultsMenuRef} - modalTitle={t('Chart Data: %s', slice.slice_name)} - modalBody={ - - } - /> - - )} - - {isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && ( - {t('View query')} + } + modalTitle={t('View query')} + modalBody={} + draggable + resizable + responsive + ref={queryMenuRef} /> - )} + ), + }); + } - {(slice.description || canExplore) && } - - {supersetCanShare && ( - {t('View as table')} + } + modalRef={resultsMenuRef} + modalTitle={t('Chart Data: %s', slice.slice_name)} + modalBody={ + + } /> - )} + ), + }); + } - {props.supersetCanCSV && ( - - } - > - {t('Export to .CSV')} - - {isPivotTable && ( - } - > - {t('Export to Pivoted .CSV')} - - )} - } - > - {t('Export to Excel')} - + const { drillToDetailMenuItem, drillToDetailByMenuItem } = + useDrillDetailMenuItems({ + formData: props.formData, + filters: modalFilters, + setFilters, + setShowModal: setDrillModalIsOpen, + key: MenuKeys.DrillToDetail, + }); - {isPivotTable && ( - } - > - {t('Export to Pivoted Excel')} - - )} + 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'), + }); - {isFeatureEnabled(FeatureFlag.AllowFullCsvExport) && - props.supersetCanCSV && - isTable && ( - <> - } - > - {t('Export to full .CSV')} - - } - > - {t('Export to full Excel')} - - - )} + 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: , + }, + ...(isPivotTable + ? [ + { + key: MenuKeys.ExportPivotCsv, + label: t('Export to Pivoted .CSV'), + icon: , + }, + { + key: MenuKeys.ExportPivotXlsx, + label: t('Export to Pivoted Excel'), + icon: , + }, + ] + : []), + { + key: MenuKeys.ExportXlsx, + label: t('Export to Excel'), + icon: , + }, + ...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) && + props.supersetCanCSV && + isTable + ? [ + { + key: MenuKeys.ExportFullCsv, + label: t('Export to full .CSV'), + icon: , + }, + { + key: MenuKeys.ExportFullXlsx, + label: t('Export to full Excel'), + icon: , + }, + ] + : []), + { + key: MenuKeys.DownloadAsImage, + label: t('Download as image'), + icon: , + }, + ], + }); + } - } - > - {t('Download as image')} - - - )} - - ); return ( <> {isFullSize && ( @@ -522,7 +538,15 @@ const SliceHeaderControls = ( /> )} menu} + popupRender={() => ( + + )} overlayStyle={dropdownOverlayStyle} trigger={['click']} placement="bottomRight" diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx index 1f45af9722d..f83ec0e3778 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx @@ -17,8 +17,8 @@ * under the License. */ import { render, screen } from 'spec/helpers/testing-library'; -import { Menu } from '@superset-ui/core/components/Menu'; -import DownloadMenuItems from '.'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; +import { useDownloadMenuItems } from '.'; const createProps = () => ({ pdfMenuItemTitle: 'Export to PDF', @@ -30,19 +30,17 @@ const createProps = () => ({ submenuKey: 'download', }); -const renderComponent = () => { - render( - - - , - { - useRedux: true, - }, - ); +const MenuWrapper = () => { + const downloadMenuItem = useDownloadMenuItems(createProps()); + const menuItems: MenuItem[] = [downloadMenuItem]; + return ; }; test('Should render menu items', () => { - renderComponent(); + render(, { + useRedux: true, + }); + expect(screen.getByText('Export to PDF')).toBeInTheDocument(); expect(screen.getByText('Download as Image')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx index cbbc6823b71..b3f66cce8ba 100644 --- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx @@ -16,16 +16,21 @@ * specific language governing permissions and limitations * under the License. */ -import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { SyntheticEvent } from 'react'; +import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core'; +import { MenuItem } from '@superset-ui/core/components/Menu'; import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot'; -import { ComponentProps } from 'react'; +import { MenuKeys } from 'src/dashboard/types'; +import downloadAsPdf from 'src/utils/downloadAsPdf'; +import downloadAsImage from 'src/utils/downloadAsImage'; +import { + LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF, + LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE, +} from 'src/logger/LogUtils'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; import { DownloadScreenshotFormat } from './types'; -import DownloadAsPdf from './DownloadAsPdf'; -import DownloadAsImage from './DownloadAsImage'; -export interface DownloadMenuItemProps - extends ComponentProps { +export interface UseDownloadMenuItemsProps { pdfMenuItemTitle: string; imageMenuItemTitle: string; dashboardTitle: string; @@ -33,56 +38,81 @@ export interface DownloadMenuItemProps dashboardId: number; title: string; disabled?: boolean; - submenuKey: string; } -const DownloadMenuItems = (props: DownloadMenuItemProps) => { +export const useDownloadMenuItems = ( + props: UseDownloadMenuItemsProps, +): MenuItem => { const { pdfMenuItemTitle, imageMenuItemTitle, logEvent, dashboardId, dashboardTitle, - submenuKey, disabled, title, - ...rest } = props; + + const { addDangerToast } = useToasts(); + const SCREENSHOT_NODE_SELECTOR = '.dashboard'; + const isWebDriverScreenshotEnabled = isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) && isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot); const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent); - return isWebDriverScreenshotEnabled ? ( - - downloadScreenshot(DownloadScreenshotFormat.PDF)} - > - {pdfMenuItemTitle} - - downloadScreenshot(DownloadScreenshotFormat.PNG)} - > - {imageMenuItemTitle} - - - ) : ( - - - - - ); -}; + const onDownloadPdf = async (e: SyntheticEvent) => { + try { + downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e); + } catch (error) { + logging.error(error); + addDangerToast(t('Sorry, something went wrong. Try again later.')); + } + logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF); + }; -export default DownloadMenuItems; + const onDownloadImage = async (e: SyntheticEvent) => { + try { + downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e); + } catch (error) { + logging.error(error); + addDangerToast(t('Sorry, something went wrong. Try again later.')); + } + logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE); + }; + + const children: MenuItem[] = isWebDriverScreenshotEnabled + ? [ + { + key: DownloadScreenshotFormat.PDF, + label: pdfMenuItemTitle, + onClick: () => downloadScreenshot(DownloadScreenshotFormat.PDF), + }, + { + key: DownloadScreenshotFormat.PNG, + label: imageMenuItemTitle, + onClick: () => downloadScreenshot(DownloadScreenshotFormat.PNG), + }, + ] + : [ + { + key: 'download-pdf', + label: pdfMenuItemTitle, + onClick: (e: any) => onDownloadPdf(e.domEvent), + }, + { + key: 'download-image', + label: imageMenuItemTitle, + onClick: (e: any) => onDownloadImage(e.domEvent), + }, + ]; + + return { + key: MenuKeys.Download, + type: 'submenu', + label: title, + disabled, + children, + }; +}; diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx index 4b6db260302..c0a84448dfa 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/ShareMenuItems.test.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { Menu } from '@superset-ui/core/components/Menu'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; import { render, screen, @@ -26,7 +26,8 @@ import { } from 'spec/helpers/testing-library'; import * as copyTextToClipboard from 'src/utils/copy'; import fetchMock from 'fetch-mock'; -import ShareMenuItems from '.'; +import { ComponentProps } from 'react'; +import { useShareMenuItems, ShareMenuItemProps } from '.'; const spy = jest.spyOn(copyTextToClipboard, 'default'); @@ -69,17 +70,23 @@ afterAll((): void => { window.location = location; }); +const MenuWrapper = ( + props: ComponentProps & { shareProps: ShareMenuItemProps }, +) => { + const shareMenuItems = useShareMenuItems(props.shareProps); + const menuItems: MenuItem[] = [shareMenuItems]; + return ; +}; + test('Should render menu items', () => { - const props = createProps(); render( - - - , + shareProps={createProps()} + />, { useRedux: true }, ); expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument(); @@ -90,14 +97,13 @@ test('Click on "Copy dashboard URL" and succeed', async () => { spy.mockResolvedValue(undefined); const props = createProps(); render( - - - , + shareProps={props} + />, { useRedux: true }, ); @@ -123,14 +129,13 @@ test('Click on "Copy dashboard URL" and fail', async () => { spy.mockRejectedValue(undefined); const props = createProps(); render( - - - , + shareProps={props} + />, { useRedux: true }, ); @@ -157,14 +162,13 @@ test('Click on "Copy dashboard URL" and fail', async () => { test('Click on "Share dashboard by email" and succeed', async () => { const props = createProps(); render( - - - , + shareProps={props} + />, { useRedux: true }, ); @@ -191,14 +195,13 @@ test('Click on "Share dashboard by email" and fail', async () => { ); const props = createProps(); render( - - - , + shareProps={props} + />, { useRedux: true }, ); diff --git a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx index 9d663958e38..ed1cacc5cd1 100644 --- a/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx +++ b/superset-frontend/src/dashboard/components/menu/ShareMenuItems/index.tsx @@ -19,12 +19,13 @@ 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'; -interface ShareMenuItemProps extends ComponentProps { +export interface ShareMenuItemProps + extends ComponentProps { url?: string; copyMenuItemTitle: string; emailMenuItemTitle: string; @@ -40,9 +41,10 @@ interface ShareMenuItemProps extends ComponentProps { setOpenKeys?: Function; title: string; disabled?: boolean; + [key: string]: any; } -const ShareMenuItems = (props: ShareMenuItemProps) => { +export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => { const { copyMenuItemTitle, emailMenuItemTitle, @@ -96,20 +98,23 @@ const ShareMenuItems = (props: ShareMenuItemProps) => { } } - return ( - - onCopyLink()}> - {copyMenuItemTitle} - - onShareByEmail()}> - {emailMenuItemTitle} - - - ); + return { + ...rest, + type: 'submenu', + label: title, + key: MenuKeys.Share, + disabled, + children: [ + { + key: MenuKeys.CopyLink, + label: copyMenuItemTitle, + onClick: onCopyLink, + }, + { + key: MenuKeys.ShareByEmail, + label: emailMenuItemTitle, + onClick: onShareByEmail, + }, + ], + }; }; -export default ShareMenuItems; diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx index 38d43caf964..264f01dc99e 100644 --- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx +++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx @@ -34,7 +34,7 @@ import { exportChart, getChartKey } from 'src/explore/exploreUtils'; import downloadAsImage from 'src/utils/downloadAsImage'; import { getChartPermalink } from 'src/utils/urlUtils'; import copyTextToClipboard from 'src/utils/copy'; -import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown'; +import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown'; import { logEvent } from 'src/logger/actions'; import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, @@ -123,12 +123,18 @@ export const useExploreAdditionalActionsMenu = ( const theme = useTheme(); const { addDangerToast, addSuccessToast } = useToasts(); const dispatch = useDispatch(); - const [showReportSubMenu, setShowReportSubMenu] = useState(null); const [isDropdownVisible, setIsDropdownVisible] = useState(false); const chart = useSelector( state => state.charts?.[getChartKey(state.explore)], ); + // Use the updated report menu items hook + const reportMenuItem = useHeaderReportMenuItems({ + chart, + showReportModal, + setCurrentReportDeleting, + }); + const { datasource } = latestQueryFormData; const shareByEmail = useCallback(async () => { @@ -203,14 +209,106 @@ export const useExploreAdditionalActionsMenu = ( } }, [addDangerToast, addSuccessToast, latestQueryFormData]); - const handleMenuClick = useCallback( - ({ key, domEvent }) => { - switch (key) { - case MENU_KEYS.EDIT_PROPERTIES: + const menu = useMemo(() => { + const menuItems = []; + + // Edit chart properties + if (slice) { + menuItems.push({ + key: MENU_KEYS.EDIT_PROPERTIES, + label: t('Edit chart properties'), + onClick: () => { onOpenPropertiesModal(); setIsDropdownVisible(false); - break; - case MENU_KEYS.EXPORT_TO_CSV: + }, + }); + } + + // On dashboards submenu + menuItems.push({ + key: MENU_KEYS.DASHBOARDS_ADDED_TO, + type: 'submenu', + label: t('On dashboards'), + children: [ + { + key: 'dashboards-content', + label: ( + + ), + }, + ], + }); + + // Divider + menuItems.push({ type: 'divider' }); + + // Download submenu + const downloadChildren = []; + + if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) { + downloadChildren.push( + { + key: MENU_KEYS.EXPORT_TO_CSV, + label: t('Export to original .CSV'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { + exportCSV(); + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED, + label: t('Export to pivoted .CSV'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { + exportCSVPivoted(); + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX, + label: t('Export to Pivoted Excel'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { + exportPivotExcel( + '.pvtTable', + slice?.slice_name ?? t('pivoted_xlsx'), + ); + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + ); + } else { + downloadChildren.push({ + key: MENU_KEYS.EXPORT_TO_CSV, + label: t('Export to .CSV'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { exportCSV(); setIsDropdownVisible(false); dispatch( @@ -219,18 +317,17 @@ export const useExploreAdditionalActionsMenu = ( chartName: slice?.slice_name, }), ); - break; - case MENU_KEYS.EXPORT_TO_CSV_PIVOTED: - exportCSVPivoted(); - setIsDropdownVisible(false); - dispatch( - logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, { - chartId: slice?.slice_id, - chartName: slice?.slice_name, - }), - ); - break; - case MENU_KEYS.EXPORT_TO_JSON: + }, + }); + } + + downloadChildren.push( + { + key: MENU_KEYS.EXPORT_TO_JSON, + label: t('Export to .JSON'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { exportJson(); setIsDropdownVisible(false); dispatch( @@ -239,8 +336,33 @@ export const useExploreAdditionalActionsMenu = ( chartName: slice?.slice_name, }), ); - break; - case MENU_KEYS.EXPORT_TO_XLSX: + }, + }, + { + key: MENU_KEYS.DOWNLOAD_AS_IMAGE, + label: t('Download as image'), + icon: , + onClick: e => { + downloadAsImage( + '.panel-body .chart-container', + slice?.slice_name ?? t('New chart'), + true, + )(e.domEvent); + setIsDropdownVisible(false); + dispatch( + logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, { + chartId: slice?.slice_id, + chartName: slice?.slice_name, + }), + ); + }, + }, + { + key: MENU_KEYS.EXPORT_TO_XLSX, + label: t('Export to Excel'), + icon: , + disabled: !canDownloadCSV, + onClick: () => { exportExcel(); setIsDropdownVisible(false); dispatch( @@ -249,225 +371,128 @@ export const useExploreAdditionalActionsMenu = ( chartName: slice?.slice_name, }), ); - break; - case MENU_KEYS.EXPORT_TO_PIVOT_XLSX: - exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx')); - setIsDropdownVisible(false); - dispatch( - logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, { - chartId: slice?.slice_id, - chartName: slice?.slice_name, - }), - ); - break; - case MENU_KEYS.DOWNLOAD_AS_IMAGE: - downloadAsImage( - '.panel-body .chart-container', - // eslint-disable-next-line camelcase - slice?.slice_name ?? t('New chart'), - true, - )(domEvent); - setIsDropdownVisible(false); - dispatch( - logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, { - chartId: slice?.slice_id, - chartName: slice?.slice_name, - }), - ); - break; - case MENU_KEYS.COPY_PERMALINK: + }, + }, + ); + + menuItems.push({ + key: MENU_KEYS.DOWNLOAD_SUBMENU, + type: 'submenu', + label: t('Download'), + children: downloadChildren, + }); + + // Share submenu + const shareChildren = [ + { + key: MENU_KEYS.COPY_PERMALINK, + label: t('Copy permalink to clipboard'), + onClick: () => { copyLink(); setIsDropdownVisible(false); - break; - case MENU_KEYS.EMBED_CODE: - setIsDropdownVisible(false); - break; - case MENU_KEYS.SHARE_BY_EMAIL: + }, + }, + { + key: MENU_KEYS.SHARE_BY_EMAIL, + label: t('Share chart by email'), + onClick: () => { shareByEmail(); setIsDropdownVisible(false); - break; - case MENU_KEYS.VIEW_QUERY: - setIsDropdownVisible(false); - break; - case MENU_KEYS.RUN_IN_SQL_LAB: - onOpenInEditor(latestQueryFormData, domEvent.metaKey); - setIsDropdownVisible(false); - break; - default: - break; - } - }, - [ - copyLink, - exportCSV, - exportCSVPivoted, - exportJson, - latestQueryFormData, - onOpenInEditor, - onOpenPropertiesModal, - shareByEmail, - slice?.slice_name, - ], - ); + }, + }, + ]; - const menu = useMemo( - () => ( - - <> - {slice && ( - - {t('Edit chart properties')} - - )} - - - - - - - {VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? ( - <> - } - disabled={!canDownloadCSV} - > - {t('Export to original .CSV')} - - } - disabled={!canDownloadCSV} - > - {t('Export to pivoted .CSV')} - - - ) : ( - } - disabled={!canDownloadCSV} - > - {t('Export to .CSV')} - - )} - } - disabled={!canDownloadCSV} - > - {t('Export to .JSON')} - - } - > - {t('Download as image')} - - } - disabled={!canDownloadCSV} - > - {t('Export to Excel')} - - } - disabled={!canDownloadCSV} - > - {t('Export to Pivoted Excel')} - - - - - {t('Copy permalink to clipboard')} - - - {t('Share chart by email')} - - {isFeatureEnabled(FeatureFlag.EmbeddableCharts) ? ( - - {t('Embed code')} - } - modalTitle={t('Embed code')} - modalBody={ - - } - maxWidth={`${theme.sizeUnit * 100}px`} - destroyOnHidden - responsive - /> - - ) : null} - - - {showReportSubMenu ? ( - <> - - - - ) : ( - - )} - + if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) { + shareChildren.push({ + key: MENU_KEYS.EMBED_CODE, + label: ( {t('View query')} +
{t('Embed code')}
} - modalTitle={t('View query')} + modalTitle={t('Embed code')} modalBody={ - + } - draggable - resizable + maxWidth={`${theme.sizeUnit * 100}px`} + destroyOnHidden responsive /> -
- {datasource && ( - - {t('Run in SQL Lab')} - - )} -
- ), - [ - addDangerToast, - canDownloadCSV, - chart, - dashboards, - handleMenuClick, - isDropdownVisible, - latestQueryFormData, - showReportSubMenu, - slice, - theme.sizeUnit, - ], - ); + ), + onClick: () => setIsDropdownVisible(false), + }); + } + + menuItems.push({ + key: MENU_KEYS.SHARE_SUBMENU, + type: 'submenu', + label: t('Share'), + children: shareChildren, + }); + + // Divider + menuItems.push({ type: 'divider' }); + + // Report menu item + if (reportMenuItem) { + menuItems.push(reportMenuItem); + } + + // View query + menuItems.push({ + key: MENU_KEYS.VIEW_QUERY, + label: ( + {t('View query')} + } + modalTitle={t('View query')} + modalBody={ + + } + draggable + resizable + responsive + /> + ), + onClick: () => setIsDropdownVisible(false), + }); + + // Run in SQL Lab + if (datasource) { + menuItems.push({ + key: MENU_KEYS.RUN_IN_SQL_LAB, + label: t('Run in SQL Lab'), + onClick: e => { + onOpenInEditor(latestQueryFormData, e.domEvent.metaKey); + setIsDropdownVisible(false); + }, + }); + } + + return ; + }, [ + addDangerToast, + canDownloadCSV, + copyLink, + dashboards, + datasource, + dispatch, + exportCSV, + exportCSVPivoted, + exportExcel, + exportJson, + latestQueryFormData, + onOpenInEditor, + onOpenPropertiesModal, + reportMenuItem, + shareByEmail, + slice, + theme.sizeUnit, + ]); + return [menu, isDropdownVisible, setIsDropdownVisible]; }; diff --git a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.test.tsx b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.test.tsx index 2a2e392c225..a14aa91c58c 100644 --- a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.test.tsx +++ b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.test.tsx @@ -18,12 +18,11 @@ */ import { act, render, screen, userEvent } from 'spec/helpers/testing-library'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; -import { Menu } from '@superset-ui/core/components/Menu'; -import HeaderReportDropdown, { HeaderReportProps } from '.'; +import { Menu, MenuItem } from '@superset-ui/core/components/Menu'; +import { useHeaderReportMenuItems, HeaderReportProps } from './index'; const createProps = () => ({ dashboardId: 1, - useTextMenu: false, setShowReportSubMenu: jest.fn, showReportModal: jest.fn, setCurrentReportDeleting: jest.fn, @@ -115,13 +114,14 @@ const stateWithUserAndReport = { }, }; +const MenuWrapper = (props: HeaderReportProps) => { + const reportMenuItems = useHeaderReportMenuItems(props); + const menuItems: MenuItem[] = [reportMenuItems]; + return ; +}; + function setup(props: HeaderReportProps, initialState = {}) { - render( - - - , - { useRedux: true, initialState }, - ); + render(, { useRedux: true, initialState }); } jest.mock('@superset-ui/core', () => ({ @@ -147,7 +147,7 @@ describe('Header Report Dropdown', () => { act(() => { setup(mockedProps, stateWithUserAndReport); }); - expect(screen.getByRole('menuitem')).toBeInTheDocument(); + expect(screen.getAllByRole('menuitem')[0]).toBeInTheDocument(); }); it('renders the dropdown correctly', async () => { @@ -155,8 +155,6 @@ describe('Header Report Dropdown', () => { act(() => { setup(mockedProps, stateWithUserAndReport); }); - const emailReportModalButton = screen.getByRole('menuitem'); - userEvent.hover(emailReportModalButton); expect(await screen.findByText('Email reports active')).toBeInTheDocument(); expect(screen.getByText('Edit email report')).toBeInTheDocument(); expect(screen.getByText('Delete email report')).toBeInTheDocument(); @@ -168,8 +166,6 @@ describe('Header Report Dropdown', () => { act(() => { setup(mockedProps, stateWithUserAndReport); }); - const emailReportModalButton = screen.getByRole('menuitem'); - userEvent.click(emailReportModalButton); const editModal = await screen.findByText('Edit email report'); userEvent.click(editModal); expect(mockedProps.showReportModal).toHaveBeenCalled(); @@ -181,49 +177,34 @@ describe('Header Report Dropdown', () => { act(() => { setup(mockedProps, stateWithUserAndReport); }); - const emailReportModalButton = screen.getByRole('menuitem'); - userEvent.click(emailReportModalButton); const deleteModal = await screen.findByText('Delete email report'); userEvent.click(deleteModal); expect(mockedProps.setCurrentReportDeleting).toHaveBeenCalled(); }); - it('renders Manage Email Reports Menu if textMenu is set to true and there is a report', async () => { - let mockedProps = createProps(); - mockedProps = { - ...mockedProps, - useTextMenu: true, - }; + it('renders Manage Email Reports Menu if there is a report', async () => { + const mockedProps = createProps(); act(() => { setup(mockedProps, stateWithUserAndReport); }); - userEvent.click(screen.getByRole('menuitem')); expect(await screen.findByText('Email reports active')).toBeInTheDocument(); expect(screen.getByText('Edit email report')).toBeInTheDocument(); expect(screen.getByText('Delete email report')).toBeInTheDocument(); }); - it('renders Schedule Email Reports if textMenu is set to true and there is a report', async () => { - let mockedProps = createProps(); - mockedProps = { - ...mockedProps, - useTextMenu: true, - }; + it('renders Schedule Email Reports if there is a report', async () => { + const mockedProps = createProps(); + act(() => { setup(mockedProps, stateWithOnlyUser); }); - userEvent.click(screen.getByRole('menuitem')); expect( await screen.findByText('Set up an email report'), ).toBeInTheDocument(); }); it('renders Schedule Email Reports as long as user has permission through any role', async () => { - let mockedProps = createProps(); - mockedProps = { - ...mockedProps, - useTextMenu: true, - }; + const mockedProps = createProps(); act(() => { setup(mockedProps, stateWithNonAdminUser); }); @@ -234,11 +215,8 @@ describe('Header Report Dropdown', () => { }); it('do not render Schedule Email Reports if user no permission', () => { - let mockedProps = createProps(); - mockedProps = { - ...mockedProps, - useTextMenu: true, - }; + const mockedProps = createProps(); + act(() => { setup(mockedProps, stateWithNonMenuAccessOnManage); }); diff --git a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx index a094cd3f658..ad80f98205c 100644 --- a/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx +++ b/superset-frontend/src/features/reports/ReportModal/HeaderReportDropdown/index.tsx @@ -16,24 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode, useEffect } from 'react'; +import { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { isEmpty } from 'lodash'; import { t, - SupersetTheme, - css, styled, FeatureFlag, isFeatureEnabled, getExtensionsRegistry, usePrevious, + css, } from '@superset-ui/core'; -import { Icons } from '@superset-ui/core/components/Icons'; -import { Switch } from '@superset-ui/core/components/Switch'; -import { AlertObject } from 'src/features/alerts/types'; -import { Menu } from '@superset-ui/core/components/Menu'; +import { MenuItem } from '@superset-ui/core/components/Menu'; import { Checkbox } from '@superset-ui/core/components'; +import { AlertObject } from 'src/features/alerts/types'; import { noOp } from 'src/utils/common'; import { ChartState } from 'src/explore/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; @@ -41,35 +37,14 @@ import { fetchUISpecificReport, toggleActive, } from 'src/features/reports/ReportModal/actions'; -import { reportSelector } from 'src/views/CRUD/hooks'; import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index'; const extensionsRegistry = getExtensionsRegistry(); -const deleteColor = (theme: SupersetTheme) => css` - color: ${theme.colorError}; -`; - -const onMenuHover = (theme: SupersetTheme) => css` - & .ant-menu-item { - padding: 5px 12px; - margin-top: 0px; - margin-bottom: 4px; - :hover { - color: ${theme.colorText}; - } - } - :hover { - background-color: ${theme.colorPrimaryBg}; - } -`; - -const onMenuItemHover = (theme: SupersetTheme) => css` - &:hover { - color: ${theme.colorText}; - background-color: ${theme.colorPrimaryBg}; - } -`; +export enum CreationMethod { + Charts = 'charts', + Dashboards = 'dashboards', +} const StyledDropdownItemWithIcon = styled.div` display: flex; @@ -85,63 +60,59 @@ const DropdownItemExtension = extensionsRegistry.get( 'report-modal.dropdown.item.icon', ); -export enum CreationMethod { - Charts = 'charts', - Dashboards = 'dashboards', -} export interface HeaderReportProps { dashboardId?: number; chart?: ChartState; - useTextMenu?: boolean; - setShowReportSubMenu?: (show: boolean) => void; - showReportSubMenu?: boolean; - submenuTitle?: string; showReportModal: () => void; setCurrentReportDeleting: (report: AlertObject | null) => void; } -// Same instance to be used in useEffects -const EMPTY_OBJECT = {}; - -export default function HeaderReportDropDown({ +export const useHeaderReportMenuItems = ({ dashboardId, chart, - useTextMenu = false, - setShowReportSubMenu, - submenuTitle, showReportModal, setCurrentReportDeleting, -}: HeaderReportProps) { +}: HeaderReportProps): MenuItem | null => { const dispatch = useDispatch(); - const report = useSelector(state => { - const resourceType = dashboardId - ? CreationMethod.Dashboards - : CreationMethod.Charts; - return ( - reportSelector(state, resourceType, dashboardId || chart?.id) || - EMPTY_OBJECT - ); + const resourceId = dashboardId || chart?.id; + const resourceType = dashboardId + ? CreationMethod.Dashboards + : CreationMethod.Charts; + + // Select the reports state and specific report with proper reactivity + const report = useSelector(state => { + if (!resourceId) return null; + // Select directly from the reports state to ensure reactivity + const reportsState = state.reports || {}; + const resourceTypeReports = reportsState[resourceType] || {}; + const reportData = resourceTypeReports[resourceId]; + + // Debug logging to understand what's happening + console.log('Report selector called:', { + resourceId, + resourceType, + reportsState: Object.keys(reportsState), + resourceTypeReports: Object.keys(resourceTypeReports), + reportData: reportData + ? { id: reportData.id, name: reportData.name } + : null, + }); + + return reportData || null; }); - const isReportActive: boolean = report?.active || false; const user: UserWithPermissionsAndRoles = useSelector< any, UserWithPermissionsAndRoles >(state => state.user); + + const prevDashboard = usePrevious(dashboardId); + + // Check if user can add reports const canAddReports = () => { - if (!isFeatureEnabled(FeatureFlag.AlertReports)) { - return false; - } - - if (!user?.userId) { - // this is in the case that there is an anonymous user. - return false; - } - - // Cannot add reports if the resource is not saved - if (!(dashboardId || chart?.id)) { - return false; - } + if (!isFeatureEnabled(FeatureFlag.AlertReports)) return false; + if (!user?.userId) return false; + if (!resourceId) return false; const roles = Object.keys(user.roles || []); const permissions = roles.map(key => @@ -152,17 +123,11 @@ export default function HeaderReportDropDown({ return permissions.some(permission => permission.length > 0); }; - const prevDashboard = usePrevious(dashboardId); - const toggleActiveKey = async (data: AlertObject, checked: boolean) => { - if (data?.id) { - dispatch(toggleActive(data, checked)); - } - }; - const shouldFetch = canAddReports() && !!((dashboardId && prevDashboard !== dashboardId) || chart?.id); + // Fetch report data when needed useEffect(() => { if (shouldFetch) { dispatch( @@ -170,113 +135,82 @@ export default function HeaderReportDropDown({ userId: user.userId, filterField: dashboardId ? 'dashboard_id' : 'chart_id', creationMethod: dashboardId ? 'dashboards' : 'charts', - resourceId: dashboardId || chart?.id, + resourceId, }), ); } - }, []); + }, [dispatch, shouldFetch, user?.userId, dashboardId, resourceId]); - const showReportSubMenu = report && setShowReportSubMenu && canAddReports(); + // Don't show anything if user can't add reports + if (!canAddReports()) { + return null; + } - useEffect(() => { - if (showReportSubMenu) { - setShowReportSubMenu(true); - } else if (!report && setShowReportSubMenu) { - setShowReportSubMenu(false); + // Handler functions + const handleShowModal = () => showReportModal(); + const handleDeleteReport = () => setCurrentReportDeleting(report); + const handleToggleActive = () => { + if (report?.id) { + dispatch(toggleActive(report, !report.active)); } - }, [report]); - - const handleShowMenu = () => { - showReportModal(); }; - const handleDeleteMenuClick = () => { - setCurrentReportDeleting(report); - }; - - const textMenu = () => - isEmpty(report) ? ( - - - {DropdownItemExtension ? ( + // If no report exists, show "Set up email report" option + if (!report || !report.id) { + return { + key: 'email-report-setup', + type: 'submenu', + label: t('Manage email report'), + children: [ + { + key: 'set-up-report', + label: DropdownItemExtension ? (
{t('Set up an email report')}
) : ( t('Set up an email report') - )} -
- -
- ) : ( - - toggleActiveKey(report, !isReportActive)} - > + ), + onClick: handleShowModal, + }, + ], + }; + } + + // If report exists, show management options + return { + key: 'email-report-manage', + type: 'submenu', + label: t('Manage email report'), + children: [ + { + key: 'toggle-active', + label: ( - + css` + margin-right: ${theme.sizeUnit}px; + `} + /> {t('Email reports active')} - - - {t('Edit email report')} - - - {t('Delete email report')} - - - ); - const menu = (title: ReactNode) => ( - - - {t('Email reports active')} - toggleActiveKey(report, checked)} - size="small" - css={theme => css` - margin-left: ${theme.sizeUnit * 2}px; - `} - /> - - showReportModal()}> - {t('Edit email report')} - - setCurrentReportDeleting(report)} - css={deleteColor} - > - {t('Delete email report')} - - - ); - - const iconMenu = () => - isEmpty(report) ? ( - showReportModal()} - > - - - ) : ( - menu() - ); - return <>{canAddReports() && (useTextMenu ? textMenu() : iconMenu())}; -} + ), + onClick: handleToggleActive, + }, + { + key: 'edit-report', + label: t('Edit email report'), + onClick: handleShowModal, + }, + { + key: 'delete-report', + label: t('Delete email report'), + onClick: handleDeleteReport, + danger: true, + }, + ], + }; +};