From b1c6121e786e457c2978b6aa7b1e6ffe096f26ff Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 2 Apr 2025 16:52:53 +0300 Subject: [PATCH] Dynamic Dashboards: Add drag and drop for tabs and rows (#103216) --- .../ElementSelectionContext.tsx | 18 +- .../components/PanelChrome/PanelChrome.tsx | 18 +- packages/grafana-ui/src/components/index.ts | 1 + packages/grafana-ui/src/utils/index.ts | 1 + .../src/utils/usePointerDistance.ts | 50 ++++++ .../edit-pane/DashboardEditPane.tsx | 36 ++-- .../scene/DashboardLayoutOrchestrator.tsx | 14 +- .../scene/layout-rows/RowItem.tsx | 8 +- .../scene/layout-rows/RowItemRenderer.tsx | 161 +++++++++++------- .../scene/layout-rows/RowsLayoutManager.tsx | 83 +-------- .../layout-rows/RowsLayoutManagerRenderer.tsx | 57 +++++-- .../scene/layout-tabs/TabItem.tsx | 10 +- .../scene/layout-tabs/TabItemRenderer.tsx | 71 ++++++-- .../scene/layout-tabs/TabsLayoutManager.tsx | 61 ++----- .../layout-tabs/TabsLayoutManagerRenderer.tsx | 64 ++++--- .../features/dashboard-scene/utils/utils.ts | 7 - 16 files changed, 363 insertions(+), 297 deletions(-) create mode 100644 packages/grafana-ui/src/utils/usePointerDistance.ts diff --git a/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx b/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx index 61627b0762c..d9a56404713 100644 --- a/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx +++ b/packages/grafana-ui/src/components/ElementSelectionContext/ElementSelectionContext.tsx @@ -1,5 +1,13 @@ import React, { createContext, useCallback, useContext } from 'react'; +export interface ElementSelectionOnSelectOptions { + /** If specified, this will ignore the shift key press */ + multi?: boolean; + + /** If true, this will make sure the element is selected */ + force?: boolean; +} + /** @alpha */ export interface ElementSelectionContextState { /** @@ -8,7 +16,7 @@ export interface ElementSelectionContextState { enabled?: boolean; /** List of currently selected elements */ selected: ElementSelectionContextItem[]; - onSelect: (item: ElementSelectionContextItem, multi?: boolean) => void; + onSelect: (item: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) => void; onClear: () => void; } @@ -21,7 +29,7 @@ export const ElementSelectionContext = createContext void; + onSelect?: (evt: React.PointerEvent, options?: ElementSelectionOnSelectOptions) => void; onClear?: () => void; } @@ -36,8 +44,8 @@ export function useElementSelection(id: string | undefined): UseElementSelection } const isSelected = context.selected.some((item) => item.id === id); - const onSelect = useCallback( - (evt) => { + const onSelect = useCallback( + (evt: React.PointerEvent, options: ElementSelectionOnSelectOptions = {}) => { if (!context.enabled) { return; } @@ -51,7 +59,7 @@ export function useElementSelection(id: string | undefined): UseElementSelection window.getSelection()?.empty(); } - context.onSelect({ id }, evt.shiftKey); + context.onSelect({ id }, { ...options, multi: options.multi ?? evt.shiftKey }); }, [context, id] ); diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index d3d805958d2..da66a749ffe 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { CSSProperties, ReactElement, ReactNode, useId, useRef, useState } from 'react'; +import { CSSProperties, ReactElement, ReactNode, useId, useState } from 'react'; import * as React from 'react'; import { useMeasure, useToggle } from 'react-use'; @@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { useStyles2, useTheme2 } from '../../themes'; import { getFocusStyles } from '../../themes/mixins'; +import { usePointerDistance } from '../../utils'; import { DelayRender } from '../../utils/DelayRender'; import { useElementSelection } from '../ElementSelectionContext/ElementSelectionContext'; import { Icon } from '../Icon/Icon'; @@ -151,7 +152,7 @@ export function PanelChrome({ const panelContentId = useId(); const panelTitleId = useId().replace(/:/g, '_'); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); - const pointerDownLocation = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const pointerDistance = usePointerDistance(); const hasHeader = !hoverHeader; @@ -200,13 +201,8 @@ export function PanelChrome({ // Mainly the tricky bit of differentiating between dragging and selecting const onPointerUp = React.useCallback( (evt: React.PointerEvent) => { - const distance = Math.hypot( - evt.clientX - pointerDownLocation.current.x, - evt.clientY - pointerDownLocation.current.y - ); - if ( - distance > 10 || + pointerDistance.check(evt) || (dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`)) ) { return; @@ -216,18 +212,18 @@ export function PanelChrome({ // By doing so, the event won't get to the document and drag will never be stopped setTimeout(() => onSelect?.(evt)); }, - [dragClassCancel, onSelect] + [dragClassCancel, onSelect, pointerDistance] ); const onPointerDown = React.useCallback( (evt: React.PointerEvent) => { evt.stopPropagation(); - pointerDownLocation.current = { x: evt.clientX, y: evt.clientY }; + pointerDistance.set(evt); onDragStart?.(evt); }, - [onDragStart] + [pointerDistance, onDragStart] ); const onContentPointerDown = React.useCallback( diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index e999d66c0b0..115a91d89e9 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -338,5 +338,6 @@ export { useElementSelection, type ElementSelectionContextState, type ElementSelectionContextItem, + type ElementSelectionOnSelectOptions, type UseElementSelectionResult, } from './ElementSelectionContext/ElementSelectionContext'; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 20d5a6c3cff..d71759bbbd2 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -9,6 +9,7 @@ export * from './tags'; export * from './scrollbar'; export * from './table'; export * from './measureText'; +export * from './usePointerDistance'; export * from './useForceUpdate'; export { SearchFunctionType } from './searchFunctions'; diff --git a/packages/grafana-ui/src/utils/usePointerDistance.ts b/packages/grafana-ui/src/utils/usePointerDistance.ts new file mode 100644 index 00000000000..a8e9c13e9a9 --- /dev/null +++ b/packages/grafana-ui/src/utils/usePointerDistance.ts @@ -0,0 +1,50 @@ +import React, { useCallback, useMemo } from 'react'; + +interface Point { + x: number; + y: number; +} +type PointerOrMouseEvent = React.PointerEvent | React.MouseEvent | PointerEvent | MouseEvent; +type PointerDistanceSet = (evt: PointerOrMouseEvent) => void; +type PointerDistanceCheck = (evt: PointerOrMouseEvent, distance?: number) => boolean; + +interface PointerDistance { + set: PointerDistanceSet; + check: PointerDistanceCheck; +} + +export function createPointerDistance(distance = 10): PointerDistance { + let initial = { x: 0, y: 0 }; + + const set: PointerDistanceSet = (evt) => { + initial = getPoint(evt); + }; + + const check: PointerDistanceCheck = (evt, overrideDistance = distance) => + checkDistance(initial, getPoint(evt), overrideDistance); + + return { set, check }; +} + +export function usePointerDistance(distance = 10): PointerDistance { + const initial = React.useRef({ x: 0, y: 0 }); + + const set = useCallback((evt) => { + initial.current = getPoint(evt); + }, []); + + const check = useCallback( + (evt, overrideDistance = distance) => checkDistance(initial.current, getPoint(evt), overrideDistance), + [distance] + ); + + return useMemo(() => ({ set, check }), [set, check]); +} + +function getPoint(evt: PointerOrMouseEvent): Point { + return { x: evt.clientX, y: evt.clientY }; +} + +function checkDistance(point1: Point, point2: Point, distance: number): boolean { + return Math.hypot(point1.x - point2.x, point1.y - point2.y) > distance; +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 305055efe0f..d1f88d9922b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -8,6 +8,7 @@ import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObj import { ElementSelectionContextItem, ElementSelectionContextState, + ElementSelectionOnSelectOptions, ScrollContainer, ToolbarButton, useSplitter, @@ -33,15 +34,13 @@ export interface DashboardEditPaneState extends SceneObjectState { showAddPane?: boolean; } -export type EditPaneTab = 'add' | 'configure' | 'outline'; - export class DashboardEditPane extends SceneObjectBase { public constructor() { super({ selectionContext: { enabled: false, selected: [], - onSelect: (item, multi) => this.selectElement(item, multi), + onSelect: (item, options) => this.selectElement(item, options), onClear: () => this.clearSelection(), }, }); @@ -83,10 +82,10 @@ export class DashboardEditPane extends SceneObjectBase { }); } - private selectElement(element: ElementSelectionContextItem, multi?: boolean) { + private selectElement(element: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) { // We should not select clones if (isInCloneChain(element.id)) { - if (multi) { + if (options.multi) { return; } @@ -96,7 +95,7 @@ export class DashboardEditPane extends SceneObjectBase { const obj = sceneGraph.findByKey(this, element.id); if (obj) { - this.selectObject(obj, element.id, multi); + this.selectObject(obj, element.id, options); } } @@ -108,16 +107,19 @@ export class DashboardEditPane extends SceneObjectBase { this.setState({ showAddPane: !this.state.showAddPane }); } - public selectObject(obj: SceneObject, id: string, multi?: boolean) { - const prevItem = this.state.selection?.getFirstObject(); - if (prevItem === obj && !multi) { - this.clearSelection(); - return; - } - - if (multi && this.state.selection?.hasValue(id)) { - this.removeMultiSelectedObject(id); - return; + public selectObject(obj: SceneObject, id: string, { multi, force }: ElementSelectionOnSelectOptions = {}) { + if (!force) { + if (multi) { + if (this.state.selection?.hasValue(id)) { + this.removeMultiSelectedObject(id); + return; + } + } else { + if (this.state.selection?.getFirstObject() === obj) { + this.clearSelection(); + return; + } + } } const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]); @@ -170,7 +172,7 @@ export class DashboardEditPane extends SceneObjectBase { } private newObjectAddedToCanvas(obj: SceneObject) { - this.selectObject(obj, obj.state.key!, false); + this.selectObject(obj, obj.state.key!); if (this.state.showAddPane) { this.setState({ showAddPane: false }); diff --git a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx index 0ca38a645bb..f97eeaa87a0 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx @@ -1,6 +1,7 @@ import { PointerEvent as ReactPointerEvent } from 'react'; import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { createPointerDistance } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget'; @@ -12,6 +13,8 @@ interface DashboardLayoutOrchestratorState extends SceneObjectState { export class DashboardLayoutOrchestrator extends SceneObjectBase { private _sourceDropTarget: DashboardDropTarget | null = null; private _lastDropTarget: DashboardDropTarget | null = null; + private _pointerDistance = createPointerDistance(); + private _isSelectedObject = false; public constructor() { super({}); @@ -29,7 +32,10 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase(); + public containerRef: React.MutableRefObject = React.createRef(); public constructor(state?: Partial) { super({ @@ -107,7 +107,7 @@ export class RowItem } public onDelete() { - this._getParentLayout().removeRow(this); + this.getParentLayout().removeRow(this); } public createMultiSelectedElement(items: SceneObject[]): RowItems { @@ -115,7 +115,7 @@ export class RowItem } public onDuplicate() { - this._getParentLayout().duplicateRow(this); + this.getParentLayout().duplicateRow(this); } public duplicate(): RowItem { @@ -191,7 +191,7 @@ export class RowItem this.setState({ collapse: !this.state.collapse }); } - private _getParentLayout(): RowsLayoutManager { + public getParentLayout(): RowsLayoutManager { return sceneGraph.getAncestor(this, RowsLayoutManager); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index 90f78405557..62510d2ab65 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -1,33 +1,33 @@ import { css, cx } from '@emotion/css'; +import { Draggable } from '@hello-pangea/dnd'; import { useCallback, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { SceneComponentProps } from '@grafana/scenes'; -import { clearButtonStyles, Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { clearButtonStyles, Icon, Tooltip, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { useIsClone } from '../../utils/clone'; -import { - useDashboardState, - useElementSelectionScene, - useInterpolatedTitle, - useIsConditionallyHidden, -} from '../../utils/utils'; +import { useDashboardState, useInterpolatedTitle, useIsConditionallyHidden } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { RowItem } from './RowItem'; export function RowItemRenderer({ model }: SceneComponentProps) { - const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget } = model.useState(); + const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState(); const isClone = useIsClone(model); const { isEditing } = useDashboardState(model); const isConditionallyHidden = useIsConditionallyHidden(model); - const { isSelected, onSelect, isSelectable } = useElementSelectionScene(model); + const { isSelected, onSelect, isSelectable } = useElementSelection(key); const title = useInterpolatedTitle(model); + const { rows } = model.getParentLayout().useState(); const styles = useStyles2(getStyles); const clearStyles = useStyles2(clearButtonStyles); const isTopLevel = model.parent?.parent instanceof DashboardScene; + const pointerDistance = usePointerDistance(); + + const myIndex = rows.findIndex((row) => row === model); const shouldGrow = !isCollapsed && fillScreen; const isHidden = isConditionallyHidden && !isEditing; @@ -37,76 +37,101 @@ export function RowItemRenderer({ model }: SceneComponentProps) { const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []); const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []); + const isDraggable = !isClone && isEditing; + if (isHidden) { return null; } return ( -
{ - // If we selected and are clicking a button inside row header then don't de-select row - if (isSelected && e.target instanceof Element && e.target.closest('button')) { - // Stop propagation otherwise dashboaed level onPointerDown will de-select row - e.stopPropagation(); - return; - } - - onSelect?.(e); - }} - > - {(!isHeaderHidden || isEditing) && ( + + {(dragProvided, dragSnapshot) => (
- + {title} + {isHeaderHidden && ( + + + + )} + + + {isDraggable && } +
+ )} + {!isCollapsed && }
)} - {!isCollapsed && } - + ); } @@ -118,6 +143,7 @@ function getStyles(theme: GrafanaTheme2) { gap: theme.spacing(1), padding: theme.spacing(0.5, 0.5, 0.5, 0), alignItems: 'center', + justifyContent: 'space-between', marginBottom: theme.spacing(1), }), rowTitleButton: css({ @@ -171,6 +197,9 @@ function getStyles(theme: GrafanaTheme2) { }, }, }), + dragging: css({ + cursor: 'move', + }), wrapperEditing: css({ padding: theme.spacing(0.5), diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index e3459375e32..978a30cc013 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -126,95 +126,30 @@ export class RowsLayoutManager extends SceneObjectBase i }); } - public addRowAbove(row: RowItem): RowItem { - const index = this.state.rows.indexOf(row); - const newRow = new RowItem({ isNew: true }); - const newRows = [...this.state.rows]; - - newRows.splice(index, 0, newRow); - - this.setState({ rows: newRows }); - this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); - - return newRow; - } - - public addRowBelow(row: RowItem): RowItem { - const rows = this.state.rows; - let index = rows.indexOf(row); - - // Be sure we don't add a row between an original row and one of its clones - while (rows[index + 1] && isClonedKey(rows[index + 1].state.key!)) { - index = index + 1; - } - - const newRow = new RowItem({ isNew: true }); - const newRows = [...this.state.rows]; - - newRows.splice(index + 1, 0, newRow); - - this.setState({ rows: newRows }); - this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true); - - return newRow; - } - public removeRow(row: RowItem) { const rows = this.state.rows.filter((r) => r !== row); this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows }); this.publishEvent(new ObjectRemovedFromCanvasEvent(row), true); } - public moveRowUp(row: RowItem) { + public moveRow(_rowKey: string, fromIndex: number, toIndex: number) { const rows = [...this.state.rows]; - const originalIndex = rows.indexOf(row); - - if (originalIndex === 0) { - return; - } - - let moveToIndex = originalIndex - 1; - - // Be sure we don't add a row between an original row and one of its clones - while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) { - moveToIndex = moveToIndex - 1; - } - - rows.splice(originalIndex, 1); - rows.splice(moveToIndex, 0, row); + const [removed] = rows.splice(fromIndex, 1); + rows.splice(toIndex, 0, removed); this.setState({ rows }); this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); } - public moveRowDown(row: RowItem) { - const rows = [...this.state.rows]; - const originalIndex = rows.indexOf(row); + public forceSelectRow(rowKey: string) { + const rowIndex = this.state.rows.findIndex((row) => row.state.key === rowKey); + const row = this.state.rows[rowIndex]; - if (originalIndex === rows.length - 1) { + if (!row) { return; } - let moveToIndex = originalIndex + 1; - - // Be sure we don't add a row between an original row and one of its clones - while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) { - moveToIndex = moveToIndex + 1; - } - - rows.splice(moveToIndex + 1, 0, row); - rows.splice(originalIndex, 1); - - this.setState({ rows }); - this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); - } - - public isFirstRow(row: RowItem): boolean { - return this.state.rows[0] === row; - } - - public isLastRow(row: RowItem): boolean { - const filteredRow = this.state.rows.filter((r) => !isClonedKey(r.state.key!)); - return filteredRow[filteredRow.length - 1] === row; + const editPane = getDashboardSceneFor(this).state.editPane; + editPane.selectObject(row!, rowKey, { force: true, multi: false }); } public static createEmpty(): RowsLayoutManager { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx index e1a6f1f3900..ae261137de9 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManagerRenderer.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps } from '@grafana/scenes'; @@ -11,29 +12,49 @@ import { useClipboardState } from '../layouts-shared/useClipboardState'; import { RowsLayoutManager } from './RowsLayoutManager'; export function RowLayoutManagerRenderer({ model }: SceneComponentProps) { - const { rows } = model.useState(); + const { rows, key } = model.useState(); const { isEditing } = useDashboardState(model); const styles = useStyles2(getStyles); const { hasCopiedRow } = useClipboardState(); return ( -
- {rows.map((row) => ( - - ))} - {isEditing && ( -
- - {hasCopiedRow && ( - - )} -
- )} -
+ model.forceSelectRow(start.draggableId)} + onDragEnd={(result) => { + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + model.moveRow(result.draggableId, result.source.index, result.destination.index); + }} + > + + {(dropProvided) => ( +
+ {rows.map((row) => ( + + ))} + {dropProvided.placeholder} + {isEditing && ( +
+ + {hasCopiedRow && ( + + )} +
+ )} +
+ )} +
+
); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index 88037e9655d..cba2188fc76 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -111,7 +111,7 @@ export class TabItem } public onDuplicate(): void { - this._getParentLayout().duplicateTab(this); + this.getParentLayout().duplicateTab(this); } public duplicate(): TabItem { @@ -123,7 +123,7 @@ export class TabItem } public onAddTab() { - this._getParentLayout().addNewTab(); + this.getParentLayout().addNewTab(); } public onChangeTitle(title: string) { @@ -154,15 +154,11 @@ export class TabItem } public getParentLayout(): TabsLayoutManager { - return this._getParentLayout(); - } - - private _getParentLayout(): TabsLayoutManager { return sceneGraph.getAncestor(this, TabsLayoutManager); } public scrollIntoView(): void { - const tabsLayout = sceneGraph.getAncestor(this, TabsLayoutManager); + const tabsLayout = this.getParentLayout(); if (tabsLayout.getCurrentTab() !== this) { tabsLayout.switchToTab(this); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index 165ed5284de..748b8c92e10 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -1,9 +1,12 @@ -import { cx } from '@emotion/css'; +import { css, cx } from '@emotion/css'; +import { Draggable } from '@hello-pangea/dnd'; import { useLocation } from 'react-router'; import { locationUtil, textUtil } from '@grafana/data'; import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; -import { Tab, useElementSelection } from '@grafana/ui'; +import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui'; + +import { useDashboardState } from '../../utils/utils'; import { TabItem } from './TabItem'; @@ -13,30 +16,62 @@ export function TabItemRenderer({ model }: SceneComponentProps) { const { tabs, currentTabIndex } = parentLayout.useState(); const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text'); const { isSelected, onSelect, isSelectable } = useElementSelection(key); + const { isEditing } = useDashboardState(model); const mySlug = model.getSlug(); const urlKey = parentLayout.getUrlKey(); const myIndex = tabs.findIndex((tab) => tab === model); const isActive = myIndex === currentTabIndex; const location = useLocation(); const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug })); + const styles = useStyles2(getStyles); + const pointerDistance = usePointerDistance(); return ( - + {(dragProvided, dragSnapshot) => ( +
dragProvided.innerRef(ref)} + className={cx(dragSnapshot.isDragging && styles.dragging)} + {...dragProvided.draggableProps} + {...dragProvided.dragHandleProps} + > + { + evt.stopPropagation(); + pointerDistance.set(evt); + }} + onPointerUp={(evt) => { + evt.stopPropagation(); + + if (!isSelectable || pointerDistance.check(evt)) { + return; + } + + onSelect?.(evt); + }} + label={titleInterpolated} + data-dashboard-drop-target-key={model.state.key} + /> +
)} - active={isActive} - role="presentation" - title={titleInterpolated} - href={href} - aria-selected={isActive} - onPointerDown={onSelect} - label={titleInterpolated} - data-dashboard-drop-target-key={model.state.key} - /> + ); } + +const getStyles = () => ({ + dragging: css({ + cursor: 'move', + }), +}); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx index d4317f97b83..f33181fb3b8 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx @@ -166,56 +166,25 @@ export class TabsLayoutManager extends SceneObjectBase i this.publishEvent(new ObjectRemovedFromCanvasEvent(tabToRemove), true); } - public addTabBefore(tab: TabItem): TabItem { - const newTab = new TabItem({ isNew: true }); - const tabs = this.state.tabs.slice(); - tabs.splice(tabs.indexOf(tab), 0, newTab); - this.setState({ tabs, currentTabIndex: this.state.currentTabIndex }); - this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); - - return newTab; - } - - public addTabAfter(tab: TabItem): TabItem { - const newTab = new TabItem({ isNew: true }); - const tabs = this.state.tabs.slice(); - tabs.splice(tabs.indexOf(tab) + 1, 0, newTab); - this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 }); - this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true); - - return newTab; - } - - public moveTabLeft(tab: TabItem) { - const currentIndex = this.state.tabs.indexOf(tab); - if (currentIndex <= 0) { - return; - } - const tabs = this.state.tabs.slice(); - tabs.splice(currentIndex, 1); - tabs.splice(currentIndex - 1, 0, tab); - this.setState({ tabs, currentTabIndex: this.state.currentTabIndex - 1 }); + public moveTab(_tabKey: string, fromIndex: number, toIndex: number) { + const tabs = [...this.state.tabs]; + const [removed] = tabs.splice(fromIndex, 1); + tabs.splice(toIndex, 0, removed); + this.setState({ tabs, currentTabIndex: toIndex }); this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); } - public moveTabRight(tab: TabItem) { - const currentIndex = this.state.tabs.indexOf(tab); - if (currentIndex >= this.state.tabs.length - 1) { + public forceSelectTab(tabKey: string) { + const tabIndex = this.state.tabs.findIndex((tab) => tab.state.key === tabKey); + const tab = this.state.tabs[tabIndex]; + + if (!tab) { return; } - const tabs = this.state.tabs.slice(); - tabs.splice(currentIndex, 1); - tabs.splice(currentIndex + 1, 0, tab); - this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 }); - this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); - } - public isFirstTab(tab: TabItem): boolean { - return this.state.tabs[0] === tab; - } - - public isLastTab(tab: TabItem): boolean { - return this.state.tabs[this.state.tabs.length - 1] === tab; + const editPane = getDashboardSceneFor(this).state.editPane; + editPane.selectObject(tab!, tabKey, { force: true, multi: false }); + this.setState({ currentTabIndex: tabIndex }); } public static createEmpty(): TabsLayoutManager { @@ -238,9 +207,9 @@ export class TabsLayoutManager extends SceneObjectBase i return new TabsLayoutManager({ tabs }); } - getUrlKey(): string { + public getUrlKey(): string { let parent = this.parent; - // Panel edit uses tab key already so we are using dtab here to not conflict + // Panel edit uses `tab` key already so we are using `dtab` here to not conflict let key = 'dtab'; while (parent) { diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx index 522d8cb82cd..25be6dd13ca 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManagerRenderer.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { Fragment } from 'react'; +import { DragDropContext, Droppable } from '@hello-pangea/dnd'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps } from '@grafana/scenes'; @@ -13,7 +13,7 @@ import { TabsLayoutManager } from './TabsLayoutManager'; export function TabsLayoutManagerRenderer({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); - const { tabs } = model.useState(); + const { tabs, key } = model.useState(); const currentTab = model.getCurrentTab(); const { layout } = currentTab.useState(); const dashboard = getDashboardSceneFor(model); @@ -23,27 +23,46 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps -
-
- {tabs.map((tab) => ( - - - - ))} -
- {isEditing && ( -
- - {hasCopiedTab && ( - + model.forceSelectTab(start.draggableId)} + onDragEnd={(result) => { + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + model.moveTab(result.draggableId, result.source.index, result.destination.index); + }} + > +
+ + {(dropProvided) => ( +
+ {tabs.map((tab) => ( + + ))} + + {dropProvided.placeholder} +
)} -
- )} -
+ + {isEditing && ( +
+ + {hasCopiedTab && ( + + )} +
+ )} +
+
{currentTab && } @@ -59,7 +78,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ flex: '1 1 auto', }), tabsBar: css({ - overflow: 'hidden', '&:hover': { '.dashboard-canvas-add-button': { filter: 'unset', diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index c51dd9765cf..c7c1a305a2a 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -12,7 +12,6 @@ import { VizPanel, VizPanelMenu, } from '@grafana/scenes'; -import { useElementSelection, UseElementSelectionResult } from '@grafana/ui'; import { t } from 'app/core/internationalization'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; @@ -463,12 +462,6 @@ export function useIsConditionallyHidden(scene: RowItem | AutoGridItem): boolean return !(conditionalRendering?.evaluate() ?? true); } -export function useElementSelectionScene(scene: SceneObject): UseElementSelectionResult { - const { key } = scene.useState(); - - return useElementSelection(key); -} - export function useInterpolatedTitle(scene: SceneObject): string { const { title } = scene.useState();