mirror of
https://github.com/apache/superset.git
synced 2026-04-29 13:04:22 +00:00
Compare commits
9 Commits
embedded-e
...
live-edits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfd75f5f3 | ||
|
|
1c1d3b542f | ||
|
|
beb6571c1d | ||
|
|
f942b5345f | ||
|
|
a36bd77beb | ||
|
|
e1b2206ff0 | ||
|
|
73379b1aff | ||
|
|
215b305e1c | ||
|
|
06dfeaa939 |
@@ -89,11 +89,15 @@ export function EditableTitle({
|
||||
onEditingChange,
|
||||
...rest
|
||||
}: EditableTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(editing);
|
||||
const [isEditingInternal, setIsEditingInternal] = useState(editing);
|
||||
// Use editing prop directly when provided, otherwise use internal state
|
||||
const isEditing = editing || isEditingInternal;
|
||||
const setIsEditing = setIsEditingInternal;
|
||||
const [currentTitle, setCurrentTitle] = useState(title);
|
||||
const [lastTitle, setLastTitle] = useState(title);
|
||||
const [inputWidth, setInputWidth] = useState<number>(0);
|
||||
const contentRef = useRef<TextAreaRef>(null);
|
||||
const prevIsEditingRef = useRef(isEditing);
|
||||
|
||||
function measureTextWidth(text: string, font = '14px Arial') {
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -122,6 +126,13 @@ export function EditableTitle({
|
||||
}
|
||||
}, [title]);
|
||||
|
||||
// Sync internal state when editing prop changes (for controlled mode)
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
setIsEditingInternal(true);
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && contentRef.current) {
|
||||
const textArea = contentRef.current.resizableTextArea?.textArea;
|
||||
@@ -132,7 +143,11 @@ export function EditableTitle({
|
||||
textArea.scrollTop = textArea.scrollHeight;
|
||||
}
|
||||
}
|
||||
onEditingChange?.(isEditing);
|
||||
// Notify parent of editing state changes
|
||||
if (prevIsEditingRef.current !== isEditing) {
|
||||
onEditingChange?.(isEditing);
|
||||
}
|
||||
prevIsEditingRef.current = isEditing;
|
||||
}, [isEditing, onEditingChange]);
|
||||
|
||||
function handleClick() {
|
||||
|
||||
@@ -17,8 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ActionCreators as UndoActionCreators } from 'redux-undo';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
||||
import {
|
||||
t,
|
||||
SupersetClient,
|
||||
logging,
|
||||
getClientErrorObject,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
addWarningToast,
|
||||
addDangerToast,
|
||||
} from 'src/components/MessageToasts/actions';
|
||||
import { TABS_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
DASHBOARD_ROOT_ID,
|
||||
@@ -28,6 +36,9 @@ import {
|
||||
import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
|
||||
import findParentId from 'src/dashboard/util/findParentId';
|
||||
import isInDifferentFilterScopes from 'src/dashboard/util/isInDifferentFilterScopes';
|
||||
import serializeActiveFilterValues from 'src/dashboard/util/serializeActiveFilterValues';
|
||||
import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import { updateLayoutComponents } from './dashboardFilters';
|
||||
import { setUnsavedChanges } from './dashboardState';
|
||||
|
||||
@@ -74,6 +85,85 @@ export const updateComponents = setUnsavedChangesAfterAction(
|
||||
}),
|
||||
);
|
||||
|
||||
// Helper function to update a component and auto-save the dashboard layout
|
||||
function updateComponentAndSave(componentId, updatedComponent, errorMessage) {
|
||||
return (dispatch, getState) => {
|
||||
const { dashboardInfo } = getState();
|
||||
const dashboardId = dashboardInfo.id;
|
||||
|
||||
// Dispatch the update for immediate UI feedback
|
||||
dispatch(
|
||||
updateComponents({
|
||||
[componentId]: updatedComponent,
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the updated layout after the dispatch
|
||||
const { dashboardLayout: updatedLayout } = getState();
|
||||
const layout = updatedLayout.present;
|
||||
|
||||
// Serialize the layout for saving
|
||||
const serializedFilters = serializeActiveFilterValues(getActiveFilters());
|
||||
|
||||
// Auto-save the position_json to persist the change
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
position_json: safeStringify(layout),
|
||||
json_metadata: safeStringify({
|
||||
...dashboardInfo.metadata,
|
||||
default_filters: safeStringify(serializedFilters),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(setUnsavedChanges(false));
|
||||
})
|
||||
.catch(async response => {
|
||||
const { error } = await getClientErrorObject(response);
|
||||
logging.error(errorMessage, error);
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('Could not save your changes. Please try again.'),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Update a slice name override and auto-save to persist the change
|
||||
export function updateSliceNameWithSave(componentId, component, nextName) {
|
||||
const updatedComponent = {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
sliceNameOverride: nextName,
|
||||
},
|
||||
};
|
||||
return updateComponentAndSave(
|
||||
componentId,
|
||||
updatedComponent,
|
||||
'Error saving slice name:',
|
||||
);
|
||||
}
|
||||
|
||||
// Update a tab title and auto-save to persist the change
|
||||
export function updateTabTitleWithSave(componentId, component, nextTitle) {
|
||||
const updatedComponent = {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
text: nextTitle,
|
||||
},
|
||||
};
|
||||
return updateComponentAndSave(
|
||||
componentId,
|
||||
updatedComponent,
|
||||
'Error saving tab title:',
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDashboardTitle(text) {
|
||||
return (dispatch, getState) => {
|
||||
const { dashboardLayout } = getState();
|
||||
|
||||
@@ -182,7 +182,41 @@ export function savePublished(id, isPublished) {
|
||||
|
||||
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
|
||||
export function toggleExpandSlice(sliceId) {
|
||||
return { type: TOGGLE_EXPAND_SLICE, sliceId };
|
||||
return (dispatch, getState) => {
|
||||
const { dashboardInfo, dashboardState } = getState();
|
||||
const dashboardId = dashboardInfo.id;
|
||||
|
||||
// Toggle the expanded state
|
||||
dispatch({ type: TOGGLE_EXPAND_SLICE, sliceId });
|
||||
|
||||
// Get updated state after toggle
|
||||
const { dashboardState: updatedState } = getState();
|
||||
const expandedSlices = updatedState.expandedSlices || {};
|
||||
|
||||
// Auto-save to persist the change
|
||||
SupersetClient.put({
|
||||
endpoint: `/api/v1/dashboard/${dashboardId}`,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
json_metadata: safeStringify({
|
||||
...dashboardInfo.metadata,
|
||||
expanded_slices: expandedSlices,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(setUnsavedChanges(false));
|
||||
})
|
||||
.catch(async response => {
|
||||
const { error } = await getClientErrorObject(response);
|
||||
logging.error('Error saving expanded slices:', error);
|
||||
dispatch(
|
||||
addDangerToast(
|
||||
t('Could not save your preferences. Please try again.'),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_EDIT_MODE = 'SET_EDIT_MODE';
|
||||
@@ -274,7 +308,7 @@ export function saveDashboardRequest(data, id, saveType) {
|
||||
dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST });
|
||||
dispatch(saveDashboardStarted());
|
||||
|
||||
const { dashboardFilters, dashboardLayout } = getState();
|
||||
const { dashboardFilters, dashboardLayout, dashboardState } = getState();
|
||||
const layout = dashboardLayout.present;
|
||||
Object.values(dashboardFilters).forEach(filter => {
|
||||
const { chartId } = filter;
|
||||
@@ -327,7 +361,7 @@ export function saveDashboardRequest(data, id, saveType) {
|
||||
color_scheme_domain: colorScheme
|
||||
? getColorSchemeDomain(colorScheme)
|
||||
: [],
|
||||
expanded_slices: data.metadata?.expanded_slices || {},
|
||||
expanded_slices: dashboardState.expandedSlices || {},
|
||||
label_colors: customLabelsColor,
|
||||
shared_label_colors: getFreshSharedLabels(sharedLabelsColor),
|
||||
map_label_colors: getFreshLabelsColorMapEntries(customLabelsColor),
|
||||
|
||||
@@ -174,6 +174,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
!isEmbedded() || uiConfig.showRowLimitWarning;
|
||||
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
// TODO: change to indicator field after it will be implemented
|
||||
const crossFilterValue = useSelector<RootState, any>(
|
||||
@@ -236,15 +237,21 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
<EditableTitle
|
||||
title={
|
||||
sliceName ||
|
||||
(editMode
|
||||
(editMode || isEditingTitle
|
||||
? '---' // this makes an empty title clickable
|
||||
: '')
|
||||
}
|
||||
canEdit={editMode}
|
||||
canEdit={editMode || isEditingTitle}
|
||||
editing={isEditingTitle}
|
||||
onSaveTitle={updateSliceName}
|
||||
showTooltip={false}
|
||||
onEditingChange={editing => {
|
||||
if (!editing) setIsEditingTitle(false);
|
||||
}}
|
||||
renderLink={
|
||||
canExplore && exploreUrl ? renderExploreLink : undefined
|
||||
canExplore && exploreUrl && !isEditingTitle
|
||||
? renderExploreLink
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -347,6 +354,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
exploreUrl={exploreUrl}
|
||||
crossFiltersEnabled={isCrossFiltersEnabled}
|
||||
exportPivotExcel={exportPivotExcel}
|
||||
onEditTitle={() => setIsEditingTitle(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -138,6 +138,7 @@ export interface SliceHeaderControlsProps {
|
||||
supersetCanCSV?: boolean;
|
||||
|
||||
crossFiltersEnabled?: boolean;
|
||||
onEditTitle?: () => void;
|
||||
}
|
||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||
RouteComponentProps;
|
||||
@@ -168,10 +169,11 @@ const SliceHeaderControls = (
|
||||
);
|
||||
const theme = useTheme();
|
||||
|
||||
const canEditDashboard = useSelector<RootState, boolean>(
|
||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||
);
|
||||
const canEditCrossFilters =
|
||||
useSelector<RootState, boolean>(
|
||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||
) &&
|
||||
canEditDashboard &&
|
||||
getChartMetadataRegistry()
|
||||
.get(props.slice.viz_type)
|
||||
?.behaviors?.includes(Behavior.InteractiveChart);
|
||||
@@ -212,6 +214,10 @@ const SliceHeaderControls = (
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
props.toggleExpandSlice?.(props.slice.slice_id);
|
||||
break;
|
||||
case MenuKeys.EditChartTitle:
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
props.onEditTitle?.();
|
||||
break;
|
||||
case MenuKeys.ExploreChart:
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
props.logExploreChart?.(props.slice.slice_id);
|
||||
@@ -375,7 +381,7 @@ const SliceHeaderControls = (
|
||||
},
|
||||
];
|
||||
|
||||
if (slice.description) {
|
||||
if (slice.description && canEditDashboard) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
label: props.isDescriptionExpanded
|
||||
@@ -384,6 +390,13 @@ const SliceHeaderControls = (
|
||||
});
|
||||
}
|
||||
|
||||
if (canEditDashboard) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.EditChartTitle,
|
||||
label: t('Edit chart title'),
|
||||
});
|
||||
}
|
||||
|
||||
if (canExplore) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ExploreChart,
|
||||
|
||||
@@ -65,6 +65,11 @@ interface ChartHolderProps {
|
||||
// dnd
|
||||
deleteComponent: (id: string, parentId: string) => void;
|
||||
updateComponents: Function;
|
||||
updateSliceNameWithSave: (
|
||||
componentId: string,
|
||||
component: LayoutItem,
|
||||
nextName: string,
|
||||
) => void;
|
||||
handleComponentDrop: (...args: unknown[]) => unknown;
|
||||
setFullSizeChartId: (chartId: number | null) => void;
|
||||
isInView: boolean;
|
||||
@@ -89,6 +94,7 @@ const ChartHolder = ({
|
||||
getComponentById = () => undefined,
|
||||
deleteComponent,
|
||||
updateComponents,
|
||||
updateSliceNameWithSave,
|
||||
handleComponentDrop,
|
||||
setFullSizeChartId,
|
||||
isInView,
|
||||
@@ -214,17 +220,9 @@ const ChartHolder = ({
|
||||
|
||||
const handleUpdateSliceName = useCallback(
|
||||
(nextName: string) => {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
sliceNameOverride: nextName,
|
||||
},
|
||||
},
|
||||
});
|
||||
updateSliceNameWithSave(component.id, component, nextName);
|
||||
},
|
||||
[component, updateComponents],
|
||||
[component, updateSliceNameWithSave],
|
||||
);
|
||||
|
||||
const handleToggleFullSize = useCallback(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Fragment, useCallback, memo, useEffect } from 'react';
|
||||
import { Fragment, useCallback, memo, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
@@ -27,7 +27,7 @@ import { EditableTitle, EmptyState } from '@superset-ui/core/components';
|
||||
import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
|
||||
import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent';
|
||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||
import AnchorLink from 'src/dashboard/components/AnchorLink';
|
||||
import TabMenu from './TabMenu';
|
||||
import {
|
||||
DragDroppable,
|
||||
Droppable,
|
||||
@@ -94,14 +94,16 @@ const TabTitleContainer = styled.div`
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
${isHighlighted ? `box-shadow: 0 0 ${sizeUnit}px ${colorPrimaryBg};` : ''}
|
||||
|
||||
.anchor-link-container {
|
||||
.anchor-link-container,
|
||||
> button {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover .anchor-link-container {
|
||||
&:hover .anchor-link-container,
|
||||
&:hover > button {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
@@ -120,7 +122,10 @@ const renderDraggableContent = dropProps =>
|
||||
|
||||
const Tab = props => {
|
||||
const dispatch = useDispatch();
|
||||
const canEdit = useSelector(state => state.dashboardInfo.dash_edit_perm);
|
||||
const canEditDashboard = useSelector(
|
||||
state => state.dashboardInfo.dash_edit_perm,
|
||||
);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const dashboardLayout = useSelector(state => state.dashboardLayout.present);
|
||||
const lastRefreshTime = useSelector(
|
||||
state => state.dashboardState.lastRefreshTime,
|
||||
@@ -167,20 +172,32 @@ const Tab = props => {
|
||||
|
||||
const handleChangeText = useCallback(
|
||||
nextTabText => {
|
||||
const { updateComponents, component } = props;
|
||||
const { updateComponents, updateTabTitleWithSave, component, editMode } =
|
||||
props;
|
||||
if (nextTabText && nextTabText !== component.meta.text) {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
text: nextTabText,
|
||||
// Use auto-save when editing from the menu (not in full edit mode)
|
||||
if (!editMode && isEditingTitle) {
|
||||
updateTabTitleWithSave(component.id, component, nextTabText);
|
||||
} else {
|
||||
updateComponents({
|
||||
[component.id]: {
|
||||
...component,
|
||||
meta: {
|
||||
...component.meta,
|
||||
text: nextTabText,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[props.updateComponents, props.component],
|
||||
[
|
||||
props.updateComponents,
|
||||
props.updateTabTitleWithSave,
|
||||
props.component,
|
||||
props.editMode,
|
||||
isEditingTitle,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -260,7 +277,7 @@ const Tab = props => {
|
||||
: t('There are no components added to this tab')
|
||||
}
|
||||
description={
|
||||
canEdit &&
|
||||
canEditDashboard &&
|
||||
(editMode ? (
|
||||
<span>
|
||||
{t('You can')}{' '}
|
||||
@@ -341,7 +358,7 @@ const Tab = props => {
|
||||
props.setDirectPathToChild,
|
||||
props.updateComponents,
|
||||
handleHoverTab,
|
||||
canEdit,
|
||||
canEditDashboard,
|
||||
handleChangeTab,
|
||||
handleChangeText,
|
||||
handleDrop,
|
||||
@@ -349,17 +366,29 @@ const Tab = props => {
|
||||
shouldDropToChild,
|
||||
]);
|
||||
|
||||
const handleEditTitle = useCallback(() => {
|
||||
setIsEditingTitle(true);
|
||||
}, []);
|
||||
|
||||
const handleEditingChange = useCallback(
|
||||
editing => {
|
||||
if (!editing) {
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
props.onTabTitleEditingChange?.(editing);
|
||||
},
|
||||
[props.onTabTitleEditingChange],
|
||||
);
|
||||
|
||||
const renderTabChild = useCallback(
|
||||
({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => {
|
||||
const {
|
||||
component,
|
||||
index,
|
||||
editMode,
|
||||
isFocused,
|
||||
isHighlighted,
|
||||
dashboardId,
|
||||
embeddedMode,
|
||||
onTabTitleEditingChange,
|
||||
} = props;
|
||||
return (
|
||||
<TabTitleContainer
|
||||
@@ -371,17 +400,18 @@ const Tab = props => {
|
||||
title={component.meta.text}
|
||||
defaultTitle={component.meta.defaultText}
|
||||
placeholder={component.meta.placeholder}
|
||||
canEdit={editMode && isFocused}
|
||||
canEdit={(editMode && isFocused) || isEditingTitle}
|
||||
onSaveTitle={handleChangeText}
|
||||
showTooltip={false}
|
||||
editing={editMode && isFocused}
|
||||
onEditingChange={onTabTitleEditingChange}
|
||||
editing={(editMode && isFocused) || isEditingTitle}
|
||||
onEditingChange={handleEditingChange}
|
||||
/>
|
||||
{!editMode && !embeddedMode && (
|
||||
<AnchorLink
|
||||
id={component.id}
|
||||
<TabMenu
|
||||
tabId={component.id}
|
||||
dashboardId={dashboardId}
|
||||
placement={index >= 5 ? 'left' : 'right'}
|
||||
canEditDashboard={canEditDashboard}
|
||||
onEditTitle={handleEditTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -396,13 +426,16 @@ const Tab = props => {
|
||||
},
|
||||
[
|
||||
props.component,
|
||||
props.index,
|
||||
props.editMode,
|
||||
props.isFocused,
|
||||
props.isHighlighted,
|
||||
props.dashboardId,
|
||||
props.onTabTitleEditingChange,
|
||||
props.embeddedMode,
|
||||
isEditingTitle,
|
||||
canEditDashboard,
|
||||
handleChangeText,
|
||||
handleEditTitle,
|
||||
handleEditingChange,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 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 { useState, useCallback } from 'react';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { getClientErrorObject, t } from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/ui';
|
||||
import { Dropdown, Icons, type MenuProps } from '@superset-ui/core/components';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { getDashboardPermalink } from 'src/utils/urlUtils';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { hasStatefulCharts } from 'src/dashboard/util/chartStateConverter';
|
||||
|
||||
export interface TabMenuProps {
|
||||
tabId: string;
|
||||
dashboardId: number;
|
||||
canEditDashboard: boolean;
|
||||
onEditTitle?: () => void;
|
||||
}
|
||||
|
||||
export default function TabMenu({
|
||||
tabId,
|
||||
dashboardId,
|
||||
canEditDashboard,
|
||||
onEditTitle,
|
||||
}: TabMenuProps) {
|
||||
const theme = useTheme();
|
||||
const { addSuccessToast, addDangerToast } = useToasts();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const { dataMask, activeTabs, chartStates, sliceEntities } = useSelector(
|
||||
(state: RootState) => ({
|
||||
dataMask: state.dataMask,
|
||||
activeTabs: state.dashboardState.activeTabs,
|
||||
chartStates: state.dashboardState.chartStates,
|
||||
sliceEntities: state.sliceEntities?.slices,
|
||||
}),
|
||||
shallowEqual,
|
||||
);
|
||||
|
||||
const handleCopyPermalink = useCallback(async () => {
|
||||
try {
|
||||
const includeChartState =
|
||||
hasStatefulCharts(sliceEntities) &&
|
||||
chartStates &&
|
||||
Object.keys(chartStates).length > 0;
|
||||
|
||||
const url = await getDashboardPermalink({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
anchor: tabId,
|
||||
chartStates: includeChartState ? chartStates : undefined,
|
||||
includeChartState,
|
||||
});
|
||||
|
||||
await copyTextToClipboard(() => Promise.resolve(url));
|
||||
addSuccessToast(t('Permalink copied to clipboard!'));
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
addDangerToast(
|
||||
(await getClientErrorObject(error)).error ||
|
||||
t('Something went wrong.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
dashboardId,
|
||||
tabId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
chartStates,
|
||||
sliceEntities,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
]);
|
||||
|
||||
const handleEmailPermalink = useCallback(async () => {
|
||||
try {
|
||||
const includeChartState =
|
||||
hasStatefulCharts(sliceEntities) &&
|
||||
chartStates &&
|
||||
Object.keys(chartStates).length > 0;
|
||||
|
||||
const url = await getDashboardPermalink({
|
||||
dashboardId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
anchor: tabId,
|
||||
chartStates: includeChartState ? chartStates : undefined,
|
||||
includeChartState,
|
||||
});
|
||||
|
||||
const emailSubject = t('Superset dashboard ');
|
||||
const emailBody = `${t('Check out this tab in dashboard:')} ${url}`;
|
||||
window.location.href = `mailto:?Subject=${encodeURIComponent(emailSubject)}&Body=${encodeURIComponent(emailBody)}`;
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
addDangerToast(
|
||||
(await getClientErrorObject(error)).error ||
|
||||
t('Something went wrong.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
dashboardId,
|
||||
tabId,
|
||||
dataMask,
|
||||
activeTabs,
|
||||
chartStates,
|
||||
sliceEntities,
|
||||
addDangerToast,
|
||||
]);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = useCallback(
|
||||
({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
setIsDropdownOpen(false);
|
||||
|
||||
switch (key) {
|
||||
case 'copy-permalink':
|
||||
handleCopyPermalink();
|
||||
break;
|
||||
case 'email-permalink':
|
||||
handleEmailPermalink();
|
||||
break;
|
||||
case 'edit-title':
|
||||
// Delay to allow menu to close first
|
||||
setTimeout(() => {
|
||||
onEditTitle?.();
|
||||
}, 0);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleCopyPermalink, handleEmailPermalink, onEditTitle],
|
||||
);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'copy-permalink',
|
||||
label: t('Copy permalink'),
|
||||
icon: <Icons.LinkOutlined iconSize="m" />,
|
||||
},
|
||||
{
|
||||
key: 'email-permalink',
|
||||
label: t('Share permalink by email'),
|
||||
icon: <Icons.MailOutlined iconSize="m" />,
|
||||
},
|
||||
...(canEditDashboard
|
||||
? [
|
||||
{
|
||||
key: 'edit-title',
|
||||
label: t('Edit tab title'),
|
||||
icon: <Icons.EditOutlined iconSize="m" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: menuItems, onClick: handleMenuClick }}
|
||||
trigger={['click']}
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
}}
|
||||
css={css`
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: ${theme.sizeUnit}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: ${theme.sizeUnit}px;
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colorBgElevated};
|
||||
}
|
||||
`}
|
||||
aria-label={t('Tab actions')}
|
||||
>
|
||||
<Icons.MoreOutlined iconSize="m" iconColor={theme.colorTextSecondary} />
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
createComponent,
|
||||
deleteComponent,
|
||||
updateComponents,
|
||||
updateSliceNameWithSave,
|
||||
updateTabTitleWithSave,
|
||||
handleComponentDrop,
|
||||
} from 'src/dashboard/actions/dashboardLayout';
|
||||
import {
|
||||
@@ -84,6 +86,8 @@ const DashboardComponent = props => {
|
||||
createComponent,
|
||||
deleteComponent,
|
||||
updateComponents,
|
||||
updateSliceNameWithSave,
|
||||
updateTabTitleWithSave,
|
||||
handleComponentDrop,
|
||||
setDirectPathToChild,
|
||||
setFullSizeChartId,
|
||||
|
||||
@@ -148,7 +148,11 @@ export default function dashboardStateReducer(state = {}, action) {
|
||||
} else {
|
||||
updatedExpandedSlices[sliceId] = true;
|
||||
}
|
||||
return { ...state, expandedSlices: updatedExpandedSlices };
|
||||
return {
|
||||
...state,
|
||||
expandedSlices: updatedExpandedSlices,
|
||||
hasUnsavedChanges: true,
|
||||
};
|
||||
},
|
||||
[ON_CHANGE]() {
|
||||
return { ...state, hasUnsavedChanges: true };
|
||||
|
||||
@@ -291,6 +291,7 @@ export enum MenuKeys {
|
||||
ForceRefresh = 'force_refresh',
|
||||
Fullscreen = 'fullscreen',
|
||||
ToggleChartDescription = 'toggle_chart_description',
|
||||
EditChartTitle = 'edit_chart_title',
|
||||
ViewQuery = 'view_query',
|
||||
ViewResults = 'view_results',
|
||||
DrillToDetail = 'drill_to_detail',
|
||||
|
||||
Reference in New Issue
Block a user