refactor(Dropdown): Migrate Dropdown to Ant Design 5 (#31972)

This commit is contained in:
Mehmet Salih Yavuz 2025-02-07 20:38:04 +03:00 committed by GitHub
parent 38c46fcafd
commit bcc61bd933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 1137 additions and 1576 deletions

View File

@ -47,12 +47,12 @@ describe.skip('Dashboard top-level controls', () => {
// Solution: pause the network before clicking, assert, then unpause network. // Solution: pause the network before clicking, assert, then unpause network.
cy.get('[data-test="refresh-chart-menu-item"]').should( cy.get('[data-test="refresh-chart-menu-item"]').should(
'have.class', 'have.class',
'ant-dropdown-menu-item-disabled', 'antd5-dropdown-menu-item-disabled',
); );
waitForChartLoad(mapSpec); waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should( cy.get('[data-test="refresh-chart-menu-item"]').should(
'not.have.class', 'not.have.class',
'ant-dropdown-menu-item-disabled', 'antd5-dropdown-menu-item-disabled',
); );
}); });
}); });
@ -65,7 +65,7 @@ describe.skip('Dashboard top-level controls', () => {
cy.get('[aria-label="more-horiz"]').click(); cy.get('[aria-label="more-horiz"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').should( cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'not.have.class', 'not.have.class',
'ant-dropdown-menu-item-disabled', 'antd5-dropdown-menu-item-disabled',
); );
cy.get('[data-test="refresh-dashboard-menu-item"]').click({ cy.get('[data-test="refresh-dashboard-menu-item"]').click({
@ -73,7 +73,7 @@ describe.skip('Dashboard top-level controls', () => {
}); });
cy.get('[data-test="refresh-dashboard-menu-item"]').should( cy.get('[data-test="refresh-dashboard-menu-item"]').should(
'have.class', 'have.class',
'ant-dropdown-menu-item-disabled', 'antd5-dropdown-menu-item-disabled',
); );
// wait all charts force refreshed. // wait all charts force refreshed.
@ -94,7 +94,7 @@ describe.skip('Dashboard top-level controls', () => {
cy.get('[aria-label="more-horiz"]').click(); cy.get('[aria-label="more-horiz"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').and( cy.get('[data-test="refresh-dashboard-menu-item"]').and(
'not.have.class', 'not.have.class',
'ant-dropdown-menu-item-disabled', 'antd5-dropdown-menu-item-disabled',
); );
}); });
}); });

View File

@ -54,15 +54,14 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
interceptV1ChartData(); interceptV1ChartData();
} }
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)') cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
.first()
.should('be.visible') .should('be.visible')
.find("[role='menu'] [role='menuitem']") .find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/) .contains(/^Drill by$/)
.trigger('mouseover', { force: true }); .trigger('mouseover', { force: true });
cy.get( cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]', '.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
) )
.should('be.visible') .should('be.visible')
.find('[role="menuitem"]') .find('[role="menuitem"]')

View File

@ -61,15 +61,14 @@ function drillToDetail(targetMenuItem: string) {
const drillToDetailBy = (targetDrill: string) => { const drillToDetailBy = (targetDrill: string) => {
interceptSamples(); interceptSamples();
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)') cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
.first()
.should('be.visible') .should('be.visible')
.find("[role='menu'] [role='menuitem']") .find("[role='menu'] [role='menuitem']")
.contains(/^Drill to detail by$/) .contains(/^Drill to detail by$/)
.trigger('mouseover', { force: true }); .trigger('mouseover', { force: true });
cy.get( cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]', '.antd5-dropdown-menu-submenu:not(.antd5-dropdown-menu-submenu-hidden) [data-test="drill-to-detail-by-submenu"]',
) )
.should('be.visible') .should('be.visible')
.find('[role="menuitem"]') .find('[role="menuitem"]')

View File

@ -57,16 +57,16 @@ function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
.trigger('mouseover'); .trigger('mouseover');
if (orientation === 'vertical') { if (orientation === 'vertical') {
cy.get('.antd5-menu-item-selected') cy.get('.antd5-dropdown-menu-item-selected')
.contains('Horizontal (Top)') .contains('Horizontal (Top)')
.should('exist'); .should('exist');
cy.get('.antd5-menu-item').contains('Vertical (Left)').click(); cy.get('.antd5-dropdown-menu-item').contains('Vertical (Left)').click();
cy.getBySel('dashboard-filters-panel').should('exist'); cy.getBySel('dashboard-filters-panel').should('exist');
} else { } else {
cy.get('.antd5-menu-item-selected') cy.get('.antd5-dropdown-menu-item-selected')
.contains('Vertical (Left)') .contains('Vertical (Left)')
.should('exist'); .should('exist');
cy.get('.antd5-menu-item').contains('Horizontal (Top)').click(); cy.get('.antd5-dropdown-menu-item').contains('Horizontal (Top)').click();
cy.getBySel('loading-indicator').should('exist'); cy.getBySel('loading-indicator').should('exist');
cy.getBySel('filter-bar').should('exist'); cy.getBySel('filter-bar').should('exist');
cy.getBySel('dashboard-filters-panel').should('not.exist'); cy.getBySel('dashboard-filters-panel').should('not.exist');

View File

@ -31,35 +31,35 @@ const SAMPLE_DASHBOARDS_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function openDashboardsAddedTo() { function openDashboardsAddedTo() {
cy.getBySel('actions-trigger').click(); cy.getBySel('actions-trigger').click();
cy.get('.ant-dropdown-menu-submenu-title') cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards') .contains('On dashboards')
.trigger('mouseover', { force: true }); .trigger('mouseover', { force: true });
} }
function closeDashboardsAddedTo() { function closeDashboardsAddedTo() {
cy.get('.ant-dropdown-menu-submenu-title') cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards') .contains('On dashboards')
.trigger('mouseout', { force: true }); .trigger('mouseout', { force: true });
cy.getBySel('actions-trigger').click(); cy.getBySel('actions-trigger').click();
} }
function verifyDashboardsSubmenuItem(dashboardName) { function verifyDashboardsSubmenuItem(dashboardName) {
cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName); cy.get('.antd5-dropdown-menu-submenu-popup').contains(dashboardName);
closeDashboardsAddedTo(); closeDashboardsAddedTo();
} }
function verifyDashboardSearch() { function verifyDashboardSearch() {
openDashboardsAddedTo(); openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup') cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]') .find('input[placeholder="Search"]')
.type('1'); .type('1');
cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard'); cy.get('.antd5-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.ant-dropdown-menu-submenu-popup') cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]') .find('input[placeholder="Search"]')
.type('Blahblah'); .type('Blahblah');
cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found'); cy.get('.antd5-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.ant-dropdown-menu-submenu-popup') cy.get('.antd5-dropdown-menu-submenu-popup')
.find('[aria-label="close-circle"]') .find('[aria-label="close-circle"]')
.click(); .click();
closeDashboardsAddedTo(); closeDashboardsAddedTo();
@ -68,8 +68,8 @@ function verifyDashboardSearch() {
function verifyDashboardLink() { function verifyDashboardLink() {
interceptDashboardGet(); interceptDashboardGet();
openDashboardsAddedTo(); openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover'); cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a') cy.get('.antd5-dropdown-menu-submenu-popup a')
.first() .first()
.invoke('removeAttr', 'target') .invoke('removeAttr', 'target')
.click(); .click();

View File

@ -36,10 +36,10 @@ describe('Download Chart > Bar chart', () => {
}; };
cy.visitChartByParams(formData); cy.visitChartByParams(formData);
cy.get('.header-with-actions .ant-dropdown-trigger').click(); cy.get('.header-with-actions .antd5-dropdown-trigger').click();
cy.get(':nth-child(3) > .ant-dropdown-menu-submenu-title').click(); cy.get(':nth-child(3) > .antd5-dropdown-menu-submenu-title').click();
cy.get( cy.get(
'.ant-dropdown-menu-submenu > .ant-dropdown-menu li:nth-child(3)', '.antd5-dropdown-menu-submenu > .antd5-dropdown-menu li:nth-child(3)',
).click(); ).click();
cy.verifyDownload('.jpg', { cy.verifyDownload('.jpg', {
contains: true, contains: true,

View File

@ -80,9 +80,9 @@ describe('SqlLab query tabs', () => {
// configure some editor settings // configure some editor settings
cy.get(editorInput).type('some random query string', { force: true }); cy.get(editorInput).type('some random query string', { force: true });
cy.get(queryLimitSelector).parent().click({ force: true }); cy.get(queryLimitSelector).parent().click({ force: true });
cy.get('.ant-dropdown-menu') cy.get('.antd5-dropdown-menu')
.last() .last()
.find('.ant-dropdown-menu-item') .find('.antd5-dropdown-menu-item')
.first() .first()
.click({ force: true }); .click({ force: true });

View File

@ -158,10 +158,10 @@ export const sqlLabView = {
runButton: '.css-d3dxop', runButton: '.css-d3dxop',
}, },
rowsLimit: { rowsLimit: {
dropdown: '.ant-dropdown-menu', dropdown: '.antd5-dropdown-menu',
limitButton: '.ant-dropdown-menu-item', limitButton: '.antd5-dropdown-menu-item',
limitButtonText: '.css-151uxnz', limitButtonText: '.css-151uxnz',
limitTextWithValue: '[class="ant-dropdown-trigger"]', limitTextWithValue: '[class="antd5-dropdown-trigger"]',
}, },
renderedTableHeader: '.ReactVirtualized__Table__headerRow', renderedTableHeader: '.ReactVirtualized__Table__headerRow',
renderedTableRow: '.ReactVirtualized__Table__row', renderedTableRow: '.ReactVirtualized__Table__row',
@ -633,7 +633,7 @@ export const dashboardView = {
refreshChart: dataTestLocator('refresh-chart-menu-item'), refreshChart: dataTestLocator('refresh-chart-menu-item'),
}, },
threeDotsMenuIcon: threeDotsMenuIcon:
'.header-with-actions .right-button-panel .ant-dropdown-trigger', '.header-with-actions .right-button-panel .antd5-dropdown-trigger',
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'), threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'), refreshDashboard: dataTestLocator('refresh-dashboard-menu-item'),
saveAsMenuOption: dataTestLocator('save-as-menu-item'), saveAsMenuOption: dataTestLocator('save-as-menu-item'),

View File

@ -43,8 +43,6 @@ export const GlobalStyles = () => (
// TODO: Remove z-indexes when Ant Design is fully upgraded to v5 // TODO: Remove z-indexes when Ant Design is fully upgraded to v5
// Prefer vanilla Ant Design z-indexes that should work out of the box // Prefer vanilla Ant Design z-indexes that should work out of the box
.ant-popover, .ant-popover,
.antd5-dropdown,
.ant-dropdown,
.ant-select-dropdown, .ant-select-dropdown,
.antd5-modal-wrap, .antd5-modal-wrap,
.antd5-modal-mask, .antd5-modal-mask,
@ -105,13 +103,6 @@ export const GlobalStyles = () => (
margin-right: 0; margin-right: 0;
} }
} }
.ant-dropdown-menu-sub .antd5-menu.antd5-menu-vertical {
box-shadow: none;
}
.ant-dropdown-menu-submenu-title,
.ant-dropdown-menu-item {
line-height: 1.5em !important;
}
`} `}
/> />
); );

View File

@ -17,12 +17,13 @@
* under the License. * under the License.
*/ */
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { styled, useTheme, t } from '@superset-ui/core'; import { useTheme, t } from '@superset-ui/core';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab'; import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import Button from 'src/components/Button';
export interface QueryLimitSelectProps { export interface QueryLimitSelectProps {
queryEditorId: string; queryEditorId: string;
@ -34,28 +35,6 @@ export function convertToNumWithSpaces(num: number) {
return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 '); return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1 ');
} }
const LimitSelectStyled = styled.span`
${({ theme }) => `
.ant-dropdown-trigger {
align-items: center;
color: ${theme.colors.grayscale.dark2};
display: flex;
font-size: 12px;
margin-right: ${theme.gridUnit * 2}px;
text-decoration: none;
border: 0;
background: transparent;
span {
display: inline-block;
margin-right: ${theme.gridUnit * 2}px;
&:last-of-type: {
margin-right: ${theme.gridUnit * 4}px;
}
}
}
`}
`;
function renderQueryLimit( function renderQueryLimit(
maxRow: number, maxRow: number,
setQueryLimit: (limit: number) => void, setQueryLimit: (limit: number) => void,
@ -94,20 +73,18 @@ const QueryLimitSelect = ({
dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit)); dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
return ( return (
<LimitSelectStyled> <Dropdown
<AntdDropdown dropdownRender={() => renderQueryLimit(maxRow, setQueryLimit)}
overlay={renderQueryLimit(maxRow, setQueryLimit)} trigger={['click']}
trigger={['click']} >
> <Button size="small" showMarginRight={false} type="link">
<button type="button" onClick={e => e.preventDefault()}> <span>{t('LIMIT')}:</span>
<span>{t('LIMIT')}:</span> <span className="limitDropdown">
<span className="limitDropdown"> {convertToNumWithSpaces(queryLimit)}
{convertToNumWithSpaces(queryLimit)} </span>
</span> <Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} /> </Button>
</button> </Dropdown>
</AntdDropdown>
</LimitSelectStyled>
); );
}; };

View File

