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';
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]
);

View File

@ -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(

View File

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

View File

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

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 {
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 });

View File

@ -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) {

View File

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

View File

@ -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),

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) {
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 {

View File

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

View File

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

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 { 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',
}),
});

View File

@ -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) {

View File

@ -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',

View File

@ -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();