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,

Reply via email to