@ -16,12 +16,10 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { FC } from 'react'; import { t, useTheme } from '@superset-ui/core';
import { t, useTheme, styled } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton'; import { DropdownButton } from 'src/components/DropdownButton';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { DropdownButtonProps } from 'antd/lib/dropdown';
interface SaveDatasetActionButtonProps { interface SaveDatasetActionButtonProps {
setShowSave: (arg0: boolean) => void; setShowSave: (arg0: boolean) => void;
@ -34,34 +32,14 @@ const SaveDatasetActionButton = ({
}: SaveDatasetActionButtonProps) => { }: SaveDatasetActionButtonProps) => {
const theme = useTheme(); const theme = useTheme();
const StyledDropdownButton = styled(
DropdownButton as FC<DropdownButtonProps>,
)`
&.ant-dropdown-button button.ant-btn.ant-btn-default {
font-weight: ${theme.gridUnit * 150};
background-color: ${theme.colors.primary.light4};
color: ${theme.colors.primary.dark1};
&:nth-of-type(2) {
&:before,
&:hover:before {
border-left: 2px solid ${theme.colors.primary.dark2};
}
}
}
span[name='caret-down'] {
margin-left: ${theme.gridUnit * 1}px;
color: ${theme.colors.primary.dark2};
}
`;
return !overlayMenu ? ( return !overlayMenu ? (
<Button onClick={() => setShowSave(true)} buttonStyle="primary"> <Button onClick={() => setShowSave(true)} buttonStyle="primary">
{t('Save')} {t('Save')}
</Button> </Button>
) : ( ) : (
<StyledDropdownButton <DropdownButton
onClick={() => setShowSave(true)} onClick={() => setShowSave(true)}
overlay={overlayMenu} dropdownRender={() => overlayMenu}
icon={ icon={
<Icons.CaretDown <Icons.CaretDown
iconColor={theme.colors.grayscale.light5} iconColor={theme.colors.grayscale.light5}
@ -71,7 +49,7 @@ const SaveDatasetActionButton = ({
trigger={['click']} trigger={['click']}
> >
{t('Save')} {t('Save')}
</StyledDropdownButton> </DropdownButton>
); );
}; };

View File

@ -56,7 +56,8 @@ import Mousetrap from 'mousetrap';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import Timer from 'src/components/Timer'; import Timer from 'src/components/Timer';
import ResizableSidebar from 'src/components/ResizableSidebar'; import ResizableSidebar from 'src/components/ResizableSidebar';
import { AntdDropdown, Skeleton } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Skeleton } from 'src/components';
import { Switch } from 'src/components/Switch'; import { Switch } from 'src/components/Switch';
import { Input } from 'src/components/Input'; import { Input } from 'src/components/Input';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
@ -868,9 +869,12 @@ const SqlEditor: FC<Props> = ({
<span> <span>
<ShareSqlLabQuery queryEditorId={queryEditor.id} /> <ShareSqlLabQuery queryEditorId={queryEditor.id} />
</span> </span>
<AntdDropdown overlay={renderDropdown()} trigger={['click']}> <Dropdown
dropdownRender={() => renderDropdown()}
trigger={['click']}
>
<Icons.MoreHoriz iconColor={theme.colors.grayscale.base} /> <Icons.MoreHoriz iconColor={theme.colors.grayscale.base} />
</AntdDropdown> </Dropdown>
</div> </div>
</> </>
)} )}

View File

@ -20,7 +20,7 @@ import { useMemo, FC } from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { Dropdown } from 'src/components/Dropdown'; import { MenuDotsDropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { styled, t, QueryState } from '@superset-ui/core'; import { styled, t, QueryState } from '@superset-ui/core';
import { import {
@ -88,10 +88,10 @@ const SqlEditorTabHeader: FC<Props> = ({ queryEditor }) => {
return ( return (
<TabTitleWrapper> <TabTitleWrapper>
<Dropdown <MenuDotsDropdown
trigger={['click']} trigger={['click']}
overlay={ overlay={
<Menu style={{ width: 176 }}> <Menu>
<Menu.Item <Menu.Item
className="close-btn" className="close-btn"
key="1" key="1"

View File

@ -30,11 +30,8 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import type { SqlLabRootState } from 'src/SqlLab/types'; import type { SqlLabRootState } from 'src/SqlLab/types';
import { import { Skeleton, AntdBreadcrumb as Breadcrumb } from 'src/components';
Skeleton, import { Dropdown } from 'src/components/Dropdown';
AntdBreadcrumb as Breadcrumb,
AntdDropdown,
} from 'src/components';
import FilterableTable from 'src/components/FilterableTable'; import FilterableTable from 'src/components/FilterableTable';
import Tabs from 'src/components/Tabs'; import Tabs from 'src/components/Tabs';
import { import {
@ -308,8 +305,8 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
<Title> <Title>
<Icons.Table iconSize="l" /> <Icons.Table iconSize="l" />
{tableName} {tableName}
<AntdDropdown <Dropdown
overlay={ dropdownRender={() => (
<Menu <Menu
onClick={({ key }) => { onClick={({ key }) => {
if (key === 'refresh-table') { if (key === 'refresh-table') {
@ -324,7 +321,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
}} }}
items={dropdownMenu} items={dropdownMenu}
/> />
} )}
trigger={['click']} trigger={['click']}
> >
<Icons.DownSquareOutlined <Icons.DownSquareOutlined
@ -332,7 +329,7 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
style={{ marginTop: 2, marginLeft: 4 }} style={{ marginTop: 2, marginLeft: 4 }}
aria-label={t('Table actions')} aria-label={t('Table actions')}
/> />
</AntdDropdown> </Dropdown>
</Title> </Title>
{isMetadataRefreshing ? ( {isMetadataRefreshing ? (
<Skeleton active /> <Skeleton active />

View File

@ -67,7 +67,7 @@ const decideType = (buttonStyle: ButtonStyle) => {
success: 'primary', success: 'primary',
secondary: 'default', secondary: 'default',
default: 'default', default: 'default',
tertiary: 'dashed', tertiary: 'default',
dashed: 'dashed', dashed: 'dashed',
link: 'link', link: 'link',
}; };

View File

@ -30,6 +30,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { import {
Behavior, Behavior,
BinaryQueryObjectFilterClause, BinaryQueryObjectFilterClause,
Column,
ContextMenuFilters, ContextMenuFilters,
ensureIsArray, ensureIsArray,
FeatureFlag, FeatureFlag,
@ -42,8 +43,11 @@ import {
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions'; import { usePermissions } from 'src/hooks/usePermissions';
import { AntdDropdown as Dropdown } from 'src/components/index'; import { Dropdown } from 'src/components/Dropdown';
import { updateDataMask } from 'src/dataMask/actions'; import { updateDataMask } from 'src/dataMask/actions';
import DrillByModal from 'src/components/Chart/DrillBy/DrillByModal';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { Dataset } from 'src/components/Chart/types';
import { DrillDetailMenuItems } from '../DrillDetail'; import { DrillDetailMenuItems } from '../DrillDetail';
import { getMenuAdjustedY } from '../utils'; import { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
@ -114,8 +118,22 @@ const ChartContextMenu = (
}>({ clientX: 0, clientY: 0 }); }>({ clientX: 0, clientY: 0 });
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [drillByColumn, setDrillByColumn] = useState<Column>();
const [showDrillByModal, setShowDrillByModal] = useState(false);
const [dataset, setDataset] = useState<Dataset>();
const verboseMap = useVerboseMap(dataset);
const menuItems = []; const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
setShowDrillByModal(true);
}, []);
const handleCloseDrillByModal = useCallback(() => {
setShowDrillByModal(false);
}, []);
const menuItems: React.JSX.Element[] = [];
const showDrillToDetail = const showDrillToDetail =
isFeatureEnabled(FeatureFlag.DrillToDetail) && isFeatureEnabled(FeatureFlag.DrillToDetail) &&
@ -249,9 +267,9 @@ const ChartContextMenu = (
formData={formData} formData={formData}
contextMenuY={clientY} contextMenuY={clientY}
submenuIndex={submenuIndex} submenuIndex={submenuIndex}
canDownload={canDownload}
open={openKeys.includes('drill-by-submenu')} open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu" key="drill-by-submenu"
onDrillBy={handleDrillBy}
{...(additionalConfig?.drillBy || {})} {...(additionalConfig?.drillBy || {})}
/>, />,
); );
@ -286,7 +304,7 @@ const ChartContextMenu = (
return ReactDOM.createPortal( return ReactDOM.createPortal(
<> <>
<Dropdown <Dropdown
overlay={ dropdownRender={() => (
<Menu <Menu
className="chart-context-menu" className="chart-context-menu"
data-test="chart-context-menu" data-test="chart-context-menu"
@ -302,15 +320,15 @@ const ChartContextMenu = (
<Menu.Item disabled>{t('No actions')}</Menu.Item> <Menu.Item disabled>{t('No actions')}</Menu.Item>
)} )}
</Menu> </Menu>
} )}
trigger={['click']} trigger={['click']}
onVisibleChange={value => { onOpenChange={value => {
setVisible(value); setVisible(value);
if (!value) { if (!value) {
setOpenKeys([]); setOpenKeys([]);
} }
}} }}
visible={visible} open={visible}
> >
<span <span
id={`hidden-span-${id}`} id={`hidden-span-${id}`}
@ -335,6 +353,16 @@ const ChartContextMenu = (
}} }}
/> />
)} )}
{showDrillByModal && drillByColumn && dataset && filters?.drillBy && (
<DrillByModal
column={drillByColumn}
drillByConfig={filters?.drillBy}
formData={formData}
onHideModal={handleCloseDrillByModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</>, </>,
document.body, document.body,
); );

View File

@ -74,7 +74,6 @@ const renderMenu = ({
<DrillByMenuItems <DrillByMenuItems
formData={formData ?? defaultFormData} formData={formData ?? defaultFormData}
drillByConfig={drillByConfig} drillByConfig={drillByConfig}
canDownload
open open
{...rest} {...rest}
/> />

View File

@ -53,10 +53,8 @@ import {
cachedSupersetGet, cachedSupersetGet,
supersetGetCache, supersetGetCache,
} from 'src/utils/cachedSupersetGet'; } from 'src/utils/cachedSupersetGet';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { InputRef } from 'antd-v5'; import { InputRef } from 'antd-v5';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip'; import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import DrillByModal from './DrillByModal';
import { getSubmenuYOffset } from '../utils'; import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation'; import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types'; import { Dataset } from '../types';
@ -74,8 +72,8 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void; onClick?: (event: MouseEvent) => void;
openNewModal?: boolean; openNewModal?: boolean;
excludedColumns?: Column[]; excludedColumns?: Column[];
canDownload: boolean;
open: boolean; open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
} }
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options'); const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
@ -106,8 +104,8 @@ export const DrillByMenuItems = ({
onClick = () => {}, onClick = () => {},
excludedColumns, excludedColumns,
openNewModal = true, openNewModal = true,
canDownload,
open, open,
onDrillBy,
...rest ...rest
}: DrillByMenuItemsProps) => { }: DrillByMenuItemsProps) => {
const theme = useTheme(); const theme = useTheme();
@ -117,25 +115,20 @@ export const DrillByMenuItems = ({
const [debouncedSearchInput, setDebouncedSearchInput] = useState(''); const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [dataset, setDataset] = useState<Dataset>(); const [dataset, setDataset] = useState<Dataset>();
const [columns, setColumns] = useState<Column[]>([]); const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<InputRef>(null); const ref = useRef<InputRef>(null);
const showSearch = const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD; loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback( const handleSelection = useCallback(
(event, column) => { (event, column) => {
onClick(event); onClick(event);
onSelection(column, drillByConfig); onSelection(column, drillByConfig);
setCurrentColumn(column); if (openNewModal && onDrillBy && dataset) {
if (openNewModal) { onDrillBy(column, dataset);
setShowModal(true);
} }
}, },
[drillByConfig, onClick, onSelection, openNewModal], [drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
); );
const closeModal = useCallback(() => {
setShowModal(false);
}, []);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@ -156,7 +149,6 @@ export const DrillByMenuItems = ({
?.behaviors.find(behavior => behavior === Behavior.DrillBy), ?.behaviors.find(behavior => behavior === Behavior.DrillBy),
[formData.viz_type], [formData.viz_type],
); );
const verboseMap = useVerboseMap(dataset);
useEffect(() => { useEffect(() => {
async function loadOptions() { async function loadOptions() {
@ -275,11 +267,11 @@ export const DrillByMenuItems = ({
const column = columns[index]; const column = columns[index];
return ( return (
<MenuItemWithTruncation <MenuItemWithTruncation
key={`drill-by-item-${column.column_name}`} menuKey={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name} tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)} onClick={e => handleSelection(e, column)}
style={style} style={style}
{...rest}
> >
{column.verbose_name || column.column_name} {column.verbose_name || column.column_name}
</MenuItemWithTruncation> </MenuItemWithTruncation>
@ -289,6 +281,7 @@ export const DrillByMenuItems = ({
return ( return (
<> <>
<Menu.SubMenu <Menu.SubMenu
key="drill-by-submenu"
title={t('Drill by')} title={t('Drill by')}
popupClassName="chart-context-submenu" popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]} popupOffset={[0, submenuYOffset]}
@ -349,16 +342,6 @@ export const DrillByMenuItems = ({
)} )}
</div> </div>
</Menu.SubMenu> </Menu.SubMenu>
{showModal && (
<DrillByModal
column={currentColumn}
drillByConfig={drillByConfig}
formData={formData}
onHideModal={closeModal}
dataset={{ ...dataset!, verbose_map: verboseMap }}
canDownload={canDownload}
/>
)}
</> </>
); );
}; };

View File

@ -60,8 +60,15 @@ const DISABLED_REASONS = {
), ),
}; };
const DisabledMenuItem = ({ children, ...props }: { children: ReactNode }) => ( const DisabledMenuItem = ({
<Menu.Item disabled {...props}> children,
menuKey,
...rest
}: {
children: ReactNode;
menuKey: string;
}) => (
<Menu.Item disabled key={menuKey} {...rest}>
<div <div
css={css` css={css`
white-space: normal; white-space: normal;
@ -183,39 +190,34 @@ const DrillDetailMenuItems = ({
} }
const drillToDetailMenuItem = drillDisabled ? ( const drillToDetailMenuItem = drillDisabled ? (
<DisabledMenuItem {...props} key="drill-to-detail-disabled"> <DisabledMenuItem menuKey="drill-to-detail-disabled" {...props}>
{DRILL_TO_DETAIL} {DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} /> <MenuItemTooltip title={drillDisabled} />
</DisabledMenuItem> </DisabledMenuItem>
) : ( ) : (
<Menu.Item <Menu.Item key="drill-to-detail" onClick={openModal.bind(null, [])}>
{...props}
key="drill-to-detail"
onClick={openModal.bind(null, [])}
>
{DRILL_TO_DETAIL} {DRILL_TO_DETAIL}
</Menu.Item> </Menu.Item>
); );
const drillToDetailByMenuItem = drillByDisabled ? ( const drillToDetailByMenuItem = drillByDisabled ? (
<DisabledMenuItem {...props} key="drill-to-detail-by-disabled"> <DisabledMenuItem menuKey="drill-to-detail-by-disabled" {...props}>
{DRILL_TO_DETAIL_BY} {DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} /> <MenuItemTooltip title={drillByDisabled} />
</DisabledMenuItem> </DisabledMenuItem>
) : ( ) : (
<Menu.SubMenu <Menu.SubMenu
{...props}
popupOffset={[0, submenuYOffset]} popupOffset={[0, submenuYOffset]}
popupClassName="chart-context-submenu" popupClassName="chart-context-submenu"
title={DRILL_TO_DETAIL_BY} title={DRILL_TO_DETAIL_BY}
key={key} key={key}
{...props}
> >
<div data-test="drill-to-detail-by-submenu"> <div data-test="drill-to-detail-by-submenu">
{filters.map((filter, i) => ( {filters.map((filter, i) => (
<MenuItemWithTruncation <MenuItemWithTruncation
{...props}
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`} tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
key={`drill-detail-filter-${i}`} menuKey={`drill-detail-filter-${i}`}
onClick={openModal.bind(null, [filter])} onClick={openModal.bind(null, [filter])}
> >
{`${DRILL_TO_DETAIL_BY} `} {`${DRILL_TO_DETAIL_BY} `}
@ -224,7 +226,6 @@ const DrillDetailMenuItems = ({
))} ))}
{filters.length > 1 && ( {filters.length > 1 && (
<Menu.Item <Menu.Item
{...props}
key="drill-detail-filter-all" key="drill-detail-filter-all"
onClick={openModal.bind(null, filters)} onClick={openModal.bind(null, filters)}
> >

View File

@ -28,12 +28,15 @@ export type MenuItemWithTruncationProps = {
children: ReactNode; children: ReactNode;
onClick?: MenuItemProps['onClick']; onClick?: MenuItemProps['onClick'];
style?: CSSProperties; style?: CSSProperties;
menuKey?: string;
}; };
export const MenuItemWithTruncation = ({ export const MenuItemWithTruncation = ({
tooltipText, tooltipText,
children, children,
...props onClick,
style,
menuKey,
}: MenuItemWithTruncationProps) => { }: MenuItemWithTruncationProps) => {
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>(); const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
@ -43,7 +46,9 @@ export const MenuItemWithTruncation = ({
display: flex; display: flex;
line-height: 1.5em; line-height: 1.5em;
`} `}
{...props} eventKey={menuKey}
onClick={onClick}
style={style}
> >
<Tooltip title={itemIsTruncated ? tooltipText : null}> <Tooltip title={itemIsTruncated ? tooltipText : null}>
<div <div

View File

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { Dropdown, DropdownProps } from '.'; import { MenuDotsDropdown, MenuDotsDropdownProps } from '.';
export default { export default {
title: 'Dropdown', title: 'Dropdown',
@ -50,8 +50,8 @@ const customOverlay = (
export const InteractiveDropdown = ({ export const InteractiveDropdown = ({
overlayType, overlayType,
...rest ...rest
}: DropdownProps & { overlayType: string }) => ( }: MenuDotsDropdownProps & { overlayType: string }) => (
<Dropdown <MenuDotsDropdown
{...rest} {...rest}
overlay={overlayType === 'custom' ? customOverlay : menu} overlay={overlayType === 'custom' ? customOverlay : menu}
/> />

View File

@ -24,13 +24,10 @@ import {
cloneElement, cloneElement,
} from 'react'; } from 'react';
import { AntdDropdown } from 'src/components';
// TODO: @geido - Remove these after dropdown is fully migrated to Antd v5
import { import {
Dropdown as Antd5Dropdown, Dropdown as AntdDropdown,
DropDownProps as Antd5DropdownProps, DropdownProps as AntdDropdownProps,
} from 'antd-v5'; } from 'antd-v5';
import { DropDownProps } from 'antd/lib/dropdown';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
@ -83,7 +80,8 @@ export enum IconOrientation {
Vertical = 'vertical', Vertical = 'vertical',
Horizontal = 'horizontal', Horizontal = 'horizontal',
} }
export interface DropdownProps extends DropDownProps {
export interface MenuDotsDropdownProps extends AntdDropdownProps {
overlay: ReactElement; overlay: ReactElement;
iconOrientation?: IconOrientation; iconOrientation?: IconOrientation;
} }
@ -100,19 +98,19 @@ const RenderIcon = (
return component; return component;
}; };
export const Dropdown = ({ export const MenuDotsDropdown = ({
overlay, overlay,
iconOrientation = IconOrientation.Vertical, iconOrientation = IconOrientation.Vertical,
...rest ...rest
}: DropdownProps) => ( }: MenuDotsDropdownProps) => (
<AntdDropdown overlay={overlay} {...rest}> <AntdDropdown dropdownRender={() => overlay} {...rest}>
<MenuDotsWrapper data-test="dropdown-trigger"> <MenuDotsWrapper data-test="dropdown-trigger">
{RenderIcon(iconOrientation)} {RenderIcon(iconOrientation)}
</MenuDotsWrapper> </MenuDotsWrapper>
</AntdDropdown> </AntdDropdown>
); );
export interface NoAnimationDropdownProps extends Antd5DropdownProps { export interface NoAnimationDropdownProps extends AntdDropdownProps {
children: ReactNode; children: ReactNode;
onBlur?: (e: FocusEvent<HTMLDivElement>) => void; onBlur?: (e: FocusEvent<HTMLDivElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
@ -126,8 +124,13 @@ export const NoAnimationDropdown = (props: NoAnimationDropdownProps) => {
}); });
return ( return (
<Antd5Dropdown overlayStyle={props.overlayStyle} {...rest}> <AntdDropdown autoFocus overlayStyle={props.overlayStyle} {...rest}>
{childrenWithProps} {childrenWithProps}
</Antd5Dropdown> </AntdDropdown>
); );
}; };
export type DropdownProps = AntdDropdownProps;
export const Dropdown = (props: DropdownProps) => (
<AntdDropdown autoFocus {...props} />
);

View File

@ -16,90 +16,39 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ReactNode, ReactElement } from 'react'; import { type ComponentProps } from 'react';
import { AntdDropdown, AntdTooltip } from 'src/components'; import { Dropdown } from 'antd-v5';
import { styled } from '@superset-ui/core'; import { Tooltip, TooltipPlacement } from 'src/components/Tooltip';
import { kebabCase } from 'lodash'; import { kebabCase } from 'lodash';
const StyledDropdownButton = styled.div` export type DropdownButtonProps = ComponentProps<typeof Dropdown.Button> & {
.ant-btn-group {
button.ant-btn {
background-color: ${({ theme }) => theme.colors.primary.dark1};
border-color: transparent;
color: ${({ theme }) => theme.colors.grayscale.light5};
font-size: 12px;
line-height: 13px;
outline: none;
&:first-of-type {
border-radius: ${({ theme }) =>
`${theme.gridUnit}px 0 0 ${theme.gridUnit}px`};
margin: 0;
}
&:disabled {
background-color: ${({ theme }) => theme.colors.grayscale.light2};
color: ${({ theme }) => theme.colors.grayscale.base};
}
&:nth-of-type(2) {
margin: 0;
border-radius: ${({ theme }) =>
`0 ${theme.gridUnit}px ${theme.gridUnit}px 0`};
width: ${({ theme }) => theme.gridUnit * 9}px;
&:before,
&:hover:before {
border-left: 1px solid ${({ theme }) => theme.colors.grayscale.light5};
content: '';
display: block;
height: ${({ theme }) => theme.gridUnit * 8}px;
margin: 0;
position: absolute;
width: ${({ theme }) => theme.gridUnit * 0.25}px;
}
&:disabled:before {
border-left: 1px solid ${({ theme }) => theme.colors.grayscale.base};
}
}
}
}
`;
export interface DropdownButtonProps {
overlay: ReactElement;
tooltip?: string; tooltip?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; tooltipPlacement?: TooltipPlacement;
buttonsRender?: ((buttons: ReactNode[]) => ReactNode[]) | undefined; };
}
export const DropdownButton = ({ export const DropdownButton = ({
overlay, dropdownRender,
tooltip, tooltip,
placement, tooltipPlacement,
children,
...rest ...rest
}: DropdownButtonProps) => { }: DropdownButtonProps) => {
const buildButton = ( const button = (
props: { <Dropdown.Button dropdownRender={dropdownRender} {...rest}>
buttonsRender?: DropdownButtonProps['buttonsRender']; {children}
} = {}, </Dropdown.Button>
) => (
<StyledDropdownButton>
<AntdDropdown.Button overlay={overlay} {...rest} {...props} />
</StyledDropdownButton>
); );
if (tooltip) { if (tooltip) {
return buildButton({ return (
buttonsRender: ([leftButton, rightButton]) => [ <Tooltip
<AntdTooltip placement={tooltipPlacement}
placement={placement} id={`${kebabCase(tooltip)}-tooltip`}
id={`${kebabCase(tooltip)}-tooltip`} title={tooltip}
title={tooltip} >
> {button}
{leftButton} </Tooltip>
</AntdTooltip>, );
rightButton,
],
});
} }
return buildButton(); return button;
}; };

View File

@ -1,56 +0,0 @@
/**
* 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 Icons from 'src/components/Icons';
import DropdownSelectableIcon, { DropDownSelectableProps } from '.';
export default {
title: 'DropdownSelectableIcon',
component: DropdownSelectableIcon,
};
export const Component = (props: DropDownSelectableProps) => (
<DropdownSelectableIcon
{...props}
icon={<Icons.Gear name="gear" iconColor="#000000" />}
/>
);
Component.args = {
info: 'Info go here',
selectedKeys: ['vertical'],
menuItems: [
{
key: 'vertical',
label: 'Vertical',
},
{
key: 'horizontal',
label: 'Horizontal',
},
],
};
Component.argTypes = {
onSelect: {
action: 'onSelect',
table: {
disable: true,
},
},
};

View File

@ -1,98 +0,0 @@
/**
* 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 { render, screen, waitFor } from 'spec/helpers/testing-library';
import Icons from 'src/components/Icons';
import userEvent from '@testing-library/user-event';
import DropdownSelectableIcon, { DropDownSelectableProps } from '.';
const mockedProps = {
menuItems: [
{
key: 'vertical',
label: 'vertical',
},
{
key: 'horizontal',
label: 'horizontal',
},
],
selectedKeys: [],
icon: <Icons.Gear name="gear" />,
};
const asyncRender = (props: DropDownSelectableProps) =>
waitFor(() => render(<DropdownSelectableIcon {...props} />));
const openMenu = () => {
userEvent.click(screen.getByRole('img', { name: 'gear' }));
};
test('should render', async () => {
const { container } = await asyncRender(mockedProps);
expect(container).toBeInTheDocument();
});
test('should render the icon', async () => {
await asyncRender(mockedProps);
expect(screen.getByRole('img', { name: 'gear' })).toBeInTheDocument();
});
test('should not render the info', async () => {
await asyncRender(mockedProps);
openMenu();
expect(
screen.queryByTestId('dropdown-selectable-info'),
).not.toBeInTheDocument();
});
test('should render the info', async () => {
const infoProps = {
...mockedProps,
info: 'Test',
};
await asyncRender(infoProps);
openMenu();
expect(screen.getByTestId('dropdown-selectable-info')).toBeInTheDocument();
expect(screen.getByText('Test')).toBeInTheDocument();
});
test('should render the menu items', async () => {
await asyncRender(mockedProps);
openMenu();
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
expect(screen.getByText('vertical')).toBeInTheDocument();
expect(screen.getByText('horizontal')).toBeInTheDocument();
});
test('should not render any selected menu item', async () => {
await asyncRender(mockedProps);
openMenu();
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
expect(screen.queryByRole('img', { name: 'check' })).not.toBeInTheDocument();
});
test('should render the selected menu items', async () => {
const selectedProps = {
...mockedProps,
selectedKeys: ['vertical'],
};
await asyncRender(selectedProps);
openMenu();
expect(screen.getByRole('img', { name: 'check' })).toBeInTheDocument();
});

View File

@ -1,177 +0,0 @@
/**
* 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 { addAlpha, styled, useTheme } from '@superset-ui/core';
import { FC, RefObject, useMemo, ReactNode, useState } from 'react';
import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton';
import { DropdownButtonProps } from 'antd/lib/dropdown';
import { Menu, MenuProps } from 'src/components/Menu';
const { SubMenu } = Menu;
type SubMenuItemProps = { key: string; label: string | ReactNode };
export interface DropDownSelectableProps extends Pick<MenuProps, 'onSelect'> {
ref?: RefObject<HTMLDivElement>;
icon: ReactNode;
info?: string;
menuItems: {
key: string;
label: string | ReactNode;
children?: SubMenuItemProps[];
divider?: boolean;
}[];
selectedKeys?: string[];
}
const StyledDropdownButton = styled(DropdownButton as FC<DropdownButtonProps>)`
button.ant-btn:first-of-type {
display: none;
}
> button.ant-btn:nth-of-type(2) {
display: inline-flex;
background-color: transparent !important;
height: unset;
padding: 0;
border: none;
width: auto !important;
.anticon {
line-height: 0;
}
&:after {
box-shadow: none !important;
}
}
`;
const StyledMenu = styled(Menu)`
${({ theme }) => `
box-shadow:
0 3px 6px -4px ${addAlpha(theme.colors.grayscale.dark2, 0.12)},
0 6px 16px 0
${addAlpha(theme.colors.grayscale.dark2, 0.08)},
0 9px 28px 8px
${addAlpha(theme.colors.grayscale.dark2, 0.05)};
.info {
font-size: ${theme.typography.sizes.s}px;
color: ${theme.colors.grayscale.base};
padding: ${theme.gridUnit}px ${theme.gridUnit * 3}px ${
theme.gridUnit
}px ${theme.gridUnit * 3}px;
}
.ant-dropdown-menu-item-selected {
color: ${theme.colors.grayscale.dark1};
background-color: ${theme.colors.primary.light5};
}
`}
`;
const StyleMenuItem = styled(Menu.Item)<{ divider?: boolean }>`
display: flex;
justify-content: space-between;
> span {
width: 100%;
}
border-bottom: ${({ divider, theme }) =>
divider ? `1px solid ${theme.colors.grayscale.light3};` : 'none;'};
`;
const StyleSubmenuItem = styled.div`
display: flex;
justify-content: space-between;
width: 100%;
> div {
flex-grow: 1;
}
`;
export default (props: DropDownSelectableProps) => {
const theme = useTheme();
const [visible, setVisible] = useState(false);
const { icon, info, menuItems, selectedKeys, onSelect } = props;
const handleVisibleChange = setVisible;
const handleMenuSelect: MenuProps['onSelect'] = info => {
if (onSelect) {
onSelect(info);
}
setVisible(false);
};
const menuItem = useMemo(
() => (label: string | ReactNode, key: string, divider?: boolean) => (
<StyleMenuItem key={key} divider={divider}>
<StyleSubmenuItem>
{label}
{selectedKeys?.includes(key) && (
<Icons.Check
iconColor={theme.colors.primary.base}
className="tick-menu-item"
iconSize="xl"
/>
)}
</StyleSubmenuItem>
</StyleMenuItem>
),
[selectedKeys, theme.colors.primary.base],
);
const overlayMenu = useMemo(
() => (
<>
{info && (
<div className="info" data-test="dropdown-selectable-info">
{info}
</div>
)}
<StyledMenu
selectedKeys={selectedKeys}
onSelect={handleMenuSelect}
selectable
>
{menuItems.map(m =>
m.children?.length ? (
<SubMenu
title={m.label}
key={m.key}
data-test="dropdown-selectable-icon-submenu"
>
{m.children.map(s => menuItem(s.label, s.key))}
</SubMenu>
) : (
menuItem(m.label, m.key, m.divider)
),
)}
</StyledMenu>
</>
),
[selectedKeys, onSelect, info, menuItems, menuItem, handleMenuSelect],
);
return (
<StyledDropdownButton
overlay={overlayMenu}
trigger={['click']}
icon={icon}
visible={visible}
onVisibleChange={handleVisibleChange}
/>
);
};

View File

@ -17,7 +17,7 @@
* under the License. * under the License.
*/ */
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import FaveStar from 'src/components/FaveStar'; import FaveStar from 'src/components/FaveStar';
@ -70,8 +70,8 @@ export const SupersetListViewCard = ({
saveFaveStar={action('saveFaveStar')} saveFaveStar={action('saveFaveStar')}
isStarred={isStarred} isStarred={isStarred}
/> />
<AntdDropdown <Dropdown
overlay={ dropdownRender={() => (
<Menu> <Menu>
<Menu.Item role="button" tabIndex={0} onClick={action('Delete')}> <Menu.Item role="button" tabIndex={0} onClick={action('Delete')}>
<Icons.Trash /> Delete <Icons.Trash /> Delete
@ -80,10 +80,10 @@ export const SupersetListViewCard = ({
<Icons.EditAlt /> Edit <Icons.EditAlt /> Edit
</Menu.Item> </Menu.Item>
</Menu> </Menu>
} )}
> >
<Icons.MoreHoriz /> <Icons.MoreHoriz />
</AntdDropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
} }
/> />

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { addAlpha, styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { ReactElement } from 'react'; import { ReactElement } from 'react';
import { Menu as AntdMenu } from 'antd-v5'; import { Menu as AntdMenu } from 'antd-v5';
import { MenuProps as AntdMenuProps } from 'antd-v5/es/menu'; import { MenuProps as AntdMenuProps } from 'antd-v5/es/menu';
@ -73,23 +73,11 @@ const StyledMenuItem = styled(AntdMenu.Item)`
} }
`; `;
// TODO: @geido - Move this to theme after fully migrating dropdown to Antd5
const StyledMenu = styled(AntdMenu)` const StyledMenu = styled(AntdMenu)`
${({ theme }) => ` &.antd5-menu-horizontal {
&.antd5-menu-horizontal { background-color: inherit;
background-color: inherit; border-bottom: 1px solid transparent;
border-bottom: 1px solid transparent; }
}
&.antd5-menu-vertical,
&.ant-dropdown-menu {
box-shadow:
0 3px 6px -4px ${addAlpha(theme.colors.grayscale.dark2, 0.12)},
0 6px 16px 0
${addAlpha(theme.colors.grayscale.dark2, 0.08)},
0 9px 28px 8px
${addAlpha(theme.colors.grayscale.dark2, 0.05)};
}
`}
`; `;
const StyledNav = styled(AntdMenu)` const StyledNav = styled(AntdMenu)`
@ -145,11 +133,6 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)`
transition: all ${({ theme }) => theme.transitionTiming}s; transition: all ${({ theme }) => theme.transitionTiming}s;
} }
} }
.ant-dropdown-menu-submenu-arrow:before,
.ant-dropdown-menu-submenu-arrow:after {
content: none !important;
}
`; `;
export type MenuMode = AntdMenuProps['mode']; export type MenuMode = AntdMenuProps['mode'];

View File

@ -18,7 +18,7 @@
*/ */
import { ReactNode, ReactElement } from 'react'; import { ReactNode, ReactElement } from 'react';
import { css, SupersetTheme, t, useTheme } from '@superset-ui/core'; import { css, SupersetTheme, t, useTheme } from '@superset-ui/core';
import { AntdDropdown, AntdDropdownProps } from 'src/components'; import { Dropdown, DropdownProps } from 'src/components/Dropdown';
import { TooltipPlacement } from 'src/components/Tooltip'; import { TooltipPlacement } from 'src/components/Tooltip';
import { import {
DynamicEditableTitle, DynamicEditableTitle,
@ -116,7 +116,7 @@ export type PageHeaderWithActionsProps = {
titlePanelAdditionalItems: ReactNode; titlePanelAdditionalItems: ReactNode;
rightPanelAdditionalItems: ReactNode; rightPanelAdditionalItems: ReactNode;
additionalActionsMenu: ReactElement; additionalActionsMenu: ReactElement;
menuDropdownProps: Omit<AntdDropdownProps, 'overlay'>; menuDropdownProps: Omit<DropdownProps, 'overlay'>;
tooltipProps?: { tooltipProps?: {
text?: string; text?: string;
placement?: TooltipPlacement; placement?: TooltipPlacement;
@ -155,9 +155,9 @@ export const PageHeaderWithActions = ({
{rightPanelAdditionalItems} {rightPanelAdditionalItems}
<div css={additionalActionsContainerStyles}> <div css={additionalActionsContainerStyles}>
{showMenuDropdown && ( {showMenuDropdown && (
<AntdDropdown <Dropdown
trigger={['click']} trigger={['click']}
overlay={additionalActionsMenu} dropdownRender={() => additionalActionsMenu}
{...menuDropdownProps} {...menuDropdownProps}
> >
<Button <Button
@ -173,7 +173,7 @@ export const PageHeaderWithActions = ({
iconSize="l" iconSize="l"
/> />
</Button> </Button>
</AntdDropdown> </Dropdown>
)} )}
</div> </div>
</div> </div>

View File

@ -19,7 +19,7 @@
import { Key } from 'react'; import { Key } from 'react';
import cx from 'classnames'; import cx from 'classnames';
import { styled, useTheme } from '@superset-ui/core'; import { styled, useTheme } from '@superset-ui/core';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
@ -89,10 +89,10 @@ const PopoverDropdown = (props: PopoverDropdownProps) => {
const theme = useTheme(); const theme = useTheme();
const selected = options.find(opt => opt.value === value); const selected = options.find(opt => opt.value === value);
return ( return (
<AntdDropdown <Dropdown
trigger={['click']} trigger={['click']}
overlayStyle={{ zIndex: theme.zIndex.max }} overlayStyle={{ zIndex: theme.zIndex.max }}
overlay={ dropdownRender={() => (
<Menu onClick={({ key }: HandleSelectProps) => onChange(key)}> <Menu onClick={({ key }: HandleSelectProps) => onChange(key)}>
{options.map(option => ( {options.map(option => (
<MenuItem <MenuItem
@ -106,7 +106,7 @@ const PopoverDropdown = (props: PopoverDropdownProps) => {
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
} )}
> >
<div role="button" css={{ display: 'flex', alignItems: 'center' }}> <div role="button" css={{ display: 'flex', alignItems: 'center' }}>
{selected && renderButton(selected)} {selected && renderButton(selected)}
@ -115,7 +115,7 @@ const PopoverDropdown = (props: PopoverDropdownProps) => {
css={{ marginTop: theme.gridUnit * 0.5 }} css={{ marginTop: theme.gridUnit * 0.5 }}
/> />
</div> </div>
</AntdDropdown> </Dropdown>
); );
}; };

View File

@ -18,7 +18,7 @@
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { styled } from '@superset-ui/core'; import { styled } from '@superset-ui/core';
import { Dropdown, IconOrientation } from 'src/components/Dropdown'; import { MenuDotsDropdown, IconOrientation } from 'src/components/Dropdown';
import { Menu, MenuProps } from 'src/components/Menu'; import { Menu, MenuProps } from 'src/components/Menu';
/** /**
@ -126,7 +126,7 @@ export function ActionCell(props: ActionCellProps) {
setVisible(flag); setVisible(flag);
}; };
return ( return (
<Dropdown <MenuDotsDropdown
iconOrientation={IconOrientation.Horizontal} iconOrientation={IconOrientation.Horizontal}
onVisibleChange={handleVisibleChange} onVisibleChange={handleVisibleChange}
trigger={['click']} trigger={['click']}

View File

@ -23,14 +23,12 @@ import { TooltipProps, TooltipPlacement } from 'antd-v5/lib/tooltip';
export { TooltipProps, TooltipPlacement }; export { TooltipProps, TooltipPlacement };
export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => ( export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => (
<> <AntdTooltip
<AntdTooltip styles={{
styles={{ body: { overflow: 'hidden', textOverflow: 'ellipsis' },
body: { overflow: 'hidden', textOverflow: 'ellipsis' }, root: overlayStyle ?? {},
root: overlayStyle ?? {}, }}
}} color={`${supersetTheme.colors.grayscale.dark2}e6`}
color={`${supersetTheme.colors.grayscale.dark2}e6`} {...props}
{...props} />
/>
</>
); );

View File

@ -56,7 +56,6 @@ export {
Card as AntdCard, Card as AntdCard,
Checkbox as AntdCheckbox, Checkbox as AntdCheckbox,
Collapse as AntdCollapse, Collapse as AntdCollapse,
Dropdown as AntdDropdown,
Form as AntdForm, Form as AntdForm,
Input as AntdInput, Input as AntdInput,
Select as AntdSelect, Select as AntdSelect,
@ -67,5 +66,4 @@ export {
// Exported types // Exported types
export type { FormInstance } from 'antd/lib/form'; export type { FormInstance } from 'antd/lib/form';
export type { DropDownProps as AntdDropdownProps } from 'antd/lib/dropdown';
export type { RadioChangeEvent } from 'antd/lib/radio'; export type { RadioChangeEvent } from 'antd/lib/radio';

View File

@ -17,8 +17,8 @@
* under the License. * under the License.
*/ */
import { Key, ReactNode, PureComponent } from 'react'; import { Key, ReactNode, PureComponent } from 'react';
import { Dropdown } from 'src/components/Dropdown';
import rison from 'rison'; import rison from 'rison';
import { AntdDropdown } from 'src/components';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { t, styled, SupersetClient } from '@superset-ui/core'; import { t, styled, SupersetClient } from '@superset-ui/core';
@ -115,9 +115,9 @@ class CssEditor extends PureComponent<CssEditorProps, CssEditorState> {
</Menu> </Menu>
); );
return ( return (
<AntdDropdown overlay={menu} placement="bottomRight"> <Dropdown dropdownRender={() => menu} placement="bottomRight">
<Button>{t('Load a CSS template')}</Button> <Button>{t('Load a CSS template')}</Button>
</AntdDropdown> </Dropdown>
); );
} }
return null; return null;

View File

@ -53,9 +53,6 @@ jest.mock('src/components/Select/Select', () => () => (
jest.mock('src/components/Select/AsyncSelect', () => () => ( jest.mock('src/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" /> <div data-test="mock-async-select" />
)); ));
jest.mock('src/dashboard/components/Header/HeaderActionsDropdown', () => () => (
<div data-test="mock-header-actions-dropdown" />
));
jest.mock('src/components/PageHeaderWithActions', () => ({ jest.mock('src/components/PageHeaderWithActions', () => ({
PageHeaderWithActions: () => ( PageHeaderWithActions: () => (
<div data-test="mock-page-header-with-actions" /> <div data-test="mock-page-header-with-actions" />

View File

@ -1,260 +0,0 @@
/**
* 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 { shallow } from 'enzyme';
import sinon from 'sinon';
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
import { HeaderActionsDropdown } from '.';
const createProps = (): HeaderDropdownProps => ({
addSuccessToast: jest.fn(),
addDangerToast: jest.fn(),
customCss: '.ant-menu {margin-left: 100px;}',
dashboardId: 1,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
dash_save_perm: true,
userId: '1',
metadata: {},
common: {
conf: {
DASHBOARD_AUTO_REFRESH_INTERVALS: [
[0, "Don't refresh"],
[10, '10 seconds'],
],
},
},
},
dashboardTitle: 'Title',
editMode: false,
expandedSlices: {},
forceRefreshAllCharts: jest.fn(),
hasUnsavedChanges: false,
isLoading: false,
layout: {},
onChange: jest.fn(),
onSave: jest.fn(),
refreshFrequency: 200,
setRefreshFrequency: jest.fn(),
shouldPersistRefreshFrequency: false,
showPropertiesModal: jest.fn(),
startPeriodicRender: jest.fn(),
updateCss: jest.fn(),
userCanEdit: false,
userCanSave: false,
userCanShare: false,
userCanCurate: false,
lastModifiedTime: 0,
isDropdownVisible: true,
setIsDropdownVisible: jest.fn(),
directPathToChild: [],
manageEmbedded: jest.fn(),
dataMask: {},
logEvent: jest.fn(),
refreshLimit: 0,
refreshWarning: '',
});
const editModeOnProps = {
...createProps(),
editMode: true,
};
const editModeOnWithFilterScopesProps = {
...editModeOnProps,
dashboardInfo: {
...editModeOnProps.dashboardInfo,
metadata: {
filter_scopes: {
'1': { scopes: ['ROOT_ID'], immune: [] },
},
},
},
};
const guestUserProps = {
...createProps(),
dashboardInfo: {
...createProps().dashboardInfo,
userId: undefined,
},
};
function setup(props: HeaderDropdownProps) {
return render(
<div className="dashboard-header">
<HeaderActionsDropdown {...props} />
</div>,
{ useRedux: true },
);
}
fetchMock.get('glob:*/csstemplateasyncmodelview/api/read', {});
test('should render', () => {
const mockedProps = createProps();
const { container } = setup(mockedProps);
expect(container).toBeInTheDocument();
});
test('should render the Download dropdown button when not in edit mode', () => {
const mockedProps = createProps();
setup(mockedProps);
expect(
screen.getByRole('menuitem', { name: 'Download' }),
).toBeInTheDocument();
});
test('should render the menu items', async () => {
const mockedProps = createProps();
setup(mockedProps);
expect(screen.getAllByRole('menuitem')).toHaveLength(4);
expect(screen.getByText('Refresh dashboard')).toBeInTheDocument();
expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
});
test('should render the menu items in edit mode', async () => {
setup(editModeOnProps);
expect(screen.getAllByRole('menuitem')).toHaveLength(4);
expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument();
expect(screen.getByText('Edit properties')).toBeInTheDocument();
expect(screen.getByText('Edit CSS')).toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
});
test('should render the menu items in Embedded mode', async () => {
setup(guestUserProps);
expect(screen.getAllByRole('menuitem')).toHaveLength(3);
expect(screen.getByText('Refresh dashboard')).toBeInTheDocument();
expect(screen.getByText('Download')).toBeInTheDocument();
expect(screen.getByText('Set auto-refresh interval')).toBeInTheDocument();
});
test('should not render filter mapping in edit mode if explicit filter scopes undefined', async () => {
setup(editModeOnProps);
expect(screen.queryByText('Set filter mapping')).not.toBeInTheDocument();
});
test('should render filter mapping in edit mode if explicit filter scopes defined', async () => {
setup(editModeOnWithFilterScopesProps);
expect(screen.getByText('Set filter mapping')).toBeInTheDocument();
});
test('should show the share actions', async () => {
const mockedProps = createProps();
const canShareProps = {
...mockedProps,
userCanShare: true,
};
setup(canShareProps);
expect(screen.getByText('Share')).toBeInTheDocument();
});
test('should render the "Save as" menu item when user can save', async () => {
const mockedProps = createProps();
const canSaveProps = {
...mockedProps,
userCanSave: true,
};
setup(canSaveProps);
expect(screen.getByText('Save as')).toBeInTheDocument();
});
test('should NOT render the "Save as" menu item when user cannot save', async () => {
const mockedProps = createProps();
setup(mockedProps);
expect(screen.queryByText('Save as')).not.toBeInTheDocument();
});
test('should render the "Refresh dashboard" menu item as disabled when loading', async () => {
const mockedProps = createProps();
const loadingProps = {
...mockedProps,
isLoading: true,
};
setup(loadingProps);
expect(screen.getByText('Refresh dashboard').parentElement).toHaveClass(
'ant-menu-item-disabled',
);
});
test('should NOT render the "Refresh dashboard" menu item as disabled', async () => {
const mockedProps = createProps();
setup(mockedProps);
expect(screen.getByText('Refresh dashboard')).not.toHaveClass(
'ant-menu-item-disabled',
);
});
test('should render with custom css', () => {
const mockedProps = createProps();
const { customCss } = mockedProps;
setup(mockedProps);
injectCustomCss(customCss);
expect(screen.getByTestId('header-actions-menu')).toHaveStyle(
'margin-left: 100px',
);
});
test('should refresh the charts', async () => {
const mockedProps = createProps();
setup(mockedProps);
userEvent.click(screen.getByText('Refresh dashboard'));
expect(mockedProps.forceRefreshAllCharts).toHaveBeenCalledTimes(1);
expect(mockedProps.addSuccessToast).toHaveBeenCalledTimes(1);
});
test('should show the properties modal', async () => {
setup(editModeOnProps);
userEvent.click(screen.getByText('Edit properties'));
expect(editModeOnProps.showPropertiesModal).toHaveBeenCalledTimes(1);
});
describe('UNSAFE_componentWillReceiveProps', () => {
let wrapper: any;
const mockedProps = createProps();
const props = { ...mockedProps, customCss: '' };
beforeEach(() => {
wrapper = shallow(<HeaderActionsDropdown {...props} />);
wrapper.setState({ css: props.customCss });
sinon.spy(wrapper.instance(), 'setState');
});
afterEach(() => {
wrapper.instance().setState.restore();
});
it('css should update state and inject custom css', () => {
wrapper.instance().UNSAFE_componentWillReceiveProps({
...props,
customCss: mockedProps.customCss,
});
expect(wrapper.instance().setState.calledOnce).toBe(true);
const stateKeys = Object.keys(wrapper.instance().setState.lastCall.args[0]);
expect(stateKeys).toContain('css');
});
});

View File

@ -40,7 +40,6 @@ import { Button } from 'src/components/';
import { findPermission } from 'src/utils/findPermission'; import { findPermission } from 'src/utils/findPermission';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import { safeStringify } from 'src/utils/safeStringify'; import { safeStringify } from 'src/utils/safeStringify';
import ConnectedHeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActionsDropdown';
import PublishedStatus from 'src/dashboard/components/PublishedStatus'; import PublishedStatus from 'src/dashboard/components/PublishedStatus';
import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners'; import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners';
import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import PropertiesModal from 'src/dashboard/components/PropertiesModal';
@ -53,6 +52,9 @@ import {
import setPeriodicRunner, { import setPeriodicRunner, {
stopPeriodicRender, stopPeriodicRender,
} from 'src/dashboard/util/setPeriodicRunner'; } from 'src/dashboard/util/setPeriodicRunner';
import ReportModal from 'src/features/reports/ReportModal';
import DeleteModal from 'src/components/DeleteModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions';
import DashboardEmbedModal from '../EmbeddedModal'; import DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm'; import OverwriteConfirm from '../OverwriteConfirm';
@ -88,6 +90,7 @@ import { dashboardInfoChanged } from '../../actions/dashboardInfo';
import isDashboardLoading from '../../util/isDashboardLoading'; import isDashboardLoading from '../../util/isDashboardLoading';
import { useChartIds } from '../../util/charts/useChartIds'; import { useChartIds } from '../../util/charts/useChartIds';
import { useDashboardMetadataBar } from './useDashboardMetadataBar'; import { useDashboardMetadataBar } from './useDashboardMetadataBar';
import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu';
const extensionsRegistry = getExtensionsRegistry(); const extensionsRegistry = getExtensionsRegistry();
@ -160,8 +163,9 @@ const Header = () => {
const [emphasizeUndo, setEmphasizeUndo] = useState(false); const [emphasizeUndo, setEmphasizeUndo] = useState(false);
const [emphasizeRedo, setEmphasizeRedo] = useState(false); const [emphasizeRedo, setEmphasizeRedo] = useState(false);
const [showingPropertiesModal, setShowingPropertiesModal] = useState(false); const [showingPropertiesModal, setShowingPropertiesModal] = useState(false);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showingEmbedModal, setShowingEmbedModal] = useState(false); const [showingEmbedModal, setShowingEmbedModal] = useState(false);
const [showingReportModal, setShowingReportModal] = useState(false);
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const dashboardInfo = useSelector(state => state.dashboardInfo); const dashboardInfo = useSelector(state => state.dashboardInfo);
const layout = useSelector(state => state.dashboardLayout.present); const layout = useSelector(state => state.dashboardLayout.present);
const undoLength = useSelector(state => state.dashboardLayout.past.length); const undoLength = useSelector(state => state.dashboardLayout.past.length);
@ -348,10 +352,6 @@ const Header = () => {
[boundActionCreators, dashboardTitle], [boundActionCreators, dashboardTitle],
); );
const setDropdownVisible = useCallback(visible => {
setIsDropdownVisible(visible);
}, []);
const handleCtrlY = useCallback(() => { const handleCtrlY = useCallback(() => {
boundActionCreators.onRedo(); boundActionCreators.onRedo();
setEmphasizeRedo(true); setEmphasizeRedo(true);
@ -475,6 +475,14 @@ const Header = () => {
setShowingEmbedModal(false); setShowingEmbedModal(false);
}, []); }, []);
const showReportModal = useCallback(() => {
setShowingReportModal(true);
}, []);
const hideReportModal = useCallback(() => {
setShowingReportModal(false);
}, []);
const metadataBar = useDashboardMetadataBar(dashboardInfo); const metadataBar = useDashboardMetadataBar(dashboardInfo);
const userCanEdit = const userCanEdit =
@ -689,92 +697,47 @@ const Header = () => {
], ],
); );
const menuDropdownProps = useMemo( const handleReportDelete = async report => {
() => ({ await dispatch(deleteActiveReport(report));
getPopupContainer: triggerNode => setCurrentReportDeleting(null);
triggerNode.closest('.header-with-actions'), };
visible: isDropdownVisible,
onVisibleChange: setDropdownVisible,
}),
[isDropdownVisible, setDropdownVisible],
);
const additionalActionsMenu = useMemo(
() => (
<ConnectedHeaderActionsDropdown
addSuccessToast={boundActionCreators.addSuccessToast}
addDangerToast={boundActionCreators.addDangerToast}
dashboardId={dashboardInfo.id}
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
dataMask={dataMask}
layout={layout}
expandedSlices={expandedSlices}
customCss={customCss}
colorNamespace={colorNamespace}
colorScheme={colorScheme}
onSave={boundActionCreators.onSave}
onChange={boundActionCreators.onChange}
forceRefreshAllCharts={forceRefresh}
startPeriodicRender={startPeriodicRender}
refreshFrequency={refreshFrequency}
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
setRefreshFrequency={boundActionCreators.setRefreshFrequency}
updateCss={boundActionCreators.updateCss}
editMode={editMode}
hasUnsavedChanges={hasUnsavedChanges}
userCanEdit={userCanEdit}
userCanShare={userCanShare}
userCanSave={userCanSaveAs}
userCanCurate={userCanCurate}
isLoading={isLoading}
showPropertiesModal={showPropertiesModal}
manageEmbedded={showEmbedModal}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
lastModifiedTime={actualLastModifiedTime}
isDropdownVisible={isDropdownVisible}
setIsDropdownVisible={setDropdownVisible}
logEvent={boundActionCreators.logEvent}
/>
),
[
actualLastModifiedTime,
boundActionCreators.addDangerToast,
boundActionCreators.addSuccessToast,
boundActionCreators.logEvent,
boundActionCreators.onChange,
boundActionCreators.onSave,
boundActionCreators.setRefreshFrequency,
boundActionCreators.updateCss,
colorNamespace,
colorScheme,
customCss,
dashboardInfo,
dashboardTitle,
dataMask,
editMode,
expandedSlices,
forceRefresh,
hasUnsavedChanges,
isDropdownVisible,
isLoading,
layout,
refreshFrequency,
refreshLimit,
refreshWarning,
setDropdownVisible,
shouldPersistRefreshFrequency,
showEmbedModal,
showPropertiesModal,
startPeriodicRender,
userCanCurate,
userCanEdit,
userCanSaveAs,
userCanShare,
],
);
const [menu, isDropdownVisible, setIsDropdownVisible] = useHeaderActionsMenu({
addSuccessToast: boundActionCreators.addSuccessToast,
addDangerToast: boundActionCreators.addDangerToast,
dashboardInfo,
dashboardId: dashboardInfo.id,
dashboardTitle,
dataMask,
layout,
expandedSlices,
customCss,
colorNamespace,
colorScheme,
onSave: boundActionCreators.onSave,
onChange: boundActionCreators.onChange,
forceRefreshAllCharts: forceRefresh,
startPeriodicRender,
refreshFrequency,
shouldPersistRefreshFrequency,
setRefreshFrequency: boundActionCreators.setRefreshFrequency,
updateCss: boundActionCreators.updateCss,
editMode,
hasUnsavedChanges,
userCanEdit,
userCanShare,
userCanSave: userCanSaveAs,
userCanCurate,
isLoading,
showReportModal,
showPropertiesModal,
setCurrentReportDeleting,
manageEmbedded: showEmbedModal,
refreshLimit,
refreshWarning,
lastModifiedTime: actualLastModifiedTime,
logEvent: boundActionCreators.logEvent,
});
return ( return (
<div <div
css={headerContainerStyle} css={headerContainerStyle}
@ -788,8 +751,11 @@ const Header = () => {
faveStarProps={faveStarProps} faveStarProps={faveStarProps}
titlePanelAdditionalItems={titlePanelAdditionalItems} titlePanelAdditionalItems={titlePanelAdditionalItems}
rightPanelAdditionalItems={rightPanelAdditionalItems} rightPanelAdditionalItems={rightPanelAdditionalItems}
menuDropdownProps={menuDropdownProps} menuDropdownProps={{
additionalActionsMenu={additionalActionsMenu} open: isDropdownVisible,
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={user?.userId && dashboardInfo?.id} showFaveStar={user?.userId && dashboardInfo?.id}
showTitlePanelItems showTitlePanelItems
/> />
@ -806,6 +772,32 @@ const Header = () => {
/> />
)} )}
<ReportModal
userId={user.userId}
show={showingReportModal}
onHide={hideReportModal}
userEmail={user.email}
dashboardId={dashboardInfo.id}
creationMethod="dashboards"
/>
{currentReportDeleting && (
<DeleteModal
description={t(
'This action will permanently delete %s.',
currentReportDeleting?.name,
)}
onConfirm={() => {
if (currentReportDeleting) {
handleReportDelete(currentReportDeleting);
}
}}
onHide={() => setCurrentReportDeleting(null)}
open
title={t('Delete Report?')}
/>
)}
<OverwriteConfirm /> <OverwriteConfirm />
{userCanCurate && ( {userCanCurate && (
@ -817,7 +809,7 @@ const Header = () => {
)} )}
<Global <Global
styles={css` styles={css`
.ant-menu-vertical { .antd5-menu-vertical {
border-right: none; border-right: none;
} }
`} `}

View File

@ -19,6 +19,7 @@
import { Layout } from 'src/dashboard/types'; import { Layout } from 'src/dashboard/types';
import { ChartState } from 'src/explore/types'; import { ChartState } from 'src/explore/types';
import { AlertObject } from 'src/features/alerts/types';
interface DashboardInfo { interface DashboardInfo {
id: number; id: number;
@ -60,11 +61,11 @@ export interface HeaderDropdownProps {
dataMask: any; dataMask: any;
lastModifiedTime: number; lastModifiedTime: number;
logEvent: () => void; logEvent: () => void;
setIsDropdownVisible: (visible: boolean) => void;
isDropdownVisible: boolean;
refreshLimit: number; refreshLimit: number;
refreshWarning: string; refreshWarning: string;
directPathToChild: string[]; directPathToChild: string[];
showReportModal: () => void;
setCurrentReportDeleting: (alert: AlertObject | null) => void;
} }
export interface HeaderProps { export interface HeaderProps {

View File

@ -16,11 +16,11 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { PureComponent } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash'; import { useSelector } from 'react-redux';
import { connect } from 'react-redux';
import { t } from '@superset-ui/core';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { t } from '@superset-ui/core';
import { isEmpty } from 'lodash';
import { URL_PARAMS } from 'src/constants'; import { URL_PARAMS } from 'src/constants';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems'; import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems'; import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
@ -37,158 +37,156 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types'; import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types'; import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
const mapStateToProps = (state: RootState) => ({ export const useHeaderActionsMenu = ({
directPathToChild: state.dashboardState.directPathToChild, customCss,
}); dashboardId,
dashboardInfo,
interface HeaderActionsDropdownState { refreshFrequency,
css: string; shouldPersistRefreshFrequency,
showReportSubMenu: boolean | null; editMode,
} colorNamespace,
colorScheme,
export class HeaderActionsDropdown extends PureComponent< layout,
HeaderDropdownProps, expandedSlices,
HeaderActionsDropdownState onSave,
> { userCanEdit,
static defaultProps = { userCanShare,
colorNamespace: undefined, userCanSave,
colorScheme: undefined, userCanCurate,
refreshLimit: 0, isLoading,
refreshWarning: null, refreshLimit,
}; refreshWarning,
lastModifiedTime,
constructor(props: HeaderDropdownProps) { addSuccessToast,
super(props); addDangerToast,
this.state = { forceRefreshAllCharts,
css: props.customCss || '', showPropertiesModal,
showReportSubMenu: null, showReportModal,
}; manageEmbedded,
} onChange,
updateCss,
UNSAFE_componentWillReceiveProps(nextProps: HeaderDropdownProps) { startPeriodicRender,
if (this.props.customCss !== nextProps.customCss) { setRefreshFrequency,
this.setState({ css: nextProps.customCss }, () => { dashboardTitle,
injectCustomCss(nextProps.customCss); logEvent,
}); setCurrentReportDeleting,
}: HeaderDropdownProps) => {
const [css, setCss] = useState(customCss || '');
const [showReportSubMenu, setShowReportSubMenu] = useState<boolean | null>(
null,
);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
);
useEffect(() => {
if (customCss !== css) {
setCss(customCss || '');
injectCustomCss(customCss);
} }
} }, [css, customCss]);
setShowReportSubMenu = (show: boolean) => { const handleMenuClick = useCallback(
this.setState({ showReportSubMenu: show }); ({ key }: { key: string }) => {
}; switch (key) {
case MenuKeys.RefreshDashboard:
changeCss = (css: string) => { forceRefreshAllCharts();
this.props.onChange(); addSuccessToast(t('Refreshing charts'));
this.props.updateCss(css); break;
}; case MenuKeys.EditProperties:
showPropertiesModal();
changeRefreshInterval = (refreshInterval: number, isPersistent: boolean) => { break;
this.props.setRefreshFrequency(refreshInterval, isPersistent); case MenuKeys.ToggleFullscreen: {
this.props.startPeriodicRender(refreshInterval * 1000); const url = getDashboardUrl({
}; pathname: window.location.pathname,
filters: getActiveFilters(),
handleMenuClick = ({ key }: Record<string, any>) => { hash: window.location.hash,
switch (key) { standalone: getUrlParam(URL_PARAMS.standalone),
case MenuKeys.RefreshDashboard: });
this.props.forceRefreshAllCharts(); window.location.replace(url);
this.props.addSuccessToast(t('Refreshing charts')); break;
break; }
case MenuKeys.EditProperties: case MenuKeys.ManageEmbedded:
this.props.showPropertiesModal(); manageEmbedded();
break; break;
case MenuKeys.ToggleFullscreen: { default:
const url = getDashboardUrl({ break;
pathname: window.location.pathname,
filters: getActiveFilters(),
hash: window.location.hash,
standalone: getUrlParam(URL_PARAMS.standalone),
});
window.location.replace(url);
break;
} }
case MenuKeys.ManageEmbedded: { setIsDropdownVisible(false);
this.props.manageEmbedded(); },
break; [
} forceRefreshAllCharts,
default:
break;
}
};
render() {
const {
dashboardTitle,
dashboardId,
dashboardInfo,
refreshFrequency,
shouldPersistRefreshFrequency,
editMode,
customCss,
colorNamespace,
colorScheme,
layout,
expandedSlices,
onSave,
userCanEdit,
userCanShare,
userCanSave,
userCanCurate,
isLoading,
refreshLimit,
refreshWarning,
lastModifiedTime,
addSuccessToast, addSuccessToast,
addDangerToast, showPropertiesModal,
setIsDropdownVisible, manageEmbedded,
isDropdownVisible, ],
directPathToChild, );
...rest
} = this.props;
const emailTitle = t('Superset dashboard'); const changeCss = useCallback(
const emailSubject = `${emailTitle} ${dashboardTitle}`; (newCss: string) => {
const emailBody = t('Check out this dashboard: '); onChange();
updateCss(newCss);
},
[onChange, updateCss],
);
const changeRefreshInterval = useCallback(
(refreshInterval: number, isPersistent: boolean) => {
setRefreshFrequency(refreshInterval, isPersistent);
startPeriodicRender(refreshInterval * 1000);
},
[setRefreshFrequency, startPeriodicRender],
);
const emailSubject = useMemo(
() => `${t('Superset dashboard')} ${dashboardTitle}`,
[dashboardTitle],
);
const url = useMemo(
() =>
getDashboardUrl({
pathname: window.location.pathname,
filters: getActiveFilters(),
hash: window.location.hash,
}),
[],
);
const dashboardComponentId = useMemo(
() => [...(directPathToChild || [])].pop(),
[directPathToChild],
);
const menu = useMemo(() => {
const isEmbedded = !dashboardInfo?.userId; const isEmbedded = !dashboardInfo?.userId;
const url = getDashboardUrl({
pathname: window.location.pathname,
filters: getActiveFilters(),
hash: window.location.hash,
});
const refreshIntervalOptions = const refreshIntervalOptions =
dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS; dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
const dashboardComponentId = [...(directPathToChild || [])].pop();
return ( return (
<Menu selectable={false} data-test="header-actions-menu" {...rest}> <Menu
selectable={false}
data-test="header-actions-menu"
onClick={handleMenuClick}
>
{!editMode && ( {!editMode && (
<Menu.Item <Menu.Item
key={MenuKeys.RefreshDashboard} key={MenuKeys.RefreshDashboard}
data-test="refresh-dashboard-menu-item" data-test="refresh-dashboard-menu-item"
disabled={isLoading} disabled={isLoading}
onClick={this.handleMenuClick}
> >
{t('Refresh dashboard')} {t('Refresh dashboard')}
</Menu.Item> </Menu.Item>
)} )}
{!editMode && !isEmbedded && ( {!editMode && !isEmbedded && (
<Menu.Item <Menu.Item key={MenuKeys.ToggleFullscreen}>
key={MenuKeys.ToggleFullscreen}
onClick={this.handleMenuClick}
>
{getUrlParam(URL_PARAMS.standalone) {getUrlParam(URL_PARAMS.standalone)
? t('Exit fullscreen') ? t('Exit fullscreen')
: t('Enter fullscreen')} : t('Enter fullscreen')}
</Menu.Item> </Menu.Item>
)} )}
{editMode && ( {editMode && (
<Menu.Item <Menu.Item key={MenuKeys.EditProperties}>
key={MenuKeys.EditProperties}
onClick={this.handleMenuClick}
>
{t('Edit properties')} {t('Edit properties')}
</Menu.Item> </Menu.Item>
)} )}
@ -196,8 +194,8 @@ export class HeaderActionsDropdown extends PureComponent<
<Menu.Item key={MenuKeys.EditCss}> <Menu.Item key={MenuKeys.EditCss}>
<CssEditor <CssEditor
triggerNode={<div>{t('Edit CSS')}</div>} triggerNode={<div>{t('Edit CSS')}</div>}
initialCss={this.state.css} initialCss={css}
onChange={this.changeCss} onChange={changeCss}
addDangerToast={addDangerToast} addDangerToast={addDangerToast}
/> />
</Menu.Item> </Menu.Item>
@ -228,29 +226,26 @@ export class HeaderActionsDropdown extends PureComponent<
/> />
</Menu.Item> </Menu.Item>
)} )}
<Menu.SubMenu <DownloadMenuItems
key={MenuKeys.Download} submenuKey={MenuKeys.Download}
disabled={isLoading} disabled={isLoading}
title={t('Download')} title={t('Download')}
> pdfMenuItemTitle={t('Export to PDF')}
<DownloadMenuItems imageMenuItemTitle={t('Download as Image')}
pdfMenuItemTitle={t('Export to PDF')} dashboardTitle={dashboardTitle}
imageMenuItemTitle={t('Download as Image')} dashboardId={dashboardId}
dashboardTitle={dashboardTitle} logEvent={logEvent}
dashboardId={dashboardId} />
logEvent={this.props.logEvent}
/>
</Menu.SubMenu>
{userCanShare && ( {userCanShare && (
<ShareMenuItems <ShareMenuItems
key={MenuKeys.Share} disabled={isLoading}
data-test="share-dashboard-menu-item" data-test="share-dashboard-menu-item"
title={t('Share')} title={t('Share')}
url={url} url={url}
copyMenuItemTitle={t('Copy permalink to clipboard')} copyMenuItemTitle={t('Copy permalink to clipboard')}
emailMenuItemTitle={t('Share permalink by email')} emailMenuItemTitle={t('Share permalink by email')}
emailSubject={emailSubject} emailSubject={emailSubject}
emailBody={emailBody} emailBody={t('Check out this dashboard: ')}
addSuccessToast={addSuccessToast} addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast} addDangerToast={addDangerToast}
dashboardId={dashboardId} dashboardId={dashboardId}
@ -258,39 +253,33 @@ export class HeaderActionsDropdown extends PureComponent<
/> />
)} )}
{!editMode && userCanCurate && ( {!editMode && userCanCurate && (
<Menu.Item <Menu.Item key={MenuKeys.ManageEmbedded}>
key={MenuKeys.ManageEmbedded}
onClick={this.handleMenuClick}
>
{t('Embed dashboard')} {t('Embed dashboard')}
</Menu.Item> </Menu.Item>
)} )}
<Menu.Divider /> <Menu.Divider />
{!editMode ? ( {!editMode ? (
this.state.showReportSubMenu ? ( showReportSubMenu ? (
<> <>
<Menu.SubMenu title={t('Manage email report')}> <HeaderReportDropdown
<HeaderReportDropdown submenuTitle={t('Manage email report')}
dashboardId={dashboardInfo.id} dashboardId={dashboardInfo.id}
setShowReportSubMenu={this.setShowReportSubMenu} setShowReportSubMenu={setShowReportSubMenu}
showReportSubMenu={this.state.showReportSubMenu} showReportModal={showReportModal}
setIsDropdownVisible={setIsDropdownVisible} showReportSubMenu={showReportSubMenu}
isDropdownVisible={isDropdownVisible} setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu useTextMenu
/> />
</Menu.SubMenu>
<Menu.Divider /> <Menu.Divider />
</> </>
) : ( ) : (
<Menu> <HeaderReportDropdown
<HeaderReportDropdown dashboardId={dashboardInfo.id}
dashboardId={dashboardInfo.id} setShowReportSubMenu={setShowReportSubMenu}
setShowReportSubMenu={this.setShowReportSubMenu} showReportModal={showReportModal}
setIsDropdownVisible={setIsDropdownVisible} setCurrentReportDeleting={setCurrentReportDeleting}
isDropdownVisible={isDropdownVisible} useTextMenu
useTextMenu />
/>
</Menu>
) )
) : null} ) : null}
{editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && ( {editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && (
@ -302,14 +291,13 @@ export class HeaderActionsDropdown extends PureComponent<
/> />
</Menu.Item> </Menu.Item>
)} )}
<Menu.Item key={MenuKeys.AutorefreshModal}> <Menu.Item key={MenuKeys.AutorefreshModal}>
<RefreshIntervalModal <RefreshIntervalModal
addSuccessToast={addSuccessToast} addSuccessToast={addSuccessToast}
refreshFrequency={refreshFrequency} refreshFrequency={refreshFrequency}
refreshLimit={refreshLimit} refreshLimit={refreshLimit}
refreshWarning={refreshWarning} refreshWarning={refreshWarning}
onChange={this.changeRefreshInterval} onChange={changeRefreshInterval}
editMode={editMode} editMode={editMode}
refreshIntervalOptions={refreshIntervalOptions} refreshIntervalOptions={refreshIntervalOptions}
triggerNode={<div>{t('Set auto-refresh interval')}</div>} triggerNode={<div>{t('Set auto-refresh interval')}</div>}
@ -317,7 +305,18 @@ export class HeaderActionsDropdown extends PureComponent<
</Menu.Item> </Menu.Item>
</Menu> </Menu>
); );
} }, [
} css,
showReportSubMenu,
isDropdownVisible,
directPathToChild,
handleMenuClick,
changeCss,
changeRefreshInterval,
emailSubject,
url,
dashboardComponentId,
]);
export default connect(mapStateToProps)(HeaderActionsDropdown); return [menu, isDropdownVisible, setIsDropdownVisible];
};

View File

@ -22,16 +22,16 @@ import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal'; import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
import { HeaderActionsDropdown } from 'src/dashboard/components/Header/HeaderActionsDropdown';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { useHeaderActionsMenu } from './Header/useHeaderActionsDropdownMenu';
const createProps = () => ({ const createProps = () => ({
addSuccessToast: jest.fn(), addSuccessToast: jest.fn(),
addDangerToast: jest.fn(), addDangerToast: jest.fn(),
customCss: customCss:
'.header-with-actions .right-button-panel .ant-dropdown-trigger{margin-left: 100px;}', '.header-with-actions .right-button-panel .antd5-dropdown-trigger{margin-left: 100px;}',
dashboardId: 1, dashboardId: 1,
dashboardInfo: { dashboardInfo: {
id: 1, id: 1,
@ -85,12 +85,22 @@ const editModeOnProps = {
}; };
const mockStore = configureStore([thunk]); const mockStore = configureStore([thunk]);
const store = mockStore({}); const store = mockStore({
dashboardState: {
dashboardInfo: createProps().dashboardInfo,
},
});
const HeaderActionsMenu = (props: any) => {
const [menu] = useHeaderActionsMenu(props);
return <>{menu}</>;
};
const setup = (overrides?: any) => ( const setup = (overrides?: any) => (
<Provider store={store}> <Provider store={store}>
<div className="dashboard-header"> <div className="dashboard-header">
<HeaderActionsDropdown {...editModeOnProps} {...overrides} /> <HeaderActionsMenu {...editModeOnProps} {...overrides} />
</div> </div>
</Provider> </Provider>
); );

View File

@ -112,11 +112,13 @@ const renderWrapper = (
}); });
}; };
const openMenu = () => {
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
};
test('Should render', () => { test('Should render', () => {
renderWrapper(); renderWrapper();
expect( openMenu();
screen.getByRole('button', { name: 'More Options' }),
).toBeInTheDocument();
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument(); expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
}); });
@ -143,6 +145,7 @@ test('Should render default props', () => {
delete props.isExpanded; delete props.isExpanded;
renderWrapper(props); renderWrapper(props);
openMenu();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument(); expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
expect(screen.getByText('Force refresh')).toBeInTheDocument(); expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Show chart description')).toBeInTheDocument(); expect(screen.getByText('Show chart description')).toBeInTheDocument();
@ -159,6 +162,7 @@ test('Should render default props', () => {
test('Should "export to CSV"', async () => { test('Should "export to CSV"', async () => {
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.exportCSV).toHaveBeenCalledTimes(0); expect(props.exportCSV).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to .CSV')); userEvent.click(await screen.findByText('Export to .CSV'));
@ -169,6 +173,7 @@ test('Should "export to CSV"', async () => {
test('Should "export to Excel"', async () => { test('Should "export to Excel"', async () => {
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.exportXLSX).toHaveBeenCalledTimes(0); expect(props.exportXLSX).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to Excel')); userEvent.click(await screen.findByText('Export to Excel'));
@ -182,6 +187,7 @@ test('Export full CSV is under featureflag', async () => {
}; };
const props = createProps(VizType.Table); const props = createProps(VizType.Table);
renderWrapper(props); renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
@ -193,6 +199,7 @@ test('Should "export full CSV"', async () => {
}; };
const props = createProps(VizType.Table); const props = createProps(VizType.Table);
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.exportFullCSV).toHaveBeenCalledTimes(0); expect(props.exportFullCSV).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to full .CSV')); userEvent.click(await screen.findByText('Export to full .CSV'));
@ -205,6 +212,7 @@ test('Should not show export full CSV if report is not table', async () => {
[FeatureFlag.AllowFullCsvExport]: true, [FeatureFlag.AllowFullCsvExport]: true,
}; };
renderWrapper(); renderWrapper();
openMenu();
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).toBeInTheDocument(); expect(await screen.findByText('Export to .CSV')).toBeInTheDocument();
expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument(); expect(screen.queryByText('Export to full .CSV')).not.toBeInTheDocument();
@ -216,6 +224,7 @@ test('Export full Excel is under featureflag', async () => {
}; };
const props = createProps(VizType.Table); const props = createProps(VizType.Table);
renderWrapper(props); renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(await screen.findByText('Export to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument();
@ -227,6 +236,7 @@ test('Should "export full Excel"', async () => {
}; };
const props = createProps(VizType.Table); const props = createProps(VizType.Table);
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.exportFullXLSX).toHaveBeenCalledTimes(0); expect(props.exportFullXLSX).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to full Excel')); userEvent.click(await screen.findByText('Export to full Excel'));
@ -239,6 +249,7 @@ test('Should not show export full Excel if report is not table', async () => {
[FeatureFlag.AllowFullCsvExport]: true, [FeatureFlag.AllowFullCsvExport]: true,
}; };
renderWrapper(); renderWrapper();
openMenu();
userEvent.hover(screen.getByText('Download')); userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to Excel')).toBeInTheDocument(); expect(await screen.findByText('Export to Excel')).toBeInTheDocument();
expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument(); expect(screen.queryByText('Export to full Excel')).not.toBeInTheDocument();
@ -247,6 +258,7 @@ test('Should not show export full Excel if report is not table', async () => {
test('Should "Show chart description"', () => { test('Should "Show chart description"', () => {
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.toggleExpandSlice).toHaveBeenCalledTimes(0); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Show chart description')); userEvent.click(screen.getByText('Show chart description'));
expect(props.toggleExpandSlice).toHaveBeenCalledTimes(1); expect(props.toggleExpandSlice).toHaveBeenCalledTimes(1);
@ -256,6 +268,7 @@ test('Should "Show chart description"', () => {
test('Should "Force refresh"', () => { test('Should "Force refresh"', () => {
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.forceRefresh).toHaveBeenCalledTimes(0); expect(props.forceRefresh).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Force refresh')); userEvent.click(screen.getByText('Force refresh'));
expect(props.forceRefresh).toHaveBeenCalledTimes(1); expect(props.forceRefresh).toHaveBeenCalledTimes(1);
@ -266,6 +279,7 @@ test('Should "Force refresh"', () => {
test('Should "Enter fullscreen"', () => { test('Should "Enter fullscreen"', () => {
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0); expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Enter fullscreen')); userEvent.click(screen.getByText('Enter fullscreen'));
@ -278,6 +292,7 @@ test('Drill to detail modal is under featureflag', () => {
}; };
const props = createProps(); const props = createProps();
renderWrapper(props); renderWrapper(props);
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
}); });
@ -293,6 +308,7 @@ test('Should show "Drill to detail" with `can_explore` & `can_samples` perms', (
['can_explore', 'Superset'], ['can_explore', 'Superset'],
], ],
}); });
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument(); expect(screen.getByText('Drill to detail')).toBeInTheDocument();
}); });
@ -311,6 +327,7 @@ test('Should show "Drill to detail" with `can_drill` & `can_samples` perms', ()
['can_drill', 'Dashboard'], ['can_drill', 'Dashboard'],
], ],
}); });
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument(); expect(screen.getByText('Drill to detail')).toBeInTheDocument();
}); });
@ -329,6 +346,7 @@ test('Should show "Drill to detail" with both `canexplore` + `can_drill` & `can_
['can_drill', 'Dashboard'], ['can_drill', 'Dashboard'],
], ],
}); });
openMenu();
expect(screen.getByText('Drill to detail')).toBeInTheDocument(); expect(screen.getByText('Drill to detail')).toBeInTheDocument();
}); });
@ -344,6 +362,7 @@ test('Should not show "Drill to detail" with neither of required perms', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['invalid_permission', 'Dashboard']], Admin: [['invalid_permission', 'Dashboard']],
}); });
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
}); });
@ -359,6 +378,7 @@ test('Should not show "Drill to detail" only `can_dril` perm', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['can_drill', 'Dashboard']], Admin: [['can_drill', 'Dashboard']],
}); });
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
}); });
@ -371,6 +391,7 @@ test('Should show "View query"', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['can_view_query', 'Dashboard']], Admin: [['can_view_query', 'Dashboard']],
}); });
openMenu();
expect(screen.getByText('View query')).toBeInTheDocument(); expect(screen.getByText('View query')).toBeInTheDocument();
}); });
@ -383,6 +404,7 @@ test('Should not show "View query"', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['invalid_permission', 'Dashboard']], Admin: [['invalid_permission', 'Dashboard']],
}); });
openMenu();
expect(screen.queryByText('View query')).not.toBeInTheDocument(); expect(screen.queryByText('View query')).not.toBeInTheDocument();
}); });
@ -395,6 +417,7 @@ test('Should show "View as table"', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['can_view_chart_as_table', 'Dashboard']], Admin: [['can_view_chart_as_table', 'Dashboard']],
}); });
openMenu();
expect(screen.getByText('View as table')).toBeInTheDocument(); expect(screen.getByText('View as table')).toBeInTheDocument();
}); });
@ -407,6 +430,7 @@ test('Should not show "View as table"', () => {
renderWrapper(props, { renderWrapper(props, {
Admin: [['invalid_permission', 'Dashboard']], Admin: [['invalid_permission', 'Dashboard']],
}); });
openMenu();
expect(screen.queryByText('View as table')).not.toBeInTheDocument(); expect(screen.queryByText('View as table')).not.toBeInTheDocument();
}); });
@ -423,5 +447,6 @@ test('Should not show the "Edit chart" button', () => {
['can_view_chart_as_table', 'Dashboard'], ['can_view_chart_as_table', 'Dashboard'],
], ],
}); });
openMenu();
expect(screen.queryByText('Edit chart')).not.toBeInTheDocument(); expect(screen.queryByText('Edit chart')).not.toBeInTheDocument();
}); });

View File

@ -55,6 +55,7 @@ import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types'; import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal'; import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { usePermissions } from 'src/hooks/usePermissions'; import { usePermissions } from 'src/hooks/usePermissions';
import Button from 'src/components/Button';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger'; import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
@ -158,9 +159,8 @@ const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps, props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => { ) => {
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false); const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
// setting openKeys undefined falls back to uncontrolled behaviour // setting openKeys undefined falls back to uncontrolled behaviour
const [openKeys, setOpenKeys] = useState<string[] | undefined>(undefined); const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal( const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id, props.slice.slice_id,
); );
@ -241,7 +241,7 @@ const SliceHeaderControls = (
// menu closes with a delay, we need to hide it manually, // menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot // so that we don't capture it on the screenshot
const menu = document.querySelector( const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)', '.antd5-dropdown:not(.antd5-dropdown-hidden)',
) as HTMLElement; ) as HTMLElement;
if (menu) { if (menu) {
menu.style.visibility = 'hidden'; menu.style.visibility = 'hidden';
@ -284,6 +284,7 @@ const SliceHeaderControls = (
default: default:
break; break;
} }
setIsDropdownVisible(false);
}; };
const { const {
@ -334,24 +335,12 @@ const SliceHeaderControls = (
animationDuration: '0s', animationDuration: '0s',
}; };
// controlled/uncontrolled behaviour for submenus
const openKeysProps: Record<string, string[]> = {};
if (openKeys) {
openKeysProps.openKeys = openKeys;
}
const menu = ( const menu = (
<Menu <Menu
onClick={handleMenuClick} onClick={handleMenuClick}
selectable={false}
data-test={`slice_${slice.slice_id}-menu`} data-test={`slice_${slice.slice_id}-menu`}
selectedKeys={selectedKeys}
onSelect={({ selectedKeys: keys }) => setSelectedKeys(keys)}
openKeys={openKeys}
id={`slice_${slice.slice_id}-menu`} id={`slice_${slice.slice_id}-menu`}
// submenus must be rendered for handleDropdownNavigation selectable={false}
forceSubMenuRender
{...openKeysProps}
> >
<Menu.Item <Menu.Item
key={MenuKeys.ForceRefresh} key={MenuKeys.ForceRefresh}
@ -458,9 +447,7 @@ const SliceHeaderControls = (
emailBody={t('Check out this chart: ')} emailBody={t('Check out this chart: ')}
addSuccessToast={addSuccessToast} addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast} addDangerToast={addDangerToast}
setOpenKeys={setOpenKeys}
title={t('Share')} title={t('Share')}
key={MenuKeys.Share}
/> />
)} )}
@ -532,22 +519,17 @@ const SliceHeaderControls = (
overlayStyle={dropdownOverlayStyle} overlayStyle={dropdownOverlayStyle}
trigger={['click']} trigger={['click']}
placement="bottomRight" placement="bottomRight"
autoFocus open={isDropdownVisible}
forceRender onOpenChange={visible => setIsDropdownVisible(visible)}
> >
<span <Button
css={() => css` type="link"
display: flex;
align-items: center;
`}
id={`slice_${slice.slice_id}-controls`} id={`slice_${slice.slice_id}-controls`}
role="button"
aria-label="More Options" aria-label="More Options"
aria-haspopup="true" aria-haspopup="true"
tabIndex={0}
> >
<VerticalDotsTrigger /> <VerticalDotsTrigger />
</span> </Button>
</NoAnimationDropdown> </NoAnimationDropdown>
<DrillDetailModal <DrillDetailModal
formData={props.formData} formData={props.formData}

View File

@ -60,7 +60,7 @@ test('Should call download image on click', async () => {
expect(mockAddDangerToast).toHaveBeenCalledTimes(0); expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
}); });
userEvent.click(screen.getByRole('button', { name: 'Download as Image' })); userEvent.click(screen.getByRole('menuitem', { name: 'Download as Image' }));
await waitFor(() => { await waitFor(() => {
expect(downloadAsImage).toHaveBeenCalledTimes(1); expect(downloadAsImage).toHaveBeenCalledTimes(1);
@ -68,8 +68,8 @@ test('Should call download image on click', async () => {
}); });
}); });
test('Component is rendered with role="button"', async () => { test('Component is rendered with role="menuitem"', async () => {
renderComponent(); renderComponent();
const button = screen.getByRole('button', { name: 'Download as Image' }); const button = screen.getByRole('menuitem', { name: 'Download as Image' });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
}); });

