This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch live-edits in repository https://gitbox.apache.org/repos/asf/superset.git
commit f942b5345f4fedd88efe670513454411253c0e82 Author: Evan Rusackas <[email protected]> AuthorDate: Thu Jan 8 18:33:13 2026 -0800 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 <[email protected]> --- .../src/dashboard/actions/dashboardLayout.js | 51 +++++-- .../components/gridComponents/Tab/Tab.jsx | 87 +++++++---- .../components/gridComponents/Tab/TabMenu.tsx | 163 +++++++++++++++++++++ .../dashboard/containers/DashboardComponent.jsx | 2 + 4 files changed, 262 insertions(+), 41 deletions(-) diff --git a/superset-frontend/src/dashboard/actions/dashboardLayout.js b/superset-frontend/src/dashboard/actions/dashboardLayout.js index a9bc94d3ee9..36554479715 100644 --- a/superset-frontend/src/dashboard/actions/dashboardLayout.js +++ b/superset-frontend/src/dashboard/actions/dashboardLayout.js @@ -85,21 +85,12 @@ export const updateComponents = setUnsavedChangesAfterAction( }), ); -// Update a slice name override and auto-save to persist the change -export function updateSliceNameWithSave(componentId, component, nextName) { +// Helper function to update a component and auto-save the dashboard layout +function updateComponentAndSave(componentId, updatedComponent, errorMessage) { return (dispatch, getState) => { - const { dashboardInfo, dashboardLayout } = getState(); + const { dashboardInfo } = getState(); const dashboardId = dashboardInfo.id; - // Update the component with the new name override - const updatedComponent = { - ...component, - meta: { - ...component.meta, - sliceNameOverride: nextName, - }, - }; - // Dispatch the update for immediate UI feedback dispatch( updateComponents({ @@ -108,7 +99,7 @@ export function updateSliceNameWithSave(componentId, component, nextName) { ); // Get the updated layout after the dispatch - const { dashboardLayout: updatedLayout, dashboardFilters } = getState(); + const { dashboardLayout: updatedLayout } = getState(); const layout = updatedLayout.present; // Serialize the layout for saving @@ -131,7 +122,7 @@ export function updateSliceNameWithSave(componentId, component, nextName) { }) .catch(async response => { const { error } = await getClientErrorObject(response); - logging.error('Error saving slice name:', error); + logging.error(errorMessage, error); dispatch( addDangerToast( t('Could not save your changes. Please try again.'), @@ -141,6 +132,38 @@ export function updateSliceNameWithSave(componentId, component, nextName) { }; } +// 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(); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx index 8902e84a467..703f0124e34 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.jsx @@ -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, ], ); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/TabMenu.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/TabMenu.tsx new file mode 100644 index 00000000000..ac0dccbdd16 --- /dev/null +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/TabMenu.tsx @@ -0,0 +1,163 @@ +/** + * 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 handleMenuClick: MenuProps['onClick'] = useCallback( + ({ key, domEvent }) => { + domEvent.stopPropagation(); + setIsDropdownOpen(false); + + switch (key) { + case 'copy-permalink': + handleCopyPermalink(); + break; + case 'edit-title': + onEditTitle?.(); + break; + default: + break; + } + }, + [handleCopyPermalink, onEditTitle], + ); + + const menuItems: MenuProps['items'] = [ + { + key: 'copy-permalink', + label: t('Copy permalink'), + icon: <Icons.Link 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.MoreVert iconSize="m" iconColor={theme.colorTextSecondary} /> + </button> + </Dropdown> + ); +} diff --git a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx index e68ff9e8cbd..99005fd34ac 100644 --- a/superset-frontend/src/dashboard/containers/DashboardComponent.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardComponent.jsx @@ -31,6 +31,7 @@ import { deleteComponent, updateComponents, updateSliceNameWithSave, + updateTabTitleWithSave, handleComponentDrop, } from 'src/dashboard/actions/dashboardLayout'; import { @@ -86,6 +87,7 @@ const DashboardComponent = props => { deleteComponent, updateComponents, updateSliceNameWithSave, + updateTabTitleWithSave, handleComponentDrop, setDirectPathToChild, setFullSizeChartId,
