Dashboard: Auto grid options (#102374)

* Dashboard: Auto grid options

* Update

* Fix

* update trans

* Update cue

* Fix persisting auto grid options (#102744)

* Fix persisting auto grid options

* Update i18n

* fix serializer test

* update schema

* reset dashboard_object_gen.go files, run update-codegen.sh

* rename in code

* rename height fill in schema

* rename heightFill fillScreen in code

* fix test

---------

Co-authored-by: oscarkilhed <oscar.kilhed@grafana.com>
pull/102796/head^2
Torkel Ödegaard 3 months ago committed by GitHub
parent ca14ca70de
commit dc922717dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
  2. 33
      apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
  3. 30
      apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
  4. 2
      apps/dashboard/pkg/apis/dashboard_manifest.go
  5. 8
      packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
  6. 14
      packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
  7. 34
      packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
  8. 1
      packages/grafana-ui/src/components/Layout/types.ts
  9. 128
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
  10. 238
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx
  11. 47
      public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts
  12. 27
      public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts
  13. 16
      public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts
  14. 29
      public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts
  15. 14
      public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts
  16. 22
      public/locales/en-US/grafana.json

@ -563,8 +563,12 @@ ResponsiveGridLayoutKind: {
}
ResponsiveGridLayoutSpec: {
row: string
col: string
maxColumnCount?: number | *3
columnWidthMode: "narrow" | *"standard" | "wide" | "custom"
columnWidth?: number
rowHeightMode: "short" | *"standard" | "tall" | "custom"
rowHeight?: number
fillScreen?: bool | *false
items: [...ResponsiveGridLayoutItemKind]
}

@ -965,14 +965,21 @@ func NewDashboardResponsiveGridLayoutKind() *DashboardResponsiveGridLayoutKind {
// +k8s:openapi-gen=true
type DashboardResponsiveGridLayoutSpec struct {
Row string `json:"row"`
Col string `json:"col"`
MaxColumnCount *float64 `json:"maxColumnCount,omitempty"`
ColumnWidthMode DashboardResponsiveGridLayoutSpecColumnWidthMode `json:"columnWidthMode"`
ColumnWidth *float64 `json:"columnWidth,omitempty"`
RowHeightMode DashboardResponsiveGridLayoutSpecRowHeightMode `json:"rowHeightMode"`
RowHeight *float64 `json:"rowHeight,omitempty"`
FillScreen *bool `json:"fillScreen,omitempty"`
Items []DashboardResponsiveGridLayoutItemKind `json:"items"`
}
// NewDashboardResponsiveGridLayoutSpec creates a new DashboardResponsiveGridLayoutSpec object.
func NewDashboardResponsiveGridLayoutSpec() *DashboardResponsiveGridLayoutSpec {
return &DashboardResponsiveGridLayoutSpec{}
return &DashboardResponsiveGridLayoutSpec{
MaxColumnCount: (func(input float64) *float64 { return &input })(3),
FillScreen: (func(input bool) *bool { return &input })(false),
}
}
// +k8s:openapi-gen=true
@ -1795,6 +1802,26 @@ const (
DashboardConditionalRenderingVariableSpecOperatorNotEquals DashboardConditionalRenderingVariableSpecOperator = "notEquals"
)
// +k8s:openapi-gen=true
type DashboardResponsiveGridLayoutSpecColumnWidthMode string
const (
DashboardResponsiveGridLayoutSpecColumnWidthModeNarrow DashboardResponsiveGridLayoutSpecColumnWidthMode = "narrow"
DashboardResponsiveGridLayoutSpecColumnWidthModeStandard DashboardResponsiveGridLayoutSpecColumnWidthMode = "standard"
DashboardResponsiveGridLayoutSpecColumnWidthModeWide DashboardResponsiveGridLayoutSpecColumnWidthMode = "wide"
DashboardResponsiveGridLayoutSpecColumnWidthModeCustom DashboardResponsiveGridLayoutSpecColumnWidthMode = "custom"
)
// +k8s:openapi-gen=true
type DashboardResponsiveGridLayoutSpecRowHeightMode string
const (
DashboardResponsiveGridLayoutSpecRowHeightModeShort DashboardResponsiveGridLayoutSpecRowHeightMode = "short"
DashboardResponsiveGridLayoutSpecRowHeightModeStandard DashboardResponsiveGridLayoutSpecRowHeightMode = "standard"
DashboardResponsiveGridLayoutSpecRowHeightModeTall DashboardResponsiveGridLayoutSpecRowHeightMode = "tall"
DashboardResponsiveGridLayoutSpecRowHeightModeCustom DashboardResponsiveGridLayoutSpecRowHeightMode = "custom"
)
// +k8s:openapi-gen=true
type DashboardTimeSettingsSpecWeekStart string

@ -3395,20 +3395,44 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardResponsiveGridLayoutSpec(ref co
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"row": {
"maxColumnCount": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
Format: "double",
},
},
"columnWidthMode": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"col": {
"columnWidth": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
Format: "double",
},
},
"rowHeightMode": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"rowHeight": {
SchemaProps: spec.SchemaProps{
Type: []string{"number"},
Format: "double",
},
},
"fillScreen": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
Format: "",
},
},
"items": {
SchemaProps: spec.SchemaProps{
Type: []string{"array"},
@ -3423,7 +3447,7 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardResponsiveGridLayoutSpec(ref co
},
},
},
Required: []string{"row", "col", "items"},
Required: []string{"columnWidthMode", "rowHeightMode", "items"},
},
},
Dependencies: []string{

@ -11,6 +11,8 @@ import (
"github.com/grafana/grafana-app-sdk/app"
)
var ()
var appManifestData = app.ManifestData{
AppName: "dashboard",
Group: "dashboard.grafana.app",

@ -563,8 +563,12 @@ ResponsiveGridLayoutKind: {
}
ResponsiveGridLayoutSpec: {
row: string,
col: string,
maxColumnCount?: number | *3
columnWidthMode: "narrow" | *"standard" | "wide" | "custom"
columnWidth?: number
rowHeightMode: "short" | *"standard" | "tall" | "custom"
rowHeight?: number
fillScreen?: bool | *false
items: [...ResponsiveGridLayoutItemKind]
}

@ -824,14 +824,20 @@ export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => (
});
export interface ResponsiveGridLayoutSpec {
row: string;
col: string;
maxColumnCount?: number;
columnWidthMode: "narrow" | "standard" | "wide" | "custom";
columnWidth?: number;
rowHeightMode: "short" | "standard" | "tall" | "custom";
rowHeight?: number;
fillScreen?: boolean;
items: ResponsiveGridLayoutItemKind[];
}
export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({
row: "",
col: "",
maxColumnCount: 3,
columnWidthMode: "standard",
rowHeightMode: "standard",
fillScreen: false,
items: [],
});

@ -333,13 +333,13 @@ export const defaultValueMapping = (): ValueMapping => (defaultValueMap());
// Maps text values to a color or different display text and color.
// For example, you can configure a value mapping so that all instances of the value 10 appear as Perfection! rather than the number.
export interface ValueMap {
type: MappingType & "value";
type: unknown;
// Map with <value_to_match>: ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } }
options: Record<string, ValueMappingResult>;
}
export const defaultValueMap = (): ValueMap => ({
type: "value",
type: "unknown",
options: {},
});
@ -370,7 +370,7 @@ export const defaultValueMappingResult = (): ValueMappingResult => ({
// Maps numerical ranges to a display text and color.
// For example, if a value is within a certain range, you can configure a range value mapping to display Low or High rather than the number.
export interface RangeMap {
type: MappingType & "range";
type: unknown;
// Range to match against and the result to apply when the value is within the range
options: {
// Min value of the range. It can be null which means -Infinity
@ -383,7 +383,7 @@ export interface RangeMap {
}
export const defaultRangeMap = (): RangeMap => ({
type: "range",
type: "unknown",
options: {
from: 0,
to: 0,
@ -394,7 +394,7 @@ export const defaultRangeMap = (): RangeMap => ({
// Maps regular expressions to replacement text and a color.
// For example, if a value is www.example.com, you can configure a regex value mapping so that Grafana displays www and truncates the domain.
export interface RegexMap {
type: MappingType & "regex";
type: unknown;
// Regular expression to match against and the result to apply when the value matches the regex
options: {
// Regular expression to match against
@ -405,7 +405,7 @@ export interface RegexMap {
}
export const defaultRegexMap = (): RegexMap => ({
type: "regex",
type: "unknown",
options: {
pattern: "",
result: defaultValueMappingResult(),
@ -416,7 +416,7 @@ export const defaultRegexMap = (): RegexMap => ({
// See SpecialValueMatch to see the list of special values.
// For example, you can configure a special value mapping so that null values appear as N/A.
export interface SpecialValueMap {
type: MappingType & "special";
type: unknown;
options: {
// Special value to match against
match: SpecialValueMatch;
@ -426,7 +426,7 @@ export interface SpecialValueMap {
}
export const defaultSpecialValueMap = (): SpecialValueMap => ({
type: "special",
type: "unknown",
options: {
match: "true",
result: defaultValueMappingResult(),
@ -691,7 +691,7 @@ export interface RowsLayoutRowSpec {
collapsed: boolean;
conditionalRendering?: ConditionalRenderingGroupKind;
repeat?: RowRepeatOptions;
layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind;
layout: GridLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind | RowsLayoutKind;
}
export const defaultRowsLayoutRowSpec = (): RowsLayoutRowSpec => ({
@ -788,14 +788,20 @@ export const defaultResponsiveGridLayoutKind = (): ResponsiveGridLayoutKind => (
});
export interface ResponsiveGridLayoutSpec {
row: string;
col: string;
maxColumnCount?: number;
columnWidthMode: "narrow" | "standard" | "wide" | "custom";
columnWidth?: number;
rowHeightMode: "short" | "standard" | "tall" | "custom";
rowHeight?: number;
fillScreen?: boolean;
items: ResponsiveGridLayoutItemKind[];
}
export const defaultResponsiveGridLayoutSpec = (): ResponsiveGridLayoutSpec => ({
row: "",
col: "",
maxColumnCount: 3,
columnWidthMode: "standard",
rowHeightMode: "standard",
fillScreen: false,
items: [],
});
@ -859,7 +865,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
export interface TabsLayoutTabSpec {
title?: string;
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind;
layout: GridLayoutKind | RowsLayoutKind | ResponsiveGridLayoutKind | TabsLayoutKind;
}
export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({

@ -18,6 +18,7 @@ export type JustifyContent =
| 'space-between'
| 'space-around'
| 'space-evenly'
| 'stretch'
| 'start'
| 'end'
| 'left'

@ -1,4 +1,5 @@
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { GRID_CELL_VMARGIN } from 'app/core/constants';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -20,8 +21,19 @@ import { getEditOptions } from './ResponsiveGridLayoutManagerEditor';
interface ResponsiveGridLayoutManagerState extends SceneObjectState {
layout: ResponsiveGridLayout;
maxColumnCount: number;
rowHeight: AutoGridRowHeight;
columnWidth: AutoGridColumnWidth;
fillScreen: boolean;
}
export type AutoGridColumnWidth = 'narrow' | 'standard' | 'wide' | 'custom' | number;
export type AutoGridRowHeight = 'short' | 'standard' | 'tall' | 'custom' | number;
export const AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT = 3;
export const AUTO_GRID_DEFAULT_COLUMN_WIDTH = 'standard';
export const AUTO_GRID_DEFAULT_ROW_HEIGHT = 'standard';
export class ResponsiveGridLayoutManager
extends SceneObjectBase<ResponsiveGridLayoutManagerState>
implements DashboardLayoutManager
@ -45,10 +57,30 @@ export class ResponsiveGridLayoutManager
public readonly descriptor = ResponsiveGridLayoutManager.descriptor;
public static defaultCSS = {
templateColumns: 'repeat(auto-fit, minmax(400px, auto))',
autoRows: 'minmax(300px, auto)',
};
public constructor(state: Partial<ResponsiveGridLayoutManagerState>) {
const maxColumnCount = state.maxColumnCount ?? AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT;
const columnWidth = state.columnWidth ?? AUTO_GRID_DEFAULT_COLUMN_WIDTH;
const rowHeight = state.rowHeight ?? AUTO_GRID_DEFAULT_ROW_HEIGHT;
const fillScreen = state.fillScreen ?? false;
super({
...state,
maxColumnCount,
columnWidth,
rowHeight,
fillScreen,
layout:
state.layout ??
new ResponsiveGridLayout({
templateColumns: getTemplateColumnsTemplate(maxColumnCount, columnWidth),
autoRows: getAutoRowsTemplate(rowHeight, fillScreen),
}),
});
// @ts-ignore
this.state.layout.getDragClassCancel = () => 'drag-cancel';
this.state.layout.isDraggable = () => true;
}
public addPanel(vizPanel: VizPanel) {
const panelId = dashboardSceneGraph.getNextPanelId(this);
@ -159,24 +191,46 @@ export class ResponsiveGridLayoutManager
return getEditOptions(this);
}
public changeColumns(columns: string) {
this.state.layout.setState({ templateColumns: columns });
public onMaxColumnCountChanged(maxColumnCount: number) {
this.setState({ maxColumnCount: maxColumnCount });
this.state.layout.setState({
templateColumns: getTemplateColumnsTemplate(maxColumnCount, this.state.columnWidth),
});
}
public changeRows(rows: string) {
this.state.layout.setState({ autoRows: rows });
public onColumnWidthChanged(columnWidth: AutoGridColumnWidth) {
if (columnWidth === 'custom') {
columnWidth = getNamedColumWidthInPixels(this.state.columnWidth);
}
public static createEmpty(): ResponsiveGridLayoutManager {
return new ResponsiveGridLayoutManager({
layout: new ResponsiveGridLayout({
children: [],
templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns,
autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows,
}),
this.setState({ columnWidth: columnWidth });
this.state.layout.setState({
templateColumns: getTemplateColumnsTemplate(this.state.maxColumnCount, this.state.columnWidth),
});
}
public onFillScreenChanged(fillScreen: boolean) {
this.setState({ fillScreen });
this.state.layout.setState({
autoRows: getAutoRowsTemplate(this.state.rowHeight, fillScreen),
});
}
public onRowHeightChanged(rowHeight: AutoGridRowHeight) {
if (rowHeight === 'custom') {
rowHeight = getNamedHeightInPixels(this.state.rowHeight);
}
this.setState({ rowHeight });
this.state.layout.setState({
autoRows: getAutoRowsTemplate(rowHeight, this.state.fillScreen),
});
}
public static createEmpty(): ResponsiveGridLayoutManager {
return new ResponsiveGridLayoutManager({});
}
public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager {
const panels = layout.getVizPanels();
const children: ResponsiveGridItem[] = [];
@ -195,3 +249,47 @@ export class ResponsiveGridLayoutManager
function ResponsiveGridLayoutManagerRenderer({ model }: SceneComponentProps<ResponsiveGridLayoutManager>) {
return <model.state.layout.Component model={model.state.layout} />;
}
export function getTemplateColumnsTemplate(maxColumnCount: number, columnWidth: AutoGridColumnWidth) {
return `repeat(auto-fit, minmax(min(max(100% / ${maxColumnCount} - ${GRID_CELL_VMARGIN}px, ${getNamedColumWidthInPixels(columnWidth)}px), 100%), 1fr))`;
}
function getNamedColumWidthInPixels(columnWidth: AutoGridColumnWidth) {
if (typeof columnWidth === 'number') {
return columnWidth;
}
switch (columnWidth) {
case 'narrow':
return 192;
case 'wide':
return 768;
case 'custom':
case 'standard':
default:
return 448;
}
}
function getNamedHeightInPixels(rowHeight: AutoGridRowHeight) {
if (typeof rowHeight === 'number') {
return rowHeight;
}
switch (rowHeight) {
case 'short':
return 128;
case 'tall':
return 512;
case 'custom':
case 'standard':
default:
return 320;
}
}
export function getAutoRowsTemplate(rowHeight: AutoGridRowHeight, fillScreen: boolean) {
const rowHeightPixels = getNamedHeightInPixels(rowHeight);
const maxRowHeightValue = fillScreen ? 'auto' : `${rowHeightPixels}px`;
return `minmax(${rowHeightPixels}px, ${maxRowHeightValue})`;
}

@ -1,25 +1,27 @@
import { SelectableValue } from '@grafana/data';
import { Select } from '@grafana/ui';
import { capitalize } from 'lodash';
import React, { useEffect } from 'react';
import { Button, Combobox, ComboboxOption, Field, InlineSwitch, Input, Stack } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
const sizes = [100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 650];
import { AutoGridColumnWidth, AutoGridRowHeight, ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): OptionsPaneItemDescriptor[] {
const options: OptionsPaneItemDescriptor[] = [];
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.columns', 'Columns'),
title: 'Column options',
skipField: true,
render: () => <GridLayoutColumns layoutManager={layoutManager} />,
})
);
options.push(
new OptionsPaneItemDescriptor({
title: t('dashboard.responsive-layout.options.rows', 'Rows'),
title: 'Row height options',
skipField: true,
render: () => <GridLayoutRows layoutManager={layoutManager} />,
})
);
@ -28,56 +30,210 @@ export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): Opti
}
function GridLayoutColumns({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
const { templateColumns } = layoutManager.state.layout.useState();
const colOptions: Array<SelectableValue<string>> = [
{ label: t('dashboard.responsive-layout.options.one-column', '1 column'), value: `1fr` },
{ label: t('dashboard.responsive-layout.options.two-columns', '2 columns'), value: `1fr 1fr` },
{ label: t('dashboard.responsive-layout.options.three-columns', '3 columns'), value: `1fr 1fr 1fr` },
];
for (const size of sizes) {
colOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `repeat(auto-fit, minmax(${size}px, auto))`,
});
const { maxColumnCount, columnWidth } = layoutManager.useState();
const [inputRef, setInputRef] = React.useState<HTMLInputElement | null>(null);
const [focusInput, setFocusInput] = React.useState(false);
const [customMinWidthError, setCustomMinWidthError] = React.useState(false);
useEffect(() => {
if (focusInput && inputRef) {
inputRef.focus();
setFocusInput(false);
}
}, [focusInput, inputRef]);
const minWidthOptions: Array<ComboboxOption<AutoGridColumnWidth>> = [
'narrow' as const,
'standard' as const,
'wide' as const,
'custom' as const,
].map((value) => ({
label: capitalize(value),
value,
}));
const isStandardMinWidth = typeof columnWidth === 'string';
const minWidthLabel = isStandardMinWidth
? t('dashboard.responsive-layout.options.min-width', 'Min column width')
: t('dashboard.responsive-layout.options.min-width-custom', 'Custom min width');
const colOptions = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'].map((value) => ({ label: value, value }));
const onCustomMinWidthChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const pixels = parseInt(e.target.value, 10);
if (isNaN(pixels) || pixels < 50 || pixels > 2000) {
setCustomMinWidthError(true);
return;
} else if (customMinWidthError) {
setCustomMinWidthError(false);
}
layoutManager.onColumnWidthChanged(pixels);
};
const onNamedMinWidthChanged = (value: ComboboxOption<AutoGridColumnWidth>) => {
if (value.value === 'custom') {
setFocusInput(true);
}
layoutManager.onColumnWidthChanged(value.value);
};
const onClearCustomMinWidth = () => {
if (customMinWidthError) {
setCustomMinWidthError(false);
}
layoutManager.onColumnWidthChanged('standard');
};
return (
<Select
<Stack gap={2} justifyContent={'stretch'}>
<Field
label={minWidthLabel}
invalid={customMinWidthError}
error={
customMinWidthError
? t('dashboard.responsive-layout.options.min-width-error', 'A number between 50 and 2000 is required')
: undefined
}
>
{isStandardMinWidth ? (
<Combobox options={minWidthOptions} value={columnWidth} onChange={onNamedMinWidthChanged} />
) : (
<Input
defaultValue={columnWidth}
onBlur={onCustomMinWidthChanged}
ref={(ref) => setInputRef(ref)}
type="number"
min={50}
max={2000}
invalid={customMinWidthError}
suffix={
<Button
size="sm"
fill="text"
icon="times"
tooltip={t(
'dashboard.responsive-layout.options.min-width-custom-clear',
'Back to standard min column width'
)}
onClick={onClearCustomMinWidth}
>
{t('dashboard.responsive-layout.options.custom-min-width.clear', 'Clear')}
</Button>
}
/>
)}
</Field>
<Field label={t('dashboard.responsive-layout.options.max-columns', 'Max columns')}>
<Combobox
options={colOptions}
value={String(templateColumns)}
onChange={({ value }) => layoutManager.changeColumns(value!)}
allowCustomValue={true}
value={String(maxColumnCount)}
onChange={({ value }) => layoutManager.onMaxColumnCountChanged(parseInt(value, 10))}
/>
</Field>
</Stack>
);
}
function GridLayoutRows({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) {
const { autoRows } = layoutManager.state.layout.useState();
const { rowHeight, fillScreen } = layoutManager.useState();
const [inputRef, setInputRef] = React.useState<HTMLInputElement | null>(null);
const [focusInput, setFocusInput] = React.useState(false);
const [customMinWidthError, setCustomMinWidthError] = React.useState(false);
useEffect(() => {
if (focusInput && inputRef) {
inputRef.focus();
setFocusInput(false);
}
}, [focusInput, inputRef]);
const minWidthOptions: Array<ComboboxOption<AutoGridRowHeight>> = [
'short' as const,
'standard' as const,
'tall' as const,
'custom' as const,
].map((value) => ({
label: capitalize(value),
value,
}));
const rowOptions: Array<SelectableValue<string>> = [];
const isStandardHeight = typeof rowHeight === 'string';
const rowHeightLabel = rowHeight
? t('dashboard.responsive-layout.options.min-height', 'Row height')
: t('dashboard.responsive-layout.options.min-height-custom', 'Custom row height');
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }),
value: `minmax(${size}px, auto)`,
});
const onCustomHeightChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const pixels = parseInt(e.target.value, 10);
if (isNaN(pixels) || pixels < 50 || pixels > 2000) {
setCustomMinWidthError(true);
return;
} else if (customMinWidthError) {
setCustomMinWidthError(false);
}
for (const size of sizes) {
rowOptions.push({
label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }),
value: `${size}px`,
});
layoutManager.onRowHeightChanged(pixels);
};
const onNamedMinHeightChanged = (value: ComboboxOption<AutoGridRowHeight>) => {
if (value.value === 'custom') {
setFocusInput(true);
}
layoutManager.onRowHeightChanged(value.value);
};
const onClearCustomRowHeight = () => {
if (customMinWidthError) {
setCustomMinWidthError(false);
}
layoutManager.onRowHeightChanged('standard');
};
return (
<Select
options={rowOptions}
value={String(autoRows)}
onChange={({ value }) => layoutManager.changeRows(value!)}
allowCustomValue={true}
<Stack gap={2} wrap={true}>
<Field
label={rowHeightLabel}
invalid={customMinWidthError}
error={
customMinWidthError
? t('dashboard.responsive-layout.options.min-height-error', 'A number between 50 and 2000 is required')
: undefined
}
>
{isStandardHeight ? (
<Combobox options={minWidthOptions} value={rowHeight} onChange={onNamedMinHeightChanged} width={18} />
) : (
<Input
defaultValue={rowHeight}
onBlur={onCustomHeightChanged}
ref={(ref) => setInputRef(ref)}
width={18}
type="number"
min={50}
max={2000}
invalid={customMinWidthError}
suffix={
<Button
size="sm"
fill="text"
icon="times"
tooltip={t(
'dashboard.responsive-layout.options.min-width-custom-clear',
'Back to standard min column width'
)}
onClick={onClearCustomRowHeight}
>
{t('dashboard.responsive-layout.options.custom-min-height.clear', 'Clear')}
</Button>
}
/>
)}
</Field>
<Field label={t('dashboard.responsive-layout.options.height-fill', 'Fill screen')}>
<InlineSwitch value={fillScreen} onChange={() => layoutManager.onFillScreenChanged(!fillScreen)} />
</Field>
</Stack>
);
}

@ -2,7 +2,16 @@ import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/d
import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem';
import { ResponsiveGridLayout } from '../../scene/layout-responsive-grid/ResponsiveGridLayout';
import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import {
AUTO_GRID_DEFAULT_COLUMN_WIDTH,
AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT,
AUTO_GRID_DEFAULT_ROW_HEIGHT,
AutoGridColumnWidth,
AutoGridRowHeight,
getAutoRowsTemplate,
getTemplateColumnsTemplate,
ResponsiveGridLayoutManager,
} from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getGridItemKeyForPanelId } from '../../utils/utils';
@ -14,10 +23,10 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
return {
kind: 'ResponsiveGridLayout',
spec: {
col:
layoutManager.state.layout.state.templateColumns?.toString() ??
ResponsiveGridLayoutManager.defaultCSS.templateColumns,
row: layoutManager.state.layout.state.autoRows?.toString() ?? ResponsiveGridLayoutManager.defaultCSS.autoRows,
maxColumnCount: layoutManager.state.maxColumnCount,
fillScreen: layoutManager.state.fillScreen,
...serializeAutoGridColumnWidth(layoutManager.state.columnWidth),
...serializeAutoGridRowHeight(layoutManager.state.rowHeight),
items: layoutManager.state.layout.state.children.map((child) => {
if (!(child instanceof ResponsiveGridItem)) {
throw new Error('Expected ResponsiveGridItem');
@ -73,11 +82,35 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
});
return new ResponsiveGridLayoutManager({
maxColumnCount: layout.spec.maxColumnCount,
columnWidth: layout.spec.columnWidthMode === 'custom' ? layout.spec.columnWidth : layout.spec.columnWidthMode,
rowHeight: layout.spec.rowHeightMode === 'custom' ? layout.spec.rowHeight : layout.spec.rowHeightMode,
fillScreen: layout.spec.fillScreen,
layout: new ResponsiveGridLayout({
templateColumns: layout.spec.col,
autoRows: layout.spec.row,
templateColumns: getTemplateColumnsTemplate(
layout.spec.maxColumnCount ?? AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT,
layout.spec.columnWidth ?? AUTO_GRID_DEFAULT_COLUMN_WIDTH
),
autoRows: getAutoRowsTemplate(
layout.spec.rowHeight ?? AUTO_GRID_DEFAULT_ROW_HEIGHT,
layout.spec.fillScreen ?? false
),
children,
}),
});
}
}
function serializeAutoGridColumnWidth(columnWidth: AutoGridColumnWidth) {
return {
columnWidthMode: typeof columnWidth === 'number' ? 'custom' : columnWidth,
columnWidth: typeof columnWidth === 'number' ? columnWidth : undefined,
};
}
function serializeAutoGridRowHeight(rowHeight: AutoGridRowHeight) {
return {
rowHeightMode: typeof rowHeight === 'number' ? 'custom' : rowHeight,
rowHeight: typeof rowHeight === 'number' ? rowHeight : undefined,
};
}

@ -46,8 +46,9 @@ describe('deserialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
row: 'minmax(min-content, max-content)',
col: 'repeat(auto-fit, minmax(400px, 1fr))',
columnWidthMode: 'standard',
rowHeightMode: 'standard',
maxColumnCount: 4,
items: [],
},
},
@ -75,8 +76,9 @@ describe('deserialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
row: 'minmax(min-content, max-content)',
col: 'repeat(auto-fit, minmax(400px, 1fr))',
columnWidthMode: 'standard',
rowHeightMode: 'standard',
maxColumnCount: 4,
items: [],
},
},
@ -233,11 +235,10 @@ describe('serialization', () => {
title: 'Row 1',
isCollapsed: false,
layout: new ResponsiveGridLayoutManager({
layout: new ResponsiveGridLayout({
children: [],
templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
autoRows: 'minmax(min-content, max-content)',
}),
columnWidth: 'standard',
rowHeight: 'standard',
maxColumnCount: 4,
layout: new ResponsiveGridLayout({}),
}),
}),
new RowItem({
@ -269,8 +270,12 @@ describe('serialization', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
row: 'minmax(min-content, max-content)',
col: 'repeat(auto-fit, minmax(400px, 1fr))',
columnWidth: undefined,
rowHeight: undefined,
fillScreen: false,
rowHeightMode: 'standard',
columnWidthMode: 'standard',
maxColumnCount: 4,
items: [],
},
},

@ -28,7 +28,13 @@ describe('deserialization', () => {
tabs: [
{
kind: 'TabsLayoutTab',
spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } },
spec: {
title: 'Tab 1',
layout: {
kind: 'ResponsiveGridLayout',
spec: { columnWidthMode: 'standard', rowHeightMode: 'standard', maxColumnCount: 4, items: [] },
},
},
},
],
},
@ -64,7 +70,13 @@ describe('deserialization', () => {
tabs: [
{
kind: 'TabsLayoutTab',
spec: { title: 'Tab 1', layout: { kind: 'ResponsiveGridLayout', spec: { row: '', col: '', items: [] } } },
spec: {
title: 'Tab 1',
layout: {
kind: 'ResponsiveGridLayout',
spec: { columnWidthMode: 'standard', rowHeightMode: 'standard', maxColumnCount: 4, items: [] },
},
},
},
{ kind: 'TabsLayoutTab', spec: { title: 'Tab 2', layout: { kind: 'GridLayout', spec: { items: [] } } } },
],

@ -507,8 +507,10 @@ describe('transformSaveModelSchemaV2ToScene', () => {
dashboard.spec.layout = {
kind: 'ResponsiveGridLayout',
spec: {
col: 'colString',
row: 'rowString',
maxColumnCount: 4,
columnWidthMode: 'custom',
columnWidth: 100,
rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@ -525,8 +527,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const scene = transformSaveModelSchemaV2ToScene(dashboard);
const layoutManager = scene.state.body as ResponsiveGridLayoutManager;
expect(layoutManager.descriptor.kind).toBe('ResponsiveGridLayout');
expect(layoutManager.state.layout.state.templateColumns).toBe('colString');
expect(layoutManager.state.layout.state.autoRows).toBe('rowString');
expect(layoutManager.state.maxColumnCount).toBe(4);
expect(layoutManager.state.columnWidth).toBe(100);
expect(layoutManager.state.rowHeight).toBe('standard');
expect(layoutManager.state.layout.state.children.length).toBe(1);
const gridItem = layoutManager.state.layout.state.children[0] as ResponsiveGridItem;
expect(gridItem.state.body.state.key).toBe('panel-1');
@ -545,8 +548,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
col: 'colString',
row: 'rowString',
maxColumnCount: 4,
columnWidthMode: 'standard',
rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@ -571,8 +575,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(layoutManager.state.tabs.length).toBe(1);
expect(layoutManager.state.tabs[0].state.title).toBe('tab1');
const gridLayoutManager = layoutManager.state.tabs[0].state.layout as ResponsiveGridLayoutManager;
expect(gridLayoutManager.state.layout.state.templateColumns).toBe('colString');
expect(gridLayoutManager.state.layout.state.autoRows).toBe('rowString');
expect(gridLayoutManager.state.maxColumnCount).toBe(4);
expect(gridLayoutManager.state.columnWidth).toBe('standard');
expect(gridLayoutManager.state.rowHeight).toBe('standard');
expect(gridLayoutManager.state.layout.state.children.length).toBe(1);
const gridItem = gridLayoutManager.state.layout.state.children[0] as ResponsiveGridItem;
expect(gridItem.state.body.state.key).toBe('panel-1');
@ -592,8 +597,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
layout: {
kind: 'ResponsiveGridLayout',
spec: {
col: 'colString',
row: 'rowString',
maxColumnCount: 4,
columnWidthMode: 'standard',
rowHeightMode: 'standard',
items: [
{
kind: 'ResponsiveGridLayoutItem',
@ -645,6 +651,9 @@ describe('transformSaveModelSchemaV2ToScene', () => {
expect(layoutManager.state.rows.length).toBe(2);
const row1Manager = layoutManager.state.rows[0].state.layout as ResponsiveGridLayoutManager;
expect(row1Manager.descriptor.kind).toBe('ResponsiveGridLayout');
expect(row1Manager.state.maxColumnCount).toBe(4);
expect(row1Manager.state.columnWidth).toBe('standard');
expect(row1Manager.state.rowHeight).toBe('standard');
const row1GridItem = row1Manager.state.layout.state.children[0] as ResponsiveGridItem;
expect(row1GridItem.state.body.state.key).toBe('panel-1');

@ -631,9 +631,11 @@ describe('dynamic layouts', () => {
const scene = setupDashboardScene(
getMinimalSceneState(
new ResponsiveGridLayoutManager({
columnWidth: 100,
rowHeight: 'standard',
maxColumnCount: 4,
fillScreen: true,
layout: new ResponsiveGridLayout({
autoRows: 'rowString',
templateColumns: 'colString',
children: [
new ResponsiveGridItem({
body: new VizPanel({}),
@ -649,8 +651,12 @@ describe('dynamic layouts', () => {
const result = transformSceneToSaveModelSchemaV2(scene);
expect(result.layout.kind).toBe('ResponsiveGridLayout');
const respGridLayout = result.layout.spec as ResponsiveGridLayoutSpec;
expect(respGridLayout.col).toBe('colString');
expect(respGridLayout.row).toBe('rowString');
expect(respGridLayout.columnWidthMode).toBe('custom');
expect(respGridLayout.columnWidth).toBe(100);
expect(respGridLayout.rowHeightMode).toBe('standard');
expect(respGridLayout.rowHeight).toBeUndefined();
expect(respGridLayout.maxColumnCount).toBe(4);
expect(respGridLayout.fillScreen).toBe(true);
expect(respGridLayout.items.length).toBe(2);
expect(respGridLayout.items[0].kind).toBe('ResponsiveGridLayoutItem');
});

@ -1528,13 +1528,21 @@
},
"name": "Auto grid",
"options": {
"columns": "Columns",
"fixed": "Fixed: {{size}}px",
"min": "Min: {{size}}px",
"one-column": "1 column",
"rows": "Rows",
"three-columns": "3 columns",
"two-columns": "2 columns"
"custom-min-height": {
"clear": "Clear"
},
"custom-min-width": {
"clear": "Clear"
},
"height-fill": "Fill screen",
"max-columns": "Max columns",
"min-height": "Row height",
"min-height-custom": "Custom row height",
"min-height-error": "A number between 50 and 2000 is required",
"min-width": "Min column width",
"min-width-custom": "Custom min width",
"min-width-custom-clear": "Back to standard min column width",
"min-width-error": "A number between 50 and 2000 is required"
}
},
"rows-layout": {

Loading…
Cancel
Save