diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue index 2396c0cc1aa..9a7dbf5717e 100644 --- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue +++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue @@ -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] } diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go index 801cd807bd3..95cf7dac3a4 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go @@ -965,14 +965,21 @@ func NewDashboardResponsiveGridLayoutKind() *DashboardResponsiveGridLayoutKind { // +k8s:openapi-gen=true type DashboardResponsiveGridLayoutSpec struct { - Row string `json:"row"` - Col string `json:"col"` - Items []DashboardResponsiveGridLayoutItemKind `json:"items"` + 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 diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go index 580015fc2ed..bb10dc9f5e4 100644 --- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go +++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go @@ -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{ diff --git a/apps/dashboard/pkg/apis/dashboard_manifest.go b/apps/dashboard/pkg/apis/dashboard_manifest.go index 9980594ef44..bc68fa86ca6 100644 --- a/apps/dashboard/pkg/apis/dashboard_manifest.go +++ b/apps/dashboard/pkg/apis/dashboard_manifest.go @@ -11,6 +11,8 @@ import ( "github.com/grafana/grafana-app-sdk/app" ) +var () + var appManifestData = app.ManifestData{ AppName: "dashboard", Group: "dashboard.grafana.app", diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue index e0fdc845ec3..bce9256027d 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue @@ -563,9 +563,13 @@ ResponsiveGridLayoutKind: { } ResponsiveGridLayoutSpec: { - row: string, - col: string, - items: [...ResponsiveGridLayoutItemKind] + maxColumnCount?: number | *3 + columnWidthMode: "narrow" | *"standard" | "wide" | "custom" + columnWidth?: number + rowHeightMode: "short" | *"standard" | "tall" | "custom" + rowHeight?: number + fillScreen?: bool | *false + items: [...ResponsiveGridLayoutItemKind] } ResponsiveGridLayoutItemKind: { diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts index 022ee9f3bc8..5c51aa73b4f 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts @@ -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: [], }); diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts index f0ed47f1546..d0e6bbb4641 100644 --- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts +++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts @@ -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 : ValueMappingResult. For example: { "10": { text: "Perfection!", color: "green" } } options: Record; } 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 => ({ diff --git a/packages/grafana-ui/src/components/Layout/types.ts b/packages/grafana-ui/src/components/Layout/types.ts index f76d7738942..293e67af3f7 100644 --- a/packages/grafana-ui/src/components/Layout/types.ts +++ b/packages/grafana-ui/src/components/Layout/types.ts @@ -18,6 +18,7 @@ export type JustifyContent = | 'space-between' | 'space-around' | 'space-evenly' + | 'stretch' | 'start' | 'end' | 'left' diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 0885e481c69..5379a8c3305 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -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 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) { + 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,22 +191,44 @@ 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); + } + + 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({ - layout: new ResponsiveGridLayout({ - children: [], - templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns, - autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows, - }), - }); + return new ResponsiveGridLayoutManager({}); } public static createFromLayout(layout: DashboardLayoutManager): ResponsiveGridLayoutManager { @@ -195,3 +249,47 @@ export class ResponsiveGridLayoutManager function ResponsiveGridLayoutManagerRenderer({ model }: SceneComponentProps) { return ; } + +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})`; +} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx index 94202f1a200..84fde95cf4c 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManagerEditor.tsx @@ -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: () => , }) ); options.push( new OptionsPaneItemDescriptor({ - title: t('dashboard.responsive-layout.options.rows', 'Rows'), + title: 'Row height options', + skipField: true, render: () => , }) ); @@ -28,56 +30,210 @@ export function getEditOptions(layoutManager: ResponsiveGridLayoutManager): Opti } function GridLayoutColumns({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) { - const { templateColumns } = layoutManager.state.layout.useState(); + const { maxColumnCount, columnWidth } = layoutManager.useState(); + const [inputRef, setInputRef] = React.useState(null); + const [focusInput, setFocusInput] = React.useState(false); + const [customMinWidthError, setCustomMinWidthError] = React.useState(false); - const colOptions: Array> = [ - { 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` }, - ]; + useEffect(() => { + if (focusInput && inputRef) { + inputRef.focus(); + setFocusInput(false); + } + }, [focusInput, inputRef]); - 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 minWidthOptions: Array> = [ + '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) => { + 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) => { + if (value.value === 'custom') { + setFocusInput(true); + } + layoutManager.onColumnWidthChanged(value.value); + }; + + const onClearCustomMinWidth = () => { + if (customMinWidthError) { + setCustomMinWidthError(false); + } + + layoutManager.onColumnWidthChanged('standard'); + }; return ( - setInputRef(ref)} + type="number" + min={50} + max={2000} + invalid={customMinWidthError} + suffix={ + + } + /> + )} + + + layoutManager.onMaxColumnCountChanged(parseInt(value, 10))} + /> + + ); } function GridLayoutRows({ layoutManager }: { layoutManager: ResponsiveGridLayoutManager }) { - const { autoRows } = layoutManager.state.layout.useState(); + const { rowHeight, fillScreen } = layoutManager.useState(); + const [inputRef, setInputRef] = React.useState(null); + const [focusInput, setFocusInput] = React.useState(false); + const [customMinWidthError, setCustomMinWidthError] = React.useState(false); - const rowOptions: Array> = []; + useEffect(() => { + if (focusInput && inputRef) { + inputRef.focus(); + setFocusInput(false); + } + }, [focusInput, inputRef]); - for (const size of sizes) { - rowOptions.push({ - label: t('dashboard.responsive-layout.options.min', 'Min: {{size}}px', { size }), - value: `minmax(${size}px, auto)`, - }); - } + const minWidthOptions: Array> = [ + 'short' as const, + 'standard' as const, + 'tall' as const, + 'custom' as const, + ].map((value) => ({ + label: capitalize(value), + value, + })); - for (const size of sizes) { - rowOptions.push({ - label: t('dashboard.responsive-layout.options.fixed', 'Fixed: {{size}}px', { size }), - value: `${size}px`, - }); - } + 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'); + + const onCustomHeightChanged = (e: React.ChangeEvent) => { + const pixels = parseInt(e.target.value, 10); + if (isNaN(pixels) || pixels < 50 || pixels > 2000) { + setCustomMinWidthError(true); + return; + } else if (customMinWidthError) { + setCustomMinWidthError(false); + } + + layoutManager.onRowHeightChanged(pixels); + }; + + const onNamedMinHeightChanged = (value: ComboboxOption) => { + if (value.value === 'custom') { + setFocusInput(true); + } + layoutManager.onRowHeightChanged(value.value); + }; + + const onClearCustomRowHeight = () => { + if (customMinWidthError) { + setCustomMinWidthError(false); + } + + layoutManager.onRowHeightChanged('standard'); + }; return ( - setInputRef(ref)} + width={18} + type="number" + min={50} + max={2000} + invalid={customMinWidthError} + suffix={ + + } + /> + )} + + + layoutManager.onFillScreenChanged(!fillScreen)} /> + + ); } diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts index 72a04194f82..9159ca0943b 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts @@ -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, + }; +} diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts index 4d5429e1b34..82d24caefff 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts @@ -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: [], }, }, diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts index e1e0fc7901e..35214720bce 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.test.ts @@ -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: [] } } } }, ], diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index e1bd8b6fe0f..fd16165b8bd 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -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'); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index ff1df0ded25..403432cf8ba 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -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'); }); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 0868a4cdbd0..bf22d37294e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {