Dashboard: New UX for switching layouts (#102268)

* Layout switching

* Update

* Update

* Update

* Update

* Update

* unline styles

* fixing lint issue
This commit is contained in:
Torkel Ödegaard
2025-03-17 13:50:34 +01:00
committed by GitHub
parent 2b3a36b572
commit 74705bd5b3
11 changed files with 291 additions and 73 deletions

View File

@ -61,6 +61,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(2), height: theme.spacing(2),
border: `1px solid ${theme.colors.border.medium}`, border: `1px solid ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.circle, borderRadius: theme.shape.radius.circle,
cursor: 'pointer',
margin: '3px 0' /* Space for box-shadow when focused */, margin: '3px 0' /* Space for box-shadow when focused */,
':checked': { ':checked': {
@ -100,6 +101,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'grid', display: 'grid',
gridTemplateColumns: `${theme.spacing(2)} auto`, gridTemplateColumns: `${theme.spacing(2)} auto`,
gap: theme.spacing(1), gap: theme.spacing(1),
cursor: 'pointer',
}), }),
description: css({ description: css({
fontSize: theme.typography.size.sm, fontSize: theme.typography.size.sm,

View File

@ -6,7 +6,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { DashboardScene } from '../scene/DashboardScene'; import { DashboardScene } from '../scene/DashboardScene';
import { DashboardLayoutSelector } from '../scene/layouts-shared/DashboardLayoutSelector'; import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
export class DashboardEditableElement implements EditableDashboardElement { export class DashboardEditableElement implements EditableDashboardElement {
@ -41,24 +41,14 @@ export class DashboardEditableElement implements EditableDashboardElement {
title: t('dashboard.options.description', 'Description'), title: t('dashboard.options.description', 'Description'),
render: () => <DashboardDescriptionInput dashboard={dashboard} />, render: () => <DashboardDescriptionInput dashboard={dashboard} />,
}) })
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.layout.common.layout', 'Layout'),
render: () => <DashboardLayoutSelector layoutManager={body} />,
})
); );
if (body.getOptions) {
for (const option of body.getOptions()) {
editPaneHeaderOptions.addItem(option);
}
}
return editPaneHeaderOptions; return editPaneHeaderOptions;
}, [body, dashboard]); }, [dashboard]);
return [dashboardOptions]; const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
} }
public renderActions(): ReactNode { public renderActions(): ReactNode {

View File

@ -51,11 +51,12 @@ export class DefaultGridLayoutManager
return t('dashboard.default-layout.name', 'Custom'); return t('dashboard.default-layout.name', 'Custom');
}, },
get description() { get description() {
return t('dashboard.default-layout.description', 'Manually size and position panels'); return t('dashboard.default-layout.description', 'Position and size each panel individually');
}, },
id: 'default-grid', id: 'default-grid',
createFromLayout: DefaultGridLayoutManager.createFromLayout, createFromLayout: DefaultGridLayoutManager.createFromLayout,
kind: 'GridLayout', kind: 'GridLayout',
isGridLayout: true,
}; };
public readonly descriptor = DefaultGridLayoutManager.descriptor; public readonly descriptor = DefaultGridLayoutManager.descriptor;

View File

@ -26,15 +26,15 @@ export class ResponsiveGridLayoutManager
public static readonly descriptor: LayoutRegistryItem = { public static readonly descriptor: LayoutRegistryItem = {
get name() { get name() {
return t('dashboard.responsive-layout.name', 'Auto'); return t('dashboard.responsive-layout.name', 'Auto grid');
}, },
get description() { get description() {
return t('dashboard.responsive-layout.description', 'Automatically positions panels into a grid.'); return t('dashboard.responsive-layout.description', 'Panels resize to fit and form uniform grids');
}, },
id: 'responsive-grid', id: 'responsive-grid',
createFromLayout: ResponsiveGridLayoutManager.createFromLayout, createFromLayout: ResponsiveGridLayoutManager.createFromLayout,
kind: 'ResponsiveGridLayout', kind: 'ResponsiveGridLayout',
isGridLayout: true,
}; };
public readonly descriptor = ResponsiveGridLayoutManager.descriptor; public readonly descriptor = ResponsiveGridLayoutManager.descriptor;

View File

@ -12,7 +12,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor'; import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { DashboardLayoutSelector } from '../layouts-shared/DashboardLayoutSelector'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { useEditPaneInputAutoFocus } from '../layouts-shared/utils'; import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
import { RowItem } from './RowItem'; import { RowItem } from './RowItem';
@ -32,20 +32,8 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
title: t('dashboard.rows-layout.option.height', 'Height'), title: t('dashboard.rows-layout.option.height', 'Height'),
render: () => <RowHeightSelect row={model} />, render: () => <RowHeightSelect row={model} />,
}) })
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.layout.common.layout', 'Layout'),
render: () => <DashboardLayoutSelector layoutManager={layout} />,
})
); );
if (layout.getOptions) {
for (const option of layout.getOptions()) {
editPaneHeaderOptions.addItem(option);
}
}
editPaneHeaderOptions editPaneHeaderOptions
.addItem( .addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
@ -61,13 +49,15 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
); );
return editPaneHeaderOptions; return editPaneHeaderOptions;
}, [layout, model]); }, [model]);
const layoutCategory = useLayoutCategory(layout);
const conditionalRenderingOptions = useMemo(() => { const conditionalRenderingOptions = useMemo(() => {
return useConditionalRenderingEditor(model.state.conditionalRendering); return useConditionalRenderingEditor(model.state.conditionalRendering);
}, [model]); }, [model]);
const editOptions = [rowOptions]; const editOptions = [rowOptions, layoutCategory];
if (conditionalRenderingOptions) { if (conditionalRenderingOptions) {
editOptions.push(conditionalRenderingOptions); editOptions.push(conditionalRenderingOptions);

View File

@ -29,12 +29,12 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return t('dashboard.rows-layout.name', 'Rows'); return t('dashboard.rows-layout.name', 'Rows');
}, },
get description() { get description() {
return t('dashboard.rows-layout.description', 'Rows layout'); return t('dashboard.rows-layout.description', 'Collapsable panel groups with headings');
}, },
id: 'rows-layout', id: 'rows-layout',
createFromLayout: RowsLayoutManager.createFromLayout, createFromLayout: RowsLayoutManager.createFromLayout,
kind: 'RowsLayout', kind: 'RowsLayout',
isGridLayout: false,
}; };
public readonly descriptor = RowsLayoutManager.descriptor; public readonly descriptor = RowsLayoutManager.descriptor;

View File

@ -30,11 +30,12 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
return t('dashboard.tabs-layout.name', 'Tabs'); return t('dashboard.tabs-layout.name', 'Tabs');
}, },
get description() { get description() {
return t('dashboard.tabs-layout.description', 'Tabs layout'); return t('dashboard.tabs-layout.description', 'Organize panels into horizontal tabs');
}, },
id: 'tabs-layout', id: 'tabs-layout',
createFromLayout: TabsLayoutManager.createFromLayout, createFromLayout: TabsLayoutManager.createFromLayout,
kind: 'TabsLayout', kind: 'TabsLayout',
isGridLayout: false,
}; };
public readonly descriptor = TabsLayoutManager.descriptor; public readonly descriptor = TabsLayoutManager.descriptor;

View File

@ -1,6 +1,8 @@
import { css, cx } from '@emotion/css';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Select } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data';
import { RadioButtonDot, Stack, useStyles2, Text } from '@grafana/ui';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -10,53 +12,171 @@ import { isLayoutParent } from '../types/LayoutParent';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { layoutRegistry } from './layoutRegistry'; import { layoutRegistry } from './layoutRegistry';
import { findParentLayout } from './utils';
export interface Props { export interface Props {
layoutManager: DashboardLayoutManager; layoutManager: DashboardLayoutManager;
} }
export function DashboardLayoutSelector({ layoutManager }: Props) { export function DashboardLayoutSelector({ layoutManager }: Props) {
const options = useMemo(() => { const isGridLayout = layoutManager.descriptor.isGridLayout;
const parentLayout = findParentLayout(layoutManager); const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
const parentLayoutId = parentLayout?.descriptor.id;
return layoutRegistry const styles = useStyles2(getStyles);
.list()
.filter((layout) => layout.id !== parentLayoutId)
.map((layout) => ({
label: layout.name,
value: layout,
}));
}, [layoutManager]);
const currentLayoutId = layoutManager.descriptor.id;
const currentOption = options.find((option) => option.value.id === currentLayoutId);
return ( return (
<Select <div role="radiogroup" className={styles.radioGroup}>
options={options} {options.map((opt) => {
value={currentOption} switch (opt.id) {
onChange={(option) => { case 'rows-layout':
if (option.value?.id !== currentOption?.value.id) { return (
changeLayoutTo(layoutManager, option.value!); <LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
>
<div className={styles.rowsLayoutViz}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<div style={{ gridColumn: 'span 3', fontSize: '6px' }}> &nbsp; .-.-.-.-.-</div>
<GridCell />
<GridCell />
<GridCell />
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<div style={{ gridColumn: 'span 3', fontSize: '6px' }}> &nbsp; .-.-.-.-.-</div>
<GridCell />
<GridCell />
<GridCell />
</div>
</LayoutRadioButton>
);
case 'tabs-layout':
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
>
<Stack direction="column" gap={0.5} height={'100%'}>
<div className={styles.tabsBar}>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<div className={cx(styles.tab, styles.tabActive)}>-.-.-</div>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<div className={styles.tab}>-.-.-</div>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
<div className={styles.tab}>-.-.-</div>
</div>
<div className={styles.tabsVizTabContent}>
<GridCell />
<GridCell />
</div>
</Stack>
</LayoutRadioButton>
);
case 'responsive-grid':
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
>
<div className={styles.autoGridViz}>
<GridCell />
<GridCell />
<GridCell />
<GridCell />
</div>
</LayoutRadioButton>
);
case 'custom-grid':
default:
return (
<LayoutRadioButton
label={opt.name}
id={opt.id}
description={opt.description!}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={() => changeLayoutTo(layoutManager, opt)}
>
<div className={styles.customGridViz}>
<GridCell colSpan={2} />
<div className={styles.customGridVizInner}>
<GridCell />
<GridCell />
</div>
<GridCell />
<GridCell colSpan={2} />
</div>
</LayoutRadioButton>
);
} }
}} })}
/> </div>
); );
} }
interface LayoutRadioButtonProps {
label: string;
id: string;
description: string;
isSelected: boolean;
onSelect: () => void;
children: React.ReactNode;
}
function LayoutRadioButton({ label, id, description, isSelected, children, onSelect }: LayoutRadioButtonProps) {
const styles = useStyles2(getStyles);
return (
// This outer div is just so that the radio dot can be outside the
// label (as the RadioButtonDot has a label element and they can't nest)
<div className={styles.radioButtonOuter}>
<label
htmlFor={`layout-${id}`}
tabIndex={0}
className={cx(styles.radioButton, isSelected && styles.radioButtonActive)}
>
{children}
<Stack direction="column" gap={1} justifyContent="space-between" grow={1}>
<Text weight="medium">{label}</Text>
<Text variant="bodySmall" color="secondary">
{description}
</Text>
</Stack>
</label>
<div className={styles.radioDot}>
<RadioButtonDot id={`layout-${id}`} name={'layout'} label={<></>} onChange={onSelect} checked={isSelected} />
</div>
</div>
);
}
function GridCell({ colSpan = 1 }: { colSpan?: number }) {
const styles = useStyles2(getStyles);
return <div className={styles.gridCell} style={{ gridColumn: `span ${colSpan}` }}></div>;
}
export function useLayoutCategory(layoutManager: DashboardLayoutManager) { export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
return useMemo(() => { return useMemo(() => {
const categoryName = layoutManager.descriptor.isGridLayout
? t('dashboard.layout.common.grid', 'Grid')
: t('dashboard.layout.common.layout', 'Layout');
const layoutCategory = new OptionsPaneCategoryDescriptor({ const layoutCategory = new OptionsPaneCategoryDescriptor({
title: 'Layout', title: categoryName,
id: 'layout-options', id: 'layout-options',
isOpenDefault: true, isOpenDefault: true,
}); });
layoutCategory.addItem( layoutCategory.addItem(
new OptionsPaneItemDescriptor({ new OptionsPaneItemDescriptor({
title: t('dashboard.layout.common.layout', 'Layout'), title: '',
skipField: true,
render: () => <DashboardLayoutSelector layoutManager={layoutManager} />, render: () => <DashboardLayoutSelector layoutManager={layoutManager} />,
}) })
); );
@ -77,3 +197,100 @@ function changeLayoutTo(currentLayout: DashboardLayoutManager, newLayoutDescript
layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout)); layoutParent.switchLayout(newLayoutDescriptor.createFromLayout(currentLayout));
} }
} }
const getStyles = (theme: GrafanaTheme2) => {
return {
radioButtonOuter: css({
position: 'relative',
}),
radioDot: css({
position: 'absolute',
top: theme.spacing(0.5),
right: theme.spacing(0),
}),
radioGroup: css({
backgroundColor: theme.colors.background.primary,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
marginBottom: theme.spacing(2),
}),
radioButton: css({
alignItems: 'flex-start',
gap: theme.spacing(1.5),
padding: theme.spacing(1),
border: `1px solid ${theme.colors.border.weak}`,
cursor: 'pointer',
borderRadius: theme.shape.radius.default,
display: 'grid',
gridTemplateColumns: `80px 1fr`,
gridTemplateRows: '70px',
}),
radioButtonActive: css({
border: `1px solid ${theme.colors.primary.border}`,
}),
gridCell: css({
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.medium}`,
}),
tab: css({
width: theme.spacing(2),
height: theme.spacing(1),
fontSize: '5px',
display: 'flex',
alignItems: 'center',
position: 'relative',
justifyContent: 'center',
}),
tabActive: css({
'&:before': {
content: '" "',
position: 'absolute',
height: 1,
bottom: 0,
left: 0,
right: 0,
background: theme.colors.gradients.brandHorizontal,
},
}),
tabsBar: css({
display: 'flex',
gap: theme.spacing(0.5),
borderBottom: `1px solid ${theme.colors.border.medium}`,
}),
rowsLayoutViz: css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: '10px 1fr 10px 1fr',
gap: '4px',
height: '100%',
}),
tabsVizTabContent: css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr',
gap: '4px',
flexGrow: 1,
}),
autoGridViz: css({
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gridTemplateRows: 'repeat(2, 1fr)',
gap: '4px',
height: '100%',
}),
customGridViz: css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gridTemplateRows: 'repeat(2, 1fr)',
gap: '4px',
height: '100%',
}),
customGridVizInner: css({
display: 'grid',
gridTemplateColumns: 'repeat(1, 1fr)',
gridTemplateRows: 'repeat(2, 1fr)',
gap: '4px',
}),
};
};

View File

@ -1,3 +1,4 @@
import { config } from '@grafana/runtime';
import { SceneGridRow } from '@grafana/scenes'; import { SceneGridRow } from '@grafana/scenes';
import { NewObjectAddedToCanvasEvent } from '../../edit-pane/shared'; import { NewObjectAddedToCanvasEvent } from '../../edit-pane/shared';
@ -31,13 +32,20 @@ export function addNewTabTo(layout: DashboardLayoutManager): TabItem {
} }
export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGridRow { export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGridRow {
if (layout instanceof RowsLayoutManager) { /**
const row = layout.addNewRow(); * If new layouts feature is disabled we add old school rows to the custom grid layout
layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true); */
return row; if (!config.featureToggles.dashboardNewLayouts) {
if (layout instanceof DefaultGridLayoutManager) {
const row = layout.addNewRow();
layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true);
return row;
} else {
throw new Error('New dashboard layouts feature not enabled but new layout found');
}
} }
if (layout instanceof DefaultGridLayoutManager) { if (layout instanceof RowsLayoutManager) {
const row = layout.addNewRow(); const row = layout.addNewRow();
layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true); layout.publishEvent(new NewObjectAddedToCanvasEvent(row), true);
return row; return row;
@ -48,6 +56,9 @@ export function addNewRowTo(layout: DashboardLayoutManager): RowItem | SceneGrid
return addNewRowTo(currentTab.state.layout); return addNewRowTo(currentTab.state.layout);
} }
// If we want to add a row and current layout is custom grid or auto we migrate to rows layout
// And wrap current layout in a row
const layoutParent = layout.parent!; const layoutParent = layout.parent!;
if (!isLayoutParent(layoutParent)) { if (!isLayoutParent(layoutParent)) {
throw new Error('Parent layout is not a LayoutParent'); throw new Error('Parent layout is not a LayoutParent');

View File

@ -23,4 +23,9 @@ export interface LayoutRegistryItem<S = {}> extends RegistryItem {
* Schema kind of layout * Schema kind of layout
*/ */
kind?: DashboardV2Spec['layout']['kind']; kind?: DashboardV2Spec['layout']['kind'];
/**
* Is grid layout (that contains panels)
*/
isGridLayout: boolean;
} }

View File

@ -1066,7 +1066,7 @@
} }
}, },
"default-layout": { "default-layout": {
"description": "Manually size and position panels", "description": "Position and size each panel individually",
"item-options": { "item-options": {
"repeat": { "repeat": {
"direction": { "direction": {
@ -1215,6 +1215,7 @@
"copy-or-duplicate": "Copy or Duplicate", "copy-or-duplicate": "Copy or Duplicate",
"delete": "Delete", "delete": "Delete",
"duplicate": "Duplicate", "duplicate": "Duplicate",
"grid": "Grid",
"layout": "Layout" "layout": "Layout"
} }
}, },
@ -1238,8 +1239,8 @@
} }
}, },
"responsive-layout": { "responsive-layout": {
"description": "Automatically positions panels into a grid.", "description": "Panels resize to fit and form uniform grids",
"name": "Auto", "name": "Auto grid",
"options": { "options": {
"columns": "Columns", "columns": "Columns",
"fixed": "Fixed: {{size}}px", "fixed": "Fixed: {{size}}px",
@ -1251,7 +1252,7 @@
} }
}, },
"rows-layout": { "rows-layout": {
"description": "Rows layout", "description": "Collapsable panel groups with headings",
"name": "Rows", "name": "Rows",
"option": { "option": {
"height": "Height", "height": "Height",
@ -1286,7 +1287,7 @@
} }
}, },
"tabs-layout": { "tabs-layout": {
"description": "Tabs layout", "description": "Organize panels into horizontal tabs",
"menu": { "menu": {
"move-tab": "Move tab" "move-tab": "Move tab"
}, },