Dashboards: Rows layout & editing (#96895)

* Dashboard: Panel edit and support for more layout items

* It's working

* Fix discard issue

* remove unused file

* Update

* Editing for responsive grid items now work

* Update

* Update

* Update

* WIP rows

* progres

* Progress

* Progress

* Focus selection works

* Update

* Update

* Progress

* Update

* Editing rows work

* Row editing works

* fix delete

* Update

* Row options fix

* Fix selecting rows

* Update

* update

* Update

* Update

* Remove cog icon button

* add import to toolbar

* Update

* Update
pull/97717/head
Torkel Ödegaard 5 months ago committed by GitHub
parent d762a96436
commit 71b8f487e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      .betterer.results
  2. 31
      public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx
  3. 16
      public/app/features/dashboard-scene/edit-pane/DashboardEditPaneBehavior.tsx
  4. 30
      public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx
  5. 130
      public/app/features/dashboard-scene/edit-pane/VizPanelEditPaneBehavior.tsx
  6. 36
      public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx
  7. 213
      public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
  8. 52
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  9. 22
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx
  10. 112
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
  11. 242
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  12. 113
      public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
  13. 66
      public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx
  14. 105
      public/app/features/dashboard-scene/scene/layouts-shared/LayoutEditChrome.tsx
  15. 7
      public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts
  16. 17
      public/app/features/dashboard-scene/scene/types.ts
  17. 51
      public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts
  18. 2
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
  19. 3
      public/app/features/dashboard-scene/utils/dashboardSceneGraph.ts
  20. 15
      public/app/features/dashboard-scene/utils/utils.ts
  21. 6
      public/app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor.tsx

@ -1965,6 +1965,11 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/edit-pane/VizPanelEditPaneBehavior.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
@ -2088,7 +2093,10 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "12"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"]
],
"public/app/features/dashboard-scene/scene/PanelMenuBehavior.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
@ -2096,6 +2104,11 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/scene/PanelSearchLayout.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/dashboard-scene/scene/row-actions/RowActions.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],

