mirror of https://github.com/grafana/grafana
DashboardLayouts: Multi-select elements (#99257)
* wip * refactor to map Co-authored-by: Sergej-Vlasov <sergej.vlasov@grafana.com> * 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 <sergej.vlasov@grafana.com>pull/99994/head
parent
62aaec14b6
commit
d96c1169c2
@ -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 }; |
||||
} |
@ -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<string, SceneObjectRef<SceneObject>>; |
||||
private sameType?: boolean; |
||||
|
||||
private _isMultiSelection: boolean; |
||||
|
||||
constructor(values: Array<[string, SceneObjectRef<SceneObject>]>) { |
||||
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<SceneObject>]>; contextItems: ElementSelectionContextItem[] } { |
||||
const ref = obj.getRef(); |
||||
let contextItems = [{ id }]; |
||||
let selection: Array<[string, SceneObjectRef<SceneObject>]> = [[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<SceneObject>]>; |
||||
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<SceneObject>]> { |
||||
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; |
||||
} |
||||
} |
@ -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 ( |
||||
<Stack direction="column"> |
||||
<Text> |
||||
<Trans i18nKey="dashboard.edit-pane.objects.multi-select.selection-number">No. of objects selected: </Trans> |
||||
{this.items?.length} |
||||
</Text> |
||||
<Stack direction="row"> |
||||
<Button size="sm" variant="secondary" icon="copy" /> |
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" /> |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,55 @@ |
||||
import { ReactNode } from 'react'; |
||||
|
||||
import { SceneObject, VizPanel } from '@grafana/scenes'; |
||||
import { Button, Stack, Text } from '@grafana/ui'; |
||||
import { Trans } from 'app/core/internationalization'; |
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../scene/types'; |
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; |
||||
|
||||
export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement { |
||||
public isMultiSelectedEditableDashboardElement: true = true; |
||||
|
||||
private items?: VizPanel[]; |
||||
|
||||
constructor(items: SceneObject[]) { |
||||
this.items = []; |
||||
|
||||
for (const item of items) { |
||||
if (item instanceof VizPanel) { |
||||
this.items.push(item); |
||||
} |
||||
} |
||||
} |
||||
|
||||
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { |
||||
return []; |
||||
} |
||||
|
||||
public onDelete = () => { |
||||
for (const panel of this.items || []) { |
||||
const layout = dashboardSceneGraph.getLayoutManagerFor(panel); |
||||
layout.removePanel(panel); |
||||
} |
||||
}; |
||||
|
||||
public getTypeName(): string { |
||||
return 'Panels'; |
||||
} |
||||
|
||||
renderActions(): ReactNode { |
||||
return ( |
||||
<Stack direction="column"> |
||||
<Text> |
||||
<Trans i18nKey="dashboard.edit-pane.panels.multi-select.selection-number">No. of panels selected: </Trans> |
||||
{this.items?.length} |
||||
</Text> |
||||
<Stack direction="row"> |
||||
<Button size="sm" variant="secondary" icon="copy" /> |
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" /> |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
} |
||||
} |
@ -1,31 +1,17 @@ |
||||
import { useMemo } from 'react'; |
||||
|
||||
import { SceneObject, VizPanel } from '@grafana/scenes'; |
||||
import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types'; |
||||
import { ElementSelection } from './ElementSelection'; |
||||
|
||||
import { DashboardEditableElement } from './DashboardEditableElement'; |
||||
import { VizPanelEditableElement } from './VizPanelEditableElement'; |
||||
|
||||
export function useEditableElement(sceneObj: SceneObject | undefined): EditableDashboardElement | undefined { |
||||
export function useEditableElement( |
||||
selection: ElementSelection | undefined |
||||
): EditableDashboardElement | MultiSelectedEditableDashboardElement | undefined { |
||||
return useMemo(() => { |
||||
if (!sceneObj) { |
||||
if (!selection) { |
||||
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; |
||||
}, [sceneObj]); |
||||
return selection.createSelectionElement(); |
||||
}, [selection]); |
||||
} |
||||
|
@ -0,0 +1,92 @@ |
||||
import { ReactNode, useMemo } from 'react'; |
||||
|
||||
import { SceneObject } from '@grafana/scenes'; |
||||
import { Button, Stack, Switch, Text } from '@grafana/ui'; |
||||
import { t, Trans } from 'app/core/internationalization'; |
||||
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; |
||||
|
||||
import { MultiSelectedEditableDashboardElement } from '../types'; |
||||
|
||||
import { RowItem } from './RowItem'; |
||||
|
||||
export class MultiSelectedRowItemsElement implements MultiSelectedEditableDashboardElement { |
||||
public isMultiSelectedEditableDashboardElement: true = true; |
||||
|
||||
private items?: RowItem[]; |
||||
|
||||
constructor(items: SceneObject[]) { |
||||
this.items = []; |
||||
|
||||
for (const item of items) { |
||||
if (item instanceof RowItem) { |
||||
this.items.push(item); |
||||
} |
||||
} |
||||
} |
||||
|
||||
useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { |
||||
const rows = this.items; |
||||
|
||||
const rowOptions = useMemo(() => { |
||||
return new OptionsPaneCategoryDescriptor({ |
||||
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'), |
||||
id: 'ms-row-options', |
||||
isOpenDefault: true, |
||||
}).addItem( |
||||
new OptionsPaneItemDescriptor({ |
||||
title: t('dashboard.edit-pane.row.hide', 'Hide row header'), |
||||
render: () => <RowHeaderSwitch rows={rows} />, |
||||
}) |
||||
); |
||||
}, [rows]); |
||||
|
||||
return [rowOptions]; |
||||
} |
||||
|
||||
public getTypeName(): string { |
||||
return 'Rows'; |
||||
} |
||||
|
||||
public onDelete = () => { |
||||
for (const item of this.items || []) { |
||||
item.onDelete(); |
||||
} |
||||
}; |
||||
|
||||
renderActions(): ReactNode { |
||||
return ( |
||||
<Stack direction="column"> |
||||
<Text> |
||||
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number">No. of rows selected: </Trans> |
||||
{this.items?.length} |
||||
</Text> |
||||
<Stack direction="row"> |
||||
<Button size="sm" variant="secondary" icon="copy" /> |
||||
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete} icon="trash-alt" /> |
||||
</Stack> |
||||
</Stack> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export function RowHeaderSwitch({ rows }: { rows: RowItem[] | undefined }) { |
||||
if (!rows) { |
||||
return null; |
||||
} |
||||
|
||||
const { isHeaderHidden = false } = rows[0].useState(); |
||||
|
||||
return ( |
||||
<Switch |
||||
value={isHeaderHidden} |
||||
onChange={() => { |
||||
for (const row of rows) { |
||||
row.setState({ |
||||
isHeaderHidden: !isHeaderHidden, |
||||
}); |
||||
} |
||||
}} |
||||
/> |
||||
); |
||||
} |
Loading…
Reference in new issue