From a06779614e5d5bd28cd7caa983a1aa2d6fae175b Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Thu, 19 Dec 2024 12:00:59 +0100 Subject: [PATCH] Implement detect changes tracking to V2 Schema (#98153) Co-authored-by: Haris Rozajac --- .betterer.results | 10 +- .../dashboard/v2alpha0/dashboard.gen.ts | 9 +- .../dashboard/v2alpha0/dashboard.schema.cue | 6 +- .../saving/getDashboardChanges.test.ts | 46 +-- .../saving/getDashboardChanges.ts | 142 ++++++-- .../dashboard-scene/scene/DashboardScene.tsx | 2 +- .../scene/NavToolbarActions.tsx | 4 +- .../DashboardSceneSerializer.test.ts | 339 +++++++++++++++++- .../serialization/DashboardSceneSerializer.ts | 39 +- .../transformSceneToSaveModelSchemaV2.ts | 70 ++-- .../dashboard/api/ResponseTransformers.ts | 2 +- 11 files changed, 536 insertions(+), 133 deletions(-) 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,