@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, SceneObjectBase, SceneObject, SceneObjectRef } from '@grafana/scenes';
import { ToolbarButton, useStyles2 } from '@grafana/ui';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
import { ElementEditPane } from './ElementEditPane';
@ -13,7 +14,17 @@ export interface DashboardEditPaneState extends SceneObjectState {
selectedObject?: SceneObjectRef<SceneObject>;
}
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {}
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public selectObject(obj: SceneObject) {
const currentSelection = this.state.selectedObject?.resolve();
if (currentSelection === obj) {
const dashboard = getDashboardSceneFor(this);
this.setState({ selectedObject: dashboard.getRef() });
} else {
this.setState({ selectedObject: obj.getRef() });
}
}
}
export interface Props {
editPane: DashboardEditPane;
@ -57,13 +68,29 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla
);
}
const element = getEditableElementFor(selectedObject.resolve());
return (
<div className={styles.wrapper} ref={paneRef}>
<ElementEditPane obj={selectedObject.resolve()} />
<ElementEditPane element={element} key={element.getTypeName()} />
</div>
);
}
function getEditableElementFor(obj: SceneObject): EditableDashboardElement {
if (isEditableDashboardElement(obj)) {
return obj;
}
for (const behavior of obj.state.$behaviors ?? []) {
if (isEditableDashboardElement(behavior)) {
return behavior;
}
}
throw new Error("Can't find editable element for selected object");
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({

@ -1,19 +1,23 @@
import { useMemo } from 'react';
import { SceneObjectBase } from '@grafana/scenes';
import { Input, TextArea } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement } from '../scene/types';
import { getDashboardSceneFor } from '../utils/utils';
export class DummySelectedObject implements EditableDashboardElement {
export class DashboardEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
constructor(private dashboard: DashboardScene) {}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const dashboard = this.dashboard;
const dashboard = getDashboardSceneFor(this);
// When layout changes we need to update options list
const { body } = dashboard.useState();
const dashboardOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
@ -39,7 +43,9 @@ export class DummySelectedObject implements EditableDashboardElement {
);
}, [dashboard]);
return [dashboardOptions];
const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
}
public getTypeName(): string {

@ -1,21 +1,16 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
import { Stack, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
import { DashboardScene } from '../scene/DashboardScene';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types';
import { DummySelectedObject } from './DummySelectedObject';
import { EditableDashboardElement } from '../scene/types';
export interface Props {
obj: SceneObject;
element: EditableDashboardElement;
}
export function ElementEditPane({ obj }: Props) {
const element = getEditableElementFor(obj);
export function ElementEditPane({ element }: Props) {
const categories = element.useEditPaneOptions();
const styles = useStyles2(getStyles);
@ -36,25 +31,6 @@ export function ElementEditPane({ obj }: Props) {
);
}
function getEditableElementFor(obj: SceneObject): EditableDashboardElement {
if (isEditableDashboardElement(obj)) {
return obj;
}
for (const behavior of obj.state.$behaviors ?? []) {
if (isEditableDashboardElement(behavior)) {
return behavior;
}
}
// Temp thing to show somethin in edit pane
if (obj instanceof DashboardScene) {
return new DummySelectedObject(obj);
}
throw new Error("Can't find editable element for selected object");
}
function getStyles(theme: GrafanaTheme2) {
return {
noBorderTop: css({

@ -0,0 +1,130 @@
import { useMemo } from 'react';
import { sceneGraph, SceneObjectBase, VizPanel } from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions';
import {
PanelBackgroundSwitch,
PanelDescriptionTextArea,
PanelFrameTitleInput,
} from '../panel-edit/getPanelFrameOptions';
import { EditableDashboardElement, isDashboardLayoutItem } from '../scene/types';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
export class VizPanelEditPaneBehavior extends SceneObjectBase implements EditableDashboardElement {
public isEditableDashboardElement: true = true;
private getPanel(): VizPanel {
const panel = this.parent;
if (!(panel instanceof VizPanel)) {
throw new Error('VizPanelEditPaneBehavior must have a VizPanel parent');
}
return panel;
}
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const panel = this.getPanel();
const layoutElement = panel.parent!;
const panelOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Panel options',
id: 'panel-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return <PanelFrameTitleInput panel={panel} />;
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Description',
value: panel.state.description,
render: function renderDescription() {
return <PanelDescriptionTextArea panel={panel} />;
},
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Transparent background',
render: function renderTransparent() {
return <PanelBackgroundSwitch panel={panel} />;
},
})
);
}, [panel]);
const layoutCategory = useMemo(() => {
if (isDashboardLayoutItem(layoutElement) && layoutElement.getOptions) {
return layoutElement.getOptions();
}
return undefined;
}, [layoutElement]);
const { options, fieldConfig, _pluginInstanceState } = panel.useState();
const dataProvider = sceneGraph.getData(panel);
const { data } = dataProvider.useState();
const visualizationOptions = useMemo(() => {
const plugin = panel.getPlugin();
if (!plugin) {
return [];
}
return getVisualizationOptions2({
panel,
data,
plugin: plugin,
eventBus: panel.getPanelContext().eventBus,
instanceState: _pluginInstanceState,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, panel, options, fieldConfig, _pluginInstanceState]);
const categories = [panelOptions];
if (layoutCategory) {
categories.push(layoutCategory);
}
categories.push(...visualizationOptions);
return categories;
}
public getTypeName(): string {
return 'Panel';
}
public onDelete = () => {
const layout = dashboardSceneGraph.getLayoutManagerFor(this);
layout.removePanel(this.getPanel());
};
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Edit
</Button>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
</Button>
</>
);
}
}

@ -34,7 +34,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
value: panel.state.title,
popularRank: 1,
render: function renderTitle() {
return <PanelFrameTitle panel={panel} />;
return <PanelFrameTitleInput panel={panel} />;
},
addon: config.featureToggles.dashgpt && (
<GenAIPanelTitleButton
@ -50,7 +50,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
title: 'Description',
value: panel.state.description,
render: function renderDescription() {
return <DescriptionTextArea panel={panel} />;
return <PanelDescriptionTextArea panel={panel} />;
},
addon: config.featureToggles.dashgpt && (
<GenAIPanelDescriptionButton
@ -64,17 +64,7 @@ export function getPanelFrameOptions(panel: VizPanel): OptionsPaneCategoryDescri
new OptionsPaneItemDescriptor({
title: 'Transparent background',
render: function renderTransparent() {
return (
<Switch
value={panel.state.displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
return <PanelBackgroundSwitch panel={panel} />;
},
})
)
@ -116,7 +106,7 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) {
);
}
function PanelFrameTitle({ panel }: { panel: VizPanel }) {
export function PanelFrameTitleInput({ panel }: { panel: VizPanel }) {
const { title } = panel.useState();
return (
@ -128,7 +118,7 @@ function PanelFrameTitle({ panel }: { panel: VizPanel }) {
);
}
function DescriptionTextArea({ panel }: { panel: VizPanel }) {
export function PanelDescriptionTextArea({ panel }: { panel: VizPanel }) {
const { description } = panel.useState();
return (
@ -140,6 +130,22 @@ function DescriptionTextArea({ panel }: { panel: VizPanel }) {
);
}
export function PanelBackgroundSwitch({ panel }: { panel: VizPanel }) {
const { displayMode } = panel.useState();
return (
<Switch
value={displayMode === 'transparent'}
id="transparent-background"
onChange={() => {
panel.setState({
displayMode: panel.state.displayMode === 'transparent' ? 'default' : 'transparent',
});
}}
/>
);
}
function setPanelTitle(panel: VizPanel, title: string) {
panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
}

@ -60,6 +60,7 @@ export function ToolbarActions({ dashboard }: Props) {
const canSaveAs = contextSrv.hasEditPermissionInFolders;
const toolbarActions: ToolbarAction[] = [];
const leftActions: ToolbarAction[] = [];
const styles = useStyles2(getStyles);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanelScene);
@ -69,7 +70,8 @@ export function ToolbarActions({ dashboard }: Props) {
// Means we are not in settings view, fullscreen panel or edit panel
const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditing && isShowingDashboard;
const showScopesSelector = config.featureToggles.singleTopNav && config.featureToggles.scopeFilters;
const showScopesSelector = config.featureToggles.singleTopNav && config.featureToggles.scopeFilters && !isEditing;
const dashboardNewLayouts = config.featureToggles.dashboardNewLayouts;
if (!isEditingPanel) {
// This adds the precence indicators in enterprise
@ -151,74 +153,135 @@ export function ToolbarActions({ dashboard }: Props) {
addDynamicActions(toolbarActions, dynamicDashNavActions.right, 'icon-actions');
}
toolbarActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Dropdown
key="add-panel-dropdown"
onVisibleChange={(isOpen) => {
setIsAddPanelMenuOpen(isOpen);
DashboardInteractions.toolbarAddClick();
}}
overlay={() => (
<Menu>
<Menu.Item
key="add-visualization"
testId={selectors.pages.AddDashboard.itemButton('Add new visualization menu item')}
label={t('dashboard.add-menu.visualization', 'Visualization')}
onClick={() => {
const vizPanel = dashboard.onCreateNewPanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) });
}}
/>
<Menu.Item
key="add-panel-lib"
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
label={t('dashboard.add-menu.import', 'Import from library')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
/>
<Menu.Item
key="add-row"
testId={selectors.pages.AddDashboard.itemButton('Add new row menu item')}
label={t('dashboard.add-menu.row', 'Row')}
onClick={() => {
dashboard.onCreateNewRow();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
/>
<Menu.Item
key="paste-panel"
disabled={!hasCopiedPanel}
testId={selectors.pages.AddDashboard.itemButton('Add new panel from clipboard menu item')}
label={t('dashboard.add-menu.paste-panel', 'Paste panel')}
onClick={() => {
dashboard.pastePanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
}}
/>
</Menu>
)}
placement="bottom"
offset={[0, 6]}
>
if (dashboardNewLayouts) {
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-button"
variant="primary"
variant="secondary"
size="sm"
fill="outline"
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
icon="plus"
fill="text"
onClick={() => {
dashboard.onCreateNewPanel();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_visualization')}
>
<Trans i18nKey="dashboard.toolbar.add">Add</Trans>
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
Panel
</Button>
</Dropdown>
),
});
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-button"
variant="secondary"
size="sm"
icon="plus"
fill="text"
onClick={() => {
dashboard.onCreateNewRow();
}}
data-testid={selectors.components.PageToolbar.itemButton('add_row')}
>
Row
</Button>
),
});
leftActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Button
key="add-panel-lib"
variant="secondary"
size="sm"
icon="plus"
fill="text"
data-testid={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
>
Import
</Button>
),
});
} else {
toolbarActions.push({
group: 'add-panel',
condition: isEditingAndShowingDashboard,
render: () => (
<Dropdown
key="add-panel-dropdown"
onVisibleChange={(isOpen) => {
setIsAddPanelMenuOpen(isOpen);
DashboardInteractions.toolbarAddClick();
}}
overlay={() => (
<Menu>
<Menu.Item
key="add-visualization"
testId={selectors.pages.AddDashboard.itemButton('Add new visualization menu item')}
label={t('dashboard.add-menu.visualization', 'Visualization')}
onClick={() => {
const vizPanel = dashboard.onCreateNewPanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
dashboard.setState({ editPanel: buildPanelEditScene(vizPanel, true) });
}}
/>
<Menu.Item
key="add-panel-lib"
testId={selectors.pages.AddDashboard.itemButton('Add new panel from panel library menu item')}
label={t('dashboard.add-menu.import', 'Import from library')}
onClick={() => {
dashboard.onShowAddLibraryPanelDrawer();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' });
}}
/>
<Menu.Item
key="add-row"
testId={selectors.pages.AddDashboard.itemButton('Add new row menu item')}
label={t('dashboard.add-menu.row', 'Row')}
onClick={() => {
dashboard.onCreateNewRow();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
/>
<Menu.Item
key="paste-panel"
disabled={!hasCopiedPanel}
testId={selectors.pages.AddDashboard.itemButton('Add new panel from clipboard menu item')}
label={t('dashboard.add-menu.paste-panel', 'Paste panel')}
onClick={() => {
dashboard.pastePanel();
DashboardInteractions.toolbarAddButtonClicked({ item: 'paste_panel' });
}}
/>
</Menu>
)}
placement="bottom"
offset={[0, 6]}
>
<Button
key="add-panel-button"
variant="primary"
size="sm"
fill="outline"
data-testid={selectors.components.PageToolbar.itemButton('Add button')}
>
<Trans i18nKey="dashboard.toolbar.add">Add</Trans>
<Icon name={isAddPanelMenuOpen ? 'angle-up' : 'angle-down'} size="lg" />
</Button>
</Dropdown>
),
});
}
toolbarActions.push({
group: 'playlist-actions',
@ -595,6 +658,20 @@ export function ToolbarActions({ dashboard }: Props) {
},
});
const rigthActionsElements: React.ReactNode[] = renderActionElements(toolbarActions);
const leftActionsElements: React.ReactNode[] = renderActionElements(leftActions);
const hasActionsToLeftAndRight = showScopesSelector || leftActionsElements.length > 0;
return (
<Stack flex={1} minWidth={0} justifyContent={hasActionsToLeftAndRight ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
{leftActionsElements.length > 0 && <ToolbarButtonRow alignment="left">{leftActionsElements}</ToolbarButtonRow>}
<ToolbarButtonRow alignment="right">{rigthActionsElements}</ToolbarButtonRow>
</Stack>
);
}
function renderActionElements(toolbarActions: ToolbarAction[]) {
const actionElements: React.ReactNode[] = [];
let lastGroup = '';
@ -610,13 +687,7 @@ export function ToolbarActions({ dashboard }: Props) {
actionElements.push(action.render());
lastGroup = action.group;
}
return (
<Stack flex={1} minWidth={0} justifyContent={showScopesSelector ? 'space-between' : 'flex-end'}>
{showScopesSelector && <ScopesSelector />}
<ToolbarButtonRow alignment="right">{actionElements}</ToolbarButtonRow>
</Stack>
);
return actionElements;
}
function addDynamicActions(

@ -9,23 +9,18 @@ import {
sceneUtils,
SceneComponentProps,
} from '@grafana/scenes';
import { Button } from '@grafana/ui';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { Trans } from 'app/core/internationalization';
import { DashboardInteractions } from '../../utils/interactions';
import {
forceRenderChildren,
getPanelIdForVizPanel,
NEW_PANEL_HEIGHT,
NEW_PANEL_WIDTH,
getVizPanelKeyForPanelId,
getDefaultVizPanel,
} from '../../utils/utils';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { RowActions } from '../row-actions/RowActions';
import { DashboardLayoutManager, LayoutEditorProps, LayoutRegistryItem } from '../types';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { DashboardGridItem } from './DashboardGridItem';
@ -40,6 +35,8 @@ export class DefaultGridLayoutManager
extends SceneObjectBase<DefaultGridLayoutManagerState>
implements DashboardLayoutManager
{
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {
const updateResizeAndDragging = () => {
this.state.grid.setState({ isDraggable: isEditing, isResizable: isEditing });
@ -387,48 +384,7 @@ export class DefaultGridLayoutManager
});
}
public renderEditor() {
return <DefaultGridLayoutEditor layoutManager={this} />;
}
public static Component = ({ model }: SceneComponentProps<DefaultGridLayoutManager>) => {
if (!config.featureToggles.dashboardNewLayouts) {
return <model.state.grid.Component model={model.state.grid} />;
}
return (
<LayoutEditChrome layoutManager={model}>
<model.state.grid.Component model={model.state.grid} />
</LayoutEditChrome>
);
return <model.state.grid.Component model={model.state.grid} />;
};
}
function DefaultGridLayoutEditor({ layoutManager }: LayoutEditorProps<DefaultGridLayoutManager>) {
return (
<>
<Button
fill="outline"
icon="plus"
onClick={() => {
const vizPanel = getDefaultVizPanel();
layoutManager.addPanel(vizPanel);
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
}}
>
<Trans i18nKey="dashboard.add-menu.visualization">Visualization</Trans>
</Button>
<Button
fill="outline"
icon="plus"
onClick={() => {
layoutManager.addNewRow!();
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_row' });
}}
>
<Trans i18nKey="dashboard.add-menu.row">Row</Trans>
</Button>
</>
);
}

@ -1,5 +1,8 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObjectState, VizPanel, SceneObjectBase, SceneObject, SceneComponentProps } from '@grafana/scenes';
import { Switch } from '@grafana/ui';
import { Switch, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -65,7 +68,22 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
public static Component = ({ model }: SceneComponentProps<ResponsiveGridItem>) => {
const { body } = model.useState();
const style = useStyles2(getStyles);
return (
<div className={cx(style.wrapper)}>
<body.Component model={body} />
</div>
);
};
}
return <body.Component model={body} />;
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
};
}

@ -1,12 +1,10 @@
import { SelectableValue } from '@grafana/data';
import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { Button, Field, Select } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Select } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardInteractions } from '../../utils/interactions';
import { getDefaultVizPanel, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { LayoutEditChrome } from '../layouts-shared/LayoutEditChrome';
import { DashboardLayoutManager, LayoutRegistryItem, LayoutEditorProps } from '../types';
import { getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { ResponsiveGridItem } from './ResponsiveGridItem';
@ -18,6 +16,8 @@ export class ResponsiveGridLayoutManager
extends SceneObjectBase<ResponsiveGridLayoutManagerState>
implements DashboardLayoutManager
{
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {
@ -72,8 +72,8 @@ export class ResponsiveGridLayoutManager
return panels;
}
public renderEditor() {
return <AutomaticGridEditor layoutManager={this} />;
public getOptions(): OptionsPaneItemDescriptor[] {
return getOptions(this);
}
public getDescriptor(): LayoutRegistryItem {
@ -90,7 +90,13 @@ export class ResponsiveGridLayoutManager
}
public static createEmpty() {
return new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: [] }) });
return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({
children: [],
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
}),
});
}
public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager {
@ -110,18 +116,22 @@ export class ResponsiveGridLayoutManager
});
}
toSaveModel?() {
throw new Error('Method not implemented.');
}
activateRepeaters?(): void {
throw new Error('Method not implemented.');
}
public static Component = ({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) => {
return (
<LayoutEditChrome layoutManager={model}>
<model.state.layout.Component model={model.state.layout} />
</LayoutEditChrome>
);
return <model.state.layout.Component model={model.state.layout} />;
};
}
function AutomaticGridEditor({ layoutManager }: LayoutEditorProps<ResponsiveGridLayoutManager>) {
function getOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
const cssLayout = layoutManager.state.layout;
const { templateColumns, autoRows } = cssLayout.useState();
const rowOptions: Array<SelectableValue<string>> = [];
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
@ -143,39 +153,43 @@ function AutomaticGridEditor({ layoutManager }: LayoutEditorProps<ResponsiveGrid
rowOptions.push({ label: `Fixed: ${size}px`, value: `${size}px` });
}
const onColumnsChange = (value: SelectableValue<string>) => {
cssLayout.setState({ templateColumns: value.value });
};
const onRowsChange = (value: SelectableValue<string>) => {
cssLayout.setState({ autoRows: value.value });
};
options.push(
new OptionsPaneItemDescriptor({
title: 'Columns',
render: () => {
const { templateColumns } = cssLayout.useState();
return (
<Select
options={colOptions}
value={String(templateColumns)}
onChange={(value) => {
cssLayout.setState({ templateColumns: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return (
<>
<Field label="Columns">
<Select
options={colOptions}
value={String(templateColumns)}
onChange={onColumnsChange}
allowCustomValue={true}
/>
</Field>
<Field label="Row height">
<Select options={rowOptions} value={String(autoRows)} onChange={onRowsChange} />
</Field>
<Button
fill="outline"
icon="plus"
onClick={() => {
const vizPanel = getDefaultVizPanel();
layoutManager.addPanel(vizPanel);
DashboardInteractions.toolbarAddButtonClicked({ item: 'add_visualization' });
}}
>
<Trans i18nKey="dashboard.add-menu.visualization">Visualization</Trans>
</Button>
</>
options.push(
new OptionsPaneItemDescriptor({
title: 'Rows',
render: () => {
const { autoRows } = cssLayout.useState();
return (
<Select
options={rowOptions}
value={String(autoRows)}
onChange={(value) => {
cssLayout.setState({ autoRows: value.value });
}}
allowCustomValue={true}
/>
);
},
})
);
return options;
}

@ -0,0 +1,242 @@
import { css, cx } from '@emotion/css';
import { useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneObjectState, SceneObjectBase, SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, Input, RadioButtonGroup, Switch, useStyles2 } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { getDashboardSceneFor, getDefaultVizPanel } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types';
import { RowsLayoutManager } from './RowsLayoutManager';
export interface RowItemState extends SceneObjectState {
layout: DashboardLayoutManager;
title?: string;
isCollapsed?: boolean;
isHeaderHidden?: boolean;
height?: 'expand' | 'min';
}
export class RowItem extends SceneObjectBase<RowItemState> implements LayoutParent, EditableDashboardElement {
public isEditableDashboardElement: true = true;
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] {
const row = this;
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: 'Row options',
id: 'row-options',
isOpenDefault: true,
})
.addItem(
new OptionsPaneItemDescriptor({
title: 'Title',
render: () => <RowTitleInput row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Height',
render: () => <RowHeightSelect row={row} />,
})
)
.addItem(
new OptionsPaneItemDescriptor({
title: 'Hide row header',
render: () => <RowHeaderSwitch row={row} />,
})
);
}, [row]);
const { layout } = this.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, layoutOptions];
}
public getTypeName(): string {
return 'Row';
}
public onDelete = () => {
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
layout.removeRow(this);
};
public renderActions(): React.ReactNode {
return (
<>
<Button size="sm" variant="secondary">
Copy
</Button>
<Button size="sm" variant="primary" onClick={this.onAddPanel} fill="outline">
Add panel
</Button>
<Button size="sm" variant="destructive" fill="outline" onClick={this.onDelete}>
Delete
</Button>
</>
);
}
public getLayout(): DashboardLayoutManager {
return this.state.layout;
}
public switchLayout(layout: DashboardLayoutManager): void {
this.setState({ layout });
}
public onCollapseToggle = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
public onAddPanel = () => {
const vizPanel = getDefaultVizPanel();
this.state.layout.addPanel(vizPanel);
};
public onEdit = () => {
const dashboard = getDashboardSceneFor(this);
dashboard.state.editPane.selectObject(this);
};
public static Component = ({ model }: SceneComponentProps<RowItem>) => {
const { layout, title, isCollapsed, height = 'expand' } = model.useState();
const { isEditing } = getDashboardSceneFor(model).useState();
const styles = useStyles2(getStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
const shouldGrow = !isCollapsed && height === 'expand';
return (
<div
className={cx(styles.wrapper, isCollapsed && styles.wrapperCollapsed, shouldGrow && styles.wrapperGrow)}
ref={ref}
>
<div className={styles.rowHeader}>
<button
onClick={model.onCollapseToggle}
className={styles.rowTitleButton}
aria-label={isCollapsed ? 'Expand row' : 'Collapse row'}
data-testid={selectors.components.DashboardRow.title(titleInterpolated)}
>
<Icon name={isCollapsed ? 'angle-right' : 'angle-down'} />
<span className={styles.rowTitle} role="heading">
{titleInterpolated}
</span>
</button>
{isEditing && <Button icon="pen" variant="secondary" size="sm" fill="text" onClick={() => model.onEdit()} />}
</div>
{!isCollapsed && <layout.Component model={layout} />}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
rowHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 0, 0.5, 0),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'center',
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
rowTitleButton: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
minWidth: 0,
gap: theme.spacing(1),
}),
rowTitle: css({
fontSize: theme.typography.h5.fontSize,
fontWeight: theme.typography.fontWeightMedium,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
flexGrow: 1,
minWidth: 0,
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
width: '100%',
}),
wrapperGrow: css({
flexGrow: 1,
}),
wrapperCollapsed: css({
flexGrow: 0,
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
rowActions: css({
display: 'flex',
opacity: 0,
}),
};
}
export function RowTitleInput({ row }: { row: RowItem }) {
const { title } = row.useState();
return <Input value={title} onChange={(e) => row.setState({ title: e.currentTarget.value })} />;
}
export function RowHeaderSwitch({ row }: { row: RowItem }) {
const { isHeaderHidden } = row.useState();
return (
<Switch
value={isHeaderHidden}
onChange={() => {
row.setState({
isHeaderHidden: !row.state.isHeaderHidden,
});
}}
/>
);
}
export function RowHeightSelect({ row }: { row: RowItem }) {
const { height = 'expand' } = row.useState();
const options = [
{ label: 'Expand', value: 'expand' as const },
{ label: 'Min', value: 'min' as const },
];
return (
<RadioButtonGroup
options={options}
value={height}
onChange={(option) =>
row.setState({
height: option,
})
}
/>
);
}

@ -0,0 +1,113 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutRegistryItem } from '../types';
import { RowItem } from './RowItem';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
}
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutManager {
public isDashboardLayoutManager: true = true;
public editModeChanged(isEditing: boolean): void {}
public addPanel(vizPanel: VizPanel): void {}
public addNewRow(): void {
this.setState({
rows: [
...this.state.rows,
new RowItem({
title: 'New row',
layout: ResponsiveGridLayoutManager.createEmpty(),
}),
],
});
}
public getNextPanelId(): number {
return 0;
}
public removePanel(panel: VizPanel) {}
public removeRow(row: RowItem) {
this.setState({
rows: this.state.rows.filter((r) => r !== row),
});
}
public duplicatePanel(panel: VizPanel): void {
throw new Error('Method not implemented.');
}
public getVizPanels(): VizPanel[] {
const panels: VizPanel[] = [];
for (const row of this.state.rows) {
const innerPanels = row.state.layout.getVizPanels();
panels.push(...innerPanels);
}
return panels;
}
public getOptions() {
return [];
}
public getDescriptor(): LayoutRegistryItem {
return RowsLayoutManager.getDescriptor();
}
public static getDescriptor(): LayoutRegistryItem {
return {
name: 'Rows',
description: 'Rows layout',
id: 'rows-layout',
createFromLayout: RowsLayoutManager.createFromLayout,
};
}
public static createEmpty() {
return new RowsLayoutManager({ rows: [] });
}
public static createFromLayout(layout: DashboardLayoutManager): RowsLayoutManager {
const row = new RowItem({ layout: layout.clone(), title: 'Row title' });
return new RowsLayoutManager({ rows: [row] });
}
public static Component = ({ model }: SceneComponentProps<RowsLayoutManager>) => {
const { rows } = model.useState();
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
{rows.map((row) => (
<RowItem.Component model={row} key={row.state.key!} />
))}
</div>
);
};
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
height: '100%',
width: '100%',
}),
};
}

@ -0,0 +1,66 @@
import { useMemo } from 'react';
import { Select } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types';
import { layoutRegistry } from './layoutRegistry';
export interface Props {
layoutManager: DashboardLayoutManager;
}
export function DashboardLayoutSelector({ layoutManager }: { layoutManager: DashboardLayoutManager }) {
const layouts = layoutRegistry.list();
const options = layouts.map((layout) => ({
label: layout.name,
value: layout,
}));
const currentLayoutId = layoutManager.getDescriptor().id;
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId);
return (
<Select
options={options}
value={currentLayoutOption}
onChange={(option) => changeLayoutTo(layoutManager, option.value!)}
/>
);
}
export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
return useMemo(() => {
const layoutCategory = new OptionsPaneCategoryDescriptor({
title: 'Layout',
id: 'layout-options',
isOpenDefault: true,
});
layoutCategory.addItem(
new OptionsPaneItemDescriptor({
title: 'Type',
render: function renderTitle() {
return <DashboardLayoutSelector layoutManager={layoutManager} />;
},
})
);
if (layoutManager.getOptions) {
for (const option of layoutManager.getOptions()) {
layoutCategory.addItem(option);
}
}
return layoutCategory;
}, [layoutManager]);
}
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) {
const layoutParent = currentLayout.parent;
if (layoutParent && isLayoutParent(layoutParent)) {
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
}
}

@ -1,105 +0,0 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Field, Select } from '@grafana/ui';
import { getDashboardSceneFor } from '../../utils/utils';
import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types';
import { layoutRegistry } from './layoutRegistry';
interface Props {
layoutManager: DashboardLayoutManager;
children: React.ReactNode;
}
export function LayoutEditChrome({ layoutManager, children }: Props) {
const styles = useStyles2(getStyles);
const { isEditing } = getDashboardSceneFor(layoutManager).useState();
const layouts = layoutRegistry.list();
const options = layouts.map((layout) => ({
label: layout.name,
value: layout,
}));
const currentLayoutId = layoutManager.getDescriptor().id;
const currentLayoutOption = options.find((option) => option.value.id === currentLayoutId);
return (
<div className={styles.wrapper}>
{isEditing && (
<div className={styles.editHeader}>
<Field label="Layout type">
<Select
options={options}
value={currentLayoutOption}
onChange={(option) => changeLayoutTo(layoutManager, option.value!)}
/>
</Field>
{layoutManager.renderEditor?.()}
</div>
)}
{children}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
editHeader: css({
width: '100%',
display: 'flex',
gap: theme.spacing(1),
padding: theme.spacing(0, 1, 0.5, 1),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'flex-end',
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(1),
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
wrapper: css({
display: 'flex',
flexDirection: 'column',
flex: '1 1 0',
width: '100%',
}),
icon: css({
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
background: 'transparent',
border: 'none',
gap: theme.spacing(1),
}),
rowTitle: css({}),
rowActions: css({
display: 'flex',
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 200ms ease-in',
},
'&:hover, &:focus-within': {
opacity: 1,
},
}),
};
}
function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescriptor: LayoutRegistryItem) {
const layoutParent = currentLayout.parent;
if (layoutParent && isLayoutParent(layoutParent)) {
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
}
}

