Dynamic Dashboards: Add drag and drop for tabs and rows (#103216)
This commit is contained in:
+13
-5
@@ -1,5 +1,13 @@
|
||||
import React, { createContext, useCallback, useContext } from 'react';
|
||||
|
||||
export interface ElementSelectionOnSelectOptions {
|
||||
/** If specified, this will ignore the shift key press */
|
||||
multi?: boolean;
|
||||
|
||||
/** If true, this will make sure the element is selected */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export interface ElementSelectionContextState {
|
||||
/**
|
||||
@@ -8,7 +16,7 @@ export interface ElementSelectionContextState {
|
||||
enabled?: boolean;
|
||||
/** List of currently selected elements */
|
||||
selected: ElementSelectionContextItem[];
|
||||
onSelect: (item: ElementSelectionContextItem, multi?: boolean) => void;
|
||||
onSelect: (item: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +29,7 @@ export const ElementSelectionContext = createContext<ElementSelectionContextStat
|
||||
export interface UseElementSelectionResult {
|
||||
isSelected?: boolean;
|
||||
isSelectable?: boolean;
|
||||
onSelect?: (evt: React.PointerEvent) => void;
|
||||
onSelect?: (evt: React.PointerEvent, options?: ElementSelectionOnSelectOptions) => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
@@ -36,8 +44,8 @@ export function useElementSelection(id: string | undefined): UseElementSelection
|
||||
}
|
||||
|
||||
const isSelected = context.selected.some((item) => item.id === id);
|
||||
const onSelect = useCallback<React.PointerEventHandler>(
|
||||
(evt) => {
|
||||
const onSelect = useCallback(
|
||||
(evt: React.PointerEvent, options: ElementSelectionOnSelectOptions = {}) => {
|
||||
if (!context.enabled) {
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +59,7 @@ export function useElementSelection(id: string | undefined): UseElementSelection
|
||||
window.getSelection()?.empty();
|
||||
}
|
||||
|
||||
context.onSelect({ id }, evt.shiftKey);
|
||||
context.onSelect({ id }, { ...options, multi: options.multi ?? evt.shiftKey });
|
||||
},
|
||||
[context, id]
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { CSSProperties, ReactElement, ReactNode, useId, useRef, useState } from 'react';
|
||||
import { CSSProperties, ReactElement, ReactNode, useId, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useMeasure, useToggle } from 'react-use';
|
||||
|
||||
@@ -8,6 +8,7 @@ import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
import { usePointerDistance } from '../../utils';
|
||||
import { DelayRender } from '../../utils/DelayRender';
|
||||
import { useElementSelection } from '../ElementSelectionContext/ElementSelectionContext';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
@@ -151,7 +152,7 @@ export function PanelChrome({
|
||||
const panelContentId = useId();
|
||||
const panelTitleId = useId().replace(/:/g, '_');
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
|
||||
const pointerDownLocation = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const pointerDistance = usePointerDistance();
|
||||
|
||||
const hasHeader = !hoverHeader;
|
||||
|
||||
@@ -200,13 +201,8 @@ export function PanelChrome({
|
||||
// Mainly the tricky bit of differentiating between dragging and selecting
|
||||
const onPointerUp = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
const distance = Math.hypot(
|
||||
evt.clientX - pointerDownLocation.current.x,
|
||||
evt.clientY - pointerDownLocation.current.y
|
||||
);
|
||||
|
||||
if (
|
||||
distance > 10 ||
|
||||
pointerDistance.check(evt) ||
|
||||
(dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`))
|
||||
) {
|
||||
return;
|
||||
@@ -216,18 +212,18 @@ export function PanelChrome({
|
||||
// By doing so, the event won't get to the document and drag will never be stopped
|
||||
setTimeout(() => onSelect?.(evt));
|
||||
},
|
||||
[dragClassCancel, onSelect]
|
||||
[dragClassCancel, onSelect, pointerDistance]
|
||||
);
|
||||
|
||||
const onPointerDown = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
pointerDownLocation.current = { x: evt.clientX, y: evt.clientY };
|
||||
pointerDistance.set(evt);
|
||||
|
||||
onDragStart?.(evt);
|
||||
},
|
||||
[onDragStart]
|
||||
[pointerDistance, onDragStart]
|
||||
);
|
||||
|
||||
const onContentPointerDown = React.useCallback(
|
||||
|
||||
@@ -338,5 +338,6 @@ export {
|
||||
useElementSelection,
|
||||
type ElementSelectionContextState,
|
||||
type ElementSelectionContextItem,
|
||||
type ElementSelectionOnSelectOptions,
|
||||
type UseElementSelectionResult,
|
||||
} from './ElementSelectionContext/ElementSelectionContext';
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './tags';
|
||||
export * from './scrollbar';
|
||||
export * from './table';
|
||||
export * from './measureText';
|
||||
export * from './usePointerDistance';
|
||||
export * from './useForceUpdate';
|
||||
export { SearchFunctionType } from './searchFunctions';
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
type PointerOrMouseEvent = React.PointerEvent | React.MouseEvent | PointerEvent | MouseEvent;
|
||||
type PointerDistanceSet = (evt: PointerOrMouseEvent) => void;
|
||||
type PointerDistanceCheck = (evt: PointerOrMouseEvent, distance?: number) => boolean;
|
||||
|
||||
interface PointerDistance {
|
||||
set: PointerDistanceSet;
|
||||
check: PointerDistanceCheck;
|
||||
}
|
||||
|
||||
export function createPointerDistance(distance = 10): PointerDistance {
|
||||
let initial = { x: 0, y: 0 };
|
||||
|
||||
const set: PointerDistanceSet = (evt) => {
|
||||
initial = getPoint(evt);
|
||||
};
|
||||
|
||||
const check: PointerDistanceCheck = (evt, overrideDistance = distance) =>
|
||||
checkDistance(initial, getPoint(evt), overrideDistance);
|
||||
|
||||
return { set, check };
|
||||
}
|
||||
|
||||
export function usePointerDistance(distance = 10): PointerDistance {
|
||||
const initial = React.useRef<Point>({ x: 0, y: 0 });
|
||||
|
||||
const set = useCallback<PointerDistance['set']>((evt) => {
|
||||
initial.current = getPoint(evt);
|
||||
}, []);
|
||||
|
||||
const check = useCallback<PointerDistance['check']>(
|
||||
(evt, overrideDistance = distance) => checkDistance(initial.current, getPoint(evt), overrideDistance),
|
||||
[distance]
|
||||
);
|
||||
|
||||
return useMemo(() => ({ set, check }), [set, check]);
|
||||
}
|
||||
|
||||
function getPoint(evt: PointerOrMouseEvent): Point {
|
||||
return { x: evt.clientX, y: evt.clientY };
|
||||
}
|
||||
|
||||
function checkDistance(point1: Point, point2: Point, distance: number): boolean {
|
||||
return Math.hypot(point1.x - point2.x, point1.y - point2.y) > distance;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObj
|
||||
import {
|
||||
ElementSelectionContextItem,
|
||||
ElementSelectionContextState,
|
||||
ElementSelectionOnSelectOptions,
|
||||
ScrollContainer,
|
||||
ToolbarButton,
|
||||
useSplitter,
|
||||
@@ -33,15 +34,13 @@ export interface DashboardEditPaneState extends SceneObjectState {
|
||||
showAddPane?: boolean;
|
||||
}
|
||||
|
||||
export type EditPaneTab = 'add' | 'configure' | 'outline';
|
||||
|
||||
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
public constructor() {
|
||||
super({
|
||||
selectionContext: {
|
||||
enabled: false,
|
||||
selected: [],
|
||||
onSelect: (item, multi) => this.selectElement(item, multi),
|
||||
onSelect: (item, options) => this.selectElement(item, options),
|
||||
onClear: () => this.clearSelection(),
|
||||
},
|
||||
});
|
||||
@@ -83,10 +82,10 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
});
|
||||
}
|
||||
|
||||
private selectElement(element: ElementSelectionContextItem, multi?: boolean) {
|
||||
private selectElement(element: ElementSelectionContextItem, options: ElementSelectionOnSelectOptions) {
|
||||
// We should not select clones
|
||||
if (isInCloneChain(element.id)) {
|
||||
if (multi) {
|
||||
if (options.multi) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,7 +95,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
|
||||
const obj = sceneGraph.findByKey(this, element.id);
|
||||
if (obj) {
|
||||
this.selectObject(obj, element.id, multi);
|
||||
this.selectObject(obj, element.id, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,16 +107,19 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
this.setState({ showAddPane: !this.state.showAddPane });
|
||||
}
|
||||
|
||||
public selectObject(obj: SceneObject, id: string, multi?: boolean) {
|
||||
const prevItem = this.state.selection?.getFirstObject();
|
||||
if (prevItem === obj && !multi) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (multi && this.state.selection?.hasValue(id)) {
|
||||
this.removeMultiSelectedObject(id);
|
||||
return;
|
||||
public selectObject(obj: SceneObject, id: string, { multi, force }: ElementSelectionOnSelectOptions = {}) {
|
||||
if (!force) {
|
||||
if (multi) {
|
||||
if (this.state.selection?.hasValue(id)) {
|
||||
this.removeMultiSelectedObject(id);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (this.state.selection?.getFirstObject() === obj) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]);
|
||||
@@ -170,7 +172,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
|
||||
}
|
||||
|
||||
private newObjectAddedToCanvas(obj: SceneObject) {
|
||||
this.selectObject(obj, obj.state.key!, false);
|
||||
this.selectObject(obj, obj.state.key!);
|
||||
|
||||
if (this.state.showAddPane) {
|
||||
this.setState({ showAddPane: false });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PointerEvent as ReactPointerEvent } from 'react';
|
||||
|
||||
import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
|
||||
import { createPointerDistance } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
|
||||
@@ -12,6 +13,8 @@ interface DashboardLayoutOrchestratorState extends SceneObjectState {
|
||||
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
|
||||
private _sourceDropTarget: DashboardDropTarget | null = null;
|
||||
private _lastDropTarget: DashboardDropTarget | null = null;
|
||||
private _pointerDistance = createPointerDistance();
|
||||
private _isSelectedObject = false;
|
||||
|
||||
public constructor() {
|
||||
super({});
|
||||
@@ -29,7 +32,10 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
};
|
||||
}
|
||||
|
||||
public startDraggingSync(_evt: ReactPointerEvent, panel: VizPanel): void {
|
||||
public startDraggingSync(evt: ReactPointerEvent, panel: VizPanel): void {
|
||||
this._pointerDistance.set(evt);
|
||||
this._isSelectedObject = false;
|
||||
|
||||
const dropTarget = sceneGraph.findObject(panel, isDashboardDropTarget);
|
||||
|
||||
if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
|
||||
@@ -64,6 +70,12 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
|
||||
}
|
||||
|
||||
private _onPointerMove(evt: PointerEvent) {
|
||||
if (!this._isSelectedObject && this.state.draggingPanel && this._pointerDistance.check(evt)) {
|
||||
this._isSelectedObject = true;
|
||||
const panel = this.state.draggingPanel?.resolve();
|
||||
this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false });
|
||||
}
|
||||
|
||||
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
|
||||
|
||||
if (!dropTarget) {
|
||||
|
||||
@@ -59,7 +59,7 @@ export class RowItem
|
||||
public readonly isEditableDashboardElement = true;
|
||||
public readonly isDashboardDropTarget = true;
|
||||
private _layoutRestorer = new LayoutRestorer();
|
||||
public containerRef = React.createRef<HTMLDivElement>();
|
||||
public containerRef: React.MutableRefObject<HTMLDivElement | null> = React.createRef<HTMLDivElement>();
|
||||
|
||||
public constructor(state?: Partial<RowItemState>) {
|
||||
super({
|
||||
@@ -107,7 +107,7 @@ export class RowItem
|
||||
}
|
||||
|
||||
public onDelete() {
|
||||
this._getParentLayout().removeRow(this);
|
||||
this.getParentLayout().removeRow(this);
|
||||
}
|
||||
|
||||
public createMultiSelectedElement(items: SceneObject[]): RowItems {
|
||||
@@ -115,7 +115,7 @@ export class RowItem
|
||||
}
|
||||
|
||||
public onDuplicate() {
|
||||
this._getParentLayout().duplicateRow(this);
|
||||
this.getParentLayout().duplicateRow(this);
|
||||
}
|
||||
|
||||
public duplicate(): RowItem {
|
||||
@@ -191,7 +191,7 @@ export class RowItem
|
||||
this.setState({ collapse: !this.state.collapse });
|
||||
}
|
||||
|
||||
private _getParentLayout(): RowsLayoutManager {
|
||||
public getParentLayout(): RowsLayoutManager {
|
||||
return sceneGraph.getAncestor(this, RowsLayoutManager);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Draggable } from '@hello-pangea/dnd';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { clearButtonStyles, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { clearButtonStyles, Icon, Tooltip, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
|
||||
import { useIsClone } from '../../utils/clone';
|
||||
import {
|
||||
useDashboardState,
|
||||
useElementSelectionScene,
|
||||
useInterpolatedTitle,
|
||||
useIsConditionallyHidden,
|
||||
} from '../../utils/utils';
|
||||
import { useDashboardState, useInterpolatedTitle, useIsConditionallyHidden } from '../../utils/utils';
|
||||
import { DashboardScene } from '../DashboardScene';
|
||||
|
||||
import { RowItem } from './RowItem';
|
||||
|
||||
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget } = model.useState();
|
||||
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
|
||||
const isClone = useIsClone(model);
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const isConditionallyHidden = useIsConditionallyHidden(model);
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelectionScene(model);
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(key);
|
||||
const title = useInterpolatedTitle(model);
|
||||
const { rows } = model.getParentLayout().useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
const clearStyles = useStyles2(clearButtonStyles);
|
||||
const isTopLevel = model.parent?.parent instanceof DashboardScene;
|
||||
const pointerDistance = usePointerDistance();
|
||||
|
||||
const myIndex = rows.findIndex((row) => row === model);
|
||||
|
||||
const shouldGrow = !isCollapsed && fillScreen;
|
||||
const isHidden = isConditionallyHidden && !isEditing;
|
||||
@@ -37,76 +37,101 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
|
||||
const onHeaderEnter = useCallback(() => setSelectableHighlight(true), []);
|
||||
const onHeaderLeave = useCallback(() => setSelectableHighlight(false), []);
|
||||
|
||||
const isDraggable = !isClone && isEditing;
|
||||
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={model.containerRef}
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
isEditing && !isCollapsed && styles.wrapperEditing,
|
||||
isEditing && isCollapsed && styles.wrapperEditingCollapsed,
|
||||
isCollapsed && styles.wrapperCollapsed,
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
isConditionallyHidden && 'dashboard-visible-hidden-element',
|
||||
!isClone && isSelected && 'dashboard-selected-element',
|
||||
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
// If we selected and are clicking a button inside row header then don't de-select row
|
||||
if (isSelected && e.target instanceof Element && e.target.closest('button')) {
|
||||
// Stop propagation otherwise dashboaed level onPointerDown will de-select row
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(e);
|
||||
}}
|
||||
>
|
||||
{(!isHeaderHidden || isEditing) && (
|
||||
<Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}>
|
||||
{(dragProvided, dragSnapshot) => (
|
||||
<div
|
||||
className={cx(isHeaderHidden && 'dashboard-visible-hidden-element', styles.rowHeader, 'dashboard-row-header')}
|
||||
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
|
||||
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
|
||||
>
|
||||
<button
|
||||
onClick={() => model.onCollapseToggle()}
|
||||
className={cx(clearStyles, styles.rowTitleButton)}
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? t('dashboard.rows-layout.row.expand', 'Expand row')
|
||||
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
|
||||
ref={(ref) => {
|
||||
dragProvided.innerRef(ref);
|
||||
model.containerRef.current = ref;
|
||||
}}
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
className={cx(
|
||||
styles.wrapper,
|
||||
dragSnapshot.isDragging && styles.dragging,
|
||||
isEditing && !isCollapsed && styles.wrapperEditing,
|
||||
isEditing && isCollapsed && styles.wrapperEditingCollapsed,
|
||||
isCollapsed && styles.wrapperCollapsed,
|
||||
shouldGrow && styles.wrapperGrow,
|
||||
isConditionallyHidden && 'dashboard-visible-hidden-element',
|
||||
!isClone && isSelected && 'dashboard-selected-element',
|
||||
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
)}
|
||||
onPointerDown={(evt) => {
|
||||
evt.stopPropagation();
|
||||
pointerDistance.set(evt);
|
||||
}}
|
||||
onPointerUp={(evt) => {
|
||||
// If we selected and are clicking a button inside row header then don't de-select row
|
||||
if (isSelected && evt.target instanceof Element && evt.target.closest('button')) {
|
||||
// Stop propagation otherwise dashboaed level onPointerDown will de-select row
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
data-testid={selectors.components.DashboardRow.title(title!)}
|
||||
>
|
||||
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<span
|
||||
|
||||
if (pointerDistance.check(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => onSelect?.(evt));
|
||||
}}
|
||||
{...dragProvided.draggableProps}
|
||||
>
|
||||
{(!isHeaderHidden || isEditing) && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.rowTitle,
|
||||
isHeaderHidden && styles.rowTitleHidden,
|
||||
!isTopLevel && styles.rowTitleNested,
|
||||
isCollapsed && styles.rowTitleCollapsed
|
||||
isHeaderHidden && 'dashboard-visible-hidden-element',
|
||||
styles.rowHeader,
|
||||
'dashboard-row-header'
|
||||
)}
|
||||
role="heading"
|
||||
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
|
||||
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
{title}
|
||||
{isHeaderHidden && (
|
||||
<Tooltip
|
||||
content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')}
|
||||
<button
|
||||
onClick={() => model.onCollapseToggle()}
|
||||
className={cx(clearStyles, styles.rowTitleButton)}
|
||||
aria-label={
|
||||
isCollapsed
|
||||
? t('dashboard.rows-layout.row.expand', 'Expand row')
|
||||
: t('dashboard.rows-layout.row.collapse', 'Collapse row')
|
||||
}
|
||||
data-testid={selectors.components.DashboardRow.title(title!)}
|
||||
>
|
||||
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
|
||||
<span
|
||||
className={cx(
|
||||
styles.rowTitle,
|
||||
isHeaderHidden && styles.rowTitleHidden,
|
||||
!isTopLevel && styles.rowTitleNested,
|
||||
isCollapsed && styles.rowTitleCollapsed
|
||||
)}
|
||||
role="heading"
|
||||
>
|
||||
<Icon name="eye-slash" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{title}
|
||||
{isHeaderHidden && (
|
||||
<Tooltip
|
||||
content={t('dashboard.rows-layout.header-hidden-tooltip', 'Row header only visible in edit mode')}
|
||||
>
|
||||
<Icon name="eye-slash" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{isDraggable && <Icon name="draggabledots" />}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && <layout.Component model={layout} />}
|
||||
</div>
|
||||
)}
|
||||
{!isCollapsed && <layout.Component model={layout} />}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +143,7 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(0.5, 0.5, 0.5, 0),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
rowTitleButton: css({
|
||||
@@ -171,6 +197,9 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
dragging: css({
|
||||
cursor: 'move',
|
||||
}),
|
||||
wrapperEditing: css({
|
||||
padding: theme.spacing(0.5),
|
||||
|
||||
|
||||
@@ -126,95 +126,30 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
});
|
||||
}
|
||||
|
||||
public addRowAbove(row: RowItem): RowItem {
|
||||
const index = this.state.rows.indexOf(row);
|
||||
const newRow = new RowItem({ isNew: true });
|
||||
const newRows = [...this.state.rows];
|
||||
|
||||
newRows.splice(index, 0, newRow);
|
||||
|
||||
this.setState({ rows: newRows });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true);
|
||||
|
||||
return newRow;
|
||||
}
|
||||
|
||||
public addRowBelow(row: RowItem): RowItem {
|
||||
const rows = this.state.rows;
|
||||
let index = rows.indexOf(row);
|
||||
|
||||
// Be sure we don't add a row between an original row and one of its clones
|
||||
while (rows[index + 1] && isClonedKey(rows[index + 1].state.key!)) {
|
||||
index = index + 1;
|
||||
}
|
||||
|
||||
const newRow = new RowItem({ isNew: true });
|
||||
const newRows = [...this.state.rows];
|
||||
|
||||
newRows.splice(index + 1, 0, newRow);
|
||||
|
||||
this.setState({ rows: newRows });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newRow), true);
|
||||
|
||||
return newRow;
|
||||
}
|
||||
|
||||
public removeRow(row: RowItem) {
|
||||
const rows = this.state.rows.filter((r) => r !== row);
|
||||
this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows });
|
||||
this.publishEvent(new ObjectRemovedFromCanvasEvent(row), true);
|
||||
}
|
||||
|
||||
public moveRowUp(row: RowItem) {
|
||||
public moveRow(_rowKey: string, fromIndex: number, toIndex: number) {
|
||||
const rows = [...this.state.rows];
|
||||
const originalIndex = rows.indexOf(row);
|
||||
|
||||
if (originalIndex === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let moveToIndex = originalIndex - 1;
|
||||
|
||||
// Be sure we don't add a row between an original row and one of its clones
|
||||
while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) {
|
||||
moveToIndex = moveToIndex - 1;
|
||||
}
|
||||
|
||||
rows.splice(originalIndex, 1);
|
||||
rows.splice(moveToIndex, 0, row);
|
||||
const [removed] = rows.splice(fromIndex, 1);
|
||||
rows.splice(toIndex, 0, removed);
|
||||
this.setState({ rows });
|
||||
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
|
||||
}
|
||||
|
||||
public moveRowDown(row: RowItem) {
|
||||
const rows = [...this.state.rows];
|
||||
const originalIndex = rows.indexOf(row);
|
||||
public forceSelectRow(rowKey: string) {
|
||||
const rowIndex = this.state.rows.findIndex((row) => row.state.key === rowKey);
|
||||
const row = this.state.rows[rowIndex];
|
||||
|
||||
if (originalIndex === rows.length - 1) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
let moveToIndex = originalIndex + 1;
|
||||
|
||||
// Be sure we don't add a row between an original row and one of its clones
|
||||
while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) {
|
||||
moveToIndex = moveToIndex + 1;
|
||||
}
|
||||
|
||||
rows.splice(moveToIndex + 1, 0, row);
|
||||
rows.splice(originalIndex, 1);
|
||||
|
||||
this.setState({ rows });
|
||||
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
|
||||
}
|
||||
|
||||
public isFirstRow(row: RowItem): boolean {
|
||||
return this.state.rows[0] === row;
|
||||
}
|
||||
|
||||
public isLastRow(row: RowItem): boolean {
|
||||
const filteredRow = this.state.rows.filter((r) => !isClonedKey(r.state.key!));
|
||||
return filteredRow[filteredRow.length - 1] === row;
|
||||
const editPane = getDashboardSceneFor(this).state.editPane;
|
||||
editPane.selectObject(row!, rowKey, { force: true, multi: false });
|
||||
}
|
||||
|
||||
public static createEmpty(): RowsLayoutManager {
|
||||
|
||||
+39
-18
@@ -1,4 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
@@ -11,29 +12,49 @@ import { useClipboardState } from '../layouts-shared/useClipboardState';
|
||||
import { RowsLayoutManager } from './RowsLayoutManager';
|
||||
|
||||
export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayoutManager>) {
|
||||
const { rows } = model.useState();
|
||||
const { rows, key } = model.useState();
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const styles = useStyles2(getStyles);
|
||||
const { hasCopiedRow } = useClipboardState();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
{rows.map((row) => (
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
))}
|
||||
{isEditing && (
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans>
|
||||
</Button>
|
||||
{hasCopiedRow && (
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DragDropContext
|
||||
onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
|
||||
onDragEnd={(result) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.destination.index === result.source.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.moveRow(result.draggableId, result.source.index, result.destination.index);
|
||||
}}
|
||||
>
|
||||
<Droppable droppableId={key!} direction="vertical">
|
||||
{(dropProvided) => (
|
||||
<div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{rows.map((row) => (
|
||||
<row.Component model={row} key={row.state.key!} />
|
||||
))}
|
||||
{dropProvided.placeholder}
|
||||
{isEditing && (
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewRow()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.new-row">New row</Trans>
|
||||
</Button>
|
||||
{hasCopiedRow && (
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteRow()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.paste-row">Paste row</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ export class TabItem
|
||||
}
|
||||
|
||||
public onDuplicate(): void {
|
||||
this._getParentLayout().duplicateTab(this);
|
||||
this.getParentLayout().duplicateTab(this);
|
||||
}
|
||||
|
||||
public duplicate(): TabItem {
|
||||
@@ -123,7 +123,7 @@ export class TabItem
|
||||
}
|
||||
|
||||
public onAddTab() {
|
||||
this._getParentLayout().addNewTab();
|
||||
this.getParentLayout().addNewTab();
|
||||
}
|
||||
|
||||
public onChangeTitle(title: string) {
|
||||
@@ -154,15 +154,11 @@ export class TabItem
|
||||
}
|
||||
|
||||
public getParentLayout(): TabsLayoutManager {
|
||||
return this._getParentLayout();
|
||||
}
|
||||
|
||||
private _getParentLayout(): TabsLayoutManager {
|
||||
return sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
}
|
||||
|
||||
public scrollIntoView(): void {
|
||||
const tabsLayout = sceneGraph.getAncestor(this, TabsLayoutManager);
|
||||
const tabsLayout = this.getParentLayout();
|
||||
if (tabsLayout.getCurrentTab() !== this) {
|
||||
tabsLayout.switchToTab(this);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Draggable } from '@hello-pangea/dnd';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { locationUtil, textUtil } from '@grafana/data';
|
||||
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
|
||||
import { Tab, useElementSelection } from '@grafana/ui';
|
||||
import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
|
||||
import { TabItem } from './TabItem';
|
||||
|
||||
@@ -13,30 +16,62 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
|
||||
const { tabs, currentTabIndex } = parentLayout.useState();
|
||||
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(key);
|
||||
const { isEditing } = useDashboardState(model);
|
||||
const mySlug = model.getSlug();
|
||||
const urlKey = parentLayout.getUrlKey();
|
||||
const myIndex = tabs.findIndex((tab) => tab === model);
|
||||
const isActive = myIndex === currentTabIndex;
|
||||
const location = useLocation();
|
||||
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug }));
|
||||
const styles = useStyles2(getStyles);
|
||||
const pointerDistance = usePointerDistance();
|
||||
|
||||
return (
|
||||
<Tab
|
||||
ref={model.containerRef}
|
||||
truncate
|
||||
className={cx(
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
<Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isEditing}>
|
||||
{(dragProvided, dragSnapshot) => (
|
||||
<div
|
||||
ref={(ref) => dragProvided.innerRef(ref)}
|
||||
className={cx(dragSnapshot.isDragging && styles.dragging)}
|
||||
{...dragProvided.draggableProps}
|
||||
{...dragProvided.dragHandleProps}
|
||||
>
|
||||
<Tab
|
||||
ref={model.containerRef}
|
||||
truncate
|
||||
className={cx(
|
||||
isSelected && 'dashboard-selected-element',
|
||||
isSelectable && !isSelected && 'dashboard-selectable-element',
|
||||
isDropTarget && 'dashboard-drop-target'
|
||||
)}
|
||||
active={isActive}
|
||||
role="presentation"
|
||||
title={titleInterpolated}
|
||||
href={href}
|
||||
aria-selected={isActive}
|
||||
onPointerDown={(evt) => {
|
||||
evt.stopPropagation();
|
||||
pointerDistance.set(evt);
|
||||
}}
|
||||
onPointerUp={(evt) => {
|
||||
evt.stopPropagation();
|
||||
|
||||
if (!isSelectable || pointerDistance.check(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(evt);
|
||||
}}
|
||||
label={titleInterpolated}
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
active={isActive}
|
||||
role="presentation"
|
||||
title={titleInterpolated}
|
||||
href={href}
|
||||
aria-selected={isActive}
|
||||
onPointerDown={onSelect}
|
||||
label={titleInterpolated}
|
||||
data-dashboard-drop-target-key={model.state.key}
|
||||
/>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => ({
|
||||
dragging: css({
|
||||
cursor: 'move',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -166,56 +166,25 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
this.publishEvent(new ObjectRemovedFromCanvasEvent(tabToRemove), true);
|
||||
}
|
||||
|
||||
public addTabBefore(tab: TabItem): TabItem {
|
||||
const newTab = new TabItem({ isNew: true });
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(tabs.indexOf(tab), 0, newTab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true);
|
||||
|
||||
return newTab;
|
||||
}
|
||||
|
||||
public addTabAfter(tab: TabItem): TabItem {
|
||||
const newTab = new TabItem({ isNew: true });
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(tabs.indexOf(tab) + 1, 0, newTab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
|
||||
this.publishEvent(new NewObjectAddedToCanvasEvent(newTab), true);
|
||||
|
||||
return newTab;
|
||||
}
|
||||
|
||||
public moveTabLeft(tab: TabItem) {
|
||||
const currentIndex = this.state.tabs.indexOf(tab);
|
||||
if (currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(currentIndex, 1);
|
||||
tabs.splice(currentIndex - 1, 0, tab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex - 1 });
|
||||
public moveTab(_tabKey: string, fromIndex: number, toIndex: number) {
|
||||
const tabs = [...this.state.tabs];
|
||||
const [removed] = tabs.splice(fromIndex, 1);
|
||||
tabs.splice(toIndex, 0, removed);
|
||||
this.setState({ tabs, currentTabIndex: toIndex });
|
||||
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
|
||||
}
|
||||
|
||||
public moveTabRight(tab: TabItem) {
|
||||
const currentIndex = this.state.tabs.indexOf(tab);
|
||||
if (currentIndex >= this.state.tabs.length - 1) {
|
||||
public forceSelectTab(tabKey: string) {
|
||||
const tabIndex = this.state.tabs.findIndex((tab) => tab.state.key === tabKey);
|
||||
const tab = this.state.tabs[tabIndex];
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
const tabs = this.state.tabs.slice();
|
||||
tabs.splice(currentIndex, 1);
|
||||
tabs.splice(currentIndex + 1, 0, tab);
|
||||
this.setState({ tabs, currentTabIndex: this.state.currentTabIndex + 1 });
|
||||
this.publishEvent(new ObjectsReorderedOnCanvasEvent(this), true);
|
||||
}
|
||||
|
||||
public isFirstTab(tab: TabItem): boolean {
|
||||
return this.state.tabs[0] === tab;
|
||||
}
|
||||
|
||||
public isLastTab(tab: TabItem): boolean {
|
||||
return this.state.tabs[this.state.tabs.length - 1] === tab;
|
||||
const editPane = getDashboardSceneFor(this).state.editPane;
|
||||
editPane.selectObject(tab!, tabKey, { force: true, multi: false });
|
||||
this.setState({ currentTabIndex: tabIndex });
|
||||
}
|
||||
|
||||
public static createEmpty(): TabsLayoutManager {
|
||||
@@ -238,9 +207,9 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
|
||||
return new TabsLayoutManager({ tabs });
|
||||
}
|
||||
|
||||
getUrlKey(): string {
|
||||
public getUrlKey(): string {
|
||||
let parent = this.parent;
|
||||
// Panel edit uses tab key already so we are using dtab here to not conflict
|
||||
// Panel edit uses `tab` key already so we are using `dtab` here to not conflict
|
||||
let key = 'dtab';
|
||||
|
||||
while (parent) {
|
||||
|
||||
+41
-23
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Fragment } from 'react';
|
||||
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
@@ -13,7 +13,7 @@ import { TabsLayoutManager } from './TabsLayoutManager';
|
||||
|
||||
export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLayoutManager>) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { tabs } = model.useState();
|
||||
const { tabs, key } = model.useState();
|
||||
const currentTab = model.getCurrentTab();
|
||||
const { layout } = currentTab.useState();
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
@@ -23,27 +23,46 @@ export function TabsLayoutManagerRenderer({ model }: SceneComponentProps<TabsLay
|
||||
return (
|
||||
<div className={styles.tabLayoutContainer}>
|
||||
<TabsBar className={styles.tabsBar}>
|
||||
<div className={styles.tabsRow}>
|
||||
<div className={styles.tabsContainer}>
|
||||
{tabs.map((tab) => (
|
||||
<Fragment key={tab.state.key!}>
|
||||
<tab.Component model={tab} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
{isEditing && (
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans>
|
||||
</Button>
|
||||
{hasCopiedTab && (
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans>
|
||||
</Button>
|
||||
<DragDropContext
|
||||
onBeforeDragStart={(start) => model.forceSelectTab(start.draggableId)}
|
||||
onDragEnd={(result) => {
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.destination.index === result.source.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.moveTab(result.draggableId, result.source.index, result.destination.index);
|
||||
}}
|
||||
>
|
||||
<div className={styles.tabsRow}>
|
||||
<Droppable droppableId={key!} direction="horizontal">
|
||||
{(dropProvided) => (
|
||||
<div className={styles.tabsContainer} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
|
||||
{tabs.map((tab) => (
|
||||
<tab.Component model={tab} key={tab.state.key!} />
|
||||
))}
|
||||
|
||||
{dropProvided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Droppable>
|
||||
{isEditing && (
|
||||
<div className="dashboard-canvas-add-button">
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.addNewTab()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.new-tab">New tab</Trans>
|
||||
</Button>
|
||||
{hasCopiedTab && (
|
||||
<Button icon="plus" variant="primary" fill="text" onClick={() => model.pasteTab()}>
|
||||
<Trans i18nKey="dashboard.canvas-actions.paste-tab">Paste tab</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</TabsBar>
|
||||
<TabContent className={styles.tabContentContainer}>
|
||||
{currentTab && <layout.Component model={layout} />}
|
||||
@@ -59,7 +78,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
flex: '1 1 auto',
|
||||
}),
|
||||
tabsBar: css({
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
'.dashboard-canvas-add-button': {
|
||||
filter: 'unset',
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
VizPanel,
|
||||
VizPanelMenu,
|
||||
} from '@grafana/scenes';
|
||||
import { useElementSelection, UseElementSelectionResult } from '@grafana/ui';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
|
||||
|
||||
@@ -463,12 +462,6 @@ export function useIsConditionallyHidden(scene: RowItem | AutoGridItem): boolean
|
||||
return !(conditionalRendering?.evaluate() ?? true);
|
||||
}
|
||||
|
||||
export function useElementSelectionScene(scene: SceneObject): UseElementSelectionResult {
|
||||
const { key } = scene.useState();
|
||||
|
||||
return useElementSelection(key);
|
||||
}
|
||||
|
||||
export function useInterpolatedTitle<T extends SceneObjectState & { title?: string }>(scene: SceneObject<T>): string {
|
||||
const { title } = scene.useState();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user