Dashboard: Canvas add buttons to custom grid (#103181)

* Custom grid add actions

* find empty space

* Update

* Update

* Update

* Update
pull/103270/head
Torkel Ödegaard 4 months ago committed by GitHub
parent 0aeefedb0c
commit aea7f87732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      public/app/features/dashboard-scene/edit-pane/EditPaneHeader.tsx
  2. 82
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  3. 132
      public/app/features/dashboard-scene/scene/layout-default/findSpaceForNewPanel.ts
  4. 61
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx
  5. 70
      public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx

@ -33,7 +33,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
size="lg"
onClick={onGoBack}
tooltip={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
aria-abel={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
aria-label={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
/>
)}
<Text>{elementInfo.typeName}</Text>

@ -1,3 +1,6 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import {
SceneObjectState,
@ -13,6 +16,7 @@ import {
SceneGridLayoutDragStartEvent,
} from '@grafana/scenes';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { useStyles2 } from '@grafana/ui';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { t } from 'app/core/internationalization';
import DashboardEmpty from 'app/features/dashboard/dashgrid/DashboardEmpty';
@ -35,11 +39,13 @@ import {
useDashboard,
getLayoutOrchestratorFor,
} from '../../utils/utils';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { DashboardGridItem } from './DashboardGridItem';
import { RowRepeaterBehavior } from './RowRepeaterBehavior';
import { findSpaceForNewPanel } from './findSpaceForNewPanel';
import { RowActions } from './row-actions/RowActions';
interface DefaultGridLayoutManagerState extends SceneObjectState {
@ -102,18 +108,28 @@ export class DefaultGridLayoutManager
vizPanel.setState({ key: getVizPanelKeyForPanelId(panelId) });
vizPanel.clearParent();
const newGridItem = new DashboardGridItem({
height: NEW_PANEL_HEIGHT,
width: NEW_PANEL_WIDTH,
x: 0,
y: 0,
body: vizPanel,
key: getGridItemKeyForPanelId(panelId),
});
this.state.grid.setState({
children: [newGridItem, ...this.state.grid.state.children],
});
// With new edit mode we add panels to the bottom of the grid
if (config.featureToggles.dashboardNewLayouts) {
const emptySpace = findSpaceForNewPanel(this.state.grid);
const newGridItem = new DashboardGridItem({
...emptySpace,
body: vizPanel,
key: getGridItemKeyForPanelId(panelId),
});
this.state.grid.setState({ children: [...this.state.grid.state.children, newGridItem] });
} else {
const newGridItem = new DashboardGridItem({
height: NEW_PANEL_HEIGHT,
width: NEW_PANEL_WIDTH,
x: 0,
y: 0,
body: vizPanel,
key: getGridItemKeyForPanelId(panelId),
});
this.state.grid.setState({ children: [newGridItem, ...this.state.grid.state.children] });
}
this.publishEvent(new NewObjectAddedToCanvasEvent(vizPanel), true);
}
@ -505,6 +521,8 @@ export class DefaultGridLayoutManager
function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<DefaultGridLayoutManager>) {
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = useDashboard(model);
const { isEditing } = dashboard.useState();
const styles = useStyles2(getStyles);
// If we are top level layout and we have no children, show empty state
if (model.parent === dashboard && children.length === 0) {
@ -513,5 +531,43 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
);
}
return <model.state.grid.Component model={model.state.grid} />;
return (
<div className={cx(styles.container, isEditing && styles.containerEditing)}>
{model.state.grid.Component && <model.state.grid.Component model={model.state.grid} />}
{isEditing && (
<div className={styles.actionsWrapper}>
<CanvasGridAddActions layoutManager={model} />
</div>
)}
</div>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css({
width: '100%',
display: 'flex',
flexGrow: 1,
flexDirection: 'column',
}),
containerEditing: css({
// In editing the add actions should live at the bottom of the grid so we have to
// disable flex grow on the SceneGridLayouts first div
'> div:first-child': {
flexGrow: `0 !important`,
minHeight: '250px',
},
'&:hover': {
'.dashboard-canvas-add-button': {
opacity: 1,
filter: 'unset',
},
},
}),
actionsWrapper: css({
position: 'relative',
paddingBottom: theme.spacing(5),
}),
};
}

@ -0,0 +1,132 @@
import { SceneGridItemLike, SceneGridLayout, SceneGridRow } from '@grafana/scenes';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
export interface GridCell {
x: number;
y: number;
width: number;
height: number;
}
const NEW_PANEL_MIN_WIDTH = 8;
const NEW_PANEL_MIN_HEIGHT = 6;
const NEW_PANEL_WIDTH = 12;
const NEW_PANEL_HEIGHT = 8;
export function findSpaceForNewPanel(grid: SceneGridLayout): GridCell | null {
// Build a grid of occupied spaces as a Set of "x,y" strings for fast lookup
const occupied = new Set<string>();
let maxY = 0;
function addPanelToOccupied(panel: SceneGridItemLike) {
for (let dx = 0; dx < panel.state.width!; dx++) {
for (let dy = 0; dy < panel.state.height!; dy++) {
const key = `${panel.state.x! + dx},${panel.state.y! + dy}`;
occupied.add(key);
if (panel.state.y! + panel.state.height! > maxY) {
maxY = panel.state.y! + panel.state.height!;
}
}
}
}
for (const panel of grid.state.children) {
addPanelToOccupied(panel);
if (panel instanceof SceneGridRow) {
for (const rowChild of panel.state.children) {
addPanelToOccupied(rowChild);
}
}
}
// Start scanning the grid row by row, top to bottom (infinite height)
for (let y = 0; y < Infinity; y++) {
for (let x = 0; x <= GRID_COLUMN_COUNT - NEW_PANEL_MIN_WIDTH; x++) {
let fits = true;
for (let dx = 0; dx < NEW_PANEL_MIN_WIDTH; dx++) {
for (let dy = 0; dy < NEW_PANEL_MIN_HEIGHT; dy++) {
const key = `${x + dx},${y + dy}`;
if (occupied.has(key)) {
fits = false;
break;
}
}
if (!fits) {
break;
}
}
if (fits) {
const cell = { x, y, width: NEW_PANEL_MIN_WIDTH, height: NEW_PANEL_MIN_HEIGHT };
return fillEmptySpace(occupied, cell, maxY);
}
}
if (y > maxY + NEW_PANEL_MIN_HEIGHT) {
break;
}
}
// Should technically never reach this point
return { x: 0, y: maxY, width: NEW_PANEL_WIDTH, height: NEW_PANEL_HEIGHT };
}
/**
* This tries to expand the found empty space so that it fills it as much as possible
*/
function fillEmptySpace(occupied: Set<string>, cell: GridCell, maxY: number) {
// If we are at maxY we are on a new row, return default new row panel dimensions
if (cell.y >= maxY) {
cell.width = NEW_PANEL_WIDTH;
cell.height = NEW_PANEL_HEIGHT;
return cell;
}
// Expand width
for (let x = cell.x + cell.width + 1; x <= GRID_COLUMN_COUNT; x++) {
let fits = true;
for (let y = cell.y; y < cell.y + cell.height; y++) {
const key = `${x},${y}`;
if (occupied.has(key)) {
fits = false;
break;
}
}
if (fits) {
cell.width = x - cell.x;
} else {
break;
}
}
// try to expand y
for (let y = cell.y + cell.height + 1; y <= maxY; y++) {
let fits = true;
// Some max panel height
if (cell.height >= 20) {
break;
}
for (let x = cell.x; x < cell.x + cell.width; x++) {
const key = `${x},${y}`;
if (occupied.has(key)) {
fits = false;
break;
}
}
if (fits) {
cell.height = y - cell.y;
} else {
break;
}
}
return cell;
}

@ -2,11 +2,10 @@ import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { LazyLoader, SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { useStyles2 } from '@grafana/ui';
import { getDefaultVizPanel, useDashboardState } from '../../utils/utils';
import { addNewRowTo, addNewTabTo } from '../layouts-shared/addNew';
import { useDashboardState } from '../../utils/utils';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { AutoGridLayout, AutoGridLayoutState } from './ResponsiveGridLayout';
import { AutoGridLayoutManager } from './ResponsiveGridLayoutManager';
@ -36,47 +35,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
<item.Component key={item.state.key} model={item} />
)
)}
{isEditing && (
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
<Button
variant="primary"
fill="text"
icon="plus"
onClick={() => layoutManager.addPanel(getDefaultVizPanel())}
>
<Trans i18nKey="dashboard.canvas-actions.add-panel">Add panel</Trans>
</Button>
<Dropdown
overlay={
<Menu>
<Menu.Item
icon="list-ul"
label={t('dashboard.canvas-actions.group-into-row', 'Group into row')}
onClick={() => {
addNewRowTo(layoutManager);
}}
></Menu.Item>
<Menu.Item
icon="layers"
label={t('dashboard.canvas-actions.group-into-tab', 'Group into tab')}
onClick={() => {
addNewTabTo(layoutManager);
}}
></Menu.Item>
</Menu>
}
>
<Button
variant="primary"
fill="text"
icon="layers"
onClick={() => layoutManager.addPanel(getDefaultVizPanel())}
>
<Trans i18nKey="dashboard.canvas-actions.group-panels">Group panels</Trans>
</Button>
</Dropdown>
</div>
)}
{isEditing && <CanvasGridAddActions layoutManager={layoutManager} />}
</div>
);
}
@ -125,18 +84,6 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
width: '100%',
height: '100%',
}),
addAction: css({
position: 'absolute',
padding: theme.spacing(1, 0),
height: theme.spacing(5),
bottom: 0,
left: 0,
right: 0,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('opacity'),
},
}),
dragging: css({
position: 'fixed',
top: 0,

@ -0,0 +1,70 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { getDefaultVizPanel } from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { addNewRowTo, addNewTabTo } from './addNew';
export interface Props {
layoutManager: DashboardLayoutManager;
}
export function CanvasGridAddActions({ layoutManager }: Props) {
const styles = useStyles2(getStyles);
return (
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
<Button variant="primary" fill="text" icon="plus" onClick={() => layoutManager.addPanel(getDefaultVizPanel())}>
<Trans i18nKey="dashboard.canvas-actions.add-panel">Add panel</Trans>
</Button>
<Dropdown
overlay={
<Menu>
<Menu.Item
icon="list-ul"
label={t('dashboard.canvas-actions.group-into-row', 'Group into row')}
onClick={() => {
addNewRowTo(layoutManager);
}}
></Menu.Item>
<Menu.Item
icon="layers"
label={t('dashboard.canvas-actions.group-into-tab', 'Group into tab')}
onClick={() => {
addNewTabTo(layoutManager);
}}
></Menu.Item>
</Menu>
}
>
<Button
variant="primary"
fill="text"
icon="layers"
onClick={() => layoutManager.addPanel(getDefaultVizPanel())}
>
<Trans i18nKey="dashboard.canvas-actions.group-panels">Group panels</Trans>
</Button>
</Dropdown>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
addAction: css({
position: 'absolute',
padding: theme.spacing(1, 0),
height: theme.spacing(5),
bottom: 0,
left: 0,
right: 0,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('opacity'),
},
}),
});
Loading…
Cancel
Save