diff --git a/.betterer.results b/.betterer.results
index ae639730cc6..9dcac9eed07 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -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 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 or use t()", "0"],
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
index c92ddd9e94d..7fbac4c909b 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.gen.ts
@@ -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;
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
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 4df22d4cc27..d6a71bb211b 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
@@ -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
diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts
index 0abdef8e56d..ae184d6aecf 100644
--- a/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts
+++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts
@@ -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');
});
diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts
index 79ef18ea047..2d540e4a42e 100644
--- a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts
+++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts
@@ -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;
}
diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
index edb9303fa1f..91ea9ad323b 100644
--- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx
+++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx
@@ -173,7 +173,7 @@ export class DashboardScene extends SceneObjectBase {
private _prevScrollPos?: number;
// TODO: use feature toggle to allow v2 serializer
- private _serializer: DashboardSceneSerializerLike = getDashboardSceneSerializer(true);
+ private _serializer: DashboardSceneSerializerLike = getDashboardSceneSerializer();
public constructor(state: Partial) {
super({
diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
index 87315eef5ec..0c5cb166c51 100644
--- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
+++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx
@@ -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 (
({
+ ...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) {
+ 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;
+}
diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
index 5edefb6d1c6..c5491887ec3 100644
--- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
+++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts
@@ -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 {
- // 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 {
+ if (config.featureToggles.useV2DashboardsAPI) {
return new V2DashboardSerializer();
}
diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
index 562b354255c..cdd5c2a4505 100644
--- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
+++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts
@@ -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;
diff --git a/public/app/features/dashboard/api/ResponseTransformers.ts b/public/app/features/dashboard/api/ResponseTransformers.ts
index 98b7ac12cdc..224a6a2575e 100644
--- a/public/app/features/dashboard/api/ResponseTransformers.ts
+++ b/public/app/features/dashboard/api/ResponseTransformers.ts
@@ -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,