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';
|
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]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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 });
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue