Add drag & drop for new button

This commit is contained in:
idastambuk
2026-01-08 14:59:15 +01:00
parent 48fe1ec634
commit fa50d21811
4 changed files with 105 additions and 30 deletions
@@ -46,7 +46,7 @@ export function SidebarComp({ children, contextValue }: Props) {
return (
<SidebarContext.Provider value={contextValue}>
<div ref={ref} className={className} style={style}>
<div ref={ref} className={className} style={style} data-testid="dashboard-edit-pane-sidebar">
{!tabsMode && <SidebarResizer />}
{children}
</div>
@@ -36,7 +36,11 @@ export function DashboardEditPaneRenderer({ editPane, dashboard }: Props) {
const hasUid = Boolean(uid);
const selectedObject = selection?.getFirstObject();
const isNewElement = selection?.isNewElement() ?? false;
const [selectedLayoutElement, setSelectedLayoutElement] = useState(selectedObject);
// the layout element that was selected when opening the 'add' pane
// used when adding new panel from the sidebar
const [selectedLayoutElement, setSelectedLayoutElement] = useState<DashboardScene | SceneObject<SceneObjectState>>(
dashboard
);
const editableElement = useMemo(() => {
if (selection) {
@@ -46,7 +50,7 @@ export function DashboardEditPaneRenderer({ editPane, dashboard }: Props) {
return undefined;
}, [selection]);
// saves the row or tab selected when opening the 'add' pane, so new panels can later be added to it
const onSetLayoutElement = (obj: SceneObject<SceneObjectState> | undefined) => {
if (obj instanceof RowItem || obj instanceof TabItem) {
setSelectedLayoutElement(obj);
@@ -80,7 +84,7 @@ export function DashboardEditPaneRenderer({ editPane, dashboard }: Props) {
)}
{openPane === 'add' && (
<Sidebar.OpenPane>
<DashboardSidePaneNew onAddPanel={onAddNewPanel} />
<DashboardSidePaneNew onAddPanel={onAddNewPanel} dashboard={dashboard} />
</Sidebar.OpenPane>
)}
{openPane === 'outline' && (
@@ -1,30 +1,60 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Sidebar, Text, useStyles2 } from '@grafana/ui';
import addPanelImg from 'img/dashboards/add-panel.png';
import { getDashboardSceneFor } from '../utils/utils';
export function DashboardSidePaneNew({ onAddPanel }: {onAddPanel: () => void}) {
export function DashboardSidePaneNew({ onAddPanel, dashboard }: { onAddPanel: () => void; dashboard: SceneObject }) {
const styles = useStyles2(getStyles);
const orchestrator = getDashboardSceneFor(dashboard).state.layoutOrchestrator;
return (
<div className={styles.sidePanel}>
<Sidebar.PaneHeader title={t('dashboard.add.pane-header', 'Add')} />
<div className={styles.sidePanelContainer}>
<Text weight="medium">{t('dashboard.add.new-panel.title', 'Panel')}</Text>
<Text variant="bodySmall">
{t('dashboard.add.new-panel.description', 'Drag or click to add a panel')}
</Text>
<div className={styles.imageContainer}>
<button className={styles.noStyles} onClick={onAddPanel} aria-label={t('dashboard.add.new-panel.title', 'Panel')}>
<img alt="Add panel click area" src={addPanelImg} />
</button>
</div>
</div>
</div>
<DragDropContext onDragStart={() => orchestrator?.startDraggingNewPanel()} onDragEnd={() => {}}>
<Droppable droppableId="side-drop-id" isDropDisabled>
{(dropProvided) => (
<div className={styles.sidePanel} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
<Sidebar.PaneHeader title={t('dashboard.add.pane-header', 'Add')} />
<div className={styles.sidePanelContainer}>
<Text weight="medium">{t('dashboard.add.new-panel.title', 'Panel')}</Text>
<Text variant="bodySmall">
{t('dashboard.add.new-panel.description', 'Drag or click to add a panel')}
</Text>
<div className={styles.dragContainer}>
<Draggable draggableId="new-panel-drag" index={0}>
{(dragProvided, dragSnapshot) => {
return (
<div
role="button"
tabIndex={0}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
className={cx(styles.imageContainer, dragSnapshot.isDragging && styles.dragging)}
onClick={onAddPanel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAddPanel();
}
}}
aria-label={t('dashboard.add.new-panel.title', 'Panel')}
>
<img alt="Add panel click area" src={addPanelImg} draggable={false} />
</div>
);
}}
</Draggable>
</div>
</div>
</div>
)}
</Droppable>
</DragDropContext>
);
}
@@ -33,24 +63,28 @@ function getStyles(theme: GrafanaTheme2) {
sidePanel: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
dragging: css({
cursor: 'move',
}),
sidePanelContainer: css({
padding: theme.spacing(2),
span: {
display: 'block',
},
}),
dragContainer: css({
height: 150,
}),
imageContainer: css({
opacity: 0.8,
cursor: 'pointer',
opacity: 0.8,
overflow: 'hidden',
padding: theme.spacing(2, 0),
borderRadius: theme.shape.radius.sm,
cursor: 'pointer',
width: '100%',
'&:hover': {
opacity: 1,
}
}),
noStyles: css({
all: 'unset',
},
}),
};
}
@@ -11,11 +11,16 @@ import {
} from '@grafana/scenes';
import { createPointerDistance } from '@grafana/ui';
import { getDefaultVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { RowItem } from './layout-rows/RowItem';
import { TabItem } from './layout-tabs/TabItem';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
interface DashboardLayoutOrchestratorState extends SceneObjectState {
draggingGridItem?: SceneObjectRef<SceneGridItemLike>;
isDraggingNewPanel?: boolean;
}
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
@@ -59,10 +64,19 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this.setState({ draggingGridItem: gridItem.getRef() });
}
public startDraggingNewPanel(): void {
document.body.addEventListener('pointermove', this._onPointerMove);
document.body.addEventListener('pointerup', this._stopDraggingSync, true);
this.setState({ isDraggingNewPanel: true });
}
private _stopDraggingSync(_evt: PointerEvent) {
const gridItem = this.state.draggingGridItem?.resolve();
if (this._sourceDropTarget !== this._lastDropTarget) {
if (this.state.isDraggingNewPanel) {
this._dropNewPanel(_evt);
} else if (this._sourceDropTarget !== this._lastDropTarget) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
setTimeout(() => {
@@ -76,12 +90,35 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
logWarning(warningMessage);
}
});
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingGridItem: undefined });
}
}
private _dropNewPanel(_evt: PointerEvent) {
const elementsUnderPoint = document.elementsFromPoint(_evt.clientX, _evt.clientY);
// if the cursor is in the sidebar, don't add panel
const isInSidebar = elementsUnderPoint.some((el) => el.closest('[data-testid="dashboard-edit-pane-sidebar"]') !== null);
if (isInSidebar) {
return;
}
const panel = getDefaultVizPanel();
if (!this._lastDropTarget) {
// if no lastDropTarget and not in Sidebar, treat the dashboard itself as the drop target
this._getDashboard().addPanel(panel);
}
if (this._lastDropTarget instanceof RowItem || this._lastDropTarget instanceof TabItem) {
this._lastDropTarget.getLayout().addPanel(panel);
}
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
this.setState({ draggingGridItem: undefined });
this.setState({ isDraggingNewPanel: false });
}
private _onPointerMove(evt: PointerEvent) {