Implement detect changes tracking to V2 Schema (#98153)

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
This commit is contained in:
Ivan Ortega Alba 2024-12-19 12:00:59 +01:00 committed by GitHub
parent 148289258f
commit a06779614e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 536 additions and 133 deletions

View File

@ -3103,7 +3103,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Do not use any type assertions.", "4"]
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Do not use any type assertions.", "5"]
],
"public/app/features/dashboard-scene/saving/shared.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@ -3192,10 +3193,11 @@ exports[`better eslint`] = {
],
"public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"]
[0, 0, 0, "Do not use any type assertions.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
],
"public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],

View File

@ -27,10 +27,10 @@ export interface DashboardV2Spec {
// Links with references to other dashboards or external websites.
links: DashboardLink[];
// Tags associated with dashboard.
tags?: string[];
tags: string[];
timeSettings: TimeSettingsSpec;
// Configured template variables.
variables: (QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind)[];
variables: VariableKind[];
// |* more element types in the future
elements: Record<string, PanelKind>;
annotations: AnnotationQueryKind[];
@ -49,6 +49,7 @@ export const defaultDashboardV2Spec = (): DashboardV2Spec => ({
preload: false,
editable: true,
links: [],
tags: [],
timeSettings: defaultTimeSettingsSpec(),
variables: [],
elements: {},
@ -801,6 +802,10 @@ export type VariableType = "query" | "adhoc" | "groupby" | "constant" | "datasou
export const defaultVariableType = (): VariableType => ("query");
export type VariableKind = QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind;
export const defaultVariableKind = (): VariableKind => (defaultQueryVariableKind());
// Sort variable options
// Accepted values are:
// `disabled`: No sorting

View File

@ -36,12 +36,12 @@ DashboardV2Spec: {
links: [...DashboardLink]
// Tags associated with dashboard.
tags?: [...string]
tags: [...string]
timeSettings: TimeSettingsSpec
// Configured template variables.
variables: [...QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind]
variables: [...VariableKind]
elements: [ElementReference.name]: PanelKind // |* more element types in the future
@ -548,6 +548,8 @@ VariableCustomFormatterFn: {
VariableType: "query" | "adhoc" | "groupby" | "constant" | "datasource" | "interval" | "textbox" | "custom" |
"system" | "snapshot"
VariableKind: QueryVariableKind | TextVariableKind | ConstantVariableKind | DatasourceVariableKind | IntervalVariableKind | CustomVariableKind | GroupByVariableKind | AdhocVariableKind
// Sort variable options
// Accepted values are:
// `disabled`: No sorting

View File

@ -1,32 +1,21 @@
import { AdHocVariableModel } from '@grafana/data';
import { Dashboard, Panel } from '@grafana/schema';
import { adHocVariableFiltersEqual, getRawDashboardChanges, getPanelChanges } from './getDashboardChanges';
describe('adHocVariableFiltersEqual', () => {
it('should compare empty filters', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [] } as unknown as AdHocVariableModel,
{ filters: [] } as unknown as AdHocVariableModel
)
).toBeTruthy();
expect(adHocVariableFiltersEqual([], [])).toBeTruthy();
});
it('should compare different length filter arrays', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [] } as unknown as AdHocVariableModel,
{ filters: [{ value: '', key: '', operator: '' }] } as unknown as AdHocVariableModel
)
).toBeFalsy();
expect(adHocVariableFiltersEqual([], [{ value: '', key: '', operator: '' }])).toBeFalsy();
});
it('should compare equal filter arrays', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
[{ value: 'asd', key: 'qwe', operator: 'wer' }]
)
).toBeTruthy();
});
@ -34,8 +23,8 @@ describe('adHocVariableFiltersEqual', () => {
it('should compare different filter arrays where operator differs', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
{ filters: [{ value: 'asd', key: 'qwe', operator: 'weee' }] } as unknown as AdHocVariableModel
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
[{ value: 'asd', key: 'qwe', operator: 'weee' }]
)
).toBeFalsy();
});
@ -43,8 +32,8 @@ describe('adHocVariableFiltersEqual', () => {
it('should compare different filter arrays where key differs', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
{ filters: [{ value: 'asd', key: 'qwer', operator: 'wer' }] } as unknown as AdHocVariableModel
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
[{ value: 'asd', key: 'qwer', operator: 'wer' }]
)
).toBeFalsy();
});
@ -52,8 +41,8 @@ describe('adHocVariableFiltersEqual', () => {
it('should compare different filter arrays where value differs', () => {
expect(
adHocVariableFiltersEqual(
{ filters: [{ value: 'asd', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel,
{ filters: [{ value: 'asdio', key: 'qwe', operator: 'wer' }] } as unknown as AdHocVariableModel
[{ value: 'asd', key: 'qwe', operator: 'wer' }],
[{ value: 'asdio', key: 'qwe', operator: 'wer' }]
)
).toBeFalsy();
});
@ -65,23 +54,14 @@ describe('adHocVariableFiltersEqual', () => {
it('should compare two adhoc variables where both are missing the filter property and return true', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
expect(
adHocVariableFiltersEqual({} as unknown as AdHocVariableModel, {} as unknown as AdHocVariableModel)
).toBeTruthy();
expect(adHocVariableFiltersEqual(undefined, undefined)).toBeTruthy();
expect(warnSpy).toHaveBeenCalledWith('Adhoc variable filter property is undefined');
});
it('should compare two adhoc variables where one has no filter property and return false', () => {
it('should compare two adhoc variables where one is undefined and return false', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementationOnce(() => {});
expect(
adHocVariableFiltersEqual(
{} as unknown as AdHocVariableModel,
{
filters: [{ value: 'asdio', key: 'qwe', operator: 'wer' }],
} as unknown as AdHocVariableModel
)
).toBeFalsy();
expect(adHocVariableFiltersEqual(undefined, [{ value: 'asdio', key: 'qwe', operator: 'wer' }])).toBeFalsy();
expect(warnSpy).toHaveBeenCalledWith('Adhoc variable filter property is undefined');
});

View File

@ -2,7 +2,12 @@
import type { AdHocVariableModel, TypedVariableModel } from '@grafana/data';
import { Dashboard, Panel, VariableOption } from '@grafana/schema';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import {
AdHocFilterWithLabels,
AdhocVariableSpec,
DashboardV2Spec,
VariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { jsonDiff } from '../settings/version-history/utils';
@ -30,7 +35,6 @@ export function isEqual(a: VariableOption | undefined, b: VariableOption | undef
return a === b || (a && b && a.selected === b.selected && deepEqual(a.text, b.text) && deepEqual(a.value, b.value));
}
// TODO[schema v2]
export function getRawDashboardV2Changes(
initial: DashboardV2Spec,
changed: DashboardV2Spec,
@ -38,16 +42,33 @@ export function getRawDashboardV2Changes(
saveVariables?: boolean,
saveRefresh?: boolean
) {
const initialSaveModel = initial;
const changedSaveModel = changed;
const hasTimeChanged = getHasTimeChanged(changedSaveModel.timeSettings, initialSaveModel.timeSettings);
const hasVariableValueChanges = applyVariableChangesV2(changedSaveModel, initialSaveModel, saveVariables);
const hasRefreshChanged = changedSaveModel.timeSettings.autoRefresh !== initialSaveModel.timeSettings.autoRefresh;
if (!saveTimeRange) {
changedSaveModel.timeSettings.from = initialSaveModel.timeSettings.from;
changedSaveModel.timeSettings.to = initialSaveModel.timeSettings.to;
}
if (!saveRefresh) {
changedSaveModel.timeSettings.autoRefresh = initialSaveModel.timeSettings.autoRefresh;
}
const diff = jsonDiff(initialSaveModel, changedSaveModel);
const diffCount = Object.values(diff).reduce((acc, cur) => acc + cur.length, 0);
return {
changedSaveModel: changed,
initialSaveModel: initial,
diffs: jsonDiff(initial, changed),
diffCount: 0,
hasChanges: false,
hasTimeChanges: false,
isNew: false,
hasVariableValueChanges: false,
hasRefreshChange: false,
changedSaveModel,
initialSaveModel,
diffs: diff,
diffCount,
hasChanges: diffCount > 0,
hasTimeChanges: hasTimeChanged,
hasVariableValueChanges,
hasRefreshChange: hasRefreshChanged,
};
}
@ -60,7 +81,7 @@ export function getRawDashboardChanges(
) {
const initialSaveModel = initial;
const changedSaveModel = changed;
const hasTimeChanged = getHasTimeChanged(changedSaveModel, initialSaveModel);
const hasTimeChanged = getHasTimeChanged(changedSaveModel.time, initialSaveModel.time);
const hasVariableValueChanges = applyVariableChanges(changedSaveModel, initialSaveModel, saveVariables);
const hasRefreshChanged = changedSaveModel.refresh !== initialSaveModel.refresh;
@ -88,35 +109,107 @@ export function getRawDashboardChanges(
};
}
export function getHasTimeChanged(saveModel: Dashboard, originalSaveModel: Dashboard) {
return saveModel.time?.from !== originalSaveModel.time?.from || saveModel.time?.to !== originalSaveModel.time?.to;
interface DefaultPersistedTimeValue {
from?: string;
to?: string;
}
export function getHasTimeChanged(
newRange: DefaultPersistedTimeValue = {},
previousRange: DefaultPersistedTimeValue = {}
) {
return newRange.from !== previousRange.from || newRange.to !== previousRange.to;
}
export function adHocVariableFiltersEqual(a: AdHocVariableModel, b: AdHocVariableModel) {
if (a.filters === undefined && b.filters === undefined) {
export function adHocVariableFiltersEqual(filtersA?: AdHocFilterWithLabels[], filtersB?: AdHocFilterWithLabels[]) {
if (filtersA === undefined && filtersB === undefined) {
console.warn('Adhoc variable filter property is undefined');
return true;
}
if ((a.filters === undefined && b.filters !== undefined) || (b.filters === undefined && a.filters !== undefined)) {
if ((filtersA === undefined && filtersB !== undefined) || (filtersB === undefined && filtersA !== undefined)) {
console.warn('Adhoc variable filter property is undefined');
return false;
}
if (a.filters.length !== b.filters.length) {
if (filtersA?.length !== filtersB?.length) {
return false;
}
for (let i = 0; i < a.filters.length; i++) {
const aFilter = a.filters[i];
const bFilter = b.filters[i];
if (aFilter.key !== bFilter.key || aFilter.operator !== bFilter.operator || aFilter.value !== bFilter.value) {
for (let i = 0; i < (filtersA?.length ?? 0); i++) {
const aFilter = filtersA?.[i];
const bFilter = filtersB?.[i];
if (aFilter?.key !== bFilter?.key || aFilter?.operator !== bFilter?.operator || aFilter?.value !== bFilter?.value) {
return false;
}
}
return true;
}
export function applyVariableChangesV2(
saveModel: DashboardV2Spec,
originalSaveModel: DashboardV2Spec,
saveVariables?: boolean
) {
const originalVariables = originalSaveModel.variables ?? [];
const variablesToSave = saveModel.variables ?? [];
let hasVariableValueChanges = false;
for (const variable of variablesToSave) {
const hasCurrentValueToSave = (v: VariableKind) =>
v.kind === 'QueryVariable' ||
v.kind === 'CustomVariable' ||
v.kind === 'DatasourceVariable' ||
v.kind === 'ConstantVariable' ||
v.kind === 'IntervalVariable' ||
v.kind === 'TextVariable' ||
v.kind === 'GroupByVariable';
const hasOptionsToSave = (v: VariableKind) =>
v.kind === 'QueryVariable' ||
v.kind === 'CustomVariable' ||
v.kind === 'DatasourceVariable' ||
v.kind === 'IntervalVariable' ||
v.kind === 'GroupByVariable';
const original = originalVariables.find(
({ spec, kind }) => spec.name === variable.spec.name && kind === variable.kind
);
if (!original) {
continue;
}
if (
hasCurrentValueToSave(variable) &&
hasCurrentValueToSave(original) &&
!isEqual(variable.spec.current, original.spec.current)
) {
hasVariableValueChanges = true;
} else if (
variable.kind === 'AdhocVariable' &&
original.kind === 'AdhocVariable' &&
!adHocVariableFiltersEqual(variable.spec.filters, original.spec.filters)
) {
hasVariableValueChanges = true;
}
if (!saveVariables) {
if (variable.kind === 'AdhocVariable') {
variable.spec.filters = (original.spec as AdhocVariableSpec).filters;
} else {
if (hasCurrentValueToSave(variable) && hasCurrentValueToSave(original)) {
variable.spec.current = original.spec.current;
}
if (hasOptionsToSave(variable) && hasOptionsToSave(original)) {
variable.spec.options = original.spec.options;
}
}
}
}
return hasVariableValueChanges;
}
export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Dashboard, saveVariables?: boolean) {
const originalVariables = originalSaveModel.templating?.list ?? [];
const variablesToSave = saveModel.templating?.list ?? [];
@ -138,7 +231,10 @@ export function applyVariableChanges(saveModel: Dashboard, originalSaveModel: Da
hasVariableValueChanges = true;
} else if (
variable.type === 'adhoc' &&
!adHocVariableFiltersEqual(variable as AdHocVariableModel, original as AdHocVariableModel)
!adHocVariableFiltersEqual(
(variable as AdHocVariableModel | undefined)?.filters,
(original as AdHocVariableModel | undefined)?.filters
)
) {
hasVariableValueChanges = true;
}

View File

@ -173,7 +173,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
private _prevScrollPos?: number;
// TODO: use feature toggle to allow v2 serializer
private _serializer: DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> = getDashboardSceneSerializer(true);
private _serializer: DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> = getDashboardSceneSerializer();
public constructor(state: Partial<DashboardSceneState>) {
super({

View File

@ -645,10 +645,10 @@ export function ToolbarActions({ dashboard }: Props) {
},
});
// Will open a schema v2 editor drawer. Only available with dashboardSchemaV2 feature toggle on.
// Will open a schema v2 editor drawer. Only available with useV2DashboardsAPI feature toggle on.
toolbarActions.push({
group: 'main-buttons',
condition: uid && config.featureToggles.dashboardSchemaV2,
condition: uid && config.featureToggles.useV2DashboardsAPI,
render: () => {
return (
<ToolbarButton

View File

@ -7,7 +7,12 @@ import {
SceneRefreshPicker,
} from '@grafana/scenes';
import { Dashboard, VariableModel } from '@grafana/schema';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import {
DashboardV2Spec,
defaultDashboardV2Spec,
defaultPanelSpec,
defaultTimeSettingsSpec,
} from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
@ -15,9 +20,24 @@ import { transformSceneToSaveModel } from '../serialization/transformSceneToSave
import { findVizPanelByKey } from '../utils/utils';
import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer';
import { transformSaveModelSchemaV2ToScene } from './transformSaveModelSchemaV2ToScene';
import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getDataSourceSrv: () => {
return {
getInstanceSettings: jest.fn(),
};
},
}));
describe('DashboardSceneSerializer', () => {
describe('v1 schema', () => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = false;
});
it('Can detect no changes', () => {
const dashboard = setup();
const result = dashboard.getDashboardChanges(false);
@ -309,18 +329,226 @@ describe('DashboardSceneSerializer', () => {
});
describe('v2 schema', () => {
beforeEach(() => {
config.featureToggles.useV2DashboardsAPI = true;
});
it('Can detect no changes', () => {
const dashboard = setupV2();
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can detect time changed', () => {
const dashboard = setupV2();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasTimeChanges).toBe(true);
});
it('Can save time change', () => {
const dashboard = setupV2();
sceneGraph.getTimeRange(dashboard).setState({ from: 'now-10h', to: 'now' });
const result = dashboard.getDashboardChanges(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect folder change', () => {
const dashboard = setupV2();
dashboard.state.meta.folderUid = 'folder-2';
const result = dashboard.getDashboardChanges(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(0); // Diff count is 0 because the diff contemplate only the model
expect(result.hasFolderChanges).toBe(true);
});
it('Can detect refresh changed', () => {
const dashboard = setupV2();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '10m' });
}
const result = dashboard.getDashboardChanges(false, false, false);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
expect(result.hasRefreshChange).toBe(true);
});
it('Can save refresh change', () => {
const dashboard = setupV2();
const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker);
if (refreshPicker instanceof SceneRefreshPicker) {
refreshPicker.setState({ refresh: '10m' });
}
const result = dashboard.getDashboardChanges(false, false, true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
describe('variable changes', () => {
it('Can detect variable change', () => {
const dashboard = setupV2();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(false);
expect(result.diffCount).toBe(0);
});
it('Can save variable value change', () => {
const dashboard = setupV2();
const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable;
appVar.changeValueTo('app2');
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(true);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(2);
});
describe('Experimental variables', () => {
beforeAll(() => {
config.featureToggles.groupByVariable = true;
});
afterAll(() => {
config.featureToggles.groupByVariable = false;
});
it('Can detect group by static options change', () => {
const dashboard = setupV2({
variables: [
{
kind: 'GroupByVariable',
spec: {
current: {
text: 'Host',
value: 'host',
},
datasource: {
type: 'ds',
uid: 'ds-uid',
},
name: 'GroupBy',
options: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
multi: false,
includeAll: false,
hide: 'dontHide',
skipUrlSync: false,
},
},
],
});
const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable;
variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, true);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
it('Can detect adhoc filter static options change', () => {
const dashboard = setupV2({
variables: [
{
kind: 'AdhocVariable',
spec: {
name: 'adhoc',
label: 'Adhoc Label',
description: 'Adhoc Description',
datasource: {
uid: 'gdev-prometheus',
type: 'prometheus',
},
hide: 'dontHide',
skipUrlSync: false,
filters: [],
baseFilters: [],
defaultKeys: [
{
text: 'Host',
value: 'host',
},
{
text: 'Region',
value: 'region',
},
],
},
},
],
});
const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable;
variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] });
const result = dashboard.getDashboardChanges(false, false);
expect(result.hasVariableValueChanges).toBe(false);
expect(result.hasChanges).toBe(true);
expect(result.diffCount).toBe(1);
});
});
});
describe('Saving from panel edit', () => {
it('Should commit panel edit changes', () => {
const dashboard = setupV2();
const panel = findVizPanelByKey(dashboard, 'panel-1')!;
const editScene = buildPanelEditScene(panel);
dashboard.onEnterEditMode();
dashboard.setState({ editPanel: editScene });
editScene.state.panelRef.resolve().setState({ title: 'changed title' });
const result = dashboard.getDashboardChanges(false, true);
const panelSaveModel = (result.changedSaveModel as DashboardV2Spec).elements['panel-1'].spec;
expect(panelSaveModel.title).toBe('changed title');
});
});
it('should throw on getTrackingInformation', () => {
const serializer = new V2DashboardSerializer();
expect(() => serializer.getTrackingInformation()).toThrow('Method not implemented.');
});
it('should throw on getSaveAsModel', () => {
const serializer = new V2DashboardSerializer();
const dashboard = setup();
expect(() => serializer.getSaveAsModel(dashboard, {})).toThrow('Method not implemented.');
});
it('should throw on getDashboardChangesFromScene', () => {
const serializer = new V2DashboardSerializer();
const dashboard = setup();
expect(() => serializer.getDashboardChangesFromScene(dashboard)).toThrow('Method not implemented.');
});
it('should throw on onSaveComplete', () => {
const serializer = new V2DashboardSerializer();
@ -335,19 +563,10 @@ describe('DashboardSceneSerializer', () => {
})
).toThrow('Method not implemented.');
});
it('should throw on getDashboardChangesFromScene', () => {
const serializer = new V2DashboardSerializer();
expect(() => serializer.getTrackingInformation()).toThrow('Method not implemented.');
});
});
});
interface ScenarioOptions {
fromPanelEdit?: boolean;
}
function setup(options: ScenarioOptions = {}) {
function setup() {
const dashboard = transformSaveModelToScene({
dashboard: {
title: 'hello',
@ -382,3 +601,89 @@ function setup(options: ScenarioOptions = {}) {
return dashboard;
}
function setupV2(spec?: Partial<DashboardV2Spec>) {
const dashboard = transformSaveModelSchemaV2ToScene({
kind: 'DashboardWithAccessInfo',
spec: {
...defaultDashboardV2Spec(),
title: 'hello',
schemaVersion: 30,
timeSettings: {
...defaultTimeSettingsSpec(),
autoRefresh: '10s',
from: 'now-1h',
to: 'now',
},
elements: {
'panel-1': {
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Panel 1',
},
},
},
layout: {
kind: 'GridLayout',
spec: {
items: [
{
kind: 'GridLayoutItem',
spec: {
x: 0,
y: 0,
width: 12,
height: 8,
element: {
kind: 'ElementReference',
name: 'panel-1',
},
},
},
],
},
},
variables: [
{
kind: 'CustomVariable',
spec: {
name: 'app',
label: 'Query Variable',
description: 'A query variable',
skipUrlSync: false,
hide: 'dontHide',
options: [],
multi: false,
current: {
text: 'app1',
value: 'app1',
},
query: 'app1',
allValue: '',
includeAll: false,
},
},
],
...spec,
},
apiVersion: 'v1',
metadata: {
name: 'dashboard-test',
resourceVersion: '1',
creationTimestamp: '2023-01-01T00:00:00Z',
},
access: {
canEdit: true,
canSave: true,
canStar: true,
canShare: true,
},
});
const initialSaveModel = transformSceneToSaveModelSchemaV2(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
return dashboard;
}

View File

@ -5,7 +5,7 @@ import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDa
import { getV1SchemaPanelCounts, getV1SchemaVariables } from 'app/features/dashboard/utils/tracking';
import { SaveDashboardResponseDTO } from 'app/types';
import { getRawDashboardChanges } from '../saving/getDashboardChanges';
import { getRawDashboardChanges, getRawDashboardV2Changes } from '../saving/getDashboardChanges';
import { DashboardChangeInfo } from '../saving/shared';
import { DashboardScene } from '../scene/DashboardScene';
@ -126,10 +126,28 @@ export class V2DashboardSerializer implements DashboardSceneSerializerLike<Dashb
return {} as DashboardV2Spec;
}
getDashboardChangesFromScene(scene: DashboardScene) {
throw new Error('v2 schema: Method not implemented.');
// eslint-disable-next-line
return {} as DashboardChangeInfo;
getDashboardChangesFromScene(
scene: DashboardScene,
options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean }
) {
const changedSaveModel = this.getSaveModel(scene);
const changeInfo = getRawDashboardV2Changes(
this.initialSaveModel!,
changedSaveModel,
options.saveTimeRange,
options.saveVariables,
options.saveRefresh
);
const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid;
const isNew = scene.getInitialState()?.meta.isNew;
return {
...changeInfo,
hasFolderChanges,
hasChanges: changeInfo.hasChanges || hasFolderChanges,
isNew,
};
}
onSaveComplete(saveModel: DashboardV2Spec, result: SaveDashboardResponseDTO): void {
@ -142,15 +160,8 @@ export class V2DashboardSerializer implements DashboardSceneSerializerLike<Dashb
}
}
export function getDashboardSceneSerializer(
forceLegacy?: boolean
): DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> {
// When we have end-to-end v2 API integration, this will be controlled by a feature toggle, no need for forceLegacy
if (forceLegacy) {
return new V1DashboardSerializer();
}
if (config.featureToggles.dashboardSchemaV2) {
export function getDashboardSceneSerializer(): DashboardSceneSerializerLike<Dashboard | DashboardV2Spec> {
if (config.featureToggles.useV2DashboardsAPI) {
return new V2DashboardSerializer();
}

View File

@ -114,11 +114,13 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
// EOF layout
};
if (isDashboardSchemaV2(dashboardSchemaV2)) {
return dashboardSchemaV2;
try {
validateDashboardSchemaV2(dashboardSchemaV2);
return dashboardSchemaV2 as DashboardV2Spec;
} catch (reason) {
console.error('Error transforming dashboard to schema v2: ' + reason, dashboardSchemaV2);
throw new Error('Error transforming dashboard to schema v2: ' + reason);
}
console.error('Error transforming dashboard to schema v2');
throw new Error('Error transforming dashboard to schema v2');
}
function getCursorSync(state: DashboardSceneState) {
@ -436,7 +438,7 @@ function getDefaultDataSourceRef(): DataSourceRef | undefined {
const defaultDatasource = config.bootData.settings.defaultDatasource;
// get default datasource type
const dsList = config.bootData.settings.datasources;
const dsList = config.bootData.settings.datasources ?? {};
const ds = dsList[defaultDatasource];
if (ds) {
@ -447,98 +449,98 @@ function getDefaultDataSourceRef(): DataSourceRef | undefined {
}
// Function to know if the dashboard transformed is a valid DashboardV2Spec
function isDashboardSchemaV2(dash: any): dash is DashboardV2Spec {
function validateDashboardSchemaV2(dash: any): dash is DashboardV2Spec {
if (typeof dash !== 'object' || dash === null) {
return false;
throw new Error('Dashboard is not an object or is null');
}
if (typeof dash.title !== 'string') {
return false;
throw new Error('Title is not a string');
}
if (typeof dash.description !== 'string') {
return false;
throw new Error('Description is not a string');
}
if (typeof dash.cursorSync !== 'string') {
return false;
throw new Error('CursorSync is not a string');
}
if (typeof dash.liveNow !== 'boolean') {
return false;
throw new Error('LiveNow is not a boolean');
}
if (typeof dash.preload !== 'boolean') {
return false;
throw new Error('Preload is not a boolean');
}
if (typeof dash.editable !== 'boolean') {
return false;
throw new Error('Editable is not a boolean');
}
if (!Array.isArray(dash.links)) {
return false;
throw new Error('Links is not an array');
}
if (!Array.isArray(dash.tags)) {
return false;
throw new Error('Tags is not an array');
}
if (dash.id !== undefined && typeof dash.id !== 'number') {
return false;
throw new Error('ID is not a number');
}
// Time settings
if (typeof dash.timeSettings !== 'object' || dash.timeSettings === null) {
return false;
throw new Error('TimeSettings is not an object or is null');
}
if (typeof dash.timeSettings.timezone !== 'string') {
return false;
throw new Error('Timezone is not a string');
}
if (typeof dash.timeSettings.from !== 'string') {
return false;
throw new Error('From is not a string');
}
if (typeof dash.timeSettings.to !== 'string') {
return false;
throw new Error('To is not a string');
}
if (typeof dash.timeSettings.autoRefresh !== 'string') {
return false;
throw new Error('AutoRefresh is not a string');
}
if (!Array.isArray(dash.timeSettings.autoRefreshIntervals)) {
return false;
throw new Error('AutoRefreshIntervals is not an array');
}
if (!Array.isArray(dash.timeSettings.quickRanges)) {
return false;
throw new Error('QuickRanges is not an array');
}
if (typeof dash.timeSettings.hideTimepicker !== 'boolean') {
return false;
throw new Error('HideTimepicker is not a boolean');
}
if (typeof dash.timeSettings.weekStart !== 'string') {
return false;
throw new Error('WeekStart is not a string');
}
if (typeof dash.timeSettings.fiscalYearStartMonth !== 'number') {
return false;
throw new Error('FiscalYearStartMonth is not a number');
}
if (dash.timeSettings.nowDelay !== undefined && typeof dash.timeSettings.nowDelay !== 'string') {
return false;
throw new Error('NowDelay is not a string');
}
// Other sections
if (!Array.isArray(dash.variables)) {
return false;
throw new Error('Variables is not an array');
}
if (typeof dash.elements !== 'object' || dash.elements === null) {
return false;
throw new Error('Elements is not an object or is null');
}
if (!Array.isArray(dash.annotations)) {
return false;
throw new Error('Annotations is not an array');
}
// Layout
if (typeof dash.layout !== 'object' || dash.layout === null) {
return false;
throw new Error('Layout is not an object or is null');
}
if (dash.layout.kind !== 'GridLayout') {
return false;
throw new Error('Layout kind is not GridLayout');
}
if (typeof dash.layout.spec !== 'object' || dash.layout.spec === null) {
return false;
throw new Error('Layout spec is not an object or is null');
}
if (!Array.isArray(dash.layout.spec.items)) {
return false;
throw new Error('Layout spec items is not an array');
}
return true;

View File

@ -39,7 +39,7 @@ export function ensureV2Response(
const spec: DashboardV2Spec = {
title: dashboard.title,
description: dashboard.description,
tags: dashboard.tags,
tags: dashboard.tags ?? [],
schemaVersion: dashboard.schemaVersion,
cursorSync: transformCursorSynctoEnum(dashboard.graphTooltip),
preload: dashboard.preload || dashboardDefaults.preload,