mirror of https://github.com/grafana/grafana.git
				
				
				
			Dynamic Dashboards: Add drag and drop for tabs and rows (#103216)
This commit is contained in:
		
							parent
							
								
									a67cb174c7
								
							
						
					
					
						commit
						b1c6121e78
					
				|  | @ -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<ElementSelectionContextStat | |||
| export interface UseElementSelectionResult { | ||||
|   isSelected?: boolean; | ||||
|   isSelectable?: boolean; | ||||
|   onSelect?: (evt: React.PointerEvent) => 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<React.PointerEventHandler>( | ||||
|     (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] | ||||
|   ); | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -338,5 +338,6 @@ export { | |||
|   useElementSelection, | ||||
|   type ElementSelectionContextState, | ||||
|   type ElementSelectionContextItem, | ||||
|   type ElementSelectionOnSelectOptions, | ||||
|   type UseElementSelectionResult, | ||||
| } from './ElementSelectionContext/ElementSelectionContext'; | ||||
|  |  | |||
|  | @ -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'; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<Point>({ x: 0, y: 0 }); | ||||
| 
 | ||||
|   const set = useCallback<PointerDistance['set']>((evt) => { | ||||
|     initial.current = getPoint(evt); | ||||
|   }, []); | ||||
| 
 | ||||
|   const check = useCallback<PointerDistance['check']>( | ||||
|     (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; | ||||
| } | ||||
|  | @ -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<DashboardEditPaneState> { | ||||
|   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<DashboardEditPaneState> { | |||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   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<DashboardEditPaneState> { | |||
| 
 | ||||
|     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<DashboardEditPaneState> { | |||
|     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<DashboardEditPaneState> { | |||
|   } | ||||
| 
 | ||||
|   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 }); | ||||
|  |  | |||
|  | @ -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<DashboardLayoutOrchestratorState> { | ||||
|   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<DashboardLayout | |||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   public startDraggingSync(_evt: ReactPointerEvent, panel: VizPanel): void { | ||||
|   public startDraggingSync(evt: ReactPointerEvent, panel: VizPanel): void { | ||||
|     this._pointerDistance.set(evt); | ||||
|     this._isSelectedObject = false; | ||||
| 
 | ||||
|     const dropTarget = sceneGraph.findObject(panel, isDashboardDropTarget); | ||||
| 
 | ||||
|     if (!dropTarget || !isDashboardDropTarget(dropTarget)) { | ||||
|  | @ -64,6 +70,12 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout | |||
|   } | ||||
| 
 | ||||
|   private _onPointerMove(evt: PointerEvent) { | ||||
|     if (!this._isSelectedObject && this.state.draggingPanel && this._pointerDistance.check(evt)) { | ||||
|       this._isSelectedObject = true; | ||||
|       const panel = this.state.draggingPanel?.resolve(); | ||||
|       this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false }); | ||||
|     } | ||||
| 
 | ||||
|     const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget; | ||||
| 
 | ||||
|     if (!dropTarget) { | ||||
|  |  | |||
|  | @ -59,7 +59,7 @@ export class RowItem | |||
|   public readonly isEditableDashboardElement = true; | ||||
|   public readonly isDashboardDropTarget = true; | ||||
|   private _layoutRestorer = new LayoutRestorer(); | ||||
|   public containerRef = React.createRef<HTMLDivElement>(); | ||||
|   public containerRef: React.MutableRefObject<HTMLDivElement | null> = React.createRef<HTMLDivElement>(); | ||||
| 
 | ||||
|   public constructor(state?: Partial<RowItemState>) { | ||||
|     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); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<RowItem>) { | ||||
|   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<RowItem>) { | |||
|   const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []); | ||||
|   const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []); | ||||
| 
 | ||||
|   const isDraggable = !isClone && isEditing; | ||||
| 
 | ||||
|   if (isHidden) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       ref={model.containerRef} | ||||
|       data-dashboard-drop-target-key={model.state.key} | ||||
|       className={cx( | ||||
|         styles.wrapper, | ||||
|         isEditing && !isCollapsed && styles.wrapperEditing, | ||||
|         isEditing && isCollapsed && styles.wrapperEditingCollapsed, | ||||
|         isCollapsed && styles.wrapperCollapsed, | ||||
|         shouldGrow && styles.wrapperGrow, | ||||
|         isConditionallyHidden && 'dashboard-visible-hidden-element', | ||||
|         !isClone && isSelected && 'dashboard-selected-element', | ||||
|         !isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element', | ||||
|         isDropTarget && 'dashboard-drop-target' | ||||
|       )} | ||||
|       onPointerDown={(e) => { | ||||
|         // 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) && ( | ||||
|     <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}> | ||||
|       {(dragProvided, dragSnapshot) => ( | ||||
|         <div | ||||
|           className={cx(isHeaderHidden && 'dashboard-visible-hidden-element', styles.rowHeader, 'dashboard-row-header')} | ||||
|           onMouseEnter={isSelectable ? onHeaderEnter : undefined} | ||||
|           onMouseLeave={isSelectable ? onHeaderLeave : undefined} | ||||
|         > | ||||
|           <button | ||||
|             onClick={() => model.onCollapseToggle()} | ||||
|             className={cx(clearStyles, styles.rowTitleButton)} | ||||
|             aria-label={ | ||||
|               isCollapsed | ||||
|                 ? t('dashboard.rows-layout.row.expand', 'Expand row') | ||||
|                 : t('dashboard.rows-layout.row.collapse', 'Collapse row') | ||||
|           ref={(ref) => { | ||||
|             dragProvided.innerRef(ref); | ||||
|             model.containerRef.current = ref; | ||||
|           }} | ||||
|           data-dashboard-drop-target-key={model.state.key} | ||||
|           className={cx( | ||||
|             styles.wrapper, | ||||
|             dragSnapshot.isDragging && styles.dragging, | ||||
|             isEditing && !isCollapsed && styles.wrapperEditing, | ||||
|             isEditing && isCollapsed && styles.wrapperEditingCollapsed, | ||||
|             isCollapsed && styles.wrapperCollapsed, | ||||
|             shouldGrow && styles.wrapperGrow, | ||||
|             isConditionallyHidden && 'dashboard-visible-hidden-element', | ||||
|             !isClone && isSelected && 'dashboard-selected-element', | ||||
|             !isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element', | ||||
|             isDropTarget && 'dashboard-drop-target' | ||||
|           )} | ||||
|           onPointerDown={(evt) => { | ||||
|             evt.stopPropagation(); | ||||
|             pointerDistance.set(evt); | ||||
|           }} | ||||
|           onPointerUp={(evt) => { | ||||
|             // If we selected and are clicking a button inside row header then don't de-select row
 | ||||
|             if (isSelected && evt.target instanceof Element && evt.target.closest('button')) { | ||||
|               // Stop propagation otherwise dashboaed level onPointerDown will de-select row
 | ||||
|               evt.stopPropagation(); | ||||
|               return; | ||||
|             } | ||||
|             data-testid={selectors.components.DashboardRow.title(title!)} | ||||
|           > | ||||
|             <Icon name={isCollapsed ? 'angle-right' : 'angle-down'} /> | ||||
|             <span | ||||
| 
 | ||||
|             if (pointerDistance.check(evt)) { | ||||
|               return; | ||||
|             } | ||||
| 
 | ||||
|             setTimeout(() => onSelect?.(evt)); | ||||
|           }} | ||||
|           {...dragProvided.draggableProps} | ||||
|         > | ||||
|           {(!isHeaderHidden || isEditing) && ( | ||||
|             <div | ||||
|               className={cx( | ||||
|                 styles.rowTitle, | ||||
|                 isHeaderHidden && styles.rowTitleHidden, | ||||
|                 !isTopLevel && styles.rowTitleNested, | ||||
|                 isCollapsed && styles.rowTitleCollapsed | ||||
|                 isHeaderHidden && 'dashboard-visible-hidden-element', | ||||
|                 styles.rowHeader, | ||||
|                 'dashboard-row-header' | ||||
|               )} | ||||
|               role="heading" | ||||
|               onMouseEnter={isSelectable ? onHeaderEnter : undefined} | ||||
|               onMouseLeave={isSelectable ? onHeaderLeave : undefined} | ||||
|               {...dragProvided.dragHandleProps} | ||||
|             > | ||||
|               {title} | ||||
|               {isHeaderHidden && ( | ||||
|                 <Tooltip | ||||
|                   content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')} | ||||
|               <button | ||||
|                 onClick={() => model.onCollapseToggle()} | ||||
|                 className={cx(clearStyles, styles.rowTitleButton)} | ||||
|                 aria-label={ | ||||
|                   isCollapsed | ||||
|                     ? t('dashboard.rows-layout.row.expand', 'Expand row') | ||||
|                     : t('dashboard.rows-layout.row.collapse', 'Collapse row') | ||||
|                 } | ||||
|                 data-testid={selectors.components.DashboardRow.title(title!)} | ||||
|               > | ||||
|                 <Icon name={isCollapsed ? 'angle-right' : 'angle-down'} /> | ||||
|                 <span | ||||
|                   className={cx( | ||||
|                     styles.rowTitle, | ||||
|                     isHeaderHidden && styles.rowTitleHidden, | ||||
|                     !isTopLevel && styles.rowTitleNested, | ||||
|                     isCollapsed && styles.rowTitleCollapsed | ||||
|                   )} | ||||
|                   role="heading" | ||||
|                 > | ||||
|                   <Icon name="eye-slash" /> | ||||
|                 </Tooltip> | ||||
|               )} | ||||
|             </span> | ||||
|           </button> | ||||
|                   {title} | ||||
|                   {isHeaderHidden && ( | ||||
|                     <Tooltip | ||||
|                       content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')} | ||||
|                     > | ||||
|                       <Icon name="eye-slash" /> | ||||
|                     </Tooltip> | ||||
|                   )} | ||||
|                 </span> | ||||
|               </button> | ||||
|               {isDraggable && <Icon name="draggabledots" />} | ||||
|             </div> | ||||
|           )} | ||||
|           {!isCollapsed && <layout.Component model={layout} />} | ||||
|         </div> | ||||
|       )} | ||||
|       {!isCollapsed && <layout.Component model={layout} />} | ||||
|     </div> | ||||
|     </Draggable> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
|  | @ -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), | ||||
| 
 | ||||
|  |  | |||
|  | @ -126,95 +126,30 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> 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 { | ||||
|  |  | |||
|  | @ -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<RowsLayoutManager>) { | ||||
|   const { rows } = model.useState(); | ||||
|   const { rows, key } = model.useState(); | ||||
|   const { isEditing } = useDashboardState(model); | ||||
|   const styles = useStyles2(getStyles); | ||||
|   const { hasCopiedRow } = useClipboardState(); | ||||
| 
 | ||||
|   return ( | ||||
|     <div className={styles.wrapper}> | ||||
|       {rows.map((row) => ( | ||||
|         <row.Component model={row} key={row.state.key!} /> | ||||
|       ))} | ||||
|       {isEditing && ( | ||||
|         <div className="dashboard-canvas-add-button"> | ||||
|           <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}> | ||||
|             <Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans> | ||||
|           </Button> | ||||
|           {hasCopiedRow && ( | ||||
|             <Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}> | ||||
|               <Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans> | ||||
|             </Button> | ||||
|           )} | ||||
|         </div> | ||||
|       )} | ||||
|     </div> | ||||
|     <DragDropContext | ||||
|       onBeforeDragStart={(start) => 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); | ||||
|       }} | ||||
|     > | ||||
|       <Droppable droppableId={key!} direction="vertical"> | ||||
|         {(dropProvided) => ( | ||||
|           <div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}> | ||||
|             {rows.map((row) => ( | ||||
|               <row.Component model={row} key={row.state.key!} /> | ||||
|             ))} | ||||
|             {dropProvided.placeholder} | ||||
|             {isEditing && ( | ||||
|               <div className="dashboard-canvas-add-button"> | ||||
|                 <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}> | ||||
|                   <Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans> | ||||
|                 </Button> | ||||
|                 {hasCopiedRow && ( | ||||
|                   <Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}> | ||||
|                     <Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans> | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         )} | ||||
|       </Droppable> | ||||
|     </DragDropContext> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|     } | ||||
|  |  | |||
|  | @ -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<TabItem>) { | |||
|   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 ( | ||||
|     <Tab | ||||
|       ref={model.containerRef} | ||||
|       truncate | ||||
|       className={cx( | ||||
|         isSelected && 'dashboard-selected-element', | ||||
|         isSelectable && !isSelected && 'dashboard-selectable-element', | ||||
|         isDropTarget && 'dashboard-drop-target' | ||||
|     <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isEditing}> | ||||
|       {(dragProvided, dragSnapshot) => ( | ||||
|         <div | ||||
|           ref={(ref) => dragProvided.innerRef(ref)} | ||||
|           className={cx(dragSnapshot.isDragging && styles.dragging)} | ||||
|           {...dragProvided.draggableProps} | ||||
|           {...dragProvided.dragHandleProps} | ||||
|         > | ||||
|           <Tab | ||||
|             ref={model.containerRef} | ||||
|             truncate | ||||
|             className={cx( | ||||
|               isSelected && 'dashboard-selected-element', | ||||
|               isSelectable && !isSelected && 'dashboard-selectable-element', | ||||
|               isDropTarget && 'dashboard-drop-target' | ||||
|             )} | ||||
|             active={isActive} | ||||
|             role="presentation" | ||||
|             title={titleInterpolated} | ||||
|             href={href} | ||||
|             aria-selected={isActive} | ||||
|             onPointerDown={(evt) => { | ||||
|               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} | ||||
|           /> | ||||
|         </div> | ||||
|       )} | ||||
|       active={isActive} | ||||
|       role="presentation" | ||||
|       title={titleInterpolated} | ||||
|       href={href} | ||||
|       aria-selected={isActive} | ||||
|       onPointerDown={onSelect} | ||||
|       label={titleInterpolated} | ||||
|       data-dashboard-drop-target-key={model.state.key} | ||||
|     /> | ||||
|     </Draggable> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const getStyles = () => ({ | ||||
|   dragging: css({ | ||||
|     cursor: 'move', | ||||
|   }), | ||||
| }); | ||||
|  |  | |||
|  | @ -166,56 +166,25 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> 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<TabsLayoutManagerState> 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) { | ||||
|  |  | |||
|  | @ -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<TabsLayoutManager>) { | ||||
|   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<TabsLay | |||
|   return ( | ||||
|     <div className={styles.tabLayoutContainer}> | ||||
|       <TabsBar className={styles.tabsBar}> | ||||
|         <div className={styles.tabsRow}> | ||||
|           <div className={styles.tabsContainer}> | ||||
|             {tabs.map((tab) => ( | ||||
|               <Fragment key={tab.state.key!}> | ||||
|                 <tab.Component model={tab} /> | ||||
|               </Fragment> | ||||
|             ))} | ||||
|           </div> | ||||
|           {isEditing && ( | ||||
|             <div className="dashboard-canvas-add-button"> | ||||
|               <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}> | ||||
|                 <Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans> | ||||
|               </Button> | ||||
|               {hasCopiedTab && ( | ||||
|                 <Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}> | ||||
|                   <Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans> | ||||
|                 </Button> | ||||
|         <DragDropContext | ||||
|           onBeforeDragStart={(start) => 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); | ||||
|           }} | ||||
|         > | ||||
|           <div className={styles.tabsRow}> | ||||
|             <Droppable droppableId={key!} direction="horizontal"> | ||||
|               {(dropProvided) => ( | ||||
|                 <div className={styles.tabsContainer} ref={dropProvided.innerRef} {...dropProvided.droppableProps}> | ||||
|                   {tabs.map((tab) => ( | ||||
|                     <tab.Component model={tab} key={tab.state.key!} /> | ||||
|                   ))} | ||||
| 
 | ||||
|                   {dropProvided.placeholder} | ||||
|                 </div> | ||||
|               )} | ||||
|             </div> | ||||
|           )} | ||||
|         </div> | ||||
|             </Droppable> | ||||
|             {isEditing && ( | ||||
|               <div className="dashboard-canvas-add-button"> | ||||
|                 <Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}> | ||||
|                   <Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans> | ||||
|                 </Button> | ||||
|                 {hasCopiedTab && ( | ||||
|                   <Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}> | ||||
|                     <Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans> | ||||
|                   </Button> | ||||
|                 )} | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|         </DragDropContext> | ||||
|       </TabsBar> | ||||
|       <TabContent className={styles.tabContentContainer}> | ||||
|         {currentTab && <layout.Component model={layout} />} | ||||
|  | @ -59,7 +78,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ | |||
|     flex: '1 1 auto', | ||||
|   }), | ||||
|   tabsBar: css({ | ||||
|     overflow: 'hidden', | ||||
|     '&:hover': { | ||||
|       '.dashboard-canvas-add-button': { | ||||
|         filter: 'unset', | ||||
|  |  | |||
|  | @ -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<T extends SceneObjectState & { title?: string }>(scene: SceneObject<T>): string { | ||||
|   const { title } = scene.useState(); | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue