From d96c1169c25d2c4f6740ac7827101ade44a597cb Mon Sep 17 00:00:00 2001 From: Victor Marin <36818606+mdvictor@users.noreply.github.com> Date: Mon, 3 Feb 2025 17:21:38 +0200 Subject: [PATCH] DashboardLayouts: Multi-select elements (#99257) * wip * refactor to map Co-authored-by: Sergej-Vlasov * refactor + allow selecting any kind of elements * rename class * refactor + tests * cr changes * fix deselection on shift clicking multiselected objects * i18n * fix * move logic to elementSelection * lint fix * unselecting last multiselected item should reopen dashboard options --------- Co-authored-by: Sergej-Vlasov --- .../edit-pane/DashboardEditPane.tsx | 66 +++++-- .../edit-pane/DashboardEditPaneSplitter.tsx | 8 +- .../edit-pane/ElementEditPane.tsx | 6 +- .../edit-pane/ElementSelection.test.ts | 178 +++++++++++++++++ .../edit-pane/ElementSelection.ts | 184 ++++++++++++++++++ .../MultiSelectedObjectsEditableElement.tsx | 40 ++++ .../MultiSelectedVizPanelsEditableElement.tsx | 55 ++++++ .../edit-pane/VizPanelEditableElement.tsx | 4 +- .../edit-pane/useEditableElement.ts | 30 +-- .../MultiSelectedRowItemsElement.tsx | 92 +++++++++ .../scene/layout-rows/RowItem.tsx | 13 +- .../scene/layout-rows/RowsLayoutManager.tsx | 2 +- .../features/dashboard-scene/scene/types.ts | 38 +++- public/locales/en-US/grafana.json | 31 ++- public/locales/pseudo-LOCALE/grafana.json | 31 ++- 15 files changed, 715 insertions(+), 63 deletions(-) create mode 100644 public/app/features/dashboard-scene/edit-pane/ElementSelection.test.ts create mode 100644 public/app/features/dashboard-scene/edit-pane/ElementSelection.ts create mode 100644 public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx create mode 100644 public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx create mode 100644 public/app/features/dashboard-scene/scene/layout-rows/MultiSelectedRowItemsElement.tsx diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 6376687d480..381011a89ff 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -2,24 +2,18 @@ import { css } from '@emotion/css'; import { useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { - SceneObjectState, - SceneObjectBase, - SceneObject, - SceneObjectRef, - sceneGraph, - useSceneObjectState, -} from '@grafana/scenes'; +import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObjectState } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui'; import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; import { ElementEditPane } from './ElementEditPane'; +import { ElementSelection } from './ElementSelection'; import { useEditableElement } from './useEditableElement'; export interface DashboardEditPaneState extends SceneObjectState { - selectedObject?: SceneObjectRef; + selection?: ElementSelection; selectionContext: ElementSelectionContextState; } @@ -42,7 +36,7 @@ export class DashboardEditPane extends SceneObjectBase { public disableSelection() { this.setState({ selectionContext: { ...this.state.selectionContext, selected: [], enabled: false }, - selectedObject: undefined, + selection: undefined, }); } @@ -64,17 +58,49 @@ export class DashboardEditPane extends SceneObjectBase { } public selectObject(obj: SceneObject, id: string, multi?: boolean) { - const currentSelection = this.state.selectedObject?.resolve(); - if (currentSelection === obj) { + if (!this.state.selection) { + return; + } + + const prevItem = this.state.selection.getFirstObject(); + if (prevItem === obj && !multi) { + this.clearSelection(); + return; + } + + if (multi && this.state.selection.hasValue(id)) { + this.removeMultiSelectedObject(id); + return; + } + + const { selection, contextItems: selected } = this.state.selection.getStateWithValue(id, obj, !!multi); + + this.setState({ + selection: new ElementSelection(selection), + selectionContext: { + ...this.state.selectionContext, + selected, + }, + }); + } + + private removeMultiSelectedObject(id: string) { + if (!this.state.selection) { + return; + } + + const { entries, contextItems: selected } = this.state.selection.getStateWithoutValueAt(id); + + if (entries.length === 0) { this.clearSelection(); return; } this.setState({ - selectedObject: obj.getRef(), + selection: new ElementSelection([...entries]), selectionContext: { ...this.state.selectionContext, - selected: [{ id }], + selected, }, }); } @@ -82,7 +108,7 @@ export class DashboardEditPane extends SceneObjectBase { public clearSelection() { const dashboard = getDashboardSceneFor(this); this.setState({ - selectedObject: dashboard.getRef(), + selection: new ElementSelection([[dashboard.state.uid!, dashboard.getRef()]]), selectionContext: { ...this.state.selectionContext, selected: [], @@ -103,9 +129,11 @@ export interface Props { export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse }: Props) { // Activate the edit pane useEffect(() => { - if (!editPane.state.selectedObject) { + if (!editPane.state.selection) { const dashboard = getDashboardSceneFor(editPane); - editPane.setState({ selectedObject: dashboard.getRef() }); + editPane.setState({ + selection: new ElementSelection([[dashboard.state.uid!, dashboard.getRef()]]), + }); } editPane.enableSelection(); @@ -115,10 +143,10 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla }; }, [editPane]); - const { selectedObject } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); + const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true }); const styles = useStyles2(getStyles); const paneRef = useRef(null); - const editableElement = useEditableElement(selectedObject?.resolve()); + const editableElement = useEditableElement(selection); if (!editableElement) { return null; diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index c45c47be262..b558a1e2abb 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -76,7 +76,13 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
editPane.clearSelection()} + onPointerDown={(evt) => { + if (evt.shiftKey) { + return; + } + + editPane.clearSelection(); + }} >
{controls}
diff --git a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx index ff2ef751086..b60b48fd549 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx @@ -4,14 +4,14 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Stack, useStyles2 } from '@grafana/ui'; import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory'; -import { EditableDashboardElement } from '../scene/types'; +import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types'; export interface Props { - element: EditableDashboardElement; + element: EditableDashboardElement | MultiSelectedEditableDashboardElement; } export function ElementEditPane({ element }: Props) { - const categories = element.useEditPaneOptions(); + const categories = element.useEditPaneOptions ? element.useEditPaneOptions() : []; const styles = useStyles2(getStyles); return ( diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.test.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.test.ts new file mode 100644 index 00000000000..f76e7ecc23a --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.test.ts @@ -0,0 +1,178 @@ +import { SceneTimeRange, VizPanel } from '@grafana/scenes'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; + +import { DashboardEditableElement } from './DashboardEditableElement'; +import { ElementSelection } from './ElementSelection'; +import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement'; +import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement'; +import { VizPanelEditableElement } from './VizPanelEditableElement'; + +let panel1: VizPanel, panel2: VizPanel, scene: DashboardScene; + +describe('ElementSelection', () => { + beforeAll(() => { + const testScene = buildScene(); + + panel1 = testScene.panel1; + panel2 = testScene.panel2; + scene = testScene.scene; + }); + + it('returns a single object when only one is selected', () => { + const selection = new ElementSelection([['id1', panel1.getRef()]]); + + expect(selection.isMultiSelection).toBe(false); + expect(selection.getSelection()).toBe(panel1); + }); + + it('returns multiple objects when multiple are selected', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + expect(selection.isMultiSelection).toBe(true); + expect(selection.getSelection()).toEqual([panel1, panel2]); + }); + + it('delete element', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + selection.removeValue('id1'); + expect(selection.isMultiSelection).toBe(false); + expect(selection.getSelection()).toEqual(panel2); + }); + + it('returns entries', () => { + const ref1 = panel1.getRef(); + const ref2 = panel2.getRef(); + + const selection = new ElementSelection([ + ['id1', ref1], + ['id2', ref2], + ]); + + expect(selection.isMultiSelection).toBe(true); + expect(selection.getSelectionEntries()).toEqual([ + ['id1', ref1], + ['id2', ref2], + ]); + }); + + it('returns the first selected object through getFirstObject', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + expect(selection.isMultiSelection).toBe(true); + expect(selection.getFirstObject()).toBe(panel1); + }); + + it('creates correct element type for single selection', () => { + const vizSelection = new ElementSelection([['id1', panel1.getRef()]]); + expect(vizSelection.createSelectionElement()).toBeInstanceOf(VizPanelEditableElement); + + const dashboardSelection = new ElementSelection([['id1', scene.getRef()]]); + expect(dashboardSelection.createSelectionElement()).toBeInstanceOf(DashboardEditableElement); + }); + + it('creates correct element type for multi-selection of same type', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + expect(selection.createSelectionElement()).toBeInstanceOf(MultiSelectedVizPanelsEditableElement); + }); + + it('creates MultiSelectedObjectsEditableElement for selection of different object types', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', scene.getRef()], + ]); + + expect(selection.createSelectionElement()).toBeInstanceOf(MultiSelectedObjectsEditableElement); + }); + + it('handles empty selection correctly', () => { + const selection = new ElementSelection([]); + expect(selection.getSelection()).toBeUndefined(); + expect(selection.getFirstObject()).toBeUndefined(); + expect(selection.createSelectionElement()).toBeUndefined(); + }); + + it('returns the entries with the specified value removed', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ['id3', scene.getRef()], + ]); + + const { entries, contextItems } = selection.getStateWithoutValueAt('id2'); + expect(entries).toEqual([ + ['id1', panel1.getRef()], + ['id3', scene.getRef()], + ]); + expect(contextItems).toEqual([{ id: 'id1' }, { id: 'id3' }]); + }); + + it('returns the entries with the specified value added in a multi-select scenario', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + const { selection: entries, contextItems } = selection.getStateWithValue('id3', scene, true); + + expect(entries).toEqual([ + ['id3', panel1.getRef()], + ['id1', panel2.getRef()], + ['id2', scene.getRef()], + ]); + expect(contextItems).toEqual([{ id: 'id3' }, { id: 'id1' }, { id: 'id2' }]); + }); + + it('returns the entries with just the specified value added in a non multi-select scenario', () => { + const selection = new ElementSelection([ + ['id1', panel1.getRef()], + ['id2', panel2.getRef()], + ]); + + const { selection: entries, contextItems } = selection.getStateWithValue('id3', scene, false); + + expect(entries).toEqual([['id3', scene.getRef()]]); + expect(contextItems).toEqual([{ id: 'id3' }]); + }); +}); + +function buildScene() { + const panel1 = new VizPanel({ + title: 'Panel A', + // pluginId: 'text', + key: 'panel-12', + }); + + const panel2 = new VizPanel({ + title: 'Panel B', + // pluginId: 'text', + key: 'panel-13', + }); + + const scene = new DashboardScene({ + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + $timeRange: new SceneTimeRange({}), + body: DefaultGridLayoutManager.fromVizPanels([panel1, panel2]), + }); + + return { panel1, panel2, scene }; +} diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts new file mode 100644 index 00000000000..29f107a2d84 --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts @@ -0,0 +1,184 @@ +import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes'; +import { ElementSelectionContextItem } from '@grafana/ui'; + +import { DashboardScene } from '../scene/DashboardScene'; +import { + EditableDashboardElement, + isBulkActionElement, + isEditableDashboardElement, + MultiSelectedEditableDashboardElement, +} from '../scene/types'; + +import { DashboardEditableElement } from './DashboardEditableElement'; +import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement'; +import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement'; +import { VizPanelEditableElement } from './VizPanelEditableElement'; + +export class ElementSelection { + private selectedObjects?: Map>; + private sameType?: boolean; + + private _isMultiSelection: boolean; + + constructor(values: Array<[string, SceneObjectRef]>) { + this.selectedObjects = new Map(values); + this._isMultiSelection = values.length > 1; + + if (this.isMultiSelection) { + this.sameType = this.checkSameType(); + } + } + + private checkSameType() { + const values = this.selectedObjects?.values(); + const firstType = values?.next().value?.resolve()?.constructor.name; + + if (!firstType) { + return false; + } + + for (let obj of values ?? []) { + if (obj.resolve()?.constructor.name !== firstType) { + return false; + } + } + + return true; + } + + public hasValue(id: string) { + return this.selectedObjects?.has(id); + } + + public removeValue(id: string) { + this.selectedObjects?.delete(id); + + if (this.selectedObjects && this.selectedObjects.size < 2) { + this.sameType = undefined; + this._isMultiSelection = false; + } + } + + public getStateWithValue( + id: string, + obj: SceneObject, + isMulti: boolean + ): { selection: Array<[string, SceneObjectRef]>; contextItems: ElementSelectionContextItem[] } { + const ref = obj.getRef(); + let contextItems = [{ id }]; + let selection: Array<[string, SceneObjectRef]> = [[id, ref]]; + + const entries = this.getSelectionEntries() ?? []; + const items = entries.map(([key]) => ({ id: key })); + + if (isMulti) { + selection = [[id, ref], ...entries]; + contextItems = [{ id }, ...items]; + } + + return { selection, contextItems }; + } + + public getStateWithoutValueAt(id: string): { + entries: Array<[string, SceneObjectRef]>; + contextItems: ElementSelectionContextItem[]; + } { + this.removeValue(id); + const entries = this.getSelectionEntries() ?? []; + const contextItems = entries.map(([key]) => ({ id: key })); + + return { entries, contextItems }; + } + + public getSelection(): SceneObject | SceneObject[] | undefined { + if (this.isMultiSelection) { + return this.getSceneObjects(); + } + + return this.getFirstObject(); + } + + public getSelectionEntries(): Array<[string, SceneObjectRef]> { + return Array.from(this.selectedObjects?.entries() ?? []); + } + + public getFirstObject(): SceneObject | undefined { + return this.selectedObjects?.values().next().value?.resolve(); + } + + public get isMultiSelection(): boolean { + return this._isMultiSelection; + } + + private getSceneObjects(): SceneObject[] { + return Array.from(this.selectedObjects?.values() ?? []).map((obj) => obj.resolve()); + } + + public createSelectionElement() { + if (this.isMultiSelection) { + return this.createMultiSelectedElement(); + } + + return this.createSingleSelectedElement(); + } + + private createSingleSelectedElement(): EditableDashboardElement | undefined { + const sceneObj = this.selectedObjects?.values().next().value?.resolve(); + + if (!sceneObj) { + return undefined; + } + + if (isEditableDashboardElement(sceneObj)) { + return sceneObj; + } + + if (sceneObj instanceof VizPanel) { + return new VizPanelEditableElement(sceneObj); + } + + if (sceneObj instanceof DashboardScene) { + return new DashboardEditableElement(sceneObj); + } + + return undefined; + } + + private createMultiSelectedElement(): MultiSelectedEditableDashboardElement | undefined { + if (!this.isMultiSelection) { + return; + } + + const sceneObjects = this.getSceneObjects(); + + if (this.sameType) { + const firstObj = this.selectedObjects?.values().next().value?.resolve(); + + if (firstObj instanceof VizPanel) { + return new MultiSelectedVizPanelsEditableElement(sceneObjects); + } + + if (isEditableDashboardElement(firstObj!)) { + return firstObj.createMultiSelectedElement?.(sceneObjects); + } + } + + const bulkActionElements = []; + for (const sceneObject of sceneObjects) { + if (sceneObject instanceof VizPanel) { + const editableElement = new VizPanelEditableElement(sceneObject); + bulkActionElements.push(editableElement); + } + + if (isBulkActionElement(sceneObject)) { + bulkActionElements.push(sceneObject); + } + } + + if (bulkActionElements.length) { + return new MultiSelectedObjectsEditableElement(bulkActionElements); + } + + return undefined; + } +} diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx new file mode 100644 index 00000000000..574de4164c1 --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from 'react'; + +import { Stack, Text, Button } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { BulkActionElement, MultiSelectedEditableDashboardElement } from '../scene/types'; + +export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement { + public isMultiSelectedEditableDashboardElement: true = true; + private items?: BulkActionElement[]; + + constructor(items: BulkActionElement[]) { + this.items = items; + } + + public onDelete = () => { + for (const item of this.items || []) { + item.onDelete(); + } + }; + + public getTypeName(): string { + return 'Objects'; + } + + renderActions(): ReactNode { + return ( + + + No. of objects selected: + {this.items?.length} + + +