mirror of
https://github.com/apache/superset.git
synced 2026-04-21 09:04:38 +00:00
604 lines
18 KiB
TypeScript
604 lines
18 KiB
TypeScript
/**
|
|
* 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 {
|
|
MouseEvent,
|
|
Key,
|
|
KeyboardEvent,
|
|
useState,
|
|
useRef,
|
|
RefObject,
|
|
} from 'react';
|
|
|
|
import { RouteComponentProps, useHistory } from 'react-router-dom';
|
|
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
|
import {
|
|
Behavior,
|
|
css,
|
|
isFeatureEnabled,
|
|
FeatureFlag,
|
|
useTheme,
|
|
getChartMetadataRegistry,
|
|
styled,
|
|
t,
|
|
VizType,
|
|
BinaryQueryObjectFilterClause,
|
|
QueryFormData,
|
|
} from '@superset-ui/core';
|
|
import { useSelector } from 'react-redux';
|
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
|
import {
|
|
NoAnimationDropdown,
|
|
Tooltip,
|
|
Button,
|
|
ModalTrigger,
|
|
} from '@superset-ui/core/components';
|
|
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
|
|
import downloadAsImage from 'src/utils/downloadAsImage';
|
|
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
|
import { Icons } from '@superset-ui/core/components/Icons';
|
|
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
|
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
|
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
|
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
|
import { MenuKeys, RootState } from 'src/dashboard/types';
|
|
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
|
import { usePermissions } from 'src/hooks/usePermissions';
|
|
import { useDatasetDrillInfo } from 'src/hooks/apiResources/datasets';
|
|
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
|
|
import { useCrossFiltersScopingModal } from '../nativeFilters/FilterBar/CrossFilters/ScopingModal/useCrossFiltersScopingModal';
|
|
import { ViewResultsModalTrigger } from './ViewResultsModalTrigger';
|
|
|
|
const RefreshTooltip = styled.div`
|
|
${({ theme }) => css`
|
|
height: auto;
|
|
margin: ${theme.sizeUnit}px 0;
|
|
color: ${theme.colorTextLabel};
|
|
line-height: 21px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
justify-content: flex-start;
|
|
`}
|
|
`;
|
|
|
|
const getScreenshotNodeSelector = (chartId: string | number) =>
|
|
`.dashboard-chart-id-${chartId}`;
|
|
|
|
const VerticalDotsTrigger = () => {
|
|
const theme = useTheme();
|
|
return (
|
|
<Icons.EllipsisOutlined
|
|
css={css`
|
|
transform: rotate(90deg);
|
|
&:hover {
|
|
cursor: pointer;
|
|
}
|
|
`}
|
|
iconSize="xl"
|
|
iconColor={theme.colorTextLabel}
|
|
className="dot"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export interface SliceHeaderControlsProps {
|
|
slice: {
|
|
description: string;
|
|
viz_type: string;
|
|
slice_name: string;
|
|
slice_id: number;
|
|
slice_description: string;
|
|
datasource: string;
|
|
};
|
|
|
|
defaultOpen?: boolean;
|
|
componentId: string;
|
|
dashboardId: number;
|
|
chartStatus: string;
|
|
isCached: boolean[];
|
|
cachedDttm: string[] | null;
|
|
isExpanded?: boolean;
|
|
updatedDttm: number | null;
|
|
isFullSize?: boolean;
|
|
isDescriptionExpanded?: boolean;
|
|
formData: QueryFormData;
|
|
exploreUrl: string;
|
|
|
|
forceRefresh: (sliceId: number, dashboardId: number) => void;
|
|
logExploreChart?: (sliceId: number) => void;
|
|
logEvent?: (eventName: string, eventData?: object) => void;
|
|
toggleExpandSlice?: (sliceId: number) => void;
|
|
exportCSV?: (sliceId: number) => void;
|
|
exportPivotCSV?: (sliceId: number) => void;
|
|
exportFullCSV?: (sliceId: number) => void;
|
|
exportXLSX?: (sliceId: number) => void;
|
|
exportFullXLSX?: (sliceId: number) => void;
|
|
handleToggleFullSize: () => void;
|
|
exportPivotExcel?: (tableSelector: string, sliceName: string) => void;
|
|
|
|
addDangerToast: (message: string) => void;
|
|
addSuccessToast: (message: string) => void;
|
|
|
|
supersetCanExplore?: boolean;
|
|
supersetCanShare?: boolean;
|
|
supersetCanCSV?: boolean;
|
|
|
|
crossFiltersEnabled?: boolean;
|
|
}
|
|
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
|
RouteComponentProps;
|
|
|
|
const dropdownIconsStyles = css`
|
|
&&.anticon > .anticon:first-child {
|
|
margin-right: 0;
|
|
vertical-align: 0;
|
|
}
|
|
`;
|
|
|
|
const SliceHeaderControls = (
|
|
props: SliceHeaderControlsPropsWithRouter | SliceHeaderControlsProps,
|
|
) => {
|
|
const [drillModalIsOpen, setDrillModalIsOpen] = useState(false);
|
|
// setting openKeys undefined falls back to uncontrolled behaviour
|
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
|
const [openScopingModal, scopingModal] = useCrossFiltersScopingModal(
|
|
props.slice.slice_id,
|
|
);
|
|
const history = useHistory();
|
|
|
|
const queryMenuRef: RefObject<any> = useRef(null);
|
|
const resultsMenuRef: RefObject<any> = useRef(null);
|
|
|
|
const [modalFilters, setFilters] = useState<BinaryQueryObjectFilterClause[]>(
|
|
[],
|
|
);
|
|
const theme = useTheme();
|
|
|
|
const canEditCrossFilters =
|
|
useSelector<RootState, boolean>(
|
|
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
|
) &&
|
|
getChartMetadataRegistry()
|
|
.get(props.slice.viz_type)
|
|
?.behaviors?.includes(Behavior.InteractiveChart);
|
|
const canExplore = props.supersetCanExplore;
|
|
const { canDrillToDetail, canViewQuery, canViewTable } = usePermissions();
|
|
|
|
const datasetResource = useDatasetDrillInfo(
|
|
props.slice.datasource,
|
|
props.dashboardId,
|
|
props.formData,
|
|
);
|
|
|
|
const datasetWithVerboseMap =
|
|
datasetResource.status === ResourceStatus.Complete
|
|
? datasetResource.result
|
|
: undefined;
|
|
|
|
const refreshChart = () => {
|
|
if (props.updatedDttm) {
|
|
props.forceRefresh(props.slice.slice_id, props.dashboardId);
|
|
}
|
|
};
|
|
|
|
const handleMenuClick = ({
|
|
key,
|
|
domEvent,
|
|
}: {
|
|
key: Key;
|
|
domEvent: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>;
|
|
}) => {
|
|
switch (key) {
|
|
case MenuKeys.ForceRefresh:
|
|
refreshChart();
|
|
props.addSuccessToast(t('Data refreshed'));
|
|
break;
|
|
case MenuKeys.ToggleChartDescription:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.toggleExpandSlice?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.ExploreChart:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.logExploreChart?.(props.slice.slice_id);
|
|
if (domEvent.metaKey || domEvent.ctrlKey) {
|
|
domEvent.preventDefault();
|
|
window.open(props.exploreUrl, '_blank');
|
|
} else {
|
|
history.push(props.exploreUrl);
|
|
}
|
|
break;
|
|
case MenuKeys.ExportCsv:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.exportCSV?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.ExportPivotCsv:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.exportPivotCSV?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.Fullscreen:
|
|
props.handleToggleFullSize();
|
|
break;
|
|
case MenuKeys.ExportFullCsv:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.exportFullCSV?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.ExportFullXlsx:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.exportFullXLSX?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.ExportXlsx:
|
|
// eslint-disable-next-line no-unused-expressions
|
|
props.exportXLSX?.(props.slice.slice_id);
|
|
break;
|
|
case MenuKeys.DownloadAsImage: {
|
|
// 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)',
|
|
) as HTMLElement;
|
|
if (menu) {
|
|
menu.style.visibility = 'hidden';
|
|
}
|
|
downloadAsImage(
|
|
getScreenshotNodeSelector(props.slice.slice_id),
|
|
props.slice.slice_name,
|
|
true,
|
|
theme,
|
|
)(domEvent).then(() => {
|
|
if (menu) {
|
|
menu.style.visibility = 'visible';
|
|
}
|
|
});
|
|
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
|
|
chartId: props.slice.slice_id,
|
|
});
|
|
break;
|
|
}
|
|
case MenuKeys.ExportPivotXlsx: {
|
|
const sliceSelector = `#chart-id-${props.slice.slice_id}`;
|
|
props.exportPivotExcel?.(
|
|
`${sliceSelector} .pvtTable`,
|
|
props.slice.slice_name,
|
|
);
|
|
break;
|
|
}
|
|
case MenuKeys.CrossFilterScoping: {
|
|
openScopingModal();
|
|
break;
|
|
}
|
|
case MenuKeys.ViewResults: {
|
|
if (resultsMenuRef.current && !resultsMenuRef.current.showModal) {
|
|
resultsMenuRef.current.open(domEvent);
|
|
}
|
|
break;
|
|
}
|
|
case MenuKeys.DrillToDetail: {
|
|
setDrillModalIsOpen(!drillModalIsOpen);
|
|
break;
|
|
}
|
|
case MenuKeys.ViewQuery: {
|
|
if (queryMenuRef.current && !queryMenuRef.current.showModal) {
|
|
queryMenuRef.current.open(domEvent);
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
setIsDropdownVisible(false);
|
|
};
|
|
|
|
const {
|
|
componentId,
|
|
dashboardId,
|
|
slice,
|
|
isFullSize,
|
|
cachedDttm = [],
|
|
updatedDttm = null,
|
|
addSuccessToast = () => {},
|
|
addDangerToast = () => {},
|
|
supersetCanShare = false,
|
|
isCached = [],
|
|
} = props;
|
|
const isTable = slice.viz_type === VizType.Table;
|
|
const isPivotTable = slice.viz_type === VizType.PivotTable;
|
|
const cachedWhen = (cachedDttm || []).map(itemCachedDttm =>
|
|
extendedDayjs.utc(itemCachedDttm).fromNow(),
|
|
);
|
|
const updatedWhen = updatedDttm
|
|
? extendedDayjs.utc(updatedDttm).fromNow()
|
|
: '';
|
|
const getCachedTitle = (itemCached: boolean) => {
|
|
if (itemCached) {
|
|
return t('Cached %s', cachedWhen);
|
|
}
|
|
if (updatedWhen) {
|
|
return t('Fetched %s', updatedWhen);
|
|
}
|
|
return '';
|
|
};
|
|
const refreshTooltipData = [...new Set(isCached.map(getCachedTitle) || '')];
|
|
// If all queries have same cache time we can unit them to one
|
|
const refreshTooltip = refreshTooltipData.map((item, index) => (
|
|
<div key={`tooltip-${index}`}>
|
|
{refreshTooltipData.length > 1
|
|
? t('Query %s: %s', index + 1, item)
|
|
: item}
|
|
</div>
|
|
));
|
|
const fullscreenLabel = isFullSize
|
|
? t('Exit fullscreen')
|
|
: t('Enter fullscreen');
|
|
|
|
// @z-index-below-dashboard-header (100) - 1 = 99 for !isFullSize and 101 for isFullSize
|
|
const dropdownOverlayStyle = {
|
|
zIndex: isFullSize ? 101 : 99,
|
|
animationDuration: '0s',
|
|
};
|
|
|
|
const newMenuItems: MenuItem[] = [
|
|
{
|
|
key: MenuKeys.ForceRefresh,
|
|
label: (
|
|
<>
|
|
{t('Force refresh')}
|
|
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
|
|
{refreshTooltip}
|
|
</RefreshTooltip>
|
|
</>
|
|
),
|
|
disabled: props.chartStatus === 'loading',
|
|
style: { height: 'auto', lineHeight: 'initial' },
|
|
...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type
|
|
},
|
|
{
|
|
key: MenuKeys.Fullscreen,
|
|
label: fullscreenLabel,
|
|
},
|
|
{
|
|
type: 'divider',
|
|
},
|
|
];
|
|
|
|
if (slice.description) {
|
|
newMenuItems.push({
|
|
key: MenuKeys.ToggleChartDescription,
|
|
label: props.isDescriptionExpanded
|
|
? t('Hide chart description')
|
|
: t('Show chart description'),
|
|
});
|
|
}
|
|
|
|
if (canExplore) {
|
|
newMenuItems.push({
|
|
key: MenuKeys.ExploreChart,
|
|
label: (
|
|
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
|
|
{t('Edit chart')}
|
|
</Tooltip>
|
|
),
|
|
...{ 'data-test-edit-chart-name': slice.slice_name },
|
|
});
|
|
}
|
|
|
|
if (canEditCrossFilters) {
|
|
newMenuItems.push({
|
|
key: MenuKeys.CrossFilterScoping,
|
|
label: t('Cross-filtering scoping'),
|
|
});
|
|
}
|
|
|
|
if (canExplore || canEditCrossFilters) {
|
|
newMenuItems.push({ type: 'divider' });
|
|
}
|
|
|
|
if (canExplore || canViewQuery) {
|
|
newMenuItems.push({
|
|
key: MenuKeys.ViewQuery,
|
|
label: (
|
|
<ModalTrigger
|
|
triggerNode={
|
|
<div data-test="view-query-menu-item">{t('View query')}</div>
|
|
}
|
|
modalTitle={t('View query')}
|
|
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
|
draggable
|
|
resizable
|
|
responsive
|
|
ref={queryMenuRef}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
if (canExplore || canViewTable) {
|
|
newMenuItems.push({
|
|
key: MenuKeys.ViewResults,
|
|
label: (
|
|
<ViewResultsModalTrigger
|
|
canExplore={props.supersetCanExplore}
|
|
exploreUrl={props.exploreUrl}
|
|
triggerNode={
|
|
<div data-test="view-query-menu-item">{t('View as table')}</div>
|
|
}
|
|
modalRef={resultsMenuRef}
|
|
modalTitle={t('Chart Data: %s', slice.slice_name)}
|
|
modalBody={
|
|
<ResultsPaneOnDashboard
|
|
queryFormData={props.formData}
|
|
queryForce={false}
|
|
dataSize={20}
|
|
isRequest
|
|
isVisible
|
|
canDownload={!!props.supersetCanCSV}
|
|
/>
|
|
}
|
|
/>
|
|
),
|
|
});
|
|
}
|
|
|
|
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
|
useDrillDetailMenuItems({
|
|
formData: props.formData,
|
|
filters: modalFilters,
|
|
setFilters,
|
|
setShowModal: setDrillModalIsOpen,
|
|
key: MenuKeys.DrillToDetail,
|
|
});
|
|
|
|
const shareMenuItems = useShareMenuItems({
|
|
dashboardId,
|
|
dashboardComponentId: componentId,
|
|
copyMenuItemTitle: t('Copy permalink to clipboard'),
|
|
emailMenuItemTitle: t('Share chart by email'),
|
|
emailSubject: t('Superset chart'),
|
|
emailBody: t('Check out this chart: '),
|
|
addSuccessToast,
|
|
addDangerToast,
|
|
title: t('Share'),
|
|
});
|
|
|
|
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
|
newMenuItems.push(drillToDetailMenuItem);
|
|
if (drillToDetailByMenuItem) {
|
|
newMenuItems.push(drillToDetailByMenuItem);
|
|
}
|
|
}
|
|
|
|
if (slice.description || canExplore) {
|
|
newMenuItems.push({ type: 'divider' });
|
|
}
|
|
|
|
if (supersetCanShare) {
|
|
newMenuItems.push(shareMenuItems);
|
|
}
|
|
|
|
if (props.supersetCanCSV) {
|
|
newMenuItems.push({
|
|
type: 'submenu',
|
|
key: MenuKeys.Download,
|
|
label: t('Download'),
|
|
children: [
|
|
{
|
|
key: MenuKeys.ExportCsv,
|
|
label: t('Export to .CSV'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
...(isPivotTable
|
|
? [
|
|
{
|
|
key: MenuKeys.ExportPivotCsv,
|
|
label: t('Export to Pivoted .CSV'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
{
|
|
key: MenuKeys.ExportPivotXlsx,
|
|
label: t('Export to Pivoted Excel'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: MenuKeys.ExportXlsx,
|
|
label: t('Export to Excel'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
|
props.supersetCanCSV &&
|
|
isTable
|
|
? [
|
|
{
|
|
key: MenuKeys.ExportFullCsv,
|
|
label: t('Export to full .CSV'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
{
|
|
key: MenuKeys.ExportFullXlsx,
|
|
label: t('Export to full Excel'),
|
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
]
|
|
: []),
|
|
{
|
|
key: MenuKeys.DownloadAsImage,
|
|
label: t('Download as image'),
|
|
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{isFullSize && (
|
|
<Icons.FullscreenExitOutlined
|
|
style={{ fontSize: 22 }}
|
|
onClick={() => {
|
|
props.handleToggleFullSize();
|
|
}}
|
|
/>
|
|
)}
|
|
<NoAnimationDropdown
|
|
popupRender={() => (
|
|
<Menu
|
|
onClick={handleMenuClick}
|
|
data-test={`slice_${slice.slice_id}-menu`}
|
|
id={`slice_${slice.slice_id}-menu`}
|
|
selectable={false}
|
|
items={newMenuItems}
|
|
/>
|
|
)}
|
|
overlayStyle={dropdownOverlayStyle}
|
|
trigger={['click']}
|
|
placement="bottomRight"
|
|
open={isDropdownVisible}
|
|
onOpenChange={visible => setIsDropdownVisible(visible)}
|
|
>
|
|
<Button
|
|
id={`slice_${slice.slice_id}-controls`}
|
|
buttonStyle="link"
|
|
aria-label="More Options"
|
|
aria-haspopup="true"
|
|
css={theme => css`
|
|
padding: ${theme.sizeUnit * 2}px;
|
|
padding-right: 0px;
|
|
`}
|
|
>
|
|
<VerticalDotsTrigger />
|
|
</Button>
|
|
</NoAnimationDropdown>
|
|
<DrillDetailModal
|
|
formData={props.formData}
|
|
initialFilters={[]}
|
|
onHideModal={() => {
|
|
setDrillModalIsOpen(false);
|
|
}}
|
|
chartId={slice.slice_id}
|
|
showModal={drillModalIsOpen}
|
|
dataset={datasetWithVerboseMap}
|
|
/>
|
|
|
|
{canEditCrossFilters && scopingModal}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default SliceHeaderControls;
|