@ -2,8 +2,13 @@ import { Registry } from '@grafana/data';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { LayoutRegistryItem } from '../types';
export const layoutRegistry: Registry<LayoutRegistryItem> = new Registry<LayoutRegistryItem>(() => {
return [DefaultGridLayoutManager.getDescriptor(), ResponsiveGridLayoutManager.getDescriptor()];
return [
DefaultGridLayoutManager.getDescriptor(),
ResponsiveGridLayoutManager.getDescriptor(),
RowsLayoutManager.getDescriptor(),
];
});

@ -1,21 +1,20 @@
import { BusEventWithPayload, RegistryItem } from '@grafana/data';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
/**
* A scene object that usually wraps an underlying layout
* Dealing with all the state management and editing of the layout
*/
export interface DashboardLayoutManager extends SceneObject {
/** Marks it as a DashboardLayoutManager */
isDashboardLayoutManager: true;
/**
* Notify the layout manager that the edit mode has changed
* @param isEditing
*/
editModeChanged(isEditing: boolean): void;
/**
* Not sure we will need this in the long run, we should be able to handle this inside internally
*/
getNextPanelId(): number;
/**
* Remove an element / panel
* @param element
@ -54,7 +53,11 @@ export interface DashboardLayoutManager extends SceneObject {
/**
* Renders options and layout actions
*/
renderEditor?(): React.ReactNode;
getOptions?(): OptionsPaneItemDescriptor[];
}
export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager {
return 'isDashboardLayoutManager' in obj;
}
/**
@ -73,10 +76,6 @@ export interface LayoutRegistryItem extends RegistryItem {
createFromSaveModel?(saveModel: any): void;
}
export interface LayoutEditorProps<T> {
layoutManager: T;
}
/**
* This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager)
*/

