mirror of
https://github.com/apache/superset.git
synced 2026-07-01 04:15:31 +00:00
Compare commits
9 Commits
chore/ci/s
...
live-edits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfd75f5f3 | ||
|
|
1c1d3b542f | ||
|
|
beb6571c1d | ||
|
|
f942b5345f | ||
|
|
a36bd77beb | ||
|
|
e1b2206ff0 | ||
|
|
73379b1aff | ||
|
|
215b305e1c | ||
|
|
06dfeaa939 |
@@ -89,11 +89,15 @@ export function EditableTitle({
|
|||||||
onEditingChange,
|
onEditingChange,
|
||||||
...rest
|
...rest
|
||||||
}: EditableTitleProps) {
|
}: 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 [currentTitle, setCurrentTitle] = useState(title);
|
||||||
const [lastTitle, setLastTitle] = useState(title);
|
const [lastTitle, setLastTitle] = useState(title);
|
||||||
const [inputWidth, setInputWidth] = useState<number>(0);
|
const [inputWidth, setInputWidth] = useState<number>(0);
|
||||||
const contentRef = useRef<TextAreaRef>(null);
|
const contentRef = useRef<TextAreaRef>(null);
|
||||||
|
const prevIsEditingRef = useRef(isEditing);
|
||||||
|
|
||||||
function measureTextWidth(text: string, font = '14px Arial') {
|
function measureTextWidth(text: string, font = '14px Arial') {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -122,6 +126,13 @@ export function EditableTitle({
|
|||||||
}
|
}
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
|
// Sync internal state when editing prop changes (for controlled mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
setIsEditingInternal(true);
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && contentRef.current) {
|
if (isEditing && contentRef.current) {
|
||||||
const textArea = contentRef.current.resizableTextArea?.textArea;
|
const textArea = contentRef.current.resizableTextArea?.textArea;
|
||||||
@@ -132,7 +143,11 @@ export function EditableTitle({
|
|||||||
textArea.scrollTop = textArea.scrollHeight;
|
textArea.scrollTop = textArea.scrollHeight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onEditingChange?.(isEditing);
|
// Notify parent of editing state changes
|
||||||
|
if (prevIsEditingRef.current !== isEditing) {
|
||||||
|
onEditingChange?.(isEditing);
|
||||||
|
}
|
||||||
|
prevIsEditingRef.current = isEditing;
|
||||||
}, [isEditing, onEditingChange]);
|
}, [isEditing, onEditingChange]);
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
|
|||||||
@@ -17,8 +17,16 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { ActionCreators as UndoActionCreators } from 'redux-undo';
|
import { ActionCreators as UndoActionCreators } from 'redux-undo';
|
||||||
import { t } from '@superset-ui/core';
|
import {
|
||||||
import { addWarningToast } from 'src/components/MessageToasts/actions';
|
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 { TABS_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_ROOT_ID,
|
DASHBOARD_ROOT_ID,
|
||||||
@@ -28,6 +36,9 @@ import {
|
|||||||
import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
|
import dropOverflowsParent from 'src/dashboard/util/dropOverflowsParent';
|
||||||
import findParentId from 'src/dashboard/util/findParentId';
|
import findParentId from 'src/dashboard/util/findParentId';
|
||||||
import isInDifferentFilterScopes from 'src/dashboard/util/isInDifferentFilterScopes';
|
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 { updateLayoutComponents } from './dashboardFilters';
|
||||||
import { setUnsavedChanges } from './dashboardState';
|
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) {
|
export function updateDashboardTitle(text) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { dashboardLayout } = getState();
|
const { dashboardLayout } = getState();
|
||||||
|
|||||||
@@ -182,7 +182,41 @@ export function savePublished(id, isPublished) {
|
|||||||
|
|
||||||
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
|
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
|
||||||
export function toggleExpandSlice(sliceId) {
|
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';
|
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({ type: UPDATE_COMPONENTS_PARENTS_LIST });
|
||||||
dispatch(saveDashboardStarted());
|
dispatch(saveDashboardStarted());
|
||||||
|
|
||||||
const { dashboardFilters, dashboardLayout } = getState();
|
const { dashboardFilters, dashboardLayout, dashboardState } = getState();
|
||||||
const layout = dashboardLayout.present;
|
const layout = dashboardLayout.present;
|
||||||
Object.values(dashboardFilters).forEach(filter => {
|
Object.values(dashboardFilters).forEach(filter => {
|
||||||
const { chartId } = filter;
|
const { chartId } = filter;
|
||||||
@@ -327,7 +361,7 @@ export function saveDashboardRequest(data, id, saveType) {
|
|||||||
color_scheme_domain: colorScheme
|
color_scheme_domain: colorScheme
|
||||||
? getColorSchemeDomain(colorScheme)
|
? getColorSchemeDomain(colorScheme)
|
||||||
: [],
|
: [],
|
||||||
expanded_slices: data.metadata?.expanded_slices || {},
|
expanded_slices: dashboardState.expandedSlices || {},
|
||||||
label_colors: customLabelsColor,
|
label_colors: customLabelsColor,
|
||||||
shared_label_colors: getFreshSharedLabels(sharedLabelsColor),
|
shared_label_colors: getFreshSharedLabels(sharedLabelsColor),
|
||||||
map_label_colors: getFreshLabelsColorMapEntries(customLabelsColor),
|
map_label_colors: getFreshLabelsColorMapEntries(customLabelsColor),
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
|||||||
!isEmbedded() || uiConfig.showRowLimitWarning;
|
!isEmbedded() || uiConfig.showRowLimitWarning;
|
||||||
const dashboardPageId = useContext(DashboardPageIdContext);
|
const dashboardPageId = useContext(DashboardPageIdContext);
|
||||||
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
|
const [headerTooltip, setHeaderTooltip] = useState<ReactNode | null>(null);
|
||||||
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
// TODO: change to indicator field after it will be implemented
|
// TODO: change to indicator field after it will be implemented
|
||||||
const crossFilterValue = useSelector<RootState, any>(
|
const crossFilterValue = useSelector<RootState, any>(
|
||||||
@@ -236,15 +237,21 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
|||||||
<EditableTitle
|
<EditableTitle
|
||||||
title={
|
title={
|
||||||
sliceName ||
|
sliceName ||
|
||||||
(editMode
|
(editMode || isEditingTitle
|
||||||
? '---' // this makes an empty title clickable
|
? '---' // this makes an empty title clickable
|
||||||
: '')
|
: '')
|
||||||
}
|
}
|
||||||
canEdit={editMode}
|
canEdit={editMode || isEditingTitle}
|
||||||
|
editing={isEditingTitle}
|
||||||
onSaveTitle={updateSliceName}
|
onSaveTitle={updateSliceName}
|
||||||
showTooltip={false}
|
showTooltip={false}
|
||||||
|
onEditingChange={editing => {
|
||||||
|
if (!editing) setIsEditingTitle(false);
|
||||||
|
}}
|
||||||
renderLink={
|
renderLink={
|
||||||
canExplore && exploreUrl ? renderExploreLink : undefined
|
canExplore && exploreUrl && !isEditingTitle
|
||||||
|
? renderExploreLink
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,6 +354,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
|||||||
exploreUrl={exploreUrl}
|
exploreUrl={exploreUrl}
|
||||||
crossFiltersEnabled={isCrossFiltersEnabled}
|
crossFiltersEnabled={isCrossFiltersEnabled}
|
||||||
exportPivotExcel={exportPivotExcel}
|
exportPivotExcel={exportPivotExcel}
|
||||||
|
onEditTitle={() => setIsEditingTitle(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ export interface SliceHeaderControlsProps {
|
|||||||
supersetCanCSV?: boolean;
|
supersetCanCSV?: boolean;
|
||||||
|
|
||||||
crossFiltersEnabled?: boolean;
|
crossFiltersEnabled?: boolean;
|
||||||
|
onEditTitle?: () => void;
|
||||||
}
|
}
|
||||||
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
type SliceHeaderControlsPropsWithRouter = SliceHeaderControlsProps &
|
||||||
RouteComponentProps;
|
RouteComponentProps;
|
||||||
@@ -168,10 +169,11 @@ const SliceHeaderControls = (
|
|||||||
);
|
);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const canEditDashboard = useSelector<RootState, boolean>(
|
||||||
|
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||||
|
);
|
||||||
const canEditCrossFilters =
|
const canEditCrossFilters =
|
||||||
useSelector<RootState, boolean>(
|
canEditDashboard &&
|
||||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
|
||||||
) &&
|
|
||||||
getChartMetadataRegistry()
|
getChartMetadataRegistry()
|
||||||
.get(props.slice.viz_type)
|
.get(props.slice.viz_type)
|
||||||
?.behaviors?.includes(Behavior.InteractiveChart);
|
?.behaviors?.includes(Behavior.InteractiveChart);
|
||||||
@@ -212,6 +214,10 @@ const SliceHeaderControls = (
|
|||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
props.toggleExpandSlice?.(props.slice.slice_id);
|
props.toggleExpandSlice?.(props.slice.slice_id);
|
||||||
break;
|
break;
|
||||||
|
case MenuKeys.EditChartTitle:
|
||||||
|
// eslint-disable-next-line no-unused-expressions
|
||||||
|
props.onEditTitle?.();
|
||||||
|
break;
|
||||||
case MenuKeys.ExploreChart:
|
case MenuKeys.ExploreChart:
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
props.logExploreChart?.(props.slice.slice_id);
|
props.logExploreChart?.(props.slice.slice_id);
|
||||||
@@ -375,7 +381,7 @@ const SliceHeaderControls = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (slice.description) {
|
if (slice.description && canEditDashboard) {
|
||||||
newMenuItems.push({
|
newMenuItems.push({
|
||||||
key: MenuKeys.ToggleChartDescription,
|
key: MenuKeys.ToggleChartDescription,
|
||||||
label: props.isDescriptionExpanded
|
label: props.isDescriptionExpanded
|
||||||
@@ -384,6 +390,13 @@ const SliceHeaderControls = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (canEditDashboard) {
|
||||||
|
newMenuItems.push({
|
||||||
|
key: MenuKeys.EditChartTitle,
|
||||||
|
label: t('Edit chart title'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (canExplore) {
|
if (canExplore) {
|
||||||
newMenuItems.push({
|
newMenuItems.push({
|
||||||
key: MenuKeys.ExploreChart,
|
key: MenuKeys.ExploreChart,
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ interface ChartHolderProps {
|
|||||||
// dnd
|
// dnd
|
||||||
deleteComponent: (id: string, parentId: string) => void;
|
deleteComponent: (id: string, parentId: string) => void;
|
||||||
updateComponents: Function;
|
updateComponents: Function;
|
||||||
|
updateSliceNameWithSave: (
|
||||||
|
componentId: string,
|
||||||
|
component: LayoutItem,
|
||||||
|
nextName: string,
|
||||||
|
) => void;
|
||||||
handleComponentDrop: (...args: unknown[]) => unknown;
|
handleComponentDrop: (...args: unknown[]) => unknown;
|
||||||
setFullSizeChartId: (chartId: number | null) => void;
|
setFullSizeChartId: (chartId: number | null) => void;
|
||||||
isInView: boolean;
|
isInView: boolean;
|
||||||
@@ -89,6 +94,7 @@ const ChartHolder = ({
|
|||||||
getComponentById = () => undefined,
|
getComponentById = () => undefined,
|
||||||
deleteComponent,
|
deleteComponent,
|
||||||
updateComponents,
|
updateComponents,
|
||||||
|
updateSliceNameWithSave,
|
||||||
handleComponentDrop,
|
handleComponentDrop,
|
||||||
setFullSizeChartId,
|
setFullSizeChartId,
|
||||||
isInView,
|
isInView,
|
||||||
@@ -214,17 +220,9 @@ const ChartHolder = ({
|
|||||||
|
|
||||||
const handleUpdateSliceName = useCallback(
|
const handleUpdateSliceName = useCallback(
|
||||||
(nextName: string) => {
|
(nextName: string) => {
|
||||||
updateComponents({
|
updateSliceNameWithSave(component.id, component, nextName);
|
||||||
[component.id]: {
|
|
||||||
...component,
|
|
||||||
meta: {
|
|
||||||
...component.meta,
|
|
||||||
sliceNameOverride: nextName,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[component, updateComponents],
|
[component, updateSliceNameWithSave],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleFullSize = useCallback(() => {
|
const handleToggleFullSize = useCallback(() => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { Fragment, useCallback, memo, useEffect } from 'react';
|
import { Fragment, useCallback, memo, useEffect, useState } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
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 { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
|
||||||
import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent';
|
import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent';
|
||||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||||
import AnchorLink from 'src/dashboard/components/AnchorLink';
|
import TabMenu from './TabMenu';
|
||||||
import {
|
import {
|
||||||
DragDroppable,
|
DragDroppable,
|
||||||
Droppable,
|
Droppable,
|
||||||
@@ -94,14 +94,16 @@ const TabTitleContainer = styled.div`
|
|||||||
transition: box-shadow 0.2s ease-in-out;
|
transition: box-shadow 0.2s ease-in-out;
|
||||||
${isHighlighted ? `box-shadow: 0 0 ${sizeUnit}px ${colorPrimaryBg};` : ''}
|
${isHighlighted ? `box-shadow: 0 0 ${sizeUnit}px ${colorPrimaryBg};` : ''}
|
||||||
|
|
||||||
.anchor-link-container {
|
.anchor-link-container,
|
||||||
|
> button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 100%;
|
left: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .anchor-link-container {
|
&:hover .anchor-link-container,
|
||||||
|
&:hover > button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -120,7 +122,10 @@ const renderDraggableContent = dropProps =>
|
|||||||
|
|
||||||
const Tab = props => {
|
const Tab = props => {
|
||||||
const dispatch = useDispatch();
|
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 dashboardLayout = useSelector(state => state.dashboardLayout.present);
|
||||||
const lastRefreshTime = useSelector(
|
const lastRefreshTime = useSelector(
|
||||||
state => state.dashboardState.lastRefreshTime,
|
state => state.dashboardState.lastRefreshTime,
|
||||||
@@ -167,20 +172,32 @@ const Tab = props => {
|
|||||||
|
|
||||||
const handleChangeText = useCallback(
|
const handleChangeText = useCallback(
|
||||||
nextTabText => {
|
nextTabText => {
|
||||||
const { updateComponents, component } = props;
|
const { updateComponents, updateTabTitleWithSave, component, editMode } =
|
||||||
|
props;
|
||||||
if (nextTabText && nextTabText !== component.meta.text) {
|
if (nextTabText && nextTabText !== component.meta.text) {
|
||||||
updateComponents({
|
// Use auto-save when editing from the menu (not in full edit mode)
|
||||||
[component.id]: {
|
if (!editMode && isEditingTitle) {
|
||||||
...component,
|
updateTabTitleWithSave(component.id, component, nextTabText);
|
||||||
meta: {
|
} else {
|
||||||
...component.meta,
|
updateComponents({
|
||||||
text: nextTabText,
|
[component.id]: {
|
||||||
|
...component,
|
||||||
|
meta: {
|
||||||
|
...component.meta,
|
||||||
|
text: nextTabText,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[props.updateComponents, props.component],
|
[
|
||||||
|
props.updateComponents,
|
||||||
|
props.updateTabTitleWithSave,
|
||||||
|
props.component,
|
||||||
|
props.editMode,
|
||||||
|
isEditingTitle,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
const handleDrop = useCallback(
|
||||||
@@ -260,7 +277,7 @@ const Tab = props => {
|
|||||||
: t('There are no components added to this tab')
|
: t('There are no components added to this tab')
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
canEdit &&
|
canEditDashboard &&
|
||||||
(editMode ? (
|
(editMode ? (
|
||||||
<span>
|
<span>
|
||||||
{t('You can')}{' '}
|
{t('You can')}{' '}
|
||||||
@@ -341,7 +358,7 @@ const Tab = props => {
|
|||||||
props.setDirectPathToChild,
|
props.setDirectPathToChild,
|
||||||
props.updateComponents,
|
props.updateComponents,
|
||||||
handleHoverTab,
|
handleHoverTab,
|
||||||
canEdit,
|
canEditDashboard,
|
||||||
handleChangeTab,
|
handleChangeTab,
|
||||||
handleChangeText,
|
handleChangeText,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
@@ -349,17 +366,29 @@ const Tab = props => {
|
|||||||
shouldDropToChild,
|
shouldDropToChild,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const handleEditTitle = useCallback(() => {
|
||||||
|
setIsEditingTitle(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditingChange = useCallback(
|
||||||
|
editing => {
|
||||||
|
if (!editing) {
|
||||||
|
setIsEditingTitle(false);
|
||||||
|
}
|
||||||
|
props.onTabTitleEditingChange?.(editing);
|
||||||
|
},
|
||||||
|
[props.onTabTitleEditingChange],
|
||||||
|
);
|
||||||
|
|
||||||
const renderTabChild = useCallback(
|
const renderTabChild = useCallback(
|
||||||
({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => {
|
({ dropIndicatorProps, dragSourceRef, draggingTabOnTab }) => {
|
||||||
const {
|
const {
|
||||||
component,
|
component,
|
||||||
index,
|
|
||||||
editMode,
|
editMode,
|
||||||
isFocused,
|
isFocused,
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
embeddedMode,
|
embeddedMode,
|
||||||
onTabTitleEditingChange,
|
|
||||||
} = props;
|
} = props;
|
||||||
return (
|
return (
|
||||||
<TabTitleContainer
|
<TabTitleContainer
|
||||||
@@ -371,17 +400,18 @@ const Tab = props => {
|
|||||||
title={component.meta.text}
|
title={component.meta.text}
|
||||||
defaultTitle={component.meta.defaultText}
|
defaultTitle={component.meta.defaultText}
|
||||||
placeholder={component.meta.placeholder}
|
placeholder={component.meta.placeholder}
|
||||||
canEdit={editMode && isFocused}
|
canEdit={(editMode && isFocused) || isEditingTitle}
|
||||||
onSaveTitle={handleChangeText}
|
onSaveTitle={handleChangeText}
|
||||||
showTooltip={false}
|
showTooltip={false}
|
||||||
editing={editMode && isFocused}
|
editing={(editMode && isFocused) || isEditingTitle}
|
||||||
onEditingChange={onTabTitleEditingChange}
|
onEditingChange={handleEditingChange}
|
||||||
/>
|
/>
|
||||||
{!editMode && !embeddedMode && (
|
{!editMode && !embeddedMode && (
|
||||||
<AnchorLink
|
<TabMenu
|
||||||
id={component.id}
|
tabId={component.id}
|
||||||
dashboardId={dashboardId}
|
dashboardId={dashboardId}
|
||||||
placement={index >= 5 ? 'left' : 'right'}
|
canEditDashboard={canEditDashboard}
|
||||||
|
onEditTitle={handleEditTitle}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -396,13 +426,16 @@ const Tab = props => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
props.component,
|
props.component,
|
||||||
props.index,
|
|
||||||
props.editMode,
|
props.editMode,
|
||||||
props.isFocused,
|
props.isFocused,
|
||||||
props.isHighlighted,
|
props.isHighlighted,
|
||||||
props.dashboardId,
|
props.dashboardId,
|
||||||
props.onTabTitleEditingChange,
|
props.embeddedMode,
|
||||||
|
isEditingTitle,
|
||||||
|
canEditDashboard,
|
||||||
handleChangeText,
|
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,
|
createComponent,
|
||||||
deleteComponent,
|
deleteComponent,
|
||||||
updateComponents,
|
updateComponents,
|
||||||
|
updateSliceNameWithSave,
|
||||||
|
updateTabTitleWithSave,
|
||||||
handleComponentDrop,
|
handleComponentDrop,
|
||||||
} from 'src/dashboard/actions/dashboardLayout';
|
} from 'src/dashboard/actions/dashboardLayout';
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +86,8 @@ const DashboardComponent = props => {
|
|||||||
createComponent,
|
createComponent,
|
||||||
deleteComponent,
|
deleteComponent,
|
||||||
updateComponents,
|
updateComponents,
|
||||||
|
updateSliceNameWithSave,
|
||||||
|
updateTabTitleWithSave,
|
||||||
handleComponentDrop,
|
handleComponentDrop,
|
||||||
setDirectPathToChild,
|
setDirectPathToChild,
|
||||||
setFullSizeChartId,
|
setFullSizeChartId,
|
||||||
|
|||||||
@@ -148,7 +148,11 @@ export default function dashboardStateReducer(state = {}, action) {
|
|||||||
} else {
|
} else {
|
||||||
updatedExpandedSlices[sliceId] = true;
|
updatedExpandedSlices[sliceId] = true;
|
||||||
}
|
}
|
||||||
return { ...state, expandedSlices: updatedExpandedSlices };
|
return {
|
||||||
|
...state,
|
||||||
|
expandedSlices: updatedExpandedSlices,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
[ON_CHANGE]() {
|
[ON_CHANGE]() {
|
||||||
return { ...state, hasUnsavedChanges: true };
|
return { ...state, hasUnsavedChanges: true };
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ export enum MenuKeys {
|
|||||||
ForceRefresh = 'force_refresh',
|
ForceRefresh = 'force_refresh',
|
||||||
Fullscreen = 'fullscreen',
|
Fullscreen = 'fullscreen',
|
||||||
ToggleChartDescription = 'toggle_chart_description',
|
ToggleChartDescription = 'toggle_chart_description',
|
||||||
|
EditChartTitle = 'edit_chart_title',
|
||||||
ViewQuery = 'view_query',
|
ViewQuery = 'view_query',
|
||||||
ViewResults = 'view_results',
|
ViewResults = 'view_results',
|
||||||
DrillToDetail = 'drill_to_detail',
|
DrillToDetail = 'drill_to_detail',
|
||||||
|
|||||||
Reference in New Issue
Block a user