mirror of
https://github.com/grafana/grafana.git
synced 2026-02-03 20:49:50 -05:00
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:
parent
8a6c0b8b4d
commit
f937dfdcc6
8 changed files with 575 additions and 3 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue