Compare commits

...

9 Commits

Author SHA1 Message Date
Evan Rusackas
3cfd75f5f3 fix(dashboard): make EditableTitle respond immediately to editing prop
- Use editing prop directly: isEditing = editing || isEditingInternal
- This ensures the component enters edit mode immediately when prop changes
- Simplify state syncing and onEditingChange notification logic

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 11:27:46 -08:00
Evan Rusackas
1c1d3b542f fix(dashboard): add email permalink and fix edit title timing
- Add 'Share permalink by email' option to tab menu
- Delay edit title trigger to allow menu to close first

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:20:49 -08:00
Evan Rusackas
beb6571c1d fix(dashboard): use correct icon names in TabMenu
- Icons.Link -> Icons.LinkOutlined
- Icons.MoreVert -> Icons.MoreOutlined

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:37:46 -08:00
Evan Rusackas
f942b5345f feat(dashboard): add kebab menu to tabs with permalink copy and title edit
- Replace link icon with kebab menu on dashboard tabs
- Menu contains 'Copy permalink' and 'Edit tab title' options
- 'Edit tab title' only shown to users with edit permission
- Tab title changes auto-save when using menu (not in full edit mode)
- Refactor updateComponentAndSave helper for reuse

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 18:33:13 -08:00
Evan Rusackas
a36bd77beb fix(dashboard): properly detect entering vs exiting controlled edit mode
Use a ref to track previous isEditing state. This distinguishes:
- Entering controlled mode: isEditing=false, prevIsEditing=false → skip notify
- Exiting edit mode: isEditing=false, prevIsEditing=true → notify parent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:33:47 -08:00
Evan Rusackas
e1b2206ff0 fix(dashboard): fix race condition in EditableTitle controlled mode
The second useEffect was calling onEditingChange(false) before the
controlled mode sync completed, causing the parent to reset isEditingTitle
back to false. Now we skip notifying the parent during the sync period.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 17:17:58 -08:00
Evan Rusackas
73379b1aff feat(dashboard): auto-save chart title and description toggle changes
- toggleExpandSlice now auto-saves expanded_slices to json_metadata
- Add updateSliceNameWithSave action that saves position_json after title edit
- Both features now persist without entering edit mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:57:03 -08:00
Evan Rusackas
215b305e1c fix(dashboard): fix persistence for chart title and description toggle
- Fix EditableTitle useEffect dependency array (first click wasn't working)
- Add hasUnsavedChanges: true to TOGGLE_EXPAND_SLICE reducer
  (description toggle state wasn't being persisted)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:30:49 -08:00
Evan Rusackas
06dfeaa939 feat(dashboard): add inline chart title editing and fix description toggle
- Add "Edit chart title" menu option for dashboard editors
- Gate description toggle behind edit permission (was visible to all users)
- Fix expanded_slices persistence bug (toggle state was not being saved)
- Add controlled editing mode to EditableTitle component

The title editing uses the existing sliceNameOverride infrastructure,
storing dashboard-specific title overrides without modifying the
underlying chart.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:17:04 -08:00
11 changed files with 463 additions and 52 deletions

View File

@@ -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() {

View File

@@ -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();

View File

@@ -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),

View File

@@ -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)}
/>
)}
</>

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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,
],
);

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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',