Dashboard: Group layout and Grid layout categories and simplified radio toggle design (#103433)

* Group layout and more

* Change word

* Update

* changed names and added new translation keys

* Update

* Update

* revert color change

* update
This commit is contained in:
Torkel Ödegaard
2025-04-07 14:30:09 +02:00
committed by GitHub
parent fc099e9f0d
commit 46bf9e6551
14 changed files with 106 additions and 255 deletions
+3
View File
@@ -252,6 +252,9 @@ export const availableIconsIndex = {
unlock: true,
upload: true,
user: true,
ban: true,
window: true,
'window-grid': true,
'users-alt': true,
'user-arrows': true,
'vertical-align-bottom': true,
@@ -125,6 +125,7 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
fullWidth: css({
display: 'flex',
flexGrow: 1,
}),
icon: css({
marginRight: '6px',
@@ -48,7 +48,7 @@ export class DashboardEditableElement implements EditableDashboardElement {
const layoutCategory = useLayoutCategory(body);
return [dashboardOptions, layoutCategory];
return [dashboardOptions, ...layoutCategory];
}
public renderActions(): ReactNode {
@@ -72,6 +72,7 @@ export class DefaultGridLayoutManager
id: 'GridLayout',
createFromLayout: DefaultGridLayoutManager.createFromLayout,
isGridLayout: true,
icon: 'window-grid',
};
public serialize(): DashboardV2Spec['layout'] {
@@ -56,6 +56,7 @@ export class AutoGridLayoutManager
id: 'AutoGridLayout',
createFromLayout: AutoGridLayoutManager.createFromLayout,
isGridLayout: true,
icon: 'apps',
};
public serialize(): DashboardV2Spec['layout'] {
@@ -64,7 +64,7 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
const layoutCategory = useLayoutCategory(layout);
const editOptions = [rowCategory, layoutCategory, repeatCategory];
const editOptions = [rowCategory, ...layoutCategory, repeatCategory];
const conditionalRenderingCategory = useMemo(
() => useConditionalRenderingEditor(model.state.conditionalRendering),
@@ -43,6 +43,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
id: 'RowsLayout',
createFromLayout: RowsLayoutManager.createFromLayout,
isGridLayout: false,
icon: 'list-ul',
};
public serialize(): DashboardV2Spec['layout'] {
@@ -27,7 +27,7 @@ export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[]
const layoutCategory = useLayoutCategory(layout);
const editOptions = [tabCategory, layoutCategory];
const editOptions = [tabCategory, ...layoutCategory];
const conditionalRenderingCategory = useMemo(
() => useConditionalRenderingEditor(model.state.conditionalRendering),
@@ -45,6 +45,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
id: 'TabsLayout',
createFromLayout: TabsLayoutManager.createFromLayout,
isGridLayout: false,
icon: 'window',
};
public serialize(): DashboardV2Spec['layout'] {
@@ -1,8 +1,6 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { RadioButtonDot, Stack, useStyles2, Text } from '@grafana/ui';
import { RadioButtonGroup, Box } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -20,7 +18,6 @@ export interface Props {
export function DashboardLayoutSelector({ layoutManager }: Props) {
const isGridLayout = layoutManager.descriptor.isGridLayout;
const options = layoutRegistry.list().filter((layout) => layout.isGridLayout === isGridLayout);
const styles = useStyles2(getStyles);
const onChangeLayout = useCallback(
(newLayout: LayoutRegistryItem) => {
@@ -33,157 +30,36 @@ export function DashboardLayoutSelector({ layoutManager }: Props) {
[layoutManager]
);
return (
<div role="radiogroup" className={styles.radioGroup}>
{options.map((opt) => {
switch (opt.id) {
case 'RowsLayout':
return (
<LayoutRadioButton
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={onChangeLayout}
key={opt.id}
>
<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 'TabsLayout':
return (
<LayoutRadioButton
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={onChangeLayout}
key={opt.id}
>
<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 'AutoGridLayout':
return (
<LayoutRadioButton
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={onChangeLayout}
key={opt.id}
>
<div className={styles.autoGridViz}>
<GridCell />
<GridCell />
<GridCell />
<GridCell />
</div>
</LayoutRadioButton>
);
case 'GridLayout':
default:
return (
<LayoutRadioButton
item={opt}
isSelected={layoutManager.descriptor.id === opt.id}
onSelect={onChangeLayout}
key={opt.id}
>
<div className={styles.customGridViz}>
<GridCell colSpan={2} />
<div className={styles.customGridVizInner}>
<GridCell />
<GridCell />
</div>
<GridCell />
<GridCell colSpan={2} />
</div>
</LayoutRadioButton>
);
}
})}
</div>
);
}
interface LayoutRadioButtonProps {
item: LayoutRegistryItem;
isSelected: boolean;
onSelect: (item: LayoutRegistryItem) => void;
children: React.ReactNode;
}
function LayoutRadioButton({ item, isSelected, children, onSelect }: LayoutRadioButtonProps) {
const styles = useStyles2(getStyles);
const radioOptions = options.map((opt) => ({
value: opt,
label: opt.name,
icon: opt.icon,
description: opt.description,
}));
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-${item.id}`}
tabIndex={0}
className={cx(styles.radioButton, isSelected && styles.radioButtonActive)}
>
{children}
<Stack direction="column" gap={1} justifyContent="space-between" grow={1}>
<Text weight="medium">{item.name}</Text>
<Text variant="bodySmall" color="secondary">
{item.description!}
</Text>
</Stack>
</label>
<div className={styles.radioDot}>
<RadioButtonDot
id={`layout-${item.id}`}
name={'layout'}
label={<></>}
onChange={() => onSelect(item)}
checked={isSelected}
/>
</div>
</div>
<Box paddingBottom={1} display="flex" grow={1} alignItems="center">
<RadioButtonGroup fullWidth value={layoutManager.descriptor} options={radioOptions} onChange={onChangeLayout} />
</Box>
);
}
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) {
return useMemo(() => {
const categoryName = layoutManager.descriptor.isGridLayout
? t('dashboard.layout.common.grid', 'Grid')
: t('dashboard.layout.common.layout', 'Layout');
const isGridLayout = layoutManager.descriptor.isGridLayout;
const layoutCategory = new OptionsPaneCategoryDescriptor({
title: categoryName,
id: 'layout-options',
isOpenDefault: true,
const groupLayout = new OptionsPaneCategoryDescriptor({
title: t('dashboard.layout.common.group-layout', 'Group layout'),
id: 'group-layout-category',
isOpenDefault: false,
});
layoutCategory.addItem(
const gridLayout = new OptionsPaneCategoryDescriptor({
title: t('dashboard.layout.common.panel-layout', 'Panel layout'),
id: 'grid-layout-category',
isOpenDefault: false,
});
gridLayout.addItem(
new OptionsPaneItemDescriptor({
title: '',
skipField: true,
@@ -191,109 +67,32 @@ export function useLayoutCategory(layoutManager: DashboardLayoutManager) {
})
);
if (isGridLayout) {
groupLayout.props.disabledText = t(
'dashboard.layout.common.group-layout-disabled',
'No groups exists on this level'
);
} else {
groupLayout.addItem(
new OptionsPaneItemDescriptor({
title: '',
skipField: true,
render: () => <DashboardLayoutSelector layoutManager={layoutManager} />,
})
);
gridLayout.props.disabledText = t(
'dashboard.layout.common.panel-layout-disabled',
'Select a row or tab to change panel layout options'
);
}
if (layoutManager.getOptions) {
for (const option of layoutManager.getOptions()) {
layoutCategory.addItem(option);
gridLayout.addItem(option);
}
}
return layoutCategory;
return [groupLayout, gridLayout];
}, [layoutManager]);
}
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',
}),
};
};
@@ -1,4 +1,4 @@
import { RegistryItem } from '@grafana/data';
import { IconName, RegistryItem } from '@grafana/data';
import { DashboardLayoutManager } from './DashboardLayoutManager';
@@ -16,4 +16,9 @@ export interface LayoutRegistryItem extends RegistryItem {
* Is grid layout (that contains panels)
*/
isGridLayout: boolean;
/**
* icon name
*/
icon: IconName;
}
@@ -5,7 +5,7 @@ import { useLocalStorage } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Counter, useStyles2 } from '@grafana/ui';
import { Button, Counter, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { PANEL_EDITOR_UI_STATE_STORAGE_KEY } from './state/reducers';
@@ -21,6 +21,10 @@ export interface OptionsPaneCategoryProps {
isNested?: boolean;
children: ReactNode;
sandboxId?: string;
/**
* When set will disable category and show tooltip with disabledText on hover
*/
disabledText?: string;
}
const CATEGORY_PARAM_NAME = 'showCategory' as const;
@@ -37,6 +41,7 @@ export const OptionsPaneCategory = React.memo(
itemsCount,
isNested = false,
sandboxId,
disabledText,
}: OptionsPaneCategoryProps) => {
const [savedState, setSavedState] = useLocalStorage(getOptionGroupStorageKey(id), {
isExpanded: isOpenDefault,
@@ -96,6 +101,29 @@ export const OptionsPaneCategory = React.memo(
[styles.bodyNested]: isNested,
});
/**
* Disabled categories just show the disabled header and icon
*/
if (disabledText) {
return (
<div
className={boxStyles}
data-plugin-sandbox={sandboxId}
data-testid={selectors.components.OptionsGroup.group(id)}
ref={ref}
>
<Tooltip content={disabledText}>
<div className={headerStyles}>
<h6 id={`button-${id}`} className={cx(styles.title, styles.titleDisabled)}>
{renderTitle(isExpanded)}
</h6>
<Icon size="sm" name="ban" className={styles.disabledIcon} />
</div>
</Tooltip>
</div>
);
}
return (
<div
className={boxStyles}
@@ -110,7 +138,6 @@ export const OptionsPaneCategory = React.memo(
<h6 id={`button-${id}`} className={cx(styles.title, isExpanded && styles.titleExpanded)}>
{renderTitle(isExpanded)}
</h6>
<Button
data-testid={selectors.components.OptionsGroup.toggle(id)}
type="button"
@@ -133,8 +160,6 @@ export const OptionsPaneCategory = React.memo(
}
);
OptionsPaneCategory.displayName = 'OptionsPaneCategory';
const getStyles = (theme: GrafanaTheme2) => ({
box: css({
borderTop: `1px solid ${theme.colors.border.weak}`,
@@ -177,6 +202,14 @@ const getStyles = (theme: GrafanaTheme2) => ({
body: css({
padding: theme.spacing(1, 2, 1, 2),
}),
titleDisabled: css({
color: theme.colors.text.disabled,
cursor: 'not-allowed',
}),
disabledIcon: css({
color: theme.colors.text.disabled,
marginRight: theme.spacing(1),
}),
bodyNested: css({
position: 'relative',
paddingRight: 0,
@@ -16,6 +16,10 @@ export interface OptionsPaneCategoryDescriptorProps {
itemsCount?: number;
customRender?: () => React.ReactNode;
sandboxId?: string;
/**
* When set will disable category and show tooltip with disabledText on
*/
disabledText?: string;
}
/**
+4 -2
View File
@@ -2815,8 +2815,10 @@
"copy-or-duplicate": "Copy or Duplicate",
"delete": "Delete",
"duplicate": "Duplicate",
"grid": "Grid",
"layout": "Layout"
"group-layout": "Group layout",
"group-layout-disabled": "No groups exists on this level",
"panel-layout": "Panel layout",
"panel-layout-disabled": "Select a row or tab to change panel layout options"
}
},
"make-editable": {