@ -19,12 +19,14 @@ import {
SceneDataLayerProvider,
SceneDataLayerControls,
UserActionEvent,
SceneObjectState,
} from '@grafana/scenes';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { DashboardDTO, DashboardDataDTO } from 'app/types';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { DashboardEditPaneBehavior } from '../edit-pane/DashboardEditPaneBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@ -214,6 +216,32 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
});
}
const behaviorList: SceneObjectState['$behaviors'] = [
new behaviors.CursorSync({
sync: oldModel.graphTooltip,
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new DashboardEditPaneBehavior({}),
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
}),
new DashboardReloadBehavior({
reloadOnParamsChange: config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
version: oldModel.version,
}),
];
if (config.featureToggles.dashboardNewLayouts) {
behaviorList.push(new DashboardEditPaneBehavior({}));
}
const dashboardScene = new DashboardScene({
description: oldModel.description,
editable: oldModel.editable,
@ -242,28 +270,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
UNSAFE_nowDelay: oldModel.timepicker?.nowDelay,
}),
$variables: variables,
$behaviors: [
new behaviors.CursorSync({
sync: oldModel.graphTooltip,
}),
new behaviors.SceneQueryController(),
registerDashboardMacro,
registerPanelInteractionsReporter,
new behaviors.LiveNowTimer({ enabled: oldModel.liveNow }),
preserveDashboardSceneStateInLocalStorage,
addPanelsOnLoadBehavior,
new DashboardScopesFacade({
reloadOnParamsChange:
config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
}),
new DashboardReloadBehavior({
reloadOnParamsChange:
config.featureToggles.reloadDashboardsOnParamsChange && oldModel.meta.reloadOnParamsChange,
uid: oldModel.uid,
version: oldModel.version,
}),
],
$behaviors: behaviorList,
$data: new DashboardDataLayerSet({ annotationLayers, alertStatesLayer }),
controls: new DashboardControls({
variableControls: [new VariableValueSelectors({}), new SceneDataLayerControls()],

@ -34,7 +34,7 @@ import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLay
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
function setupDashboardScene(state: DashboardSceneState): DashboardScene {
function setupDashboardScene(state: Partial<DashboardSceneState>): DashboardScene {
return new DashboardScene(state);
}

@ -4,6 +4,8 @@ import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
import { DashboardScene } from '../scene/DashboardScene';
import { VizPanelLinks } from '../scene/PanelLinks';
import { getLayoutManagerFor } from './utils';
function getTimePicker(scene: DashboardScene) {
return scene.state.controls?.state.timePicker;
}
@ -53,4 +55,5 @@ export const dashboardSceneGraph = {
getVizPanels,
getDataLayers,
getCursorSync,
getLayoutManagerFor,
};

@ -18,6 +18,7 @@ import { DashboardScene } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { panelMenuBehavior } from '../scene/PanelMenuBehavior';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types';
export const NEW_PANEL_HEIGHT = 8;
export const NEW_PANEL_WIDTH = 12;
@ -220,6 +221,7 @@ export function getDefaultVizPanel(): VizPanel {
pluginId: 'timeseries',
titleItems: [new VizPanelLinks({ menu: new VizPanelLinksMenu({}) })],
hoverHeaderOffset: 0,
$behaviors: [],
menu: new VizPanelMenu({
$behaviors: [panelMenuBehavior],
}),
@ -279,3 +281,16 @@ export function activateSceneObjectAndParentTree(so: SceneObject): CancelActivat
* Useful when rendering a scene object out of context of it's parent
*/
export const activateInActiveParents = activateSceneObjectAndParentTree;
export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutManager {
let parent = sceneObject.parent;
while (parent) {
if (isDashboardLayoutManager(parent)) {
return parent;
}
parent = parent.parent;
}
throw new Error('Could not find layout manager for scene object');
}

@ -1,5 +1,7 @@
import * as React from 'react';
import { Box } from '@grafana/ui';
import { OptionsPaneCategory } from './OptionsPaneCategory';
import { OptionsPaneItemDescriptor } from './OptionsPaneItemDescriptor';
@ -57,6 +59,10 @@ export class OptionsPaneCategoryDescriptor {
return this.props.customRender();
}
if (this.props.id === '') {
return <Box padding={2}>{this.items.map((item) => item.render(searchQuery))}</Box>;
}
return (
<OptionsPaneCategory key={this.props.title} {...this.props}>
{this.items.map((item) => item.render(searchQuery))}

Loading…
Cancel
Save