View File

@ -27,7 +27,6 @@ export default function DownloadAsImage({
text, text,
logEvent, logEvent,
dashboardTitle, dashboardTitle,
...rest
}: { }: {
text: string; text: string;
dashboardTitle: string; dashboardTitle: string;
@ -46,10 +45,13 @@ export default function DownloadAsImage({
}; };
return ( return (
<Menu.Item key="download-image" {...rest}> <Menu.Item
<div onClick={onDownloadImage} role="button" tabIndex={0}> key="download-image"
{text} onClick={e => {
</div> onDownloadImage(e.domEvent);
}}
>
{text}
</Menu.Item> </Menu.Item>
); );
} }

View File

@ -58,7 +58,7 @@ test('Should call download pdf on click', async () => {
expect(mockAddDangerToast).toHaveBeenCalledTimes(0); expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
}); });
userEvent.click(screen.getByRole('button', { name: 'Export as PDF' })); userEvent.click(screen.getByRole('menuitem', { name: 'Export as PDF' }));
await waitFor(() => { await waitFor(() => {
expect(downloadAsPdf).toHaveBeenCalledTimes(1); expect(downloadAsPdf).toHaveBeenCalledTimes(1);
@ -66,8 +66,8 @@ test('Should call download pdf on click', async () => {
}); });
}); });
test('Component is rendered with role="button"', async () => { test('Component is rendered with role="menuitem"', async () => {
renderComponent(); renderComponent();
const button = screen.getByRole('button', { name: 'Export as PDF' }); const button = screen.getByRole('menuitem', { name: 'Export as PDF' });
expect(button).toBeInTheDocument(); expect(button).toBeInTheDocument();
}); });

View File

@ -27,7 +27,6 @@ export default function DownloadAsPdf({
text, text,
logEvent, logEvent,
dashboardTitle, dashboardTitle,
...rest
}: { }: {
text: string; text: string;
dashboardTitle: string; dashboardTitle: string;
@ -46,10 +45,13 @@ export default function DownloadAsPdf({
}; };
return ( return (
<Menu.Item key="download-pdf" {...rest}> <Menu.Item
<div onClick={onDownloadPdf} role="button" tabIndex={0}> key="download-pdf"
{text} onClick={e => {
</div> onDownloadPdf(e.domEvent);
}}
>
{text}
</Menu.Item> </Menu.Item>
); );
} }

View File

@ -26,11 +26,13 @@ const createProps = () => ({
dashboardTitle: 'Test Dashboard', dashboardTitle: 'Test Dashboard',
logEvent: jest.fn(), logEvent: jest.fn(),
dashboardId: 123, dashboardId: 123,
title: 'Download',
submenuKey: 'download',
}); });
const renderComponent = () => { const renderComponent = () => {
render( render(
<Menu> <Menu forceSubMenuRender>
<DownloadMenuItems {...createProps()} /> <DownloadMenuItems {...createProps()} />
</Menu>, </Menu>,
{ {
@ -41,10 +43,6 @@ const renderComponent = () => {
test('Should render menu items', () => { test('Should render menu items', () => {
renderComponent(); renderComponent();
expect( expect(screen.getByText('Export to PDF')).toBeInTheDocument();
screen.getByRole('menuitem', { name: 'Export to PDF' }), expect(screen.getByText('Download as Image')).toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'Download as Image' }),
).toBeInTheDocument();
}); });

View File

@ -17,17 +17,23 @@
* under the License. * under the License.
*/ */
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import DownloadScreenshot from './DownloadScreenshot'; import { Menu } from 'src/components/Menu';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { ComponentProps } from 'react';
import { DownloadScreenshotFormat } from './types'; import { DownloadScreenshotFormat } from './types';
import DownloadAsPdf from './DownloadAsPdf'; import DownloadAsPdf from './DownloadAsPdf';
import DownloadAsImage from './DownloadAsImage'; import DownloadAsImage from './DownloadAsImage';
export interface DownloadMenuItemProps { export interface DownloadMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
pdfMenuItemTitle: string; pdfMenuItemTitle: string;
imageMenuItemTitle: string; imageMenuItemTitle: string;
dashboardTitle: string; dashboardTitle: string;
logEvent?: Function; logEvent?: Function;
dashboardId: number; dashboardId: number;
title: string;
disabled?: boolean;
submenuKey: string;
} }
const DownloadMenuItems = (props: DownloadMenuItemProps) => { const DownloadMenuItems = (props: DownloadMenuItemProps) => {
@ -37,44 +43,45 @@ const DownloadMenuItems = (props: DownloadMenuItemProps) => {
logEvent, logEvent,
dashboardId, dashboardId,
dashboardTitle, dashboardTitle,
submenuKey,
disabled,
title,
...rest ...rest
} = props; } = props;
const isWebDriverScreenshotEnabled = const isWebDriverScreenshotEnabled =
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) && isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot); isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
return isWebDriverScreenshotEnabled ? ( return isWebDriverScreenshotEnabled ? (
<> <Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<DownloadScreenshot <Menu.Item
text={pdfMenuItemTitle} key={DownloadScreenshotFormat.PDF}
dashboardId={dashboardId} onClick={() => downloadScreenshot(DownloadScreenshotFormat.PDF)}
logEvent={logEvent} >
format={DownloadScreenshotFormat.PDF} {pdfMenuItemTitle}
{...rest} </Menu.Item>
/> <Menu.Item
<DownloadScreenshot key={DownloadScreenshotFormat.PNG}
text={imageMenuItemTitle} onClick={() => downloadScreenshot(DownloadScreenshotFormat.PNG)}
dashboardId={dashboardId} >
logEvent={logEvent} {imageMenuItemTitle}
format={DownloadScreenshotFormat.PNG} </Menu.Item>
{...rest} </Menu.SubMenu>
/>
</>
) : ( ) : (
<> <Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<DownloadAsPdf <DownloadAsPdf
text={pdfMenuItemTitle} text={pdfMenuItemTitle}
dashboardTitle={dashboardTitle} dashboardTitle={dashboardTitle}
logEvent={logEvent} logEvent={logEvent}
{...rest}
/> />
<DownloadAsImage <DownloadAsImage
text={imageMenuItemTitle} text={imageMenuItemTitle}
dashboardTitle={dashboardTitle} dashboardTitle={dashboardTitle}
logEvent={logEvent} logEvent={logEvent}
{...rest}
/> />
</> </Menu.SubMenu>
); );
}; };

View File

@ -37,6 +37,7 @@ const createProps = () => ({
emailBody: 'Check out this dashboard: ', emailBody: 'Check out this dashboard: ',
dashboardId: DASHBOARD_ID, dashboardId: DASHBOARD_ID,
title: 'Test Dashboard', title: 'Test Dashboard',
submenuKey: 'share',
}); });
const { location } = window; const { location } = window;

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { RefObject } from 'react'; import { ComponentProps, RefObject } from 'react';
import copyTextToClipboard from 'src/utils/copy'; import copyTextToClipboard from 'src/utils/copy';
import { t, logging } from '@superset-ui/core'; import { t, logging } from '@superset-ui/core';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
@ -24,7 +24,7 @@ import { getDashboardPermalink } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types'; import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux'; import { shallowEqual, useSelector } from 'react-redux';
interface ShareMenuItemProps { interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
url?: string; url?: string;
copyMenuItemTitle: string; copyMenuItemTitle: string;
emailMenuItemTitle: string; emailMenuItemTitle: string;
@ -38,8 +38,8 @@ interface ShareMenuItemProps {
shareByEmailMenuItemRef?: RefObject<any>; shareByEmailMenuItemRef?: RefObject<any>;
selectedKeys?: string[]; selectedKeys?: string[];
setOpenKeys?: Function; setOpenKeys?: Function;
key?: string;
title: string; title: string;
disabled?: boolean;
} }
const ShareMenuItems = (props: ShareMenuItemProps) => { const ShareMenuItems = (props: ShareMenuItemProps) => {
@ -52,8 +52,9 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
addSuccessToast, addSuccessToast,
dashboardId, dashboardId,
dashboardComponentId, dashboardComponentId,
key,
title, title,
disabled,
...rest
} = props; } = props;
const { dataMask, activeTabs } = useSelector( const { dataMask, activeTabs } = useSelector(
(state: RootState) => ({ (state: RootState) => ({
@ -96,7 +97,12 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
} }
return ( return (
<Menu.SubMenu title={title} key={key}> <Menu.SubMenu
title={title}
key={MenuKeys.Share}
disabled={disabled}
{...rest}
>
<Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}> <Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}>
{copyMenuItemTitle} {copyMenuItemTitle}
</Menu.Item> </Menu.Item>

View File

@ -121,13 +121,13 @@ test('Can enable/disable cross-filtering', async () => {
}); });
await setup(); await setup();
userEvent.click(screen.getByLabelText('gear')); userEvent.click(screen.getByLabelText('gear'));
const checkbox = screen.getByRole('checkbox'); const initialCheckbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked(); expect(initialCheckbox).toBeChecked();
userEvent.click(checkbox); userEvent.click(initialCheckbox);
userEvent.click(screen.getByLabelText('gear')); userEvent.click(screen.getByLabelText('gear'));
expect(checkbox).not.toBeChecked(); expect(screen.getByRole('checkbox')).not.toBeChecked();
}); });
test('Popover opens with "Vertical" selected', async () => { test('Popover opens with "Vertical" selected', async () => {
@ -178,19 +178,21 @@ test('On selection change, send request and update checked value', async () => {
userEvent.click(screen.getByLabelText('gear')); userEvent.click(screen.getByLabelText('gear'));
userEvent.hover(screen.getByText('Orientation of filter bar')); userEvent.hover(screen.getByText('Orientation of filter bar'));
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument(); const verticalItem = await screen.findByText('Vertical (Left)');
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
expect( expect(
within(screen.getAllByRole('menuitem')[4]).getByLabelText('check'), within(verticalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument(); ).toBeInTheDocument();
userEvent.click(screen.getByText('Horizontal (Top)')); userEvent.click(screen.getByText('Horizontal (Top)'));
// 1st check - checkmark appears immediately after click userEvent.click(screen.getByLabelText('gear'));
userEvent.hover(screen.getByText('Orientation of filter bar'));
const horizontalItem = await screen.findByText('Horizontal (Top)');
expect( expect(
await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'), within(horizontalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument(); ).toBeInTheDocument();
// successful query
await waitFor(() => await waitFor(() =>
expect(fetchMock.lastCall()?.[1]?.body).toEqual( expect(fetchMock.lastCall()?.[1]?.body).toEqual(
JSON.stringify({ JSON.stringify({
@ -201,23 +203,18 @@ test('On selection change, send request and update checked value', async () => {
}), }),
), ),
); );
await waitFor(() => { await waitFor(() => {
const menuitems = screen.getAllByRole('menuitem'); userEvent.click(screen.getByLabelText('gear'));
expect(menuitems.length).toBeGreaterThanOrEqual(6); userEvent.hover(screen.getByText('Orientation of filter bar'));
const updatedHorizontalItem = screen.getByText('Horizontal (Top)');
expect(
within(updatedHorizontalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument();
expect(
within(verticalItem.closest('li')!).queryByLabelText('check'),
).not.toBeInTheDocument();
}); });
userEvent.click(screen.getByLabelText('gear'));
userEvent.hover(screen.getByText('Orientation of filter bar'));
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
// 2nd check - checkmark stays after successful query
expect(
await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[4]).queryByLabelText('check'),
).not.toBeInTheDocument();
}); });
test('On failed request, restore previous selection', async () => { test('On failed request, restore previous selection', async () => {
@ -254,9 +251,8 @@ test('On failed request, restore previous selection', async () => {
userEvent.click(screen.getByLabelText('gear')); userEvent.click(screen.getByLabelText('gear'));
userEvent.hover(screen.getByText('Orientation of filter bar')); userEvent.hover(screen.getByText('Orientation of filter bar'));
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Vertical (Left)')).toBeInTheDocument();
const menuitems = screen.getAllByRole('menuitem'); const menuitems = screen.getAllByRole('menuitem');
expect(menuitems.length).toBeGreaterThanOrEqual(6); expect(menuitems.length).toBeGreaterThanOrEqual(6);
}); });

View File

@ -33,12 +33,13 @@ import {
saveCrossFiltersSetting, saveCrossFiltersSetting,
} from 'src/dashboard/actions/dashboardInfo'; } from 'src/dashboard/actions/dashboardInfo';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import DropdownSelectableIcon, {
DropDownSelectableProps,
} from 'src/components/DropdownSelectableIcon';
import Checkbox from 'src/components/Checkbox'; import Checkbox from 'src/components/Checkbox';
import { Dropdown } from 'src/components/Dropdown';
import { Button } from 'src/components';
import { Space } from 'src/components/Space';
import { clearDataMaskState } from 'src/dataMask/actions'; import { clearDataMaskState } from 'src/dataMask/actions';
import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state'; import { useFilters } from 'src/dashboard/components/nativeFilters/FilterBar/state';
import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal';
import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal'; import { useCrossFiltersScopingModal } from '../CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import FilterConfigurationLink from '../FilterConfigurationLink'; import FilterConfigurationLink from '../FilterConfigurationLink';
@ -100,6 +101,12 @@ const FilterBarSettings = () => {
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(); const [openScopingModal, scopingModal] = useCrossFiltersScopingModal();
const { openFilterConfigModal, FilterConfigModalComponent } =
useFilterConfigModal({
createNewOnOpen: filterValues.length === 0,
dashboardId,
});
const updateCrossFiltersSetting = useCallback( const updateCrossFiltersSetting = useCallback(
async isEnabled => { async isEnabled => {
if (!isEnabled) { if (!isEnabled) {
@ -133,7 +140,7 @@ const FilterBarSettings = () => {
[dispatch, filterBarOrientation], [dispatch, filterBarOrientation],
); );
const handleSelect = useCallback( const handleClick = useCallback(
( (
selection: Parameters< selection: Parameters<
Required<Pick<MenuProps, 'onSelect'>>['onSelect'] Required<Pick<MenuProps, 'onSelect'>>['onSelect']
@ -146,9 +153,16 @@ const FilterBarSettings = () => {
toggleFilterBarOrientation(selectedKey); toggleFilterBarOrientation(selectedKey);
} else if (selectedKey === CROSS_FILTERS_SCOPING_MENU_KEY) { } else if (selectedKey === CROSS_FILTERS_SCOPING_MENU_KEY) {
openScopingModal(); openScopingModal();
} else if (selectedKey === ADD_EDIT_FILTERS_MENU_KEY) {
openFilterConfigModal();
} }
}, },
[openScopingModal, toggleCrossFiltering, toggleFilterBarOrientation], [
openScopingModal,
toggleCrossFiltering,
toggleFilterBarOrientation,
openFilterConfigModal,
],
); );
const crossFiltersMenuItem = useMemo( const crossFiltersMenuItem = useMemo(
@ -168,21 +182,20 @@ const FilterBarSettings = () => {
); );
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
const items: DropDownSelectableProps['menuItems'] = []; const items: MenuProps['items'] = [];
if (canEdit) { if (canEdit) {
items.push({ items.push({
key: ADD_EDIT_FILTERS_MENU_KEY, key: ADD_EDIT_FILTERS_MENU_KEY,
label: ( label: (
<FilterConfigurationLink <FilterConfigurationLink>
dashboardId={dashboardId}
createNewOnOpen={filterValues.length === 0}
>
{t('Add or edit filters')} {t('Add or edit filters')}
</FilterConfigurationLink> </FilterConfigurationLink>
), ),
divider: canSetHorizontalFilterBar,
}); });
if (canSetHorizontalFilterBar) {
items.push({ type: 'divider' });
}
} }
if (canEdit) { if (canEdit) {
items.push({ items.push({
@ -192,8 +205,10 @@ const FilterBarSettings = () => {
items.push({ items.push({
key: CROSS_FILTERS_SCOPING_MENU_KEY, key: CROSS_FILTERS_SCOPING_MENU_KEY,
label: t('Cross-filtering scoping'), label: t('Cross-filtering scoping'),
divider: canSetHorizontalFilterBar,
}); });
if (canSetHorizontalFilterBar) {
items.push({ type: 'divider' });
}
} }
if (canSetHorizontalFilterBar) { if (canSetHorizontalFilterBar) {
items.push({ items.push({
@ -202,17 +217,31 @@ const FilterBarSettings = () => {
children: [ children: [
{ {
key: FilterBarOrientation.Vertical, key: FilterBarOrientation.Vertical,
label: t('Vertical (Left)'), label: (
<Space>
{t('Vertical (Left)')}
{selectedFilterBarOrientation ===
FilterBarOrientation.Vertical && <Icons.Check />}
</Space>
),
}, },
{ {
key: FilterBarOrientation.Horizontal, key: FilterBarOrientation.Horizontal,
label: t('Horizontal (Top)'), label: (
<Space>
{t('Horizontal (Top)')}
{selectedFilterBarOrientation ===
FilterBarOrientation.Horizontal && <Icons.Check />}
</Space>
),
}, },
], ],
...{ 'data-test': 'dropdown-selectable-icon-submenu' },
}); });
} }
return items; return items;
}, [ }, [
selectedFilterBarOrientation,
canEdit, canEdit,
canSetHorizontalFilterBar, canSetHorizontalFilterBar,
crossFiltersMenuItem, crossFiltersMenuItem,
@ -226,19 +255,24 @@ const FilterBarSettings = () => {
return ( return (
<> <>
<DropdownSelectableIcon <Dropdown
onSelect={handleSelect} menu={{
icon={ onClick: handleClick,
items: menuItems,
selectedKeys: [selectedFilterBarOrientation],
}}
trigger={['click']}
>
<Button type="link">
<Icons.Gear <Icons.Gear
name="gear" name="gear"
iconColor={theme.colors.grayscale.base} iconColor={theme.colors.grayscale.base}
data-test="filterbar-orientation-icon" data-test="filterbar-orientation-icon"
/> />
} </Button>
menuItems={menuItems} </Dropdown>
selectedKeys={[selectedFilterBarOrientation]}
/>
{scopingModal} {scopingModal}
{FilterConfigModalComponent}
</> </>
); );
}; };

View File

@ -38,11 +38,16 @@ test('should render the config link text', () => {
}); });
test('should render the modal on click', () => { test('should render the modal on click', () => {
render(<FilterConfigurationLink>Config link</FilterConfigurationLink>, { const showModal = jest.fn();
useRedux: true, render(
}); <FilterConfigurationLink onClick={showModal}>
Config link
</FilterConfigurationLink>,
{
useRedux: true,
},
);
const configLink = screen.getByText('Config link'); const configLink = screen.getByText('Config link');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
userEvent.click(configLink); userEvent.click(configLink);
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(showModal).toHaveBeenCalled();
}); });

View File

@ -16,70 +16,27 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ReactNode, FC, useCallback, useState, memo } from 'react'; import { ReactNode, FC, memo } from 'react';
import { useDispatch } from 'react-redux';
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
import { getFilterBarTestId } from '../utils'; import { getFilterBarTestId } from '../utils';
import { SaveFilterChangesType } from '../../FiltersConfigModal/types';
export interface FCBProps { export interface FCBProps {
createNewOnOpen?: boolean;
dashboardId?: number;
initialFilterId?: string;
onClick?: () => void; onClick?: () => void;
children?: ReactNode; children?: ReactNode;
} }
export const FilterConfigurationLink: FC<FCBProps> = ({ export const FilterConfigurationLink: FC<FCBProps> = ({
createNewOnOpen,
dashboardId,
initialFilterId,
onClick, onClick,
children, children,
}) => { }) => (
const dispatch = useDispatch(); <div
const [isOpen, setOpen] = useState(false); {...getFilterBarTestId('create-filter')}
const close = useCallback(() => { onClick={onClick}
setOpen(false); role="button"
}, [setOpen]); tabIndex={0}
>
const submit = useCallback( {children}
async (filterChanges: SaveFilterChangesType) => { </div>
dispatch(await setFilterConfiguration(filterChanges)); );
close();
},
[dispatch, close],
);
const handleClick = useCallback(() => {
setOpen(true);
if (onClick) {
onClick();
}
}, [setOpen, onClick]);
return (
<>
<div
{...getFilterBarTestId('create-filter')}
onClick={handleClick}
role="button"
tabIndex={0}
>
{children}
</div>
<FiltersConfigModal
isOpen={isOpen}
onSave={submit}
onCancel={close}
initialFilterId={initialFilterId}
createNewOnOpen={createNewOnOpen}
key={`filters-for-${dashboardId}`}
/>
</>
);
};
export default memo(FilterConfigurationLink); export default memo(FilterConfigurationLink);

View File

@ -0,0 +1,82 @@
/**
* 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 { useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { setFilterConfiguration } from 'src/dashboard/actions/nativeFilters';
import { SaveFilterChangesType } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/types';
import FiltersConfigModal from 'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal';
interface UseFilterConfigModalProps {
createNewOnOpen?: boolean;
dashboardId: number;
initialFilterId?: string;
}
interface UseFilterConfigModalReturn {
isFilterConfigModalOpen: boolean;
openFilterConfigModal: () => void;
closeFilterConfigModal: () => void;
handleFilterSave: (filterChanges: SaveFilterChangesType) => Promise<void>;
FilterConfigModalComponent: JSX.Element | null;
}
export const useFilterConfigModal = ({
createNewOnOpen = false,
dashboardId,
initialFilterId,
}: UseFilterConfigModalProps): UseFilterConfigModalReturn => {
const dispatch = useDispatch();
const [isFilterConfigModalOpen, setIsFilterConfigModalOpen] = useState(false);
const openFilterConfigModal = useCallback(() => {
setIsFilterConfigModalOpen(true);
}, []);
const closeFilterConfigModal = useCallback(() => {
setIsFilterConfigModalOpen(false);
}, []);
const handleFilterSave = useCallback(
async (filterChanges: SaveFilterChangesType) => {
dispatch(await setFilterConfiguration(filterChanges));
closeFilterConfigModal();
},
[dispatch, closeFilterConfigModal],
);
const FilterConfigModalComponent = isFilterConfigModalOpen ? (
<FiltersConfigModal
isOpen={isFilterConfigModalOpen}
onSave={handleFilterSave}
onCancel={closeFilterConfigModal}
key={`filters-for-${dashboardId}`}
createNewOnOpen={createNewOnOpen}
initialFilterId={initialFilterId}
/>
) : null;
return {
isFilterConfigModalOpen,
openFilterConfigModal,
closeFilterConfigModal,
handleFilterSave,
FilterConfigModalComponent,
};
};

View File

@ -58,9 +58,6 @@ const Wrapper = styled.div`
padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${ padding: ${theme.gridUnit * 3}px ${theme.gridUnit * 2}px ${
theme.gridUnit theme.gridUnit
}px; }px;
.ant-dropdown-trigger span {
padding-right: ${theme.gridUnit * 2}px;
}
`} `}
`; `;

View File

@ -19,6 +19,7 @@
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { css, SupersetTheme, useTheme, useTruncation } from '@superset-ui/core'; import { css, SupersetTheme, useTheme, useTruncation } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal';
import { RootState } from 'src/dashboard/types'; import { RootState } from 'src/dashboard/types';
import { Row, FilterName, InternalRow } from './Styles'; import { Row, FilterName, InternalRow } from './Styles';
import { FilterCardRowProps } from './types'; import { FilterCardRowProps } from './types';
@ -39,6 +40,12 @@ export const NameRow = ({
({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
); );
const { FilterConfigModalComponent, openFilterConfigModal } =
useFilterConfigModal({
dashboardId,
initialFilterId: filter.id,
});
return ( return (
<Row <Row
css={(theme: SupersetTheme) => css` css={(theme: SupersetTheme) => css`
@ -58,9 +65,10 @@ export const NameRow = ({
</InternalRow> </InternalRow>
{canEdit && ( {canEdit && (
<FilterConfigurationLink <FilterConfigurationLink
dashboardId={dashboardId} onClick={() => {
onClick={hidePopover} openFilterConfigModal();
initialFilterId={filter.id} hidePopover();
}}
> >
<Icons.Edit <Icons.Edit
iconSize="l" iconSize="l"
@ -71,6 +79,7 @@ export const NameRow = ({
/> />
</FilterConfigurationLink> </FilterConfigurationLink>
)} )}
{FilterConfigModalComponent}
</Row> </Row>
); );
}; };

View File

@ -0,0 +1,184 @@
/**
* 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 { useCallback, useEffect, useRef } from 'react';
import { useSelector } from 'react-redux';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { last } from 'lodash';
import {
logging,
t,
SupersetClient,
SupersetApiError,
} from '@superset-ui/core';
import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { RootState } from 'src/dashboard/types';
import { getDashboardUrlParams } from 'src/utils/urlUtils';
import { DownloadScreenshotFormat } from '../components/menu/DownloadMenuItems/types';
const RETRY_INTERVAL = 3000;
const MAX_RETRIES = 30;
export const useDownloadScreenshot = (
dashboardId: number,
logEvent?: Function,
) => {
const activeTabs = useSelector(
(state: RootState) => state.dashboardState.activeTabs || undefined,
);
const anchor = useSelector(
(state: RootState) =>
last(state.dashboardState.directPathToChild) || undefined,
);
const dataMask = useSelector(
(state: RootState) => state.dataMask || undefined,
);
const { addDangerToast, addSuccessToast, addInfoToast } = useToasts();
const currentIntervalIds = useRef<NodeJS.Timeout[]>([]);
const stopIntervals = useCallback(
(message?: 'success' | 'failure') => {
currentIntervalIds.current.forEach(clearInterval);
if (message === 'failure') {
addDangerToast(
t('The screenshot could not be downloaded. Please, try again later.'),
);
}
if (message === 'success') {
addSuccessToast(t('The screenshot has been downloaded.'));
}
},
[addDangerToast, addSuccessToast],
);
const downloadScreenshot = useCallback(
(format: DownloadScreenshotFormat) => {
let retries = 0;
const toastIntervalId = setInterval(
() =>
addInfoToast(
t(
'The screenshot is being generated. Please, do not leave the page.',
),
{ noDuplicate: true },
),
RETRY_INTERVAL,
);
currentIntervalIds.current = [
...(currentIntervalIds.current || []),
toastIntervalId,
];
const checkImageReady = (cacheKey: string) =>
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboardId}/screenshot/${cacheKey}/?download_format=${format}`,
headers: { Accept: 'application/pdf, image/png' },
parseMethod: 'raw',
})
.then((response: Response) => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `screenshot.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
stopIntervals('success');
})
.catch(err => {
if ((err as SupersetApiError).status === 404) {
throw new Error('Image not ready');
}
});
const fetchImageWithRetry = (cacheKey: string) => {
if (retries >= MAX_RETRIES) {
stopIntervals('failure');
logging.error('Max retries reached');
return;
}
checkImageReady(cacheKey).catch(() => {
retries += 1;
});
};
SupersetClient.post({
endpoint: `/api/v1/dashboard/${dashboardId}/cache_dashboard_screenshot/`,
jsonPayload: {
anchor,
activeTabs,
dataMask,
urlParams: getDashboardUrlParams(['edit']),
},
})
.then(({ json }) => {
const cacheKey = json?.cache_key;
if (!cacheKey) {
throw new Error('No image URL in response');
}
const retryIntervalId = setInterval(() => {
fetchImageWithRetry(cacheKey);
}, RETRY_INTERVAL);
currentIntervalIds.current.push(retryIntervalId);
fetchImageWithRetry(cacheKey);
})
.catch(error => {
logging.error(error);
stopIntervals('failure');
})
.finally(() => {
logEvent?.(
format === DownloadScreenshotFormat.PNG
? LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE
: LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
);
});
},
[
dashboardId,
anchor,
activeTabs,
dataMask,
addInfoToast,
stopIntervals,
logEvent,
],
);
useEffect(
() => () => {
if (currentIntervalIds.current.length > 0) {
stopIntervals();
}
currentIntervalIds.current = [];
},
[stopIntervals],
);
return downloadScreenshot;
};

View File

@ -103,10 +103,10 @@ export const filterCardPopoverStyle = (theme: SupersetTheme) => css`
`; `;
export const chartContextMenuStyles = (theme: SupersetTheme) => css` export const chartContextMenuStyles = (theme: SupersetTheme) => css`
.ant-dropdown-menu.chart-context-menu { .antd5-dropdown-menu.chart-context-menu {
min-width: ${theme.gridUnit * 43}px; min-width: ${theme.gridUnit * 43}px;
} }
.ant-dropdown-menu-submenu.chart-context-submenu { .antd5-dropdown-menu-submenu.chart-context-submenu {
max-width: ${theme.gridUnit * 60}px; max-width: ${theme.gridUnit * 60}px;
min-width: ${theme.gridUnit * 40}px; min-width: ${theme.gridUnit * 40}px;
} }
@ -117,7 +117,7 @@ export const focusStyle = (theme: SupersetTheme) => css`
.ant-tabs-tabpane, .ant-tabs-tabpane,
.ant-tabs-tab-btn, .ant-tabs-tab-btn,
.superset-button, .superset-button,
.superset-button.ant-dropdown-trigger, .superset-button.antd5-dropdown-trigger,
.header-controls span { .header-controls span {
&:focus-visible { &:focus-visible {
box-shadow: 0 0 0 2px ${theme.colors.primary.dark1}; box-shadow: 0 0 0 2px ${theme.colors.primary.dark1};

View File

@ -31,6 +31,9 @@ import { sliceUpdated } from 'src/explore/actions/exploreActions';
import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions';
import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions'; import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
import { applyColors, resetColors } from 'src/utils/colorScheme'; import { applyColors, resetColors } from 'src/utils/colorScheme';
import ReportModal from 'src/features/reports/ReportModal';
import DeleteModal from 'src/components/DeleteModal';
import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu'; import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
import { useExploreMetadataBar } from './useExploreMetadataBar'; import { useExploreMetadataBar } from './useExploreMetadataBar';
@ -86,6 +89,8 @@ export const ExploreChartHeader = ({
const dispatch = useDispatch(); const dispatch = useDispatch();
const { latestQueryFormData, sliceFormData } = chart; const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false); const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const updateCategoricalNamespace = async () => { const updateCategoricalNamespace = async () => {
const { dashboards } = metadata || {}; const { dashboards } = metadata || {};
const dashboard = const dashboard =
@ -128,6 +133,14 @@ export const ExploreChartHeader = ({
setIsPropertiesModalOpen(false); setIsPropertiesModalOpen(false);
}; };
const showReportModal = () => {
setIsReportModalOpen(true);
};
const closeReportModal = () => {
setIsReportModalOpen(false);
};
const showModal = useCallback(() => { const showModal = useCallback(() => {
dispatch(setSaveChartModalVisibility(true)); dispatch(setSaveChartModalVisibility(true));
}, [dispatch]); }, [dispatch]);
@ -139,6 +152,11 @@ export const ExploreChartHeader = ({
[dispatch], [dispatch],
); );
const handleReportDelete = async report => {
await dispatch(deleteActiveReport(report));
setCurrentReportDeleting(null);
};
const history = useHistory(); const history = useHistory();
const { redirectSQLLab } = actions; const { redirectSQLLab } = actions;
@ -158,6 +176,8 @@ export const ExploreChartHeader = ({
openPropertiesModal, openPropertiesModal,
ownState, ownState,
metadata?.dashboards, metadata?.dashboards,
showReportModal,
setCurrentReportDeleting,
); );
const metadataBar = useExploreMetadataBar(metadata, slice); const metadataBar = useExploreMetadataBar(metadata, slice);
@ -229,8 +249,8 @@ export const ExploreChartHeader = ({
} }
additionalActionsMenu={menu} additionalActionsMenu={menu}
menuDropdownProps={{ menuDropdownProps={{
visible: isDropdownVisible, open: isDropdownVisible,
onVisibleChange: setIsDropdownVisible, onOpenChange: setIsDropdownVisible,
}} }}
/> />
{isPropertiesModalOpen && ( {isPropertiesModalOpen && (
@ -241,6 +261,33 @@ export const ExploreChartHeader = ({
slice={slice} slice={slice}
/> />
)} )}
<ReportModal
userId={user.userId}
show={isReportModalOpen}
onHide={closeReportModal}
userEmail={user.email}
dashboardId={dashboardId}
chart={chart}
creationMethod="charts"
/>
{currentReportDeleting && (
<DeleteModal
description={t(
'This action will permanently delete %s.',
currentReportDeleting?.name,
)}
onConfirm={() => {
if (currentReportDeleting) {
handleReportDelete(currentReportDeleting);
}
}}
onHide={() => setCurrentReportDeleting(null)}
open
title={t('Delete Report?')}
/>
)}
</> </>
); );
}; };

View File

@ -20,7 +20,7 @@ import { ReactChild, useCallback, Key } from 'react';
import { t, styled } from '@superset-ui/core'; import { t, styled } from '@superset-ui/core';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
enum MenuKeys { enum MenuKeys {
@ -67,9 +67,9 @@ export const ExportToCSVDropdown = ({
); );
return ( return (
<AntdDropdown <Dropdown
trigger={['click']} trigger={['click']}
overlay={ dropdownRender={() => (
<Menu onClick={handleMenuClick} selectable={false}> <Menu onClick={handleMenuClick} selectable={false}>
<Menu.Item key={MenuKeys.ExportOriginal}> <Menu.Item key={MenuKeys.ExportOriginal}>
<MenuItemContent> <MenuItemContent>
@ -84,9 +84,9 @@ export const ExportToCSVDropdown = ({
</MenuItemContent> </MenuItemContent>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
} )}
> >
{children} {children}
</AntdDropdown> </Dropdown>
); );
}; };

View File

@ -107,6 +107,12 @@ describe('DatasourceControl', () => {
expect(screen.queryAllByRole('menuitem')).toHaveLength(3); expect(screen.queryAllByRole('menuitem')).toHaveLength(3);
}); });
// Close the menu
userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryAllByRole('menuitem')).toHaveLength(0);
});
rerender(<DatasourceControl {...{ ...props, isEditable: false }} />, { rerender(<DatasourceControl {...{ ...props, isEditable: false }} />, {
useRedux: true, useRedux: true,
useRouter: true, useRouter: true,

View File

@ -29,7 +29,7 @@ import {
} from '@superset-ui/core'; } from '@superset-ui/core';
import { getTemporalColumns } from '@superset-ui/chart-controls'; import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils'; import { getUrlParam } from 'src/utils/urlUtils';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
@ -82,12 +82,8 @@ const Styles = styled.div`
.error-alert { .error-alert {
margin: ${({ theme }) => 2 * theme.gridUnit}px; margin: ${({ theme }) => 2 * theme.gridUnit}px;
} }
.ant-dropdown-trigger { .antd5-dropdown-trigger {
margin-left: ${({ theme }) => 2 * theme.gridUnit}px; margin-left: ${({ theme }) => 2 * theme.gridUnit}px;
box-shadow: none;
&:active {
box-shadow: none;
}
} }
.btn-group .open .dropdown-toggle { .btn-group .open .dropdown-toggle {
box-shadow: none; box-shadow: none;
@ -410,8 +406,8 @@ class DatasourceControl extends PureComponent {
{extra?.warning_markdown && ( {extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} /> <WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)} )}
<AntdDropdown <Dropdown
overlay={ dropdownRender={() =>
datasource.type === DatasourceType.Query datasource.type === DatasourceType.Query
? queryDatasourceMenu ? queryDatasourceMenu
: defaultDatasourceMenu : defaultDatasourceMenu
@ -423,7 +419,7 @@ class DatasourceControl extends PureComponent {
className="datasource-modal-trigger" className="datasource-modal-trigger"
data-test="datasource-menu-trigger" data-test="datasource-menu-trigger"
/> />
</AntdDropdown> </Dropdown>
</div> </div>
{/* missing dataset */} {/* missing dataset */}
{isMissingDatasource && isMissingParams && ( {isMissingDatasource && isMissingParams && (

View File

@ -108,13 +108,6 @@ export const MenuTrigger = styled(Button)`
`} `}
`; `;
const iconReset = css`
.ant-dropdown-menu-item > & > .anticon:first-child {
margin-right: 0;
vertical-align: 0;
}
`;
export const useExploreAdditionalActionsMenu = ( export const useExploreAdditionalActionsMenu = (
latestQueryFormData, latestQueryFormData,
canDownloadCSV, canDownloadCSV,
@ -123,6 +116,8 @@ export const useExploreAdditionalActionsMenu = (
onOpenPropertiesModal, onOpenPropertiesModal,
ownState, ownState,
dashboards, dashboards,
showReportModal,
setCurrentReportDeleting,
...rest ...rest
) => { ) => {
const theme = useTheme(); const theme = useTheme();
@ -330,14 +325,14 @@ export const useExploreAdditionalActionsMenu = (
<> <>
<Menu.Item <Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV} key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined css={iconReset} />} icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV} disabled={!canDownloadCSV}
> >
{t('Export to original .CSV')} {t('Export to original .CSV')}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED} key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
icon={<Icons.FileOutlined css={iconReset} />} icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV} disabled={!canDownloadCSV}
> >
{t('Export to pivoted .CSV')} {t('Export to pivoted .CSV')}
@ -346,7 +341,7 @@ export const useExploreAdditionalActionsMenu = (
) : ( ) : (
<Menu.Item <Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV} key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined css={iconReset} />} icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV} disabled={!canDownloadCSV}
> >
{t('Export to .CSV')} {t('Export to .CSV')}
@ -354,20 +349,20 @@ export const useExploreAdditionalActionsMenu = (
)} )}
<Menu.Item <Menu.Item
key={MENU_KEYS.EXPORT_TO_JSON} key={MENU_KEYS.EXPORT_TO_JSON}
icon={<Icons.FileOutlined css={iconReset} />} icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV} disabled={!canDownloadCSV}
> >
{t('Export to .JSON')} {t('Export to .JSON')}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
key={MENU_KEYS.DOWNLOAD_AS_IMAGE} key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
icon={<Icons.FileImageOutlined css={iconReset} />} icon={<Icons.FileImageOutlined />}
> >
{t('Download as image')} {t('Download as image')}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
key={MENU_KEYS.EXPORT_TO_XLSX} key={MENU_KEYS.EXPORT_TO_XLSX}
icon={<Icons.FileOutlined css={iconReset} />} icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV} disabled={!canDownloadCSV}
> >
{t('Export to Excel')} {t('Export to Excel')}
@ -403,28 +398,25 @@ export const useExploreAdditionalActionsMenu = (
<Menu.Divider /> <Menu.Divider />
{showReportSubMenu ? ( {showReportSubMenu ? (
<> <>
<Menu.SubMenu title={t('Manage email report')}> <HeaderReportDropDown
<HeaderReportDropDown submenuTitle={t('Manage email report')}
chart={chart} chart={chart}
setShowReportSubMenu={setShowReportSubMenu} setShowReportSubMenu={setShowReportSubMenu}
showReportSubMenu={showReportSubMenu} showReportSubMenu={showReportSubMenu}
setIsDropdownVisible={setIsDropdownVisible} showReportModal={showReportModal}
isDropdownVisible={isDropdownVisible} setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu useTextMenu
/> />
</Menu.SubMenu>
<Menu.Divider /> <Menu.Divider />
</> </>
) : ( ) : (
<Menu> <HeaderReportDropDown
<HeaderReportDropDown chart={chart}
chart={chart} setShowReportSubMenu={setShowReportSubMenu}
setShowReportSubMenu={setShowReportSubMenu} showReportModal={showReportModal}
setIsDropdownVisible={setIsDropdownVisible} setCurrentReportDeleting={setCurrentReportDeleting}
isDropdownVisible={isDropdownVisible} useTextMenu
useTextMenu />
/>
</Menu>
)} )}
<Menu.Item key={MENU_KEYS.VIEW_QUERY}> <Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger <ModalTrigger

View File

@ -24,7 +24,7 @@ import Chart from 'src/types/Chart';
import ListViewCard from 'src/components/ListViewCard'; import ListViewCard from 'src/components/ListViewCard';
import Label from 'src/components/Label'; import Label from 'src/components/Label';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import FaveStar from 'src/components/FaveStar'; import FaveStar from 'src/components/FaveStar';
import FacePile from 'src/components/FacePile'; import FacePile from 'src/components/FacePile';
@ -172,9 +172,9 @@ export default function ChartCard({
isStarred={favoriteStatus} isStarred={favoriteStatus}
/> />
)} )}
<AntdDropdown overlay={menu}> <Dropdown dropdownRender={() => menu}>
<Icons.MoreVert iconColor={theme.colors.grayscale.base} /> <Icons.MoreVert iconColor={theme.colors.grayscale.base} />
</AntdDropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
} }
/> />

View File

@ -26,7 +26,7 @@ import {
SupersetClient, SupersetClient,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils'; import { CardStyles } from 'src/views/CRUD/utils';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import ListViewCard from 'src/components/ListViewCard'; import ListViewCard from 'src/components/ListViewCard';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
@ -179,9 +179,9 @@ function DashboardCard({
isStarred={favoriteStatus} isStarred={favoriteStatus}
/> />
)} )}
<AntdDropdown overlay={menu}> <Dropdown dropdownRender={() => menu}>
<Icons.MoreVert iconColor={theme.colors.grayscale.base} /> <Icons.MoreVert iconColor={theme.colors.grayscale.base} />
</AntdDropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
} }
/> />

View File

@ -409,6 +409,7 @@ const RightMenu = ({
{RightMenuExtension && <RightMenuExtension />} {RightMenuExtension && <RightMenuExtension />}
{!navbarRight.user_is_anonymous && showActionDropdown && ( {!navbarRight.user_is_anonymous && showActionDropdown && (
<StyledSubMenu <StyledSubMenu
key="sub1"
data-test="new-dropdown" data-test="new-dropdown"
title={ title={
<StyledI data-test="new-dropdown-icon" className="fa fa-plus" /> <StyledI data-test="new-dropdown-icon" className="fa fa-plus" />
@ -474,6 +475,7 @@ const RightMenu = ({
</StyledSubMenu> </StyledSubMenu>
)} )}
<StyledSubMenu <StyledSubMenu
key="sub3_settings"
title={t('Settings')} title={t('Settings')}
icon={<Icons.TriangleDown iconSize="xl" />} icon={<Icons.TriangleDown iconSize="xl" />}
> >

View File

@ -25,7 +25,7 @@ import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import { LoadingCards } from 'src/pages/Home'; import { LoadingCards } from 'src/pages/Home';
import { TableTab } from 'src/views/CRUD/types'; import { TableTab } from 'src/views/CRUD/types';
import withToasts from 'src/components/MessageToasts/withToasts'; import withToasts from 'src/components/MessageToasts/withToasts';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import { copyQueryLink, useListViewResource } from 'src/views/CRUD/hooks'; import { copyQueryLink, useListViewResource } from 'src/views/CRUD/hooks';
import ListViewCard from 'src/components/ListViewCard'; import ListViewCard from 'src/components/ListViewCard';
@ -315,11 +315,11 @@ const SavedQueries = ({
e.preventDefault(); e.preventDefault();
}} }}
> >
<AntdDropdown overlay={renderMenu(q)}> <Dropdown dropdownRender={() => renderMenu(q)}>
<Icons.MoreVert <Icons.MoreVert
iconColor={theme.colors.grayscale.base} iconColor={theme.colors.grayscale.base}
/> />
</AntdDropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
</QueryData> </QueryData>
} }

View File

@ -19,14 +19,15 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { render, screen, act } from 'spec/helpers/testing-library'; import { render, screen, act } from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core'; import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { Menu } from 'src/components/Menu';
import HeaderReportDropdown, { HeaderReportProps } from '.'; import HeaderReportDropdown, { HeaderReportProps } from '.';
const createProps = () => ({ const createProps = () => ({
dashboardId: 1, dashboardId: 1,
useTextMenu: false, useTextMenu: false,
isDropdownVisible: true,
setIsDropdownVisible: jest.fn,
setShowReportSubMenu: jest.fn, setShowReportSubMenu: jest.fn,
showReportModal: jest.fn,
setCurrentReportDeleting: jest.fn,
}); });
const stateWithOnlyUser = { const stateWithOnlyUser = {
@ -117,9 +118,9 @@ const stateWithUserAndReport = {
function setup(props: HeaderReportProps, initialState = {}) { function setup(props: HeaderReportProps, initialState = {}) {
render( render(
<div> <Menu>
<HeaderReportDropdown {...props} /> <HeaderReportDropdown {...props} />
</div>, </Menu>,
{ useRedux: true, initialState }, { useRedux: true, initialState },
); );
} }
@ -147,95 +148,90 @@ describe('Header Report Dropdown', () => {
act(() => { act(() => {
setup(mockedProps, stateWithUserAndReport); setup(mockedProps, stateWithUserAndReport);
}); });
expect(screen.getByRole('button')).toBeInTheDocument(); expect(screen.getByRole('menuitem')).toBeInTheDocument();
}); });
it('renders the dropdown correctly', () => { it('renders the dropdown correctly', async () => {
const mockedProps = createProps(); const mockedProps = createProps();
act(() => { act(() => {
setup(mockedProps, stateWithUserAndReport); setup(mockedProps, stateWithUserAndReport);
}); });
const emailReportModalButton = screen.getByRole('button'); const emailReportModalButton = screen.getByRole('menuitem');
userEvent.click(emailReportModalButton); userEvent.hover(emailReportModalButton);
expect(screen.getByText('Email reports active')).toBeInTheDocument(); expect(await screen.findByText('Email reports active')).toBeInTheDocument();
expect(screen.getByText('Edit email report')).toBeInTheDocument(); expect(screen.getByText('Edit email report')).toBeInTheDocument();
expect(screen.getByText('Delete email report')).toBeInTheDocument(); expect(screen.getByText('Delete email report')).toBeInTheDocument();
}); });
it('opens an edit modal', async () => { it('opens an edit modal', async () => {
const mockedProps = createProps(); const mockedProps = createProps();
mockedProps.showReportModal = jest.fn();
act(() => { act(() => {
setup(mockedProps, stateWithUserAndReport); setup(mockedProps, stateWithUserAndReport);
}); });
const emailReportModalButton = screen.getByRole('button'); const emailReportModalButton = screen.getByRole('menuitem');
userEvent.click(emailReportModalButton); userEvent.click(emailReportModalButton);
const editModal = screen.getByText('Edit email report'); const editModal = await screen.findByText('Edit email report');
userEvent.click(editModal); userEvent.click(editModal);
const textBoxes = await screen.findAllByText('Edit email report'); expect(mockedProps.showReportModal).toHaveBeenCalled();
expect(textBoxes).toHaveLength(2);
}); });
it('opens a delete modal', () => { it('opens a delete modal', async () => {
const mockedProps = createProps(); const mockedProps = createProps();
mockedProps.setCurrentReportDeleting = jest.fn();
act(() => { act(() => {
setup(mockedProps, stateWithUserAndReport); setup(mockedProps, stateWithUserAndReport);
}); });
const emailReportModalButton = screen.getByRole('button'); const emailReportModalButton = screen.getByRole('menuitem');
userEvent.click(emailReportModalButton); userEvent.click(emailReportModalButton);
const deleteModal = screen.getByText('Delete email report'); const deleteModal = await screen.findByText('Delete email report');
userEvent.click(deleteModal); userEvent.click(deleteModal);
expect(screen.getByText('Delete Report?')).toBeInTheDocument(); expect(mockedProps.setCurrentReportDeleting).toHaveBeenCalled();
}); });
it('renders a new report modal if there is no report', () => { it('renders Manage Email Reports Menu if textMenu is set to true and there is a report', async () => {
const mockedProps = createProps();
act(() => {
setup(mockedProps, stateWithOnlyUser);
});
const emailReportModalButton = screen.getByRole('button');
userEvent.click(emailReportModalButton);
expect(screen.getByText('Schedule a new email report')).toBeInTheDocument();
});
it('renders Manage Email Reports Menu if textMenu is set to true and there is a report', () => {
let mockedProps = createProps(); let mockedProps = createProps();
mockedProps = { mockedProps = {
...mockedProps, ...mockedProps,
useTextMenu: true, useTextMenu: true,
isDropdownVisible: true,
}; };
act(() => { act(() => {
setup(mockedProps, stateWithUserAndReport); setup(mockedProps, stateWithUserAndReport);
}); });
expect(screen.getByText('Email reports active')).toBeInTheDocument(); userEvent.click(screen.getByRole('menuitem'));
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
expect(screen.getByText('Edit email report')).toBeInTheDocument(); expect(screen.getByText('Edit email report')).toBeInTheDocument();
expect(screen.getByText('Delete 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', () => { it('renders Schedule Email Reports if textMenu is set to true and there is a report', async () => {
let mockedProps = createProps(); let mockedProps = createProps();
mockedProps = { mockedProps = {
...mockedProps, ...mockedProps,
useTextMenu: true, useTextMenu: true,
isDropdownVisible: true,
}; };
act(() => { act(() => {
setup(mockedProps, stateWithOnlyUser); setup(mockedProps, stateWithOnlyUser);
}); });
expect(screen.getByText('Set up an email report')).toBeInTheDocument(); 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', () => { it('renders Schedule Email Reports as long as user has permission through any role', async () => {
let mockedProps = createProps(); let mockedProps = createProps();
mockedProps = { mockedProps = {
...mockedProps, ...mockedProps,
useTextMenu: true, useTextMenu: true,
isDropdownVisible: true,
}; };
act(() => { act(() => {
setup(mockedProps, stateWithNonAdminUser); setup(mockedProps, stateWithNonAdminUser);
}); });
expect(screen.getByText('Set up an email report')).toBeInTheDocument(); userEvent.click(screen.getByRole('menuitem'));
expect(
await screen.findByText('Set up an email report'),
).toBeInTheDocument();
}); });
it('do not render Schedule Email Reports if user no permission', () => { it('do not render Schedule Email Reports if user no permission', () => {
@ -243,11 +239,12 @@ describe('Header Report Dropdown', () => {
mockedProps = { mockedProps = {
...mockedProps, ...mockedProps,
useTextMenu: true, useTextMenu: true,
isDropdownVisible: true,
}; };
act(() => { act(() => {
setup(mockedProps, stateWithNonMenuAccessOnManage); setup(mockedProps, stateWithNonMenuAccessOnManage);
}); });
userEvent.click(screen.getByRole('menu'));
expect( expect(
screen.queryByText('Set up an email report'), screen.queryByText('Set up an email report'),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useState, useEffect } from 'react'; import { ReactNode, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { import {
@ -24,7 +24,6 @@ import {
SupersetTheme, SupersetTheme,
css, css,
styled, styled,
useTheme,
FeatureFlag, FeatureFlag,
isFeatureEnabled, isFeatureEnabled,
getExtensionsRegistry, getExtensionsRegistry,
@ -36,15 +35,11 @@ import { AlertObject } from 'src/features/alerts/types';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import Checkbox from 'src/components/Checkbox'; import Checkbox from 'src/components/Checkbox';
import { noOp } from 'src/utils/common'; import { noOp } from 'src/utils/common';
import { NoAnimationDropdown } from 'src/components/Dropdown';
import DeleteModal from 'src/components/DeleteModal';
import ReportModal from 'src/features/reports/ReportModal';
import { ChartState } from 'src/explore/types'; import { ChartState } from 'src/explore/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { import {
fetchUISpecificReport, fetchUISpecificReport,
toggleActive, toggleActive,
deleteActiveReport,
} from 'src/features/reports/ReportModal/actions'; } from 'src/features/reports/ReportModal/actions';
import { reportSelector } from 'src/views/CRUD/hooks'; import { reportSelector } from 'src/views/CRUD/hooks';
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index'; import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
@ -99,9 +94,10 @@ export interface HeaderReportProps {
chart?: ChartState; chart?: ChartState;
useTextMenu?: boolean; useTextMenu?: boolean;
setShowReportSubMenu?: (show: boolean) => void; setShowReportSubMenu?: (show: boolean) => void;
setIsDropdownVisible?: (visible: boolean) => void;
isDropdownVisible?: boolean;
showReportSubMenu?: boolean; showReportSubMenu?: boolean;
submenuTitle?: string;
showReportModal: () => void;
setCurrentReportDeleting: (report: AlertObject | null) => void;
} }
// Same instance to be used in useEffects // Same instance to be used in useEffects
@ -112,9 +108,9 @@ export default function HeaderReportDropDown({
chart, chart,
useTextMenu = false, useTextMenu = false,
setShowReportSubMenu, setShowReportSubMenu,
setIsDropdownVisible, submenuTitle,
isDropdownVisible, showReportModal,
...rest setCurrentReportDeleting,
}: HeaderReportProps) { }: HeaderReportProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const report = useSelector<any, AlertObject>(state => { const report = useSelector<any, AlertObject>(state => {
@ -156,22 +152,13 @@ export default function HeaderReportDropDown({
return permissions.some(permission => permission.length > 0); return permissions.some(permission => permission.length > 0);
}; };
const [currentReportDeleting, setCurrentReportDeleting] =
useState<AlertObject | null>(null);
const theme = useTheme();
const prevDashboard = usePrevious(dashboardId); const prevDashboard = usePrevious(dashboardId);
const [showModal, setShowModal] = useState<boolean>(false);
const toggleActiveKey = async (data: AlertObject, checked: boolean) => { const toggleActiveKey = async (data: AlertObject, checked: boolean) => {
if (data?.id) { if (data?.id) {
dispatch(toggleActive(data, checked)); dispatch(toggleActive(data, checked));
} }
}; };
const handleReportDelete = async (report: AlertObject) => {
await dispatch(deleteActiveReport(report));
setCurrentReportDeleting(null);
};
const shouldFetch = const shouldFetch =
canAddReports() && canAddReports() &&
!!((dashboardId && prevDashboard !== dashboardId) || chart?.id); !!((dashboardId && prevDashboard !== dashboardId) || chart?.id);
@ -191,12 +178,6 @@ export default function HeaderReportDropDown({
const showReportSubMenu = report && setShowReportSubMenu && canAddReports(); const showReportSubMenu = report && setShowReportSubMenu && canAddReports();
// @z-index-below-dashboard-header (100) - 1 = 99
const dropdownOverlayStyle = {
zIndex: 99,
animationDuration: '0s',
};
useEffect(() => { useEffect(() => {
if (showReportSubMenu) { if (showReportSubMenu) {
setShowReportSubMenu(true); setShowReportSubMenu(true);
@ -206,22 +187,16 @@ export default function HeaderReportDropDown({
}, [report]); }, [report]);
const handleShowMenu = () => { const handleShowMenu = () => {
if (setIsDropdownVisible) { showReportModal();
setIsDropdownVisible(false);
setShowModal(true);
}
}; };
const handleDeleteMenuClick = () => { const handleDeleteMenuClick = () => {
if (setIsDropdownVisible) { setCurrentReportDeleting(report);
setIsDropdownVisible(false);
setCurrentReportDeleting(report);
}
}; };
const textMenu = () => const textMenu = () =>
isEmpty(report) ? ( isEmpty(report) ? (
<Menu selectable={false} {...rest} css={onMenuHover}> <Menu.SubMenu title={submenuTitle} css={onMenuHover}>
<Menu.Item onClick={handleShowMenu}> <Menu.Item onClick={handleShowMenu}>
{DropdownItemExtension ? ( {DropdownItemExtension ? (
<StyledDropdownItemWithIcon> <StyledDropdownItemWithIcon>
@ -233,9 +208,14 @@ export default function HeaderReportDropDown({
)} )}
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
</Menu> </Menu.SubMenu>
) : ( ) : (
<Menu selectable={false} css={{ border: 'none' }}> <Menu.SubMenu
title={submenuTitle}
css={css`
border: none;
`}
>
<Menu.Item <Menu.Item
css={onMenuItemHover} css={onMenuItemHover}
onClick={() => toggleActiveKey(report, !isReportActive)} onClick={() => toggleActiveKey(report, !isReportActive)}
@ -251,10 +231,15 @@ export default function HeaderReportDropDown({
<Menu.Item css={onMenuItemHover} onClick={handleDeleteMenuClick}> <Menu.Item css={onMenuItemHover} onClick={handleDeleteMenuClick}>
{t('Delete email report')} {t('Delete email report')}
</Menu.Item> </Menu.Item>
</Menu> </Menu.SubMenu>
); );
const menu = () => ( const menu = (title: ReactNode) => (
<Menu selectable={false} css={{ width: '200px' }}> <Menu.SubMenu
title={title}
css={css`
width: 200px;
`}
>
<Menu.Item> <Menu.Item>
{t('Email reports active')} {t('Email reports active')}
<Switch <Switch
@ -262,10 +247,12 @@ export default function HeaderReportDropDown({
checked={isReportActive} checked={isReportActive}
onClick={(checked: boolean) => toggleActiveKey(report, checked)} onClick={(checked: boolean) => toggleActiveKey(report, checked)}
size="small" size="small"
css={{ marginLeft: theme.gridUnit * 2 }} css={theme => css`
margin-left: ${theme.gridUnit * 2}px;
`}
/> />
</Menu.Item> </Menu.Item>
<Menu.Item onClick={() => setShowModal(true)}> <Menu.Item onClick={() => showReportModal()}>
{t('Edit email report')} {t('Edit email report')}
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
@ -274,7 +261,7 @@ export default function HeaderReportDropDown({
> >
{t('Delete email report')} {t('Delete email report')}
</Menu.Item> </Menu.Item>
</Menu> </Menu.SubMenu>
); );
const iconMenu = () => const iconMenu = () =>
@ -284,65 +271,12 @@ export default function HeaderReportDropDown({
title={t('Schedule email report')} title={t('Schedule email report')}
tabIndex={0} tabIndex={0}
className="action-button action-schedule-report" className="action-button action-schedule-report"
onClick={() => setShowModal(true)} onClick={() => showReportModal()}
> >
<Icons.Calendar /> <Icons.Calendar />
</span> </span>
) : ( ) : (
<> menu(<Icons.Calendar />)
<NoAnimationDropdown
overlay={menu()}
overlayStyle={dropdownOverlayStyle}
trigger={['click']}
getPopupContainer={(triggerNode: any) =>
triggerNode.closest('.action-button')
}
>
<span
role="button"
className="action-button action-schedule-report"
tabIndex={0}
>
<Icons.Calendar />
</span>
</NoAnimationDropdown>
</>
); );
return <>{canAddReports() && (useTextMenu ? textMenu() : iconMenu())}</>;
return (
<>
{canAddReports() && (
<>
<ReportModal
userId={user.userId}
show={showModal}
onHide={() => setShowModal(false)}
userEmail={user.email}
dashboardId={dashboardId}
chart={chart}
creationMethod={
dashboardId ? CreationMethod.Dashboards : CreationMethod.Charts
}
/>
{isDropdownVisible ? (useTextMenu ? textMenu() : iconMenu()) : null}
{currentReportDeleting && (
<DeleteModal
description={t(
'This action will permanently delete %s.',
currentReportDeleting?.name,
)}
onConfirm={() => {
if (currentReportDeleting) {
handleReportDelete(currentReportDeleting);
}
}}
onHide={() => setCurrentReportDeleting(null)}
open
title={t('Delete Report?')}
/>
)}
</>
)}
</>
);
} }

View File

@ -19,7 +19,7 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag, t, useTheme } from '@superset-ui/core'; import { isFeatureEnabled, FeatureFlag, t, useTheme } from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils'; import { CardStyles } from 'src/views/CRUD/utils';
import { AntdDropdown } from 'src/components'; import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu'; import { Menu } from 'src/components/Menu';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange'; import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import ListViewCard from 'src/components/ListViewCard'; import ListViewCard from 'src/components/ListViewCard';
@ -108,9 +108,9 @@ function TagCard({
e.preventDefault(); e.preventDefault();
}} }}
> >
<AntdDropdown overlay={menu}> <Dropdown dropdownRender={() => menu}>
<Icons.MoreVert iconColor={theme.colors.grayscale.base} /> <Icons.MoreVert iconColor={theme.colors.grayscale.base} />
</AntdDropdown> </Dropdown>
</ListViewCard.Actions> </ListViewCard.Actions>
} }
/> />