Dynamic Dashboards: Add drag and drop for tabs and rows (#103216)

This commit is contained in:
Bogdan Matei 2025-04-02 16:52:53 +03:00 committed by GitHub
parent a67cb174c7
commit b1c6121e78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 363 additions and 297 deletions

View File

@ -1,5 +1,13 @@
import React, { createContext, useCallback, useContext } from 'react'; 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 */ /** @alpha */
export interface ElementSelectionContextState { export interface ElementSelectionContextState {
/** /**
@ -8,7 +16,7 @@ export interface ElementSelectionContextState {
enabled?: boolean; enabled?: boolean;
/** List of currently selected elements */ /** List of currently selected elements */
selected: ElementSelectionContextItem[]; selected: ElementSelectionContextItem[];
onSelect: (item: ElementSelectionContextItem, multi?: boolean) => void; onSelect: (item: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) => void;
onClear: () => void; onClear: () => void;
} }
@ -21,7 +29,7 @@ export const ElementSelectionContext = createContext<ElementSelectionContextStat
export interface UseElementSelectionResult { export interface UseElementSelectionResult {
isSelected?: boolean; isSelected?: boolean;
isSelectable?: boolean; isSelectable?: boolean;
onSelect?: (evt: React.PointerEvent) => void; onSelect?: (evt: React.PointerEvent, options?: ElementSelectionOnSelectOptions) => void;
onClear?: () => void; onClear?: () => void;
} }
@ -36,8 +44,8 @@ export function useElementSelection(id: string | undefined): UseElementSelection
} }
const isSelected = context.selected.some((item) => item.id === id); const isSelected = context.selected.some((item) => item.id === id);
const onSelect = useCallback<React.PointerEventHandler>( const onSelect = useCallback(
(evt) => { (evt: React.PointerEvent, options: ElementSelectionOnSelectOptions = {}) => {
if (!context.enabled) { if (!context.enabled) {
return; return;
} }
@ -51,7 +59,7 @@ export function useElementSelection(id: string | undefined): UseElementSelection
window.getSelection()?.empty(); window.getSelection()?.empty();
} }
context.onSelect({ id }, evt.shiftKey); context.onSelect({ id }, { ...options, multi: options.multi ?? evt.shiftKey });
}, },
[context, id] [context, id]
); );

View File

@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css'; 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 * as React from 'react';
import { useMeasure, useToggle } from 'react-use'; import { useMeasure, useToggle } from 'react-use';
@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { useStyles2, useTheme2 } from '../../themes'; import { useStyles2, useTheme2 } from '../../themes';
import { getFocusStyles } from '../../themes/mixins'; import { getFocusStyles } from '../../themes/mixins';
import { usePointerDistance } from '../../utils';
import { DelayRender } from '../../utils/DelayRender'; import { DelayRender } from '../../utils/DelayRender';
import { useElementSelection } from '../ElementSelectionContext/ElementSelectionContext'; import { useElementSelection } from '../ElementSelectionContext/ElementSelectionContext';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
@ -151,7 +152,7 @@ export function PanelChrome({
const panelContentId = useId(); const panelContentId = useId();
const panelTitleId = useId().replace(/:/g, '_'); const panelTitleId = useId().replace(/:/g, '_');
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
const pointerDownLocation = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const pointerDistance = usePointerDistance();
const hasHeader = !hoverHeader; const hasHeader = !hoverHeader;
@ -200,13 +201,8 @@ export function PanelChrome({
// Mainly the tricky bit of differentiating between dragging and selecting // Mainly the tricky bit of differentiating between dragging and selecting
const onPointerUp = React.useCallback( const onPointerUp = React.useCallback(
(evt: React.PointerEvent) => { (evt: React.PointerEvent) => {
const distance = Math.hypot(
evt.clientX - pointerDownLocation.current.x,
evt.clientY - pointerDownLocation.current.y
);
if ( if (
distance > 10 || pointerDistance.check(evt) ||
(dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`)) (dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`))
) { ) {
return; 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 // By doing so, the event won't get to the document and drag will never be stopped
setTimeout(() => onSelect?.(evt)); setTimeout(() => onSelect?.(evt));
}, },
[dragClassCancel, onSelect] [dragClassCancel, onSelect, pointerDistance]
); );
const onPointerDown = React.useCallback( const onPointerDown = React.useCallback(
(evt: React.PointerEvent) => { (evt: React.PointerEvent) => {
evt.stopPropagation(); evt.stopPropagation();
pointerDownLocation.current = { x: evt.clientX, y: evt.clientY }; pointerDistance.set(evt);
onDragStart?.(evt); onDragStart?.(evt);
}, },
[onDragStart] [pointerDistance, onDragStart]
); );
const onContentPointerDown = React.useCallback( const onContentPointerDown = React.useCallback(

View File

@ -338,5 +338,6 @@ export {
useElementSelection, useElementSelection,
type ElementSelectionContextState, type ElementSelectionContextState,
type ElementSelectionContextItem, type ElementSelectionContextItem,
type ElementSelectionOnSelectOptions,
type UseElementSelectionResult, type UseElementSelectionResult,
} from './ElementSelectionContext/ElementSelectionContext'; } from './ElementSelectionContext/ElementSelectionContext';

View File

@ -9,6 +9,7 @@ export * from './tags';
export * from './scrollbar'; export * from './scrollbar';
export * from './table'; export * from './table';
export * from './measureText'; export * from './measureText';
export * from './usePointerDistance';
export * from './useForceUpdate'; export * from './useForceUpdate';
export { SearchFunctionType } from './searchFunctions'; export { SearchFunctionType } from './searchFunctions';

View File

@ -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;
}

View File

@ -8,6 +8,7 @@ import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObj
import { import {
ElementSelectionContextItem, ElementSelectionContextItem,
ElementSelectionContextState, ElementSelectionContextState,
ElementSelectionOnSelectOptions,
ScrollContainer, ScrollContainer,
ToolbarButton, ToolbarButton,
useSplitter, useSplitter,
@ -33,15 +34,13 @@ export interface DashboardEditPaneState extends SceneObjectState {
showAddPane?: boolean; showAddPane?: boolean;
} }
export type EditPaneTab = 'add' | 'configure' | 'outline';
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> { export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public constructor() { public constructor() {
super({ super({
selectionContext: { selectionContext: {
enabled: false, enabled: false,
selected: [], selected: [],
onSelect: (item, multi) => this.selectElement(item, multi), onSelect: (item, options) => this.selectElement(item, options),
onClear: () => this.clearSelection(), 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 // We should not select clones
if (isInCloneChain(element.id)) { if (isInCloneChain(element.id)) {
if (multi) { if (options.multi) {
return; return;
} }
@ -96,7 +95,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
const obj = sceneGraph.findByKey(this, element.id); const obj = sceneGraph.findByKey(this, element.id);
if (obj) { 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 }); this.setState({ showAddPane: !this.state.showAddPane });
} }
public selectObject(obj: SceneObject, id: string, multi?: boolean) { public selectObject(obj: SceneObject, id: string, { multi, force }: ElementSelectionOnSelectOptions = {}) {
const prevItem = this.state.selection?.getFirstObject(); if (!force) {
if (prevItem === obj && !multi) { if (multi) {
this.clearSelection(); if (this.state.selection?.hasValue(id)) {
return; this.removeMultiSelectedObject(id);
} return;
}
if (multi && this.state.selection?.hasValue(id)) { } else {
this.removeMultiSelectedObject(id); if (this.state.selection?.getFirstObject() === obj) {
return; this.clearSelection();
return;
}
}
} }
const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]); const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]);
@ -170,7 +172,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
} }
private newObjectAddedToCanvas(obj: SceneObject) { private newObjectAddedToCanvas(obj: SceneObject) {
this.selectObject(obj, obj.state.key!, false); this.selectObject(obj, obj.state.key!);
if (this.state.showAddPane) { if (this.state.showAddPane) {
this.setState({ showAddPane: false }); this.setState({ showAddPane: false });

View File

@ -1,6 +1,7 @@
import { PointerEvent as ReactPointerEvent } from 'react'; import { PointerEvent as ReactPointerEvent } from 'react';
import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
import { createPointerDistance } from '@grafana/ui';
import { DashboardScene } from './DashboardScene'; import { DashboardScene } from './DashboardScene';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget'; import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
@ -12,6 +13,8 @@ interface DashboardLayoutOrchestratorState extends SceneObjectState {
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> { export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
private _sourceDropTarget: DashboardDropTarget | null = null; private _sourceDropTarget: DashboardDropTarget | null = null;
private _lastDropTarget: DashboardDropTarget | null = null; private _lastDropTarget: DashboardDropTarget | null = null;
private _pointerDistance = createPointerDistance();
private _isSelectedObject = false;
public constructor() { public constructor() {
super({}); 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); const dropTarget = sceneGraph.findObject(panel, isDashboardDropTarget);
if (!dropTarget || !isDashboardDropTarget(dropTarget)) { if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
@ -64,6 +70,12 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
} }
private _onPointerMove(evt: PointerEvent) { 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; const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
if (!dropTarget) { if (!dropTarget) {

View File

@ -59,7 +59,7 @@ export class RowItem
public readonly isEditableDashboardElement = true; public readonly isEditableDashboardElement = true;
public readonly isDashboardDropTarget = true; public readonly isDashboardDropTarget = true;
private _layoutRestorer = new LayoutRestorer(); private _layoutRestorer = new LayoutRestorer();
public containerRef = React.createRef<HTMLDivElement>(); public containerRef: React.MutableRefObject<HTMLDivElement | null> = React.createRef<HTMLDivElement>();
public constructor(state?: Partial<RowItemState>) { public constructor(state?: Partial<RowItemState>) {
super({ super({
@ -107,7 +107,7 @@ export class RowItem
} }
public onDelete() { public onDelete() {
this._getParentLayout().removeRow(this); this.getParentLayout().removeRow(this);
} }
public createMultiSelectedElement(items: SceneObject[]): RowItems { public createMultiSelectedElement(items: SceneObject[]): RowItems {
@ -115,7 +115,7 @@ export class RowItem
} }
public onDuplicate() { public onDuplicate() {
this._getParentLayout().duplicateRow(this); this.getParentLayout().duplicateRow(this);
} }
public duplicate(): RowItem { public duplicate(): RowItem {
@ -191,7 +191,7 @@ export class RowItem
this.setState({ collapse: !this.state.collapse }); this.setState({ collapse: !this.state.collapse });
} }
private _getParentLayout(): RowsLayoutManager { public getParentLayout(): RowsLayoutManager {
return sceneGraph.getAncestor(this, RowsLayoutManager); return sceneGraph.getAncestor(this, RowsLayoutManager);
} }

View File

@ -1,33 +1,33 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { Draggable } from '@hello-pangea/dnd';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps } from '@grafana/scenes'; 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 { t } from 'app/core/internationalization';
import { useIsClone } from '../../utils/clone'; import { useIsClone } from '../../utils/clone';
import { import { useDashboardState, useInterpolatedTitle, useIsConditionallyHidden } from '../../utils/utils';
useDashboardState,
useElementSelectionScene,
useInterpolatedTitle,
useIsConditionallyHidden,
} from '../../utils/utils';
import { DashboardScene } from '../DashboardScene'; import { DashboardScene } from '../DashboardScene';
import { RowItem } from './RowItem'; import { RowItem } from './RowItem';
export function RowItemRenderer({ model }: SceneComponentProps<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 isClone = useIsClone(model);
const { isEditing } = useDashboardState(model); const { isEditing } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model); const isConditionallyHidden = useIsConditionallyHidden(model);
const { isSelected, onSelect, isSelectable } = useElementSelectionScene(model); const { isSelected, onSelect, isSelectable } = useElementSelection(key);
const title = useInterpolatedTitle(model); const title = useInterpolatedTitle(model);
const { rows } = model.getParentLayout().useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const clearStyles = useStyles2(clearButtonStyles); const clearStyles = useStyles2(clearButtonStyles);
const isTopLevel = model.parent?.parent instanceof DashboardScene; const isTopLevel = model.parent?.parent instanceof DashboardScene;
const pointerDistance = usePointerDistance();
const myIndex = rows.findIndex((row) => row === model);
const shouldGrow = !isCollapsed && fillScreen; const shouldGrow = !isCollapsed && fillScreen;
const isHidden = isConditionallyHidden && !isEditing; const isHidden = isConditionallyHidden && !isEditing;
@ -37,76 +37,101 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []); const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []);
const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []); const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []);
const isDraggable = !isClone && isEditing;
if (isHidden) { if (isHidden) {
return null; return null;
} }
return ( return (
<div <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}>
ref={model.containerRef} {(dragProvided, dragSnapshot) => (
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) && (
<div <div
className={cx(isHeaderHidden && 'dashboard-visible-hidden-element', styles.rowHeader, 'dashboard-row-header')} ref={(ref) => {
onMouseEnter={isSelectable ? onHeaderEnter : undefined} dragProvided.innerRef(ref);
onMouseLeave={isSelectable ? onHeaderLeave : undefined} model.containerRef.current = ref;
> }}
<button data-dashboard-drop-target-key={model.state.key}
onClick={() => model.onCollapseToggle()} className={cx(
className={cx(clearStyles, styles.rowTitleButton)} styles.wrapper,
aria-label={ dragSnapshot.isDragging && styles.dragging,
isCollapsed isEditing && !isCollapsed && styles.wrapperEditing,
? t('dashboard.rows-layout.row.expand', 'Expand row') isEditing && isCollapsed && styles.wrapperEditingCollapsed,
: t('dashboard.rows-layout.row.collapse', 'Collapse row') 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!)}
> if (pointerDistance.check(evt)) {
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} /> return;
<span }
setTimeout(() => onSelect?.(evt));
}}
{...dragProvided.draggableProps}
>
{(!isHeaderHidden || isEditing) && (
<div
className={cx( className={cx(
styles.rowTitle, isHeaderHidden && 'dashboard-visible-hidden-element',
isHeaderHidden && styles.rowTitleHidden, styles.rowHeader,
!isTopLevel && styles.rowTitleNested, 'dashboard-row-header'
isCollapsed && styles.rowTitleCollapsed
)} )}
role="heading" onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
{...dragProvided.dragHandleProps}
> >
{title} <button
{isHeaderHidden && ( onClick={() => model.onCollapseToggle()}
<Tooltip className={cx(clearStyles, styles.rowTitleButton)}
content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')} 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" /> {title}
</Tooltip> {isHeaderHidden && (
)} <Tooltip
</span> content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')}
</button> >
<Icon name="eye-slash" />
</Tooltip>
)}
</span>
</button>
{isDraggable && <Icon name="draggabledots" />}
</div>
)}
{!isCollapsed && <layout.Component model={layout} />}
</div> </div>
)} )}
{!isCollapsed && <layout.Component model={layout} />} </Draggable>
</div>
); );
} }
@ -118,6 +143,7 @@ function getStyles(theme: GrafanaTheme2) {
gap: theme.spacing(1), gap: theme.spacing(1),
padding: theme.spacing(0.5, 0.5, 0.5, 0), padding: theme.spacing(0.5, 0.5, 0.5, 0),
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
}), }),
rowTitleButton: css({ rowTitleButton: css({
@ -171,6 +197,9 @@ function getStyles(theme: GrafanaTheme2) {
}, },
}, },
}), }),
dragging: css({
cursor: 'move',
}),
wrapperEditing: css({ wrapperEditing: css({
padding: theme.spacing(0.5), padding: theme.spacing(0.5),

View File

@ -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) { public removeRow(row: RowItem) {
const rows = this.state.rows.filter((r) => r !== row); const rows = this.state.rows.filter((r) => r !== row);
this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows }); this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows });
this.publishEvent(new ObjectRemovedFromCanvasEvent(row), true); this.publishEvent(new ObjectRemovedFromCanvasEvent(row), true);
} }
public moveRowUp(row: RowItem) { public moveRow(_rowKey: string, fromIndex: number, toIndex: number) {
const rows = [...this.state.rows]; const rows = [...this.state.rows];
const originalIndex = rows.indexOf(row); const [removed] = rows.splice(fromIndex, 1);
rows.splice(toIndex, 0, removed);
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);
this.setState({ rows }); this.setState({ rows });
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
} }
public moveRowDown(row: RowItem) { public forceSelectRow(rowKey: string) {
const rows = [...this.state.rows]; const rowIndex = this.state.rows.findIndex((row) => row.state.key === rowKey);
const originalIndex = rows.indexOf(row); const row = this.state.rows[rowIndex];
if (originalIndex === rows.length - 1) { if (!row) {
return; return;
} }
let moveToIndex = originalIndex + 1; const editPane = getDashboardSceneFor(this).state.editPane;
editPane.selectObject(row!, rowKey, { force: true, multi: false });
// 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;
} }
public static createEmpty(): RowsLayoutManager { public static createEmpty(): RowsLayoutManager {

View File

@ -1,4 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps } from '@grafana/scenes';
@ -11,29 +12,49 @@ import { useClipboardState } from '../layouts-shared/useClipboardState';
import { RowsLayoutManager } from './RowsLayoutManager'; import { RowsLayoutManager } from './RowsLayoutManager';
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) { export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
const { rows } = model.useState(); const { rows, key } = model.useState();
const { isEditing } = useDashboardState(model); const { isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState(); const { hasCopiedRow } = useClipboardState();
return ( return (
<div className={styles.wrapper}> <DragDropContext
{rows.map((row) => ( onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
<row.Component model={row} key={row.state.key!} /> onDragEnd={(result) => {
))} if (!result.destination) {
{isEditing && ( return;
<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> if (result.destination.index === result.source.index) {
</Button> return;
{hasCopiedRow && ( }
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}>
<Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans> model.moveRow(result.draggableId, result.source.index, result.destination.index);
</Button> }}
)} >
</div> <Droppable droppableId={key!} direction="vertical">
)} {(dropProvided) => (
</div> <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>
); );
} }

View File

@ -111,7 +111,7 @@ export class TabItem
} }
public onDuplicate(): void { public onDuplicate(): void {
this._getParentLayout().duplicateTab(this); this.getParentLayout().duplicateTab(this);
} }
public duplicate(): TabItem { public duplicate(): TabItem {
@ -123,7 +123,7 @@ export class TabItem
} }
public onAddTab() { public onAddTab() {
this._getParentLayout().addNewTab(); this.getParentLayout().addNewTab();
} }
public onChangeTitle(title: string) { public onChangeTitle(title: string) {
@ -154,15 +154,11 @@ export class TabItem
} }
public getParentLayout(): TabsLayoutManager { public getParentLayout(): TabsLayoutManager {
return this._getParentLayout();
}
private _getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager); return sceneGraph.getAncestor(this, TabsLayoutManager);
} }
public scrollIntoView(): void { public scrollIntoView(): void {
const tabsLayout = sceneGraph.getAncestor(this, TabsLayoutManager); const tabsLayout = this.getParentLayout();
if (tabsLayout.getCurrentTab() !== this) { if (tabsLayout.getCurrentTab() !== this) {
tabsLayout.switchToTab(this); tabsLayout.switchToTab(this);
} }

View File

@ -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 { useLocation } from 'react-router';
import { locationUtil, textUtil } from '@grafana/data'; import { locationUtil, textUtil } from '@grafana/data';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes'; 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'; import { TabItem } from './TabItem';
@ -13,30 +16,62 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const { tabs, currentTabIndex } = parentLayout.useState(); const { tabs, currentTabIndex } = parentLayout.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text'); const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const { isSelected, onSelect, isSelectable } = useElementSelection(key); const { isSelected, onSelect, isSelectable } = useElementSelection(key);
const { isEditing } = useDashboardState(model);
const mySlug = model.getSlug(); const mySlug = model.getSlug();
const urlKey = parentLayout.getUrlKey(); const urlKey = parentLayout.getUrlKey();
const myIndex = tabs.findIndex((tab) => tab === model); const myIndex = tabs.findIndex((tab) => tab === model);
const isActive = myIndex === currentTabIndex; const isActive = myIndex === currentTabIndex;
const location = useLocation(); const location = useLocation();
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug })); const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug }));
const styles = useStyles2(getStyles);
const pointerDistance = usePointerDistance();
return ( return (
<Tab <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isEditing}>
ref={model.containerRef} {(dragProvided, dragSnapshot) => (
truncate <div
className={cx( ref={(ref) => dragProvided.innerRef(ref)}
isSelected && 'dashboard-selected-element', className={cx(dragSnapshot.isDragging && styles.dragging)}
isSelectable && !isSelected && 'dashboard-selectable-element', {...dragProvided.draggableProps}
isDropTarget && 'dashboard-drop-target' {...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} </Draggable>
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',
}),
});

View File

@ -166,56 +166,25 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
this.publishEvent(new ObjectRemovedFromCanvasEvent(tabToRemove), true); this.publishEvent(new ObjectRemovedFromCanvasEvent(tabToRemove), true);
} }
public addTabBefore(tab: TabItem): TabItem { public moveTab(_tabKey: string, fromIndex: number, toIndex: number) {
const newTab = new TabItem({ isNew: true }); const tabs = [...this.state.tabs];
const tabs = this.state.tabs.slice(); const [removed] = tabs.splice(fromIndex, 1);
tabs.splice(tabs.indexOf(tab), 0, newTab); tabs.splice(toIndex, 0, removed);
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex }); this.setState({ tabs, currentTabIndex: toIndex });
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 });
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true); this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
} }
public moveTabRight(tab: TabItem) { public forceSelectTab(tabKey: string) {
const currentIndex = this.state.tabs.indexOf(tab); const tabIndex = this.state.tabs.findIndex((tab) => tab.state.key === tabKey);
if (currentIndex >= this.state.tabs.length - 1) { const tab = this.state.tabs[tabIndex];
if (!tab) {
return; 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 { const editPane = getDashboardSceneFor(this).state.editPane;
return this.state.tabs[0] === tab; editPane.selectObject(tab!, tabKey, { force: true, multi: false });
} this.setState({ currentTabIndex: tabIndex });
public isLastTab(tab: TabItem): boolean {
return this.state.tabs[this.state.tabs.length - 1] === tab;
} }
public static createEmpty(): TabsLayoutManager { public static createEmpty(): TabsLayoutManager {
@ -238,9 +207,9 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
return new TabsLayoutManager({ tabs }); return new TabsLayoutManager({ tabs });
} }
getUrlKey(): string { public getUrlKey(): string {
let parent = this.parent; 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'; let key = 'dtab';
while (parent) { while (parent) {

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Fragment } from 'react'; import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps } from '@grafana/scenes';
@ -13,7 +13,7 @@ import { TabsLayoutManager } from './TabsLayoutManager';
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) { export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { tabs } = model.useState(); const { tabs, key } = model.useState();
const currentTab = model.getCurrentTab(); const currentTab = model.getCurrentTab();
const { layout } = currentTab.useState(); const { layout } = currentTab.useState();
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
@ -23,27 +23,46 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
return ( return (
<div className={styles.tabLayoutContainer}> <div className={styles.tabLayoutContainer}>
<TabsBar className={styles.tabsBar}> <TabsBar className={styles.tabsBar}>
<div className={styles.tabsRow}> <DragDropContext
<div className={styles.tabsContainer}> onBeforeDragStart={(start) => model.forceSelectTab(start.draggableId)}
{tabs.map((tab) => ( onDragEnd={(result) => {
<Fragment key={tab.state.key!}> if (!result.destination) {
<tab.Component model={tab} /> return;
</Fragment> }
))}
</div> if (result.destination.index === result.source.index) {
{isEditing && ( return;
<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> model.moveTab(result.draggableId, result.source.index, result.destination.index);
</Button> }}
{hasCopiedTab && ( >
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}> <div className={styles.tabsRow}>
<Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans> <Droppable droppableId={key!} direction="horizontal">
</Button> {(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> </Droppable>
)} {isEditing && (
</div> <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> </TabsBar>
<TabContent className={styles.tabContentContainer}> <TabContent className={styles.tabContentContainer}>
{currentTab && <layout.Component model={layout} />} {currentTab && <layout.Component model={layout} />}
@ -59,7 +78,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
flex: '1 1 auto', flex: '1 1 auto',
}), }),
tabsBar: css({ tabsBar: css({
overflow: 'hidden',
'&:hover': { '&:hover': {
'.dashboard-canvas-add-button': { '.dashboard-canvas-add-button': {
filter: 'unset', filter: 'unset',

View File

@ -12,7 +12,6 @@ import {
VizPanel, VizPanel,
VizPanelMenu, VizPanelMenu,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { useElementSelection, UseElementSelectionResult } from '@grafana/ui';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer'; import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
@ -463,12 +462,6 @@ export function useIsConditionallyHidden(scene: RowItem | AutoGridItem): boolean
return !(conditionalRendering?.evaluate() ?? true); 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 { export function useInterpolatedTitle<T extends SceneObjectState & { title?: string }>(scene: SceneObject<T>): string {
const { title } = scene.useState(); const { title } = scene.useState();