Dashboard: Round x/y/w/h when importing a dashboard with floats (#117072)

* Dashboard: Round x/y/w/h when importing a dashboard with floats

* Apply suggestion from @bfmatei

* Apply suggestion from @bfmatei

* Go once through components (#117265)

---------

Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
This commit is contained in:
Bogdan Matei 2026-02-03 14:56:46 +02:00 committed by GitHub
parent 8a6c0b8b4d
commit f937dfdcc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 575 additions and 3 deletions

View file

@ -1288,6 +1288,9 @@ export const versionedComponents = {
submit: {
[MIN_GRAFANA_VERSION]: 'data-testid-import-dashboard-submit',
},
floatGridItemsWarning: {
[MIN_GRAFANA_VERSION]: 'data-testid-import-dashboard-float-grid-items-warning',
},
},
PanelAlertTabContent: {
content: {

View file

@ -0,0 +1,113 @@
import { render, screen } from '@testing-library/react';
import { selectors } from '@grafana/e2e-selectors';
import { defaultSpec, Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Form } from 'app/core/components/Form/Form';
import { DashboardInputs, InputType, ImportFormDataV2 } from '../../types';
import { ImportDashboardFormV2 } from './ImportDashboardFormV2';
const mockInputs: DashboardInputs = {
dataSources: [
{
name: 'Prometheus',
label: 'Prometheus',
pluginId: 'prometheus',
type: InputType.DataSource,
description: 'Select a Prometheus data source',
info: 'Select prometheus',
value: '',
},
{
name: 'Loki',
label: 'Loki',
pluginId: 'loki',
type: InputType.DataSource,
description: 'Select a Loki data source',
info: 'Select loki',
value: '',
},
],
constants: [],
libraryPanels: [],
};
jest.mock('../utils/validation', () => ({
validateTitle: jest.fn().mockResolvedValue(true),
}));
jest.mock('app/core/components/Select/FolderPicker', () => ({
FolderPicker: ({ value, onChange }: { value: string; onChange: (val: string, title: string) => void }) => (
<input data-testid="folder-picker" value={value} onChange={(e) => onChange(e.target.value, 'Test Folder')} />
),
}));
jest.mock('app/features/datasources/components/picker/DataSourcePicker', () => ({
DataSourcePicker: ({
onChange,
pluginId,
current,
}: {
onChange: (ds: { uid: string; type: string; name: string }) => void;
pluginId: string;
current?: { uid: string; type: string };
}) => (
<input
data-testid={`datasource-picker-${pluginId}`}
value={current?.uid || ''}
onChange={(e) =>
onChange({
uid: e.target.value,
type: pluginId,
name: `${pluginId} instance`,
})
}
/>
),
}));
describe('ImportDashboardFormV2', () => {
const mockOnCancel = jest.fn();
const mockOnSubmit = jest.fn();
function renderForm(hasFloatGridItems = false, inputs: DashboardInputs = mockInputs) {
const defaultDashboard: DashboardV2Spec = defaultSpec();
return render(
<Form<ImportFormDataV2>
onSubmit={mockOnSubmit}
defaultValues={{ dashboard: defaultDashboard, folderUid: 'test-folder' }}
>
{({ register, errors, control, watch, getValues }) => (
<ImportDashboardFormV2
register={register}
inputs={inputs}
errors={errors}
control={control}
getValues={getValues}
onCancel={mockOnCancel}
onSubmit={mockOnSubmit}
watch={watch}
hasFloatGridItems={hasFloatGridItems}
/>
)}
</Form>
);
}
beforeEach(() => {
jest.clearAllMocks();
});
it('renders float grid items warning when hasFloatGridItems is true', () => {
renderForm(true);
expect(screen.queryByTestId(selectors.components.ImportDashboardForm.floatGridItemsWarning)).toBeInTheDocument();
});
it('does not render float grid items warning when hasFloatGridItems is false', () => {
renderForm(false);
expect(
screen.queryByTestId(selectors.components.ImportDashboardForm.floatGridItemsWarning)
).not.toBeInTheDocument();
});
});

View file

@ -4,7 +4,7 @@ import { Controller, FieldErrors, FieldPath, UseFormReturn } from 'react-hook-fo
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { ExpressionDatasourceRef } from '@grafana/runtime/internal';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input } from '@grafana/ui';
import { Button, Field, FormFieldErrors, FormsOnSubmit, Stack, Input, Alert } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@ -16,9 +16,19 @@ interface Props extends Pick<UseFormReturn<ImportFormDataV2>, 'register' | 'cont
errors: FieldErrors<ImportFormDataV2>;
onCancel: () => void;
onSubmit: FormsOnSubmit<ImportFormDataV2>;
hasFloatGridItems: boolean;
}
export const ImportDashboardFormV2 = ({ register, errors, control, inputs, getValues, onCancel, onSubmit }: Props) => {
export const ImportDashboardFormV2 = ({
register,
errors,
control,
inputs,
getValues,
onCancel,
onSubmit,
hasFloatGridItems,
}: Props) => {
const [isSubmitted, setSubmitted] = useState(false);
const [selectedDataSources, setSelectedDataSources] = useState<Record<string, DatasourceSelection>>({});
@ -119,6 +129,19 @@ export const ImportDashboardFormV2 = ({ register, errors, control, inputs, getVa
);
})}
{hasFloatGridItems && (
<Alert
severity="warning"
title={t('dashboard-scene.import-dashboard-form-v2.float-grid-items-warning-title', 'Floating grid items')}
data-testid={selectors.components.ImportDashboardForm.floatGridItemsWarning}
>
<Trans i18nKey="dashboard-scene.import-dashboard-form-v2.float-grid-items-warning-body">
The dashboard contains grid items with floating positions. This is not supported by Grafana and the numbers
will be truncated to integers.
</Trans>
</Alert>
)}
<Stack direction="row" gap={2}>
<Button
type="submit"

View file

@ -0,0 +1,219 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
import {
defaultSpec,
defaultGridLayoutKind,
Spec as DashboardV2Spec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardInputs, DashboardSource, InputType } from '../../types';
import { ImportOverviewV2 } from './ImportOverviewV2';
jest.mock('app/features/dashboard/api/dashboard_api', () => ({
getDashboardAPI: jest.fn(),
}));
jest.mock('../utils/validation', () => ({
validateTitle: jest.fn().mockResolvedValue(true),
}));
jest.mock('app/core/components/Select/FolderPicker', () => ({
FolderPicker: ({ value, onChange }: { value: string; onChange: (val: string, title: string) => void }) => (
<input data-testid="folder-picker" value={value} onChange={(e) => onChange?.(e.target.value, 'Test Folder')} />
),
}));
jest.mock('app/features/datasources/components/picker/DataSourcePicker', () => ({
DataSourcePicker: ({
onChange,
pluginId,
current,
}: {
onChange: (ds: { uid: string; type: string; name: string }) => void;
pluginId: string;
current?: { uid: string; type: string };
}) => (
<input
data-testid={`datasource-picker-${pluginId}`}
value={current?.uid || ''}
onChange={(e) =>
onChange({
uid: e.target.value,
type: pluginId,
name: `${pluginId} instance`,
})
}
/>
),
}));
const mockGetDashboardAPI = jest.mocked(getDashboardAPI);
describe('ImportOverviewV2', () => {
let saveDashboard = jest.fn().mockResolvedValue({ url: '/d/test-uid/test-dashboard' });
const mockInputs: DashboardInputs = {
dataSources: [
{
name: 'Prometheus',
pluginId: 'prometheus',
type: InputType.DataSource,
description: 'Select a Prometheus data source',
info: 'Select prometheus',
label: 'Prometheus',
value: '',
},
],
constants: [],
libraryPanels: [],
};
function renderCmp(layout: DashboardV2Spec['layout']) {
const dashboard: DashboardV2Spec = { ...defaultSpec(), title: 'Test Dashboard', layout };
render(
<ImportOverviewV2
dashboard={dashboard}
inputs={mockInputs}
meta={{ updatedAt: '', orgName: '' }}
source={DashboardSource.Json}
folderUid="test-folder"
onCancel={jest.fn()}
/>
);
}
beforeEach(() => {
jest.clearAllMocks();
saveDashboard = jest.fn().mockResolvedValue({ url: '/d/test-uid/test-dashboard' });
mockGetDashboardAPI.mockReturnValue({
saveDashboard,
getDashboardDTO: jest.fn(),
deleteDashboard: jest.fn(),
listDeletedDashboards: jest.fn(),
restoreDashboard: jest.fn(),
});
});
describe('float grid items warning', () => {
it('does not show warning when dashboard has no float grid items', async () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'panel-1' },
x: 0,
y: 0,
width: 12,
height: 8,
},
},
];
renderCmp(layout);
await waitFor(() => {
expect(
screen.queryByTestId(selectors.components.ImportDashboardForm.floatGridItemsWarning)
).not.toBeInTheDocument();
});
});
it('shows warning when dashboard has float grid items', async () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'panel-1' },
x: 1.5,
y: 0,
width: 12,
height: 8,
},
},
];
renderCmp(layout);
await waitFor(() => {
expect(
screen.queryByTestId(selectors.components.ImportDashboardForm.floatGridItemsWarning)
).toBeInTheDocument();
});
});
});
describe('onSubmit', () => {
let user = userEvent.setup();
beforeEach(() => {
user = userEvent.setup();
});
it('truncates float grid items before saving', async () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'panel-1' },
x: 1.7,
y: 2.3,
width: 12.5,
height: 8.9,
},
},
];
renderCmp(layout);
const datasourcePicker = screen.getByTestId('datasource-picker-prometheus');
await user.type(datasourcePicker, 'prom-uid');
await user.click(screen.getByRole('button', { name: /import/i }));
await waitFor(() => {
expect(saveDashboard).toHaveBeenCalled();
});
const savedData = saveDashboard.mock.calls[0][0];
const savedLayout = savedData.dashboard.layout;
// Math.trunc truncates toward zero (same as Go's int())
expect(savedLayout.spec.items[0].spec.x).toBe(1);
expect(savedLayout.spec.items[0].spec.y).toBe(2);
expect(savedLayout.spec.items[0].spec.width).toBe(12);
expect(savedLayout.spec.items[0].spec.height).toBe(8);
});
it('preserves grid items when there are no floats', async () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: {
element: { kind: 'ElementReference', name: 'panel-1' },
x: 0,
y: 0,
width: 12,
height: 8,
},
},
];
renderCmp(layout);
const datasourcePicker = screen.getByTestId('datasource-picker-prometheus');
await user.type(datasourcePicker, 'prom-uid');
await user.click(screen.getByRole('button', { name: /import/i }));
await waitFor(() => {
expect(saveDashboard).toHaveBeenCalled();
});
const savedData = saveDashboard.mock.calls[0][0];
const savedLayout = savedData.dashboard.layout;
expect(savedLayout.spec.items[0].spec.x).toBe(0);
expect(savedLayout.spec.items[0].spec.y).toBe(0);
expect(savedLayout.spec.items[0].spec.width).toBe(12);
expect(savedLayout.spec.items[0].spec.height).toBe(8);
});
});
});

View file

@ -1,3 +1,5 @@
import { useMemo } from 'react';
import { AppEvents, locationUtil } from '@grafana/data';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
@ -6,6 +8,7 @@ import { Form } from 'app/core/components/Form/Form';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardInputs, DashboardSource, ImportFormDataV2 } from '../../types';
import { truncateFloatGridItems } from '../utils/floatingGridItems';
import { applyV2Inputs } from '../utils/inputs';
import { GcomDashboardInfo } from './GcomDashboardInfo';
@ -23,12 +26,21 @@ type Props = {
};
export function ImportOverviewV2({ dashboard, inputs, meta, source, folderUid, onCancel }: Props) {
const { layout: normalizedLayout, modified: hasFloatGridItems } = useMemo(
() => truncateFloatGridItems(dashboard.layout),
[dashboard.layout]
);
async function onSubmit(form: ImportFormDataV2) {
reportInteraction(IMPORT_FINISHED_EVENT_NAME);
try {
const dashboardToSave: DashboardV2Spec = hasFloatGridItems
? { ...dashboard, layout: normalizedLayout }
: dashboard;
const dashboardWithDataSources = {
...applyV2Inputs(dashboard, form),
...applyV2Inputs(dashboardToSave, form),
title: form.dashboard.title,
};
@ -72,6 +84,7 @@ export function ImportOverviewV2({ dashboard, inputs, meta, source, folderUid, o
onCancel={onCancel}
onSubmit={onSubmit}
watch={watch}
hasFloatGridItems={hasFloatGridItems}
/>
)}
</Form>

View file

@ -0,0 +1,132 @@
import {
GridLayoutKind,
RowsLayoutKind,
TabsLayoutKind,
defaultGridLayoutKind,
defaultRowsLayoutKind,
defaultTabsLayoutKind,
defaultAutoGridLayoutKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { truncateFloatGridItems } from './floatingGridItems';
describe('truncateFloatGridItems', () => {
describe('AutoGridLayout', () => {
it('returns unchanged with modified=false', () => {
const layout = defaultAutoGridLayoutKind();
const result = truncateFloatGridItems(layout);
expect(result.layout).toBe(layout);
expect(result.modified).toBe(false);
});
});
describe('GridLayout', () => {
it('returns modified=false when all values are integers', () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 0, y: 0, width: 12, height: 8 },
},
];
const result = truncateFloatGridItems(layout);
expect(result.modified).toBe(false);
expect(result.layout).toBe(layout);
});
it('truncates float values toward zero', () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 1.7, y: 2.3, width: 12.8, height: 8.9 },
},
];
const result = truncateFloatGridItems(layout);
expect(result.modified).toBe(true);
const items = (result.layout as GridLayoutKind).spec.items;
expect(items[0].spec).toMatchObject({ x: 1, y: 2, width: 12, height: 8 });
});
it('handles mixed integer and float values', () => {
const layout = defaultGridLayoutKind();
layout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 0, y: 0, width: 12, height: 8 },
},
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-2' }, x: 12.5, y: 0, width: 12, height: 8 },
},
];
const result = truncateFloatGridItems(layout);
expect(result.modified).toBe(true);
const items = (result.layout as GridLayoutKind).spec.items;
expect(items[0].spec.x).toBe(0);
expect(items[1].spec.x).toBe(12);
});
});
describe('RowsLayout', () => {
it('recursively truncates nested grid layouts', () => {
const gridLayout = defaultGridLayoutKind();
gridLayout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 1.5, y: 2.7, width: 12, height: 8 },
},
];
const layout = defaultRowsLayoutKind();
layout.spec.rows = [{ kind: 'RowsLayoutRow', spec: { title: 'Row 1', layout: gridLayout } }];
const result = truncateFloatGridItems(layout);
expect(result.modified).toBe(true);
const nestedGrid = (result.layout as RowsLayoutKind).spec.rows[0].spec.layout as GridLayoutKind;
expect(nestedGrid.spec.items[0].spec).toMatchObject({ x: 1, y: 2 });
});
});
describe('TabsLayout', () => {
it('recursively truncates nested grid layouts', () => {
const gridLayout = defaultGridLayoutKind();
gridLayout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 0.9, y: 1.1, width: 12, height: 8 },
},
];
const layout = defaultTabsLayoutKind();
layout.spec.tabs = [{ kind: 'TabsLayoutTab', spec: { title: 'Tab 1', layout: gridLayout } }];
const result = truncateFloatGridItems(layout);
expect(result.modified).toBe(true);
const nestedGrid = (result.layout as TabsLayoutKind).spec.tabs[0].spec.layout as GridLayoutKind;
expect(nestedGrid.spec.items[0].spec).toMatchObject({ x: 0, y: 1 });
});
});
describe('deeply nested', () => {
it('handles TabsLayout > RowsLayout > GridLayout', () => {
const gridLayout = defaultGridLayoutKind();
gridLayout.spec.items = [
{
kind: 'GridLayoutItem',
spec: { element: { kind: 'ElementReference', name: 'panel-1' }, x: 1.9, y: 2.1, width: 12, height: 8 },
},
];
const rowsLayout = defaultRowsLayoutKind();
rowsLayout.spec.rows = [{ kind: 'RowsLayoutRow', spec: { title: 'Row 1', layout: gridLayout } }];
const tabsLayout = defaultTabsLayoutKind();
tabsLayout.spec.tabs = [{ kind: 'TabsLayoutTab', spec: { title: 'Tab 1', layout: rowsLayout } }];
const result = truncateFloatGridItems(tabsLayout);
expect(result.modified).toBe(true);
const tabs = result.layout as TabsLayoutKind;
const rows = tabs.spec.tabs[0].spec.layout as RowsLayoutKind;
const grid = rows.spec.rows[0].spec.layout as GridLayoutKind;
expect(grid.spec.items[0].spec).toMatchObject({ x: 1, y: 2 });
});
});
});

