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.
cy.get('[data-test="refresh-chart-menu-item"]').should(
'have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
waitForChartLoad(mapSpec);
cy.get('[data-test="refresh-chart-menu-item"]').should(
'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('[data-test="refresh-dashboard-menu-item"]').should(
'not.have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
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(
'have.class',
'ant-dropdown-menu-item-disabled',
'antd5-dropdown-menu-item-disabled',
);
// wait all charts force refreshed.
@ -94,7 +94,7 @@ describe.skip('Dashboard top-level controls', () => {
cy.get('[aria-label="more-horiz"]').click();
cy.get('[data-test="refresh-dashboard-menu-item"]').and(
'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();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)')
.first()
cy.get('.antd5-dropdown:not(.antd5-dropdown-hidden)')
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
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')
.find('[role="menuitem"]')

View File

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

View File

@ -57,16 +57,16 @@ function setFilterBarOrientation(orientation: 'vertical' | 'horizontal') {
.trigger('mouseover');
if (orientation === 'vertical') {
cy.get('.antd5-menu-item-selected')
cy.get('.antd5-dropdown-menu-item-selected')
.contains('Horizontal (Top)')
.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');
} else {
cy.get('.antd5-menu-item-selected')
cy.get('.antd5-dropdown-menu-item-selected')
.contains('Vertical (Left)')
.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('filter-bar').should('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() {
cy.getBySel('actions-trigger').click();
cy.get('.ant-dropdown-menu-submenu-title')
cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseover', { force: true });
}
function closeDashboardsAddedTo() {
cy.get('.ant-dropdown-menu-submenu-title')
cy.get('.antd5-dropdown-menu-submenu-title')
.contains('On dashboards')
.trigger('mouseout', { force: true });
cy.getBySel('actions-trigger').click();
}
function verifyDashboardsSubmenuItem(dashboardName) {
cy.get('.ant-dropdown-menu-submenu-popup').contains(dashboardName);
cy.get('.antd5-dropdown-menu-submenu-popup').contains(dashboardName);
closeDashboardsAddedTo();
}
function verifyDashboardSearch() {
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('1');
cy.get('.ant-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').contains('1 - Sample dashboard');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('input[placeholder="Search"]')
.type('Blahblah');
cy.get('.ant-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.ant-dropdown-menu-submenu-popup')
cy.get('.antd5-dropdown-menu-submenu-popup').contains('No results found');
cy.get('.antd5-dropdown-menu-submenu-popup')
.find('[aria-label="close-circle"]')
.click();
closeDashboardsAddedTo();
@ -68,8 +68,8 @@ function verifyDashboardSearch() {
function verifyDashboardLink() {
interceptDashboardGet();
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a')
cy.get('.antd5-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.antd5-dropdown-menu-submenu-popup a')
.first()
.invoke('removeAttr', 'target')
.click();

View File

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

View File

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

View File

@ -158,10 +158,10 @@ export const sqlLabView = {
runButton: '.css-d3dxop',
},
rowsLimit: {
dropdown: '.ant-dropdown-menu',
limitButton: '.ant-dropdown-menu-item',
dropdown: '.antd5-dropdown-menu',
limitButton: '.antd5-dropdown-menu-item',
limitButtonText: '.css-151uxnz',
limitTextWithValue: '[class="ant-dropdown-trigger"]',
limitTextWithValue: '[class="antd5-dropdown-trigger"]',
},
renderedTableHeader: '.ReactVirtualized__Table__headerRow',
renderedTableRow: '.ReactVirtualized__Table__row',
@ -633,7 +633,7 @@ export const dashboardView = {
refreshChart: dataTestLocator('refresh-chart-menu-item'),
},
threeDotsMenuIcon:
'.header-with-actions .right-button-panel .ant-dropdown-trigger',
'.header-with-actions .right-button-panel .antd5-dropdown-trigger',
threeDotsMenuDropdown: dataTestLocator('header-actions-menu'),
refreshDashboard: dataTestLocator('refresh-dashboard-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
// Prefer vanilla Ant Design z-indexes that should work out of the box
.ant-popover,
.antd5-dropdown,
.ant-dropdown,
.ant-select-dropdown,
.antd5-modal-wrap,
.antd5-modal-mask,
@ -105,13 +103,6 @@ export const GlobalStyles = () => (
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.
*/
import { useDispatch } from 'react-redux';
import { styled, useTheme, t } from '@superset-ui/core';
import { AntdDropdown } from 'src/components';
import { useTheme, t } from '@superset-ui/core';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons';
import { queryEditorSetQueryLimit } from 'src/SqlLab/actions/sqlLab';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import Button from 'src/components/Button';
export interface QueryLimitSelectProps {
queryEditorId: string;
@ -34,28 +35,6 @@ export function convertToNumWithSpaces(num: number) {
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(
maxRow: number,
setQueryLimit: (limit: number) => void,
@ -94,20 +73,18 @@ const QueryLimitSelect = ({
dispatch(queryEditorSetQueryLimit(queryEditor, updatedQueryLimit));
return (
<LimitSelectStyled>
<AntdDropdown
overlay={renderQueryLimit(maxRow, setQueryLimit)}
<Dropdown
dropdownRender={() => renderQueryLimit(maxRow, setQueryLimit)}
trigger={['click']}
>
<button type="button" onClick={e => e.preventDefault()}>
<Button size="small" showMarginRight={false} type="link">
<span>{t('LIMIT')}:</span>
<span className="limitDropdown">
{convertToNumWithSpaces(queryLimit)}
</span>
<Icons.TriangleDown iconColor={theme.colors.grayscale.base} />
</button>
</AntdDropdown>
</LimitSelectStyled>
</Button>
</Dropdown>
);
};

View File

@ -16,12 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { FC } from 'react';
import { t, useTheme, styled } from '@superset-ui/core';
import { t, useTheme } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { DropdownButton } from 'src/components/DropdownButton';
import Button from 'src/components/Button';
import { DropdownButtonProps } from 'antd/lib/dropdown';
interface SaveDatasetActionButtonProps {
setShowSave: (arg0: boolean) => void;
@ -34,34 +32,14 @@ const SaveDatasetActionButton = ({
}: SaveDatasetActionButtonProps) => {
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 ? (
<Button onClick={() => setShowSave(true)} buttonStyle="primary">
{t('Save')}
</Button>
) : (
<StyledDropdownButton
<DropdownButton
onClick={() => setShowSave(true)}
overlay={overlayMenu}
dropdownRender={() => overlayMenu}
icon={
<Icons.CaretDown
iconColor={theme.colors.grayscale.light5}
@ -71,7 +49,7 @@ const SaveDatasetActionButton = ({
trigger={['click']}
>
{t('Save')}
</StyledDropdownButton>
</DropdownButton>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ import { useDispatch, useSelector } from 'react-redux';
import {
Behavior,
BinaryQueryObjectFilterClause,
Column,
ContextMenuFilters,
ensureIsArray,
FeatureFlag,
@ -42,8 +43,11 @@ import {
import { RootState } from 'src/dashboard/types';
import { Menu } from 'src/components/Menu';
import { usePermissions } from 'src/hooks/usePermissions';
import { AntdDropdown as Dropdown } from 'src/components/index';
import { Dropdown } from 'src/components/Dropdown';
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 { getMenuAdjustedY } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
@ -114,8 +118,22 @@ const ChartContextMenu = (
}>({ clientX: 0, clientY: 0 });
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 =
isFeatureEnabled(FeatureFlag.DrillToDetail) &&
@ -249,9 +267,9 @@ const ChartContextMenu = (
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
canDownload={canDownload}
open={openKeys.includes('drill-by-submenu')}
key="drill-by-submenu"
onDrillBy={handleDrillBy}
{...(additionalConfig?.drillBy || {})}
/>,
);
@ -286,7 +304,7 @@ const ChartContextMenu = (
return ReactDOM.createPortal(
<>
<Dropdown
overlay={
dropdownRender={() => (
<Menu
className="chart-context-menu"
data-test="chart-context-menu"
@ -302,15 +320,15 @@ const ChartContextMenu = (
<Menu.Item disabled>{t('No actions')}</Menu.Item>
)}
</Menu>
}
)}
trigger={['click']}
onVisibleChange={value => {
onOpenChange={value => {
setVisible(value);
if (!value) {
setOpenKeys([]);
}
}}
visible={visible}
open={visible}
>
<span
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,
);

View File

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

View File

@ -53,10 +53,8 @@ import {
cachedSupersetGet,
supersetGetCache,
} from 'src/utils/cachedSupersetGet';
import { useVerboseMap } from 'src/hooks/apiResources/datasets';
import { InputRef } from 'antd-v5';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import DrillByModal from './DrillByModal';
import { getSubmenuYOffset } from '../utils';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
@ -74,8 +72,8 @@ export interface DrillByMenuItemsProps {
onClick?: (event: MouseEvent) => void;
openNewModal?: boolean;
excludedColumns?: Column[];
canDownload: boolean;
open: boolean;
onDrillBy?: (column: Column, dataset: Dataset) => void;
}
const loadDrillByOptions = getExtensionsRegistry().get('load.drillby.options');
@ -106,8 +104,8 @@ export const DrillByMenuItems = ({
onClick = () => {},
excludedColumns,
openNewModal = true,
canDownload,
open,
onDrillBy,
...rest
}: DrillByMenuItemsProps) => {
const theme = useTheme();
@ -117,25 +115,20 @@ export const DrillByMenuItems = ({
const [debouncedSearchInput, setDebouncedSearchInput] = useState('');
const [dataset, setDataset] = useState<Dataset>();
const [columns, setColumns] = useState<Column[]>([]);
const [showModal, setShowModal] = useState(false);
const [currentColumn, setCurrentColumn] = useState();
const ref = useRef<InputRef>(null);
const showSearch =
loadDrillByOptions || columns.length > SHOW_COLUMNS_SEARCH_THRESHOLD;
const handleSelection = useCallback(
(event, column) => {
onClick(event);
onSelection(column, drillByConfig);
setCurrentColumn(column);
if (openNewModal) {
setShowModal(true);
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
}
},
[drillByConfig, onClick, onSelection, openNewModal],
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
);
const closeModal = useCallback(() => {
setShowModal(false);
}, []);
useEffect(() => {
if (open) {
@ -156,7 +149,6 @@ export const DrillByMenuItems = ({
?.behaviors.find(behavior => behavior === Behavior.DrillBy),
[formData.viz_type],
);
const verboseMap = useVerboseMap(dataset);
useEffect(() => {
async function loadOptions() {
@ -275,11 +267,11 @@ export const DrillByMenuItems = ({
const column = columns[index];
return (
<MenuItemWithTruncation
key={`drill-by-item-${column.column_name}`}
menuKey={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
{...rest}
onClick={e => handleSelection(e, column)}
style={style}
{...rest}
>
{column.verbose_name || column.column_name}
</MenuItemWithTruncation>
@ -289,6 +281,7 @@ export const DrillByMenuItems = ({
return (
<>
<Menu.SubMenu
key="drill-by-submenu"
title={t('Drill by')}
popupClassName="chart-context-submenu"
popupOffset={[0, submenuYOffset]}
@ -349,16 +342,6 @@ export const DrillByMenuItems = ({
)}
</div>
</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 }) => (
<Menu.Item disabled {...props}>
const DisabledMenuItem = ({
children,
menuKey,
...rest
}: {
children: ReactNode;
menuKey: string;
}) => (
<Menu.Item disabled key={menuKey} {...rest}>
<div
css={css`
white-space: normal;
@ -183,39 +190,34 @@ const DrillDetailMenuItems = ({
}
const drillToDetailMenuItem = drillDisabled ? (
<DisabledMenuItem {...props} key="drill-to-detail-disabled">
<DisabledMenuItem menuKey="drill-to-detail-disabled" {...props}>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</DisabledMenuItem>
) : (
<Menu.Item
{...props}
key="drill-to-detail"
onClick={openModal.bind(null, [])}
>
<Menu.Item key="drill-to-detail" onClick={openModal.bind(null, [])}>
{DRILL_TO_DETAIL}
</Menu.Item>
);
const drillToDetailByMenuItem = drillByDisabled ? (
<DisabledMenuItem {...props} key="drill-to-detail-by-disabled">
<DisabledMenuItem menuKey="drill-to-detail-by-disabled" {...props}>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</DisabledMenuItem>
) : (
<Menu.SubMenu
{...props}
popupOffset={[0, submenuYOffset]}
popupClassName="chart-context-submenu"
title={DRILL_TO_DETAIL_BY}
key={key}
{...props}
>
<div data-test="drill-to-detail-by-submenu">
{filters.map((filter, i) => (
<MenuItemWithTruncation
{...props}
tooltipText={`${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`}
key={`drill-detail-filter-${i}`}
menuKey={`drill-detail-filter-${i}`}
onClick={openModal.bind(null, [filter])}
>
{`${DRILL_TO_DETAIL_BY} `}
@ -224,7 +226,6 @@ const DrillDetailMenuItems = ({
))}
{filters.length > 1 && (
<Menu.Item
{...props}
key="drill-detail-filter-all"
onClick={openModal.bind(null, filters)}
>

View File

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

View File

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

View File

@ -24,13 +24,10 @@ import {
cloneElement,
} from 'react';
import { AntdDropdown } from 'src/components';
// TODO: @geido - Remove these after dropdown is fully migrated to Antd v5
import {
Dropdown as Antd5Dropdown,
DropDownProps as Antd5DropdownProps,
Dropdown as AntdDropdown,
DropdownProps as AntdDropdownProps,
} from 'antd-v5';
import { DropDownProps } from 'antd/lib/dropdown';
import { styled } from '@superset-ui/core';
import Icons from 'src/components/Icons';
@ -83,7 +80,8 @@ export enum IconOrientation {
Vertical = 'vertical',
Horizontal = 'horizontal',
}
export interface DropdownProps extends DropDownProps {
export interface MenuDotsDropdownProps extends AntdDropdownProps {
overlay: ReactElement;
iconOrientation?: IconOrientation;
}
@ -100,19 +98,19 @@ const RenderIcon = (
return component;
};
export const Dropdown = ({
export const MenuDotsDropdown = ({
overlay,
iconOrientation = IconOrientation.Vertical,
...rest
}: DropdownProps) => (
<AntdDropdown overlay={overlay} {...rest}>
}: MenuDotsDropdownProps) => (
<AntdDropdown dropdownRender={() => overlay} {...rest}>
<MenuDotsWrapper data-test="dropdown-trigger">
{RenderIcon(iconOrientation)}
</MenuDotsWrapper>
</AntdDropdown>
);
export interface NoAnimationDropdownProps extends Antd5DropdownProps {
export interface NoAnimationDropdownProps extends AntdDropdownProps {
children: ReactNode;
onBlur?: (e: FocusEvent<HTMLDivElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
@ -126,8 +124,13 @@ export const NoAnimationDropdown = (props: NoAnimationDropdownProps) => {
});
return (
<Antd5Dropdown overlayStyle={props.overlayStyle} {...rest}>
<AntdDropdown autoFocus overlayStyle={props.overlayStyle} {...rest}>
{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
* under the License.
*/
import { ReactNode, ReactElement } from 'react';
import { type ComponentProps } from 'react';
import { AntdDropdown, AntdTooltip } from 'src/components';
import { styled } from '@superset-ui/core';
import { Dropdown } from 'antd-v5';
import { Tooltip, TooltipPlacement } from 'src/components/Tooltip';
import { kebabCase } from 'lodash';
const StyledDropdownButton = styled.div`
.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;
export type DropdownButtonProps = ComponentProps<typeof Dropdown.Button> & {
tooltip?: string;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
buttonsRender?: ((buttons: ReactNode[]) => ReactNode[]) | undefined;
}
tooltipPlacement?: TooltipPlacement;
};
export const DropdownButton = ({
overlay,
dropdownRender,
tooltip,
placement,
tooltipPlacement,
children,
...rest
}: DropdownButtonProps) => {
const buildButton = (
props: {
buttonsRender?: DropdownButtonProps['buttonsRender'];
} = {},
) => (
<StyledDropdownButton>
<AntdDropdown.Button overlay={overlay} {...rest} {...props} />
</StyledDropdownButton>
const button = (
<Dropdown.Button dropdownRender={dropdownRender} {...rest}>
{children}
</Dropdown.Button>
);
if (tooltip) {
return buildButton({
buttonsRender: ([leftButton, rightButton]) => [
<AntdTooltip
placement={placement}
return (
<Tooltip
placement={tooltipPlacement}
id={`${kebabCase(tooltip)}-tooltip`}
title={tooltip}
>
{leftButton}
</AntdTooltip>,
rightButton,
],
});
{button}
</Tooltip>
);
}
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.
*/
import { action } from '@storybook/addon-actions';
import { AntdDropdown } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import Icons from 'src/components/Icons';
import FaveStar from 'src/components/FaveStar';
@ -70,8 +70,8 @@ export const SupersetListViewCard = ({
saveFaveStar={action('saveFaveStar')}
isStarred={isStarred}
/>
<AntdDropdown
overlay={
<Dropdown
dropdownRender={() => (
<Menu>
<Menu.Item role="button" tabIndex={0} onClick={action('Delete')}>
<Icons.Trash /> Delete
@ -80,10 +80,10 @@ export const SupersetListViewCard = ({
<Icons.EditAlt /> Edit
</Menu.Item>
</Menu>
}
)}
>
<Icons.MoreHoriz />
</AntdDropdown>
</Dropdown>
</ListViewCard.Actions>
}
/>

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { addAlpha, styled } from '@superset-ui/core';
import { styled } from '@superset-ui/core';
import { ReactElement } from 'react';
import { Menu as AntdMenu } from 'antd-v5';
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)`
${({ theme }) => `
&.antd5-menu-horizontal {
background-color: inherit;
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)`
@ -145,11 +133,6 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)`
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'];

View File

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

View File

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

View File

@ -18,7 +18,7 @@
*/
import { useState, useEffect } from 'react';
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';
/**
@ -126,7 +126,7 @@ export function ActionCell(props: ActionCellProps) {
setVisible(flag);
};
return (
<Dropdown
<MenuDotsDropdown
iconOrientation={IconOrientation.Horizontal}
onVisibleChange={handleVisibleChange}
trigger={['click']}

View File

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

View File

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

View File

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

View File

@ -53,9 +53,6 @@ jest.mock('src/components/Select/Select', () => () => (
jest.mock('src/components/Select/AsyncSelect', () => () => (
<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', () => ({
PageHeaderWithActions: () => (
<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 { Tooltip } from 'src/components/Tooltip';
import { safeStringify } from 'src/utils/safeStringify';
import ConnectedHeaderActionsDropdown from 'src/dashboard/components/Header/HeaderActionsDropdown';
import PublishedStatus from 'src/dashboard/components/PublishedStatus';
import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
@ -53,6 +52,9 @@ import {
import setPeriodicRunner, {
stopPeriodicRender,
} 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 DashboardEmbedModal from '../EmbeddedModal';
import OverwriteConfirm from '../OverwriteConfirm';
@ -88,6 +90,7 @@ import { dashboardInfoChanged } from '../../actions/dashboardInfo';
import isDashboardLoading from '../../util/isDashboardLoading';
import { useChartIds } from '../../util/charts/useChartIds';
import { useDashboardMetadataBar } from './useDashboardMetadataBar';
import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu';
const extensionsRegistry = getExtensionsRegistry();
@ -160,8 +163,9 @@ const Header = () => {
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
const [emphasizeRedo, setEmphasizeRedo] = useState(false);
const [showingPropertiesModal, setShowingPropertiesModal] = useState(false);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showingEmbedModal, setShowingEmbedModal] = useState(false);
const [showingReportModal, setShowingReportModal] = useState(false);
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const dashboardInfo = useSelector(state => state.dashboardInfo);
const layout = useSelector(state => state.dashboardLayout.present);
const undoLength = useSelector(state => state.dashboardLayout.past.length);
@ -348,10 +352,6 @@ const Header = () => {
[boundActionCreators, dashboardTitle],
);
const setDropdownVisible = useCallback(visible => {
setIsDropdownVisible(visible);
}, []);
const handleCtrlY = useCallback(() => {
boundActionCreators.onRedo();
setEmphasizeRedo(true);
@ -475,6 +475,14 @@ const Header = () => {
setShowingEmbedModal(false);
}, []);
const showReportModal = useCallback(() => {
setShowingReportModal(true);
}, []);
const hideReportModal = useCallback(() => {
setShowingReportModal(false);
}, []);
const metadataBar = useDashboardMetadataBar(dashboardInfo);
const userCanEdit =
@ -689,92 +697,47 @@ const Header = () => {
],
);
const menuDropdownProps = useMemo(
() => ({
getPopupContainer: triggerNode =>
triggerNode.closest('.header-with-actions'),
visible: isDropdownVisible,
onVisibleChange: setDropdownVisible,
}),
[isDropdownVisible, setDropdownVisible],
);
const handleReportDelete = async report => {
await dispatch(deleteActiveReport(report));
setCurrentReportDeleting(null);
};
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,
const [menu, isDropdownVisible, setIsDropdownVisible] = useHeaderActionsMenu({
addSuccessToast: boundActionCreators.addSuccessToast,
addDangerToast: boundActionCreators.addDangerToast,
dashboardInfo,
dashboardId: dashboardInfo.id,
dashboardTitle,
dataMask,
editMode,
expandedSlices,
forceRefresh,
hasUnsavedChanges,
isDropdownVisible,
isLoading,
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,
setDropdownVisible,
shouldPersistRefreshFrequency,
showEmbedModal,
showPropertiesModal,
startPeriodicRender,
userCanCurate,
userCanEdit,
userCanSaveAs,
userCanShare,
],
);
lastModifiedTime: actualLastModifiedTime,
logEvent: boundActionCreators.logEvent,
});
return (
<div
css={headerContainerStyle}
@ -788,8 +751,11 @@ const Header = () => {
faveStarProps={faveStarProps}
titlePanelAdditionalItems={titlePanelAdditionalItems}
rightPanelAdditionalItems={rightPanelAdditionalItems}
menuDropdownProps={menuDropdownProps}
additionalActionsMenu={additionalActionsMenu}
menuDropdownProps={{
open: isDropdownVisible,
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={user?.userId && dashboardInfo?.id}
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 />
{userCanCurate && (
@ -817,7 +809,7 @@ const Header = () => {
)}
<Global
styles={css`
.ant-menu-vertical {
.antd5-menu-vertical {
border-right: none;
}
`}

View File

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

View File

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

View File

@ -112,11 +112,13 @@ const renderWrapper = (
});
};
const openMenu = () => {
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
};
test('Should render', () => {
renderWrapper();
expect(
screen.getByRole('button', { name: 'More Options' }),
).toBeInTheDocument();
openMenu();
expect(screen.getByTestId(`slice_${SLICE_ID}-menu`)).toBeInTheDocument();
});
@ -143,6 +145,7 @@ test('Should render default props', () => {
delete props.isExpanded;
renderWrapper(props);
openMenu();
expect(screen.getByText('Enter fullscreen')).toBeInTheDocument();
expect(screen.getByText('Force refresh')).toBeInTheDocument();
expect(screen.getByText('Show chart description')).toBeInTheDocument();
@ -159,6 +162,7 @@ test('Should render default props', () => {
test('Should "export to CSV"', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
expect(props.exportCSV).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export to .CSV'));
@ -169,6 +173,7 @@ test('Should "export to CSV"', async () => {
test('Should "export to Excel"', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
expect(props.exportXLSX).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
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);
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).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);
renderWrapper(props);
openMenu();
expect(props.exportFullCSV).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
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,
};
renderWrapper();
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to .CSV')).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);
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to Excel')).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);
renderWrapper(props);
openMenu();
expect(props.exportFullXLSX).toHaveBeenCalledTimes(0);
userEvent.hover(screen.getByText('Download'));
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,
};
renderWrapper();
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(await screen.findByText('Export to Excel')).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"', () => {
const props = createProps();
renderWrapper(props);
openMenu();
expect(props.toggleExpandSlice).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Show chart description'));
expect(props.toggleExpandSlice).toHaveBeenCalledTimes(1);
@ -256,6 +268,7 @@ test('Should "Show chart description"', () => {
test('Should "Force refresh"', () => {
const props = createProps();
renderWrapper(props);
openMenu();
expect(props.forceRefresh).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Force refresh'));
expect(props.forceRefresh).toHaveBeenCalledTimes(1);
@ -266,6 +279,7 @@ test('Should "Force refresh"', () => {
test('Should "Enter fullscreen"', () => {
const props = createProps();
renderWrapper(props);
openMenu();
expect(props.handleToggleFullSize).toHaveBeenCalledTimes(0);
userEvent.click(screen.getByText('Enter fullscreen'));
@ -278,6 +292,7 @@ test('Drill to detail modal is under featureflag', () => {
};
const props = createProps();
renderWrapper(props);
openMenu();
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'],
],
});
openMenu();
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'],
],
});
openMenu();
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'],
],
});
openMenu();
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, {
Admin: [['invalid_permission', 'Dashboard']],
});
openMenu();
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, {
Admin: [['can_drill', 'Dashboard']],
});
openMenu();
expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument();
});
@ -371,6 +391,7 @@ test('Should show "View query"', () => {
renderWrapper(props, {
Admin: [['can_view_query', 'Dashboard']],
});
openMenu();
expect(screen.getByText('View query')).toBeInTheDocument();
});
@ -383,6 +404,7 @@ test('Should not show "View query"', () => {
renderWrapper(props, {
Admin: [['invalid_permission', 'Dashboard']],
});
openMenu();
expect(screen.queryByText('View query')).not.toBeInTheDocument();
});
@ -395,6 +417,7 @@ test('Should show "View as table"', () => {
renderWrapper(props, {
Admin: [['can_view_chart_as_table', 'Dashboard']],
});
openMenu();
expect(screen.getByText('View as table')).toBeInTheDocument();
});
@ -407,6 +430,7 @@ test('Should not show "View as table"', () => {
renderWrapper(props, {
Admin: [['invalid_permission', 'Dashboard']],
});
openMenu();
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'],
],
});
openMenu();
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 DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { usePermissions } from 'src/hooks/usePermissions';
import Button from 'src/components/Button';
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
@ -158,9 +159,8 @@ const SliceHeaderControls = (
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
) => {
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<string[]>([]);
// setting openKeys undefined falls back to uncontrolled behaviour
const [openKeys, setOpenKeys] = useState<string[] | undefined>(undefined);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
props.slice.slice_id,
);
@ -241,7 +241,7 @@ const SliceHeaderControls = (
// menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
'.antd5-dropdown:not(.antd5-dropdown-hidden)',
) as HTMLElement;
if (menu) {
menu.style.visibility = 'hidden';
@ -284,6 +284,7 @@ const SliceHeaderControls = (
default:
break;
}
setIsDropdownVisible(false);
};
const {
@ -334,24 +335,12 @@ const SliceHeaderControls = (
animationDuration: '0s',
};
// controlled/uncontrolled behaviour for submenus
const openKeysProps: Record<string, string[]> = {};
if (openKeys) {
openKeysProps.openKeys = openKeys;
}
const menu = (
<Menu
onClick={handleMenuClick}
selectable={false}
data-test={`slice_${slice.slice_id}-menu`}
selectedKeys={selectedKeys}
onSelect={({ selectedKeys: keys }) => setSelectedKeys(keys)}
openKeys={openKeys}
id={`slice_${slice.slice_id}-menu`}
// submenus must be rendered for handleDropdownNavigation
forceSubMenuRender
{...openKeysProps}
selectable={false}
>
<Menu.Item
key={MenuKeys.ForceRefresh}
@ -458,9 +447,7 @@ const SliceHeaderControls = (
emailBody={t('Check out this chart: ')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
setOpenKeys={setOpenKeys}
title={t('Share')}
key={MenuKeys.Share}
/>
)}
@ -532,22 +519,17 @@ const SliceHeaderControls = (
overlayStyle={dropdownOverlayStyle}
trigger={['click']}
placement="bottomRight"
autoFocus
forceRender
open={isDropdownVisible}
onOpenChange={visible => setIsDropdownVisible(visible)}
>
<span
css={() => css`
display: flex;
align-items: center;
`}
<Button
type="link"
id={`slice_${slice.slice_id}-controls`}
role="button"
aria-label="More Options"
aria-haspopup="true"
tabIndex={0}
>
<VerticalDotsTrigger />
</span>
</Button>
</NoAnimationDropdown>
<DrillDetailModal
formData={props.formData}

View File

@ -60,7 +60,7 @@ test('Should call download image on click', async () => {
expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'Download as Image' }));
userEvent.click(screen.getByRole('menuitem', { name: 'Download as Image' }));
await waitFor(() => {
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();
const button = screen.getByRole('button', { name: 'Download as Image' });
const button = screen.getByRole('menuitem', { name: 'Download as Image' });
expect(button).toBeInTheDocument();
});

View File

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

View File

@ -58,7 +58,7 @@ test('Should call download pdf on click', async () => {
expect(mockAddDangerToast).toHaveBeenCalledTimes(0);
});
userEvent.click(screen.getByRole('button', { name: 'Export as PDF' }));
userEvent.click(screen.getByRole('menuitem', { name: 'Export as PDF' }));
await waitFor(() => {
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();
const button = screen.getByRole('button', { name: 'Export as PDF' });
const button = screen.getByRole('menuitem', { name: 'Export as PDF' });
expect(button).toBeInTheDocument();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -121,13 +121,13 @@ test('Can enable/disable cross-filtering', async () => {
});
await setup();
userEvent.click(screen.getByLabelText('gear'));
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toBeChecked();
const initialCheckbox = screen.getByRole('checkbox');
expect(initialCheckbox).toBeChecked();
userEvent.click(checkbox);
userEvent.click(initialCheckbox);
userEvent.click(screen.getByLabelText('gear'));
expect(checkbox).not.toBeChecked();
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
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.hover(screen.getByText('Orientation of filter bar'));
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
expect(screen.getByText('Horizontal (Top)')).toBeInTheDocument();
const verticalItem = await screen.findByText('Vertical (Left)');
expect(
within(screen.getAllByRole('menuitem')[4]).getByLabelText('check'),
within(verticalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument();
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(
await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'),
within(horizontalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument();
// successful query
await waitFor(() =>
expect(fetchMock.lastCall()?.[1]?.body).toEqual(
JSON.stringify({
@ -201,23 +203,18 @@ test('On selection change, send request and update checked value', async () => {
}),
),
);
await waitFor(() => {
const menuitems = screen.getAllByRole('menuitem');
expect(menuitems.length).toBeGreaterThanOrEqual(6);
});
await waitFor(() => {
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
const updatedHorizontalItem = screen.getByText('Horizontal (Top)');
expect(
await within(screen.getAllByRole('menuitem')[5]).findByLabelText('check'),
within(updatedHorizontalItem.closest('li')!).getByLabelText('check'),
).toBeInTheDocument();
expect(
within(screen.getAllByRole('menuitem')[4]).queryByLabelText('check'),
within(verticalItem.closest('li')!).queryByLabelText('check'),
).not.toBeInTheDocument();
});
});
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.hover(screen.getByText('Orientation of filter bar'));
expect(await screen.findByText('Vertical (Left)')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Vertical (Left)')).toBeInTheDocument();
const menuitems = screen.getAllByRole('menuitem');
expect(menuitems.length).toBeGreaterThanOrEqual(6);
});

View File

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

View File

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

View File

@ -16,70 +16,27 @@
* specific language governing permissions and limitations
* 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 { SaveFilterChangesType } from '../../FiltersConfigModal/types';
export interface FCBProps {
createNewOnOpen?: boolean;
dashboardId?: number;
initialFilterId?: string;
onClick?: () => void;
children?: ReactNode;
}
export const FilterConfigurationLink: FC<FCBProps> = ({
createNewOnOpen,
dashboardId,
initialFilterId,
onClick,
children,
}) => {
const dispatch = useDispatch();
const [isOpen, setOpen] = useState(false);
const close = useCallback(() => {
setOpen(false);
}, [setOpen]);
const submit = useCallback(
async (filterChanges: SaveFilterChangesType) => {
dispatch(await setFilterConfiguration(filterChanges));
close();
},
[dispatch, close],
);
const handleClick = useCallback(() => {
setOpen(true);
if (onClick) {
onClick();
}
}, [setOpen, onClick]);
return (
<>
}) => (
<div
{...getFilterBarTestId('create-filter')}
onClick={handleClick}
onClick={onClick}
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);

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 ${
theme.gridUnit
}px;
.ant-dropdown-trigger span {
padding-right: ${theme.gridUnit * 2}px;
}
`}
`;

View File

@ -19,6 +19,7 @@
import { useSelector } from 'react-redux';
import { css, SupersetTheme, useTheme, useTruncation } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { useFilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterBar/FilterConfigurationLink/useFilterConfigModal';
import { RootState } from 'src/dashboard/types';
import { Row, FilterName, InternalRow } from './Styles';
import { FilterCardRowProps } from './types';
@ -39,6 +40,12 @@ export const NameRow = ({
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
);
const { FilterConfigModalComponent, openFilterConfigModal } =
useFilterConfigModal({
dashboardId,
initialFilterId: filter.id,
});
return (
<Row
css={(theme: SupersetTheme) => css`
@ -58,9 +65,10 @@ export const NameRow = ({
</InternalRow>
{canEdit && (
<FilterConfigurationLink
dashboardId={dashboardId}
onClick={hidePopover}
initialFilterId={filter.id}
onClick={() => {
openFilterConfigModal();
hidePopover();
}}
>
<Icons.Edit
iconSize="l"
@ -71,6 +79,7 @@ export const NameRow = ({
/>
</FilterConfigurationLink>
)}
{FilterConfigModalComponent}
</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`
.ant-dropdown-menu.chart-context-menu {
.antd5-dropdown-menu.chart-context-menu {
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;
min-width: ${theme.gridUnit * 40}px;
}
@ -117,7 +117,7 @@ export const focusStyle = (theme: SupersetTheme) => css`
.ant-tabs-tabpane,
.ant-tabs-tab-btn,
.superset-button,
.superset-button.ant-dropdown-trigger,
.superset-button.antd5-dropdown-trigger,
.header-controls span {
&:focus-visible {
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 { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
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 { useExploreMetadataBar } from './useExploreMetadataBar';
@ -86,6 +89,8 @@ export const ExploreChartHeader = ({
const dispatch = useDispatch();
const { latestQueryFormData, sliceFormData } = chart;
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
const updateCategoricalNamespace = async () => {
const { dashboards } = metadata || {};
const dashboard =
@ -128,6 +133,14 @@ export const ExploreChartHeader = ({
setIsPropertiesModalOpen(false);
};
const showReportModal = () => {
setIsReportModalOpen(true);
};
const closeReportModal = () => {
setIsReportModalOpen(false);
};
const showModal = useCallback(() => {
dispatch(setSaveChartModalVisibility(true));
}, [dispatch]);
@ -139,6 +152,11 @@ export const ExploreChartHeader = ({
[dispatch],
);
const handleReportDelete = async report => {
await dispatch(deleteActiveReport(report));
setCurrentReportDeleting(null);
};
const history = useHistory();
const { redirectSQLLab } = actions;
@ -158,6 +176,8 @@ export const ExploreChartHeader = ({
openPropertiesModal,
ownState,
metadata?.dashboards,
showReportModal,
setCurrentReportDeleting,
);
const metadataBar = useExploreMetadataBar(metadata, slice);
@ -229,8 +249,8 @@ export const ExploreChartHeader = ({
}
additionalActionsMenu={menu}
menuDropdownProps={{
visible: isDropdownVisible,
onVisibleChange: setIsDropdownVisible,
open: isDropdownVisible,
onOpenChange: setIsDropdownVisible,
}}
/>
{isPropertiesModalOpen && (
@ -241,6 +261,33 @@ export const ExploreChartHeader = ({
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 Icons from 'src/components/Icons';
import { AntdDropdown } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
enum MenuKeys {
@ -67,9 +67,9 @@ export const ExportToCSVDropdown = ({
);
return (
<AntdDropdown
<Dropdown
trigger={['click']}
overlay={
dropdownRender={() => (
<Menu onClick={handleMenuClick} selectable={false}>
<Menu.Item key={MenuKeys.ExportOriginal}>
<MenuItemContent>
@ -84,9 +84,9 @@ export const ExportToCSVDropdown = ({
</MenuItemContent>
</Menu.Item>
</Menu>
}
)}
>
{children}
</AntdDropdown>
</Dropdown>
);
};

View File

@ -107,6 +107,12 @@ describe('DatasourceControl', () => {
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 }} />, {
useRedux: true,
useRouter: true,

View File

@ -29,7 +29,7 @@ import {
} from '@superset-ui/core';
import { getTemporalColumns } from '@superset-ui/chart-controls';
import { getUrlParam } from 'src/utils/urlUtils';
import { AntdDropdown } from 'src/components';
import { Dropdown } from 'src/components/Dropdown';
import { Menu } from 'src/components/Menu';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
@ -82,12 +82,8 @@ const Styles = styled.div`
.error-alert {
margin: ${({ theme }) => 2 * theme.gridUnit}px;
}
.ant-dropdown-trigger {
.antd5-dropdown-trigger {
margin-left: ${({ theme }) => 2 * theme.gridUnit}px;
box-shadow: none;
&:active {
box-shadow: none;
}
}
.btn-group .open .dropdown-toggle {
box-shadow: none;
@ -410,8 +406,8 @@ class DatasourceControl extends PureComponent {
{extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)}
<AntdDropdown
overlay={
<Dropdown
dropdownRender={() =>
datasource.type === DatasourceType.Query
? queryDatasourceMenu
: defaultDatasourceMenu
@ -423,7 +419,7 @@ class DatasourceControl extends PureComponent {
className="datasource-modal-trigger"
data-test="datasource-menu-trigger"
/>
</AntdDropdown>
</Dropdown>
</div>
{/* missing dataset */}
{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 = (
latestQueryFormData,
canDownloadCSV,
@ -123,6 +116,8 @@ export const useExploreAdditionalActionsMenu = (
onOpenPropertiesModal,
ownState,
dashboards,
showReportModal,
setCurrentReportDeleting,
...rest
) => {
const theme = useTheme();
@ -330,14 +325,14 @@ export const useExploreAdditionalActionsMenu = (
<>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined css={iconReset} />}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to original .CSV')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
icon={<Icons.FileOutlined css={iconReset} />}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to pivoted .CSV')}
@ -346,7 +341,7 @@ export const useExploreAdditionalActionsMenu = (
) : (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined css={iconReset} />}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .CSV')}
@ -354,20 +349,20 @@ export const useExploreAdditionalActionsMenu = (
)}
<Menu.Item
key={MENU_KEYS.EXPORT_TO_JSON}
icon={<Icons.FileOutlined css={iconReset} />}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .JSON')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
icon={<Icons.FileImageOutlined css={iconReset} />}
icon={<Icons.FileImageOutlined />}
>
{t('Download as image')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_XLSX}
icon={<Icons.FileOutlined css={iconReset} />}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Excel')}
@ -403,28 +398,25 @@ export const useExploreAdditionalActionsMenu = (
<Menu.Divider />
{showReportSubMenu ? (
<>
<Menu.SubMenu title={t('Manage email report')}>
<HeaderReportDropDown
submenuTitle={t('Manage email report')}
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
showReportSubMenu={showReportSubMenu}
setIsDropdownVisible={setIsDropdownVisible}
isDropdownVisible={isDropdownVisible}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
</Menu.SubMenu>
<Menu.Divider />
</>
) : (
<Menu>
<HeaderReportDropDown
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
setIsDropdownVisible={setIsDropdownVisible}
isDropdownVisible={isDropdownVisible}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
</Menu>
)}
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger

View File

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

View File

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

View File

@ -409,6 +409,7 @@ const RightMenu = ({
{RightMenuExtension && <RightMenuExtension />}
{!navbarRight.user_is_anonymous && showActionDropdown && (
<StyledSubMenu
key="sub1"
data-test="new-dropdown"
title={
<StyledI data-test="new-dropdown-icon" className="fa fa-plus" />
@ -474,6 +475,7 @@ const RightMenu = ({
</StyledSubMenu>
)}
<StyledSubMenu
key="sub3_settings"
title={t('Settings')}
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 { TableTab } from 'src/views/CRUD/types';
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 { copyQueryLink, useListViewResource } from 'src/views/CRUD/hooks';
import ListViewCard from 'src/components/ListViewCard';
@ -315,11 +315,11 @@ const SavedQueries = ({
e.preventDefault();
}}
>
<AntdDropdown overlay={renderMenu(q)}>
<Dropdown dropdownRender={() => renderMenu(q)}>
<Icons.MoreVert
iconColor={theme.colors.grayscale.base}
/>
</AntdDropdown>
</Dropdown>
</ListViewCard.Actions>
</QueryData>
}

View File

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

View File

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

View File

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