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:
@@ -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'] {
|
||||
|
||||
+1
@@ -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'] {
|
||||
|
||||
+44
-245
@@ -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' }}>⌄ .-.-.-.-.-</div>
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
<GridCell />
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<div style={{ gridColumn: 'span 3', fontSize: '6px' }}>⌄ .-.-.-.-.-</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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user