View file

@ -0,0 +1,67 @@
import {
AutoGridLayoutKind,
GridLayoutKind,
RowsLayoutKind,
TabsLayoutKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
type Layout = GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
export function truncateFloatGridItems(layout: Layout): { layout: Layout; modified: boolean } {
switch (layout.kind) {
case 'GridLayout': {
let modified = false;
const items = layout.spec.items.map((item) => {
if (item.kind !== 'GridLayoutItem') {
return item;
}
const { x, y, width, height } = item.spec;
const tx = Math.trunc(x),
ty = Math.trunc(y),
tw = Math.trunc(width),
th = Math.trunc(height);
if (tx !== x || ty !== y || tw !== width || th !== height) {
modified = true;
return { ...item, spec: { ...item.spec, x: tx, y: ty, width: tw, height: th } };
}
return item;
});
return { layout: modified ? { ...layout, spec: { ...layout.spec, items } } : layout, modified };
}
case 'RowsLayout': {
let modified = false;
const rows = layout.spec.rows.map((row) => {
if (row.kind !== 'RowsLayoutRow') {
return row;
}
const result = truncateFloatGridItems(row.spec.layout);
if (result.modified) {
modified = true;
return { ...row, spec: { ...row.spec, layout: result.layout } };
}
return row;
});
return { layout: modified ? { ...layout, spec: { ...layout.spec, rows } } : layout, modified };
}
case 'TabsLayout': {
let modified = false;
const tabs = layout.spec.tabs.map((tab) => {
if (tab.kind !== 'TabsLayoutTab') {
return tab;
}
const result = truncateFloatGridItems(tab.spec.layout);
if (result.modified) {
modified = true;
return { ...tab, spec: { ...tab.spec, layout: result.layout } };
}
return tab;
});
return { layout: modified ? { ...layout, spec: { ...layout.spec, tabs } } : layout, modified };
}
default:
return { layout, modified: false };
}
}

View file

@ -6238,6 +6238,8 @@
},
"import-dashboard-form-v2": {
"cancel": "Cancel",
"float-grid-items-warning-body": "The dashboard contains grid items with floating positions. This is not supported by Grafana and the numbers will be truncated to integers.",
"float-grid-items-warning-title": "Floating grid items",
"label-folder": "Folder"
},
"inspect-data-tab": {