From f8686be56ab760d0953081d1caed7863e91f471a Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Fri, 13 Dec 2024 10:20:22 +0100 Subject: [PATCH] Schema v2: Make DashboardScene accept v1 and v2 schema (#96931) * WIP * Refactor useSaveDashboard to make it dashboard type independent when used * DashboardPrompt: makeit Dashboard type independent * DashboardScene: accept both v1 and v2 schema * Update save dashboard command interface * Fix test * Lint * Refactor * Add onSaveComplete to DashboardSceneSerializer * Cleanup tests * Remove unused code * Refactor dashboard tracking information * added a todo * Fix betterer * Update betterer results --- .../saving/DashboardPrompt.test.tsx | 26 +- .../saving/DashboardPrompt.tsx | 19 +- .../saving/DetectChangesWorker.ts | 4 +- .../saving/SaveDashboardAsForm.tsx | 36 +- .../saving/SaveDashboardDrawer.tsx | 12 +- .../saving/SaveDashboardForm.tsx | 4 +- .../saving/getDashboardChanges.test.ts | 18 +- .../saving/getDashboardChanges.ts | 25 +- .../getDashboardChangesFromScene.test.ts | 287 ------------- .../saving/getDashboardChangesFromScene.ts | 35 -- .../dashboard-scene/saving/shared.tsx | 5 +- .../saving/useSaveDashboard.ts | 22 +- .../scene/DashboardScene.test.tsx | 14 - .../dashboard-scene/scene/DashboardScene.tsx | 46 ++- .../scene/NavToolbarActions.tsx | 19 +- .../DashboardSceneSerializer.test.ts | 384 ++++++++++++++++++ .../serialization/DashboardSceneSerializer.ts | 158 +++++++ .../transformSceneToSaveModelSchemaV2.ts | 2 +- .../settings/JsonModelEditView.tsx | 3 +- .../components/SaveDashboard/types.ts | 11 +- .../app/features/dashboard/utils/tracking.ts | 37 +- 21 files changed, 718 insertions(+), 449 deletions(-) delete mode 100644 public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts delete mode 100644 public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts create mode 100644 public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts create mode 100644 public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx index ac2014b6fd5..2b7624b779a 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.test.tsx @@ -24,14 +24,14 @@ describe('DashboardPrompt', () => { describe('when called without original dashboard', () => { it('then it should return true', () => { const scene = buildTestScene(); - expect(ignoreChanges(scene, undefined)).toBe(true); + scene.setInitialSaveModel(undefined); + expect(ignoreChanges(scene)).toBe(true); }); }); describe('when called without current dashboard', () => { it('then it should return true', () => { - const scene = buildTestScene(); - expect(ignoreChanges(null, scene.getInitialSaveModel())).toBe(true); + expect(ignoreChanges(null)).toBe(true); }); }); @@ -45,7 +45,7 @@ describe('DashboardPrompt', () => { }); contextSrv.isEditor = false; - expect(ignoreChanges(scene, scene.getInitialSaveModel())).toBe(true); + expect(ignoreChanges(scene)).toBe(true); }); }); @@ -59,10 +59,11 @@ describe('DashboardPrompt', () => { }, }); const initialSaveModel = transformSceneToSaveModel(scene); + scene.setInitialSaveModel(initialSaveModel); contextSrv.isEditor = false; - expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + expect(ignoreChanges(scene)).toBe(undefined); }); }); @@ -75,9 +76,10 @@ describe('DashboardPrompt', () => { }, }); const initialSaveModel = transformSceneToSaveModel(scene); + scene.setInitialSaveModel(initialSaveModel); contextSrv.isSignedIn = false; - expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + expect(ignoreChanges(scene)).toBe(true); }); }); @@ -90,7 +92,9 @@ describe('DashboardPrompt', () => { }, }); const initialSaveModel = transformSceneToSaveModel(scene); - expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + scene.setInitialSaveModel(initialSaveModel); + + expect(ignoreChanges(scene)).toBe(true); }); }); @@ -104,7 +108,9 @@ describe('DashboardPrompt', () => { }, }); const initialSaveModel = transformSceneToSaveModel(scene); - expect(ignoreChanges(scene, initialSaveModel)).toBe(true); + scene.setInitialSaveModel(initialSaveModel); + + expect(ignoreChanges(scene)).toBe(true); }); }); @@ -118,7 +124,9 @@ describe('DashboardPrompt', () => { }, }); const initialSaveModel = transformSceneToSaveModel(scene); - expect(ignoreChanges(scene, initialSaveModel)).toBe(undefined); + scene.setInitialSaveModel(initialSaveModel); + + expect(ignoreChanges(scene)).toBe(undefined); }); }); }); diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx index 8c8fb1c4170..3b0760001c6 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -3,13 +3,12 @@ import * as H from 'history'; import { memo, useContext, useEffect, useMemo } from 'react'; import { locationService } from '@grafana/runtime'; -import { Dashboard } from '@grafana/schema/dist/esm/index.gen'; import { ModalsContext, Modal, Button, useStyles2 } from '@grafana/ui'; import { Prompt } from 'app/core/components/FormPrompt/Prompt'; import { contextSrv } from 'app/core/services/context_srv'; import { SaveLibraryVizPanelModal } from '../panel-edit/SaveLibraryVizPanelModal'; -import { DashboardScene } from '../scene/DashboardScene'; +import { DashboardScene, isV2Dashboard } from '../scene/DashboardScene'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; interface DashboardPromptProps { @@ -23,7 +22,7 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { useEffect(() => { const handleUnload = (event: BeforeUnloadEvent) => { - if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + if (ignoreChanges(dashboard)) { return; } @@ -72,7 +71,7 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { return true; } - if (ignoreChanges(dashboard, dashboard.getInitialSaveModel())) { + if (ignoreChanges(dashboard)) { return true; } @@ -153,7 +152,13 @@ const getStyles = () => ({ /** * For some dashboards and users changes should be ignored * */ -export function ignoreChanges(current: DashboardScene | null, original?: Dashboard) { +export function ignoreChanges(scene: DashboardScene | null) { + const original = scene?.getInitialSaveModel(); + + if (original && isV2Dashboard(original)) { + throw new Error('isV2Dashboard is not implemented'); + } + if (!original) { return true; } @@ -168,11 +173,11 @@ export function ignoreChanges(current: DashboardScene | null, original?: Dashboa return true; } - if (!current) { + if (!scene) { return true; } - const { canSave, fromScript, fromFile } = current.state.meta; + const { canSave, fromScript, fromFile } = scene.state.meta; if (!contextSrv.isEditor && !canSave) { return true; } diff --git a/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts index 2d7cb6f8386..3da81d5ad5b 100644 --- a/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts +++ b/public/app/features/dashboard-scene/saving/DetectChangesWorker.ts @@ -1,6 +1,6 @@ import { Dashboard } from '@grafana/schema'; -import { getDashboardChanges } from './getDashboardChanges'; +import { getRawDashboardChanges } from './getDashboardChanges'; function _debounce(f: (...args: T[]) => void, timeout: number) { let timeoutId: NodeJS.Timeout | undefined = undefined; @@ -13,6 +13,6 @@ function _debounce(f: (...args: T[]) => void, timeout: number) { } self.onmessage = _debounce((e: MessageEvent<{ initial: Dashboard; changed: Dashboard }>) => { - const result = getDashboardChanges(e.data.initial, e.data.changed, false, false, false); + const result = getRawDashboardChanges(e.data.initial, e.data.changed, false, false, false); self.postMessage(result); }, 500); diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 84012e2f694..39dc0bca90a 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -3,7 +3,6 @@ import { ChangeEvent, useState } from 'react'; import { UseFormSetValue, useForm } from 'react-hook-form'; import { selectors } from '@grafana/e2e-selectors'; -import { Dashboard } from '@grafana/schema'; import { Button, Input, Switch, Field, Label, TextArea, Stack, Alert, Box } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; @@ -52,8 +51,17 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { const onSave = async (overwrite: boolean) => { const data = getValues(); - const dashboardToSave: Dashboard = getSaveAsDashboardSaveModel(changedSaveModel, data, changeInfo.isNew); - const result = await onSaveDashboard(dashboard, dashboardToSave, { overwrite, folderUid: data.folder.uid }); + const result = await onSaveDashboard(dashboard, { + overwrite, + folderUid: data.folder.uid, + + // save as config + saveAsCopy: true, + isNew: changeInfo.isNew, + copyTags: data.copyTags, + title: data.title, + description: data.description, + }); if (result.status === 'success') { dashboard.closeModal(); @@ -98,11 +106,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { return (
onSave(false))}> - } - invalid={!!errors.title} - error={errors.title?.message} - > + } invalid={!!errors.title} error={errors.title?.message}> } + label={} invalid={!!errors.description} error={errors.description?.message} > @@ -149,7 +153,6 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { } export interface TitleLabelProps { - dashboard: Dashboard; onChange: UseFormSetValue; } @@ -168,7 +171,6 @@ export function TitleFieldLabel(props: TitleLabelProps) { } export interface DescriptionLabelProps { - dashboard: Dashboard; onChange: UseFormSetValue; } @@ -198,15 +200,3 @@ async function validateDashboardName(title: string, formValues: SaveDashboardAsF return e instanceof Error ? e.message : 'Dashboard name is invalid'; } } - -function getSaveAsDashboardSaveModel(source: Dashboard, form: SaveDashboardAsFormDTO, isNew?: boolean): Dashboard { - // TODO remove old alerts and thresholds when copying (See getSaveAsDashboardClone) - return { - ...source, - id: null, - uid: '', - title: form.title, - description: form.description, - tags: isNew || form.copyTags ? source.tags : [], - }; -} diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx index 3502d33c1e1..c54a5bbd45a 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardDrawer.tsx @@ -7,7 +7,6 @@ import { DashboardScene } from '../scene/DashboardScene'; import { SaveDashboardAsForm } from './SaveDashboardAsForm'; import { SaveDashboardForm } from './SaveDashboardForm'; import { SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; -import { getDashboardChangesFromScene } from './getDashboardChangesFromScene'; interface SaveDashboardDrawerState extends SceneObjectState { dashboardRef: SceneObjectRef; @@ -38,12 +37,11 @@ export class SaveDashboardDrawer extends SceneObjectBase) => { const { showDiff, saveAsCopy, saveTimeRange, saveVariables, saveRefresh } = model.useState(); - const changeInfo = getDashboardChangesFromScene( - model.state.dashboardRef.resolve(), - saveTimeRange, - saveVariables, - saveRefresh - ); + + const changeInfo = model.state.dashboardRef + .resolve() + .getDashboardChanges(saveTimeRange, saveVariables, saveRefresh); + const { changedSaveModel, initialSaveModel, diffs, diffCount, hasFolderChanges } = changeInfo; const changesCount = diffCount + (hasFolderChanges ? 1 : 0); const dashboard = model.state.dashboardRef.resolve(); diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx index 871eb14ce92..39933216aa8 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardForm.tsx @@ -24,7 +24,7 @@ export interface Props { } export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { - const { changedSaveModel, hasChanges } = changeInfo; + const { hasChanges } = changeInfo; const { state, onSaveDashboard } = useSaveDashboard(false); const [options, setOptions] = useState({ @@ -32,7 +32,7 @@ export function SaveDashboardForm({ dashboard, drawer, changeInfo }: Props) { }); const onSave = async (overwrite: boolean) => { - const result = await onSaveDashboard(dashboard, changedSaveModel, { ...options, overwrite }); + const result = await onSaveDashboard(dashboard, { ...options, overwrite }); if (result.status === 'success') { dashboard.closeModal(); drawer.state.onSaveSuccess?.(); diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts index e8635add8ed..0abdef8e56d 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.test.ts @@ -1,7 +1,7 @@ import { AdHocVariableModel } from '@grafana/data'; import { Dashboard, Panel } from '@grafana/schema'; -import { adHocVariableFiltersEqual, getDashboardChanges, getPanelChanges } from './getDashboardChanges'; +import { adHocVariableFiltersEqual, getRawDashboardChanges, getPanelChanges } from './getDashboardChanges'; describe('adHocVariableFiltersEqual', () => { it('should compare empty filters', () => { @@ -134,7 +134,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(initial, changed, false, false, false); + const result = getRawDashboardChanges(initial, changed, false, false, false); expect(result).toEqual(expectedChanges); }); @@ -165,7 +165,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(newDashInitial, changed, false, false, false); + const result = getRawDashboardChanges(newDashInitial, changed, false, false, false); expect(result).toEqual(expectedChanges); }); @@ -195,7 +195,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(initial, changed, false, false, false); + const result = getRawDashboardChanges(initial, changed, false, false, false); expect(result).toEqual(expectedChanges); }); @@ -236,7 +236,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(initial, changed, true, false, false); + const result = getRawDashboardChanges(initial, changed, true, false, false); expect(result).toEqual(expectedChanges); }); @@ -263,7 +263,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: true, }; - const result = getDashboardChanges(initial, changed, false, false, false); + const result = getRawDashboardChanges(initial, changed, false, false, false); expect(result).toEqual(expectedChanges); }); @@ -301,7 +301,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: true, }; - const result = getDashboardChanges(initial, changed, false, false, true); + const result = getRawDashboardChanges(initial, changed, false, false, true); expect(result).toEqual(expectedChanges); }); @@ -341,7 +341,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(initial, changed, false, false, false); + const result = getRawDashboardChanges(initial, changed, false, false, false); expect(result).toEqual(expectedChanges); }); @@ -392,7 +392,7 @@ describe('getDashboardChanges', () => { hasRefreshChange: false, }; - const result = getDashboardChanges(initial, changed, false, true, false); + const result = getRawDashboardChanges(initial, changed, false, true, false); expect(result).toEqual(expectedChanges); }); diff --git a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts index 3a65b20e53c..79ef18ea047 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChanges.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChanges.ts @@ -1,8 +1,8 @@ // @ts-ignore -import jsonMap from 'json-source-map'; 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 { jsonDiff } from '../settings/version-history/utils'; @@ -30,7 +30,28 @@ 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)); } -export function getDashboardChanges( +// TODO[schema v2] +export function getRawDashboardV2Changes( + initial: DashboardV2Spec, + changed: DashboardV2Spec, + saveTimeRange?: boolean, + saveVariables?: boolean, + saveRefresh?: boolean +) { + return { + changedSaveModel: changed, + initialSaveModel: initial, + diffs: jsonDiff(initial, changed), + diffCount: 0, + hasChanges: false, + hasTimeChanges: false, + isNew: false, + hasVariableValueChanges: false, + hasRefreshChange: false, + }; +} + +export function getRawDashboardChanges( initial: Dashboard, changed: Dashboard, saveTimeRange?: boolean, diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts deleted file mode 100644 index 3a62712b70d..00000000000 --- a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { config } from '@grafana/runtime'; -import { - AdHocFiltersVariable, - GroupByVariable, - MultiValueVariable, - sceneGraph, - SceneRefreshPicker, -} from '@grafana/scenes'; -import { VariableModel } from '@grafana/schema'; - -import { buildPanelEditScene } from '../panel-edit/PanelEditor'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; -import { findVizPanelByKey } from '../utils/utils'; - -import { getDashboardChangesFromScene } from './getDashboardChangesFromScene'; - -describe('getDashboardChangesFromScene', () => { - it('Can detect no changes', () => { - const dashboard = setup(); - const result = getDashboardChangesFromScene(dashboard, false); - expect(result.hasChanges).toBe(false); - expect(result.diffCount).toBe(0); - }); - - it('Can detect time changed', () => { - const dashboard = setup(); - - sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); - - const result = getDashboardChangesFromScene(dashboard, false); - expect(result.hasChanges).toBe(false); - expect(result.diffCount).toBe(0); - expect(result.hasTimeChanges).toBe(true); - }); - - it('Can save time change', () => { - const dashboard = setup(); - - sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); - - const result = getDashboardChangesFromScene(dashboard, true); - expect(result.hasChanges).toBe(true); - expect(result.diffCount).toBe(1); - }); - - it('Can detect folder change', () => { - const dashboard = setup(); - - dashboard.state.meta.folderUid = 'folder-2'; - - const result = getDashboardChangesFromScene(dashboard, 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 = setup(); - - const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker); - if (refreshPicker instanceof SceneRefreshPicker) { - refreshPicker.setState({ refresh: '5s' }); - } - - const result = getDashboardChangesFromScene(dashboard, 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 = setup(); - - const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker); - if (refreshPicker instanceof SceneRefreshPicker) { - refreshPicker.setState({ refresh: '5s' }); - } - - const result = getDashboardChangesFromScene(dashboard, false, false, true); - expect(result.hasChanges).toBe(true); - expect(result.diffCount).toBe(1); - }); - - describe('variable changes', () => { - it('Can detect variable change', () => { - const dashboard = setup(); - - const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; - appVar.changeValueTo('app2'); - - const result = getDashboardChangesFromScene(dashboard, 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 = setup(); - - const appVar = sceneGraph.lookupVariable('app', dashboard) as MultiValueVariable; - appVar.changeValueTo('app2'); - - const result = getDashboardChangesFromScene(dashboard, 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 = transformSaveModelToScene({ - dashboard: { - title: 'hello', - uid: 'my-uid', - schemaVersion: 30, - panels: [ - { - id: 1, - title: 'Panel 1', - type: 'text', - }, - ], - version: 10, - templating: { - list: [ - { - type: 'groupby', - datasource: { - type: 'ds', - uid: 'ds-uid', - }, - name: 'GroupBy', - options: [ - { - text: 'Host', - value: 'host', - }, - { - text: 'Region', - value: 'region', - }, - ], - }, - ], - }, - }, - meta: {}, - }); - const initialSaveModel = transformSceneToSaveModel(dashboard); - dashboard.setInitialSaveModel(initialSaveModel); - - const variable = sceneGraph.lookupVariable('GroupBy', dashboard) as GroupByVariable; - variable.setState({ defaultOptions: [{ text: 'Host', value: 'host' }] }); - const result = getDashboardChangesFromScene(dashboard, 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 adhocVar = { - id: 'adhoc', - name: 'adhoc', - label: 'Adhoc Label', - description: 'Adhoc Description', - type: 'adhoc', - datasource: { - uid: 'gdev-prometheus', - type: 'prometheus', - }, - filters: [], - baseFilters: [], - defaultKeys: [ - { - text: 'Host', - value: 'host', - }, - { - text: 'Region', - value: 'region', - }, - ], - } as VariableModel; - - const dashboard = transformSaveModelToScene({ - dashboard: { - title: 'hello', - uid: 'my-uid', - schemaVersion: 30, - panels: [ - { - id: 1, - title: 'Panel 1', - type: 'text', - }, - ], - version: 10, - templating: { - list: [adhocVar], - }, - }, - meta: {}, - }); - - const initialSaveModel = transformSceneToSaveModel(dashboard); - dashboard.setInitialSaveModel(initialSaveModel); - - const variable = sceneGraph.lookupVariable('adhoc', dashboard) as AdHocFiltersVariable; - variable.setState({ defaultKeys: [{ text: 'Host', value: 'host' }] }); - const result = getDashboardChangesFromScene(dashboard, 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 = setup(); - 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 = getDashboardChangesFromScene(dashboard, false, true); - const panelSaveModel = result.changedSaveModel.panels![0]; - expect(panelSaveModel.title).toBe('changed title'); - }); - }); -}); - -interface ScenarioOptions { - fromPanelEdit?: boolean; -} - -function setup(options: ScenarioOptions = {}) { - const dashboard = transformSaveModelToScene({ - dashboard: { - title: 'hello', - uid: 'my-uid', - schemaVersion: 30, - panels: [ - { - id: 1, - title: 'Panel 1', - type: 'text', - }, - ], - version: 10, - templating: { - list: [ - { - name: 'app', - type: 'custom', - current: { - text: 'app1', - value: 'app1', - }, - }, - ], - }, - }, - meta: {}, - }); - - const initialSaveModel = transformSceneToSaveModel(dashboard); - dashboard.setInitialSaveModel(initialSaveModel); - - return dashboard; -} diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts deleted file mode 100644 index 5b8a4a444b0..00000000000 --- a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DashboardScene } from '../scene/DashboardScene'; -import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; - -import { getDashboardChanges as getDashboardSaveModelChanges } from './getDashboardChanges'; - -/** - * Get changes between the initial save model and the current scene. - * It also checks if the folder has changed. - * @param scene DashboardScene object - * @param saveTimeRange if true, compare the time range - * @param saveVariables if true, compare the variables - * @param saveRefresh if true, compare the refresh interval - * @returns - */ -export function getDashboardChangesFromScene( - scene: DashboardScene, - saveTimeRange?: boolean, - saveVariables?: boolean, - saveRefresh?: boolean -) { - const changeInfo = getDashboardSaveModelChanges( - scene.getInitialSaveModel()!, - transformSceneToSaveModel(scene), - saveTimeRange, - saveVariables, - saveRefresh - ); - const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid; - - return { - ...changeInfo, - hasFolderChanges, - hasChanges: changeInfo.hasChanges || hasFolderChanges, - }; -} diff --git a/public/app/features/dashboard-scene/saving/shared.tsx b/public/app/features/dashboard-scene/saving/shared.tsx index 7fd584b666e..b2c3507c626 100644 --- a/public/app/features/dashboard-scene/saving/shared.tsx +++ b/public/app/features/dashboard-scene/saving/shared.tsx @@ -3,14 +3,15 @@ import * as React from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { config, isFetchError } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import { Alert, Box, Button, Stack } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; import { Diffs } from '../settings/version-history/utils'; export interface DashboardChangeInfo { - changedSaveModel: Dashboard; - initialSaveModel: Dashboard; + changedSaveModel: Dashboard | DashboardV2Spec; + initialSaveModel: Dashboard | DashboardV2Spec; diffs: Diffs; diffCount: number; hasChanges: boolean; diff --git a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts index 04ab768aaad..7fa3694f584 100644 --- a/public/app/features/dashboard-scene/saving/useSaveDashboard.ts +++ b/public/app/features/dashboard-scene/saving/useSaveDashboard.ts @@ -7,7 +7,7 @@ import appEvents from 'app/core/app_events'; import { useAppNotification } from 'app/core/copy/appNotification'; import { updateDashboardName } from 'app/core/reducers/navBarTree'; import { useSaveDashboardMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types'; +import { SaveDashboardAsOptions, SaveDashboardOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { useDispatch } from 'app/types'; import { DashboardSavedEvent } from 'app/types/events'; @@ -20,8 +20,26 @@ export function useSaveDashboard(isCopy = false) { const [saveDashboardRtkQuery] = useSaveDashboardMutation(); const [state, onSaveDashboard] = useAsyncFn( - async (scene: DashboardScene, saveModel: Dashboard, options: SaveDashboardOptions) => { + async ( + scene: DashboardScene, + options: SaveDashboardOptions & + SaveDashboardAsOptions & { + // When provided, will take precedence over the scene's save model + rawDashboardJSON?: Dashboard; + } + ) => { { + let saveModel = options.rawDashboardJSON ?? scene.getSaveModel(); + + if (options.saveAsCopy) { + saveModel = scene.getSaveAsModel({ + isNew: options.isNew, + title: options.title, + description: options.description, + copyTags: options.copyTags, + }); + } + const result = await saveDashboardRtkQuery({ dashboard: saveModel, folderUid: options.folderUid, diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index b50b2c7734f..19a1a023f3d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -37,20 +37,6 @@ import { RowActions } from './row-actions/RowActions'; jest.mock('../settings/version-history/HistorySrv'); jest.mock('../serialization/transformSaveModelToScene'); -jest.mock('../saving/getDashboardChangesFromScene', () => ({ - // It compares the initial and changed save models and returns the differences - // By default we assume there are differences to have the dirty state test logic tested - getDashboardChangesFromScene: jest.fn(() => ({ - changedSaveModel: {}, - initialSaveModel: {}, - diffs: [], - diffCount: 0, - hasChanges: true, - hasTimeChanges: false, - isNew: false, - hasVariableValueChanges: false, - })), -})); jest.mock('../serialization/transformSceneToSaveModel'); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index e839cea20ba..94ad83c9e3f 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -25,11 +25,13 @@ import { VizPanel, } from '@grafana/scenes'; import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import appEvents from 'app/core/app_events'; import { ScrollRefElement } from 'app/core/components/NativeScrollbar'; import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getNavModel } from 'app/core/selectors/navModel'; import store from 'app/core/store'; +import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -43,6 +45,8 @@ import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; +import { DashboardChangeInfo } from '../saving/shared'; +import { DashboardSceneSerializerLike, getDashboardSceneSerializer } from '../serialization/DashboardSceneSerializer'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; @@ -149,10 +153,6 @@ export class DashboardScene extends SceneObjectBase { * State before editing started */ private _initialState?: DashboardSceneState; - /** - * The save model which the scene was originally created from - */ - private _initialSaveModel?: Dashboard; /** * Url state before editing started */ @@ -172,6 +172,9 @@ export class DashboardScene extends SceneObjectBase { private _scrollRef?: ScrollRefElement; private _prevScrollPos?: number; + // TODO: use feature toggle to allow v2 serializer + private _serializer: DashboardSceneSerializerLike = getDashboardSceneSerializer(true); + public constructor(state: Partial) { super({ title: 'Dashboard', @@ -261,13 +264,8 @@ export class DashboardScene extends SceneObjectBase { this._changeTracker.startTrackingChanges(); }; - public saveCompleted(saveModel: Dashboard, result: SaveDashboardResponseDTO, folderUid?: string) { - this._initialSaveModel = { - ...saveModel, - id: result.id, - uid: result.uid, - version: result.version, - }; + public saveCompleted(saveModel: Dashboard | DashboardV2Spec, result: SaveDashboardResponseDTO, folderUid?: string) { + this._serializer.onSaveComplete(saveModel, result); this._changeTracker.stopTrackingChanges(); @@ -640,12 +638,16 @@ export class DashboardScene extends SceneObjectBase { } public getInitialSaveModel() { - return this._initialSaveModel; + return this._serializer.initialSaveModel; } /** Hacky temp function until we refactor transformSaveModelToScene a bit */ - public setInitialSaveModel(saveModel: Dashboard) { - this._initialSaveModel = saveModel; + public setInitialSaveModel(saveModel?: Dashboard | DashboardV2Spec) { + this._serializer.initialSaveModel = saveModel; + } + + public getTrackingInformation() { + return this._serializer.getTrackingInformation(); } public async onDashboardDelete() { @@ -688,6 +690,18 @@ export class DashboardScene extends SceneObjectBase { this._scrollRef?.scrollTo(0, this._prevScrollPos!); } } + + getSaveModel(): Dashboard | DashboardV2Spec { + return this._serializer.getSaveModel(this); + } + + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { + return this._serializer.getSaveAsModel(this, options); + } + + getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo { + return this._serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh }); + } } export class DashboardVariableDependency implements SceneVariableDependencyConfigLike { @@ -751,3 +765,7 @@ export class DashboardVariableDependency implements SceneVariableDependencyConfi } } } + +export function isV2Dashboard(model: Dashboard | DashboardV2Spec): model is DashboardV2Spec { + return 'elements' in model; +} diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index f72c9d30db7..680b613ad60 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -34,7 +34,7 @@ import { DashboardInteractions } from '../utils/interactions'; import { DynamicDashNavButtonModel, dynamicDashNavActions } from '../utils/registerDynamicDashNavAction'; import { isLibraryPanel } from '../utils/utils'; -import { DashboardScene } from './DashboardScene'; +import { DashboardScene, isV2Dashboard } from './DashboardScene'; import { GoToSnapshotOriginButton } from './GoToSnapshotOriginButton'; interface Props { @@ -140,12 +140,17 @@ export function ToolbarActions({ dashboard }: Props) { toolbarActions.push({ group: 'icon-actions', condition: meta.isSnapshot && !meta.dashboardNotFound && !isEditing, - render: () => ( - - ), + render: () => { + const saveModel = dashboard.getInitialSaveModel(); + + if (saveModel && isV2Dashboard(saveModel)) { + throw new Error('v2 schema not implemented'); + } + + return ( + + ); + }, }); if (!isEditingPanel && !isEditing) { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts new file mode 100644 index 00000000000..4d03005c5cb --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.test.ts @@ -0,0 +1,384 @@ +import { config } from '@grafana/runtime'; +import { + AdHocFiltersVariable, + GroupByVariable, + MultiValueVariable, + sceneGraph, + SceneRefreshPicker, +} from '@grafana/scenes'; +import { Dashboard, VariableModel } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; + +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; +import { findVizPanelByKey } from '../utils/utils'; + +import { V1DashboardSerializer, V2DashboardSerializer } from './DashboardSceneSerializer'; + +describe('DashboardSceneSerializer', () => { + describe('v1 schema', () => { + it('Can detect no changes', () => { + const dashboard = setup(); + const result = dashboard.getDashboardChanges(false); + expect(result.hasChanges).toBe(false); + expect(result.diffCount).toBe(0); + }); + + it('Can detect time changed', () => { + const dashboard = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', 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 = setup(); + + sceneGraph.getTimeRange(dashboard).setState({ from: 'now-1h', to: 'now' }); + + const result = dashboard.getDashboardChanges(true); + expect(result.hasChanges).toBe(true); + expect(result.diffCount).toBe(1); + }); + + it('Can detect folder change', () => { + const dashboard = setup(); + + 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 = setup(); + + const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker); + if (refreshPicker instanceof SceneRefreshPicker) { + refreshPicker.setState({ refresh: '5s' }); + } + + 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 = setup(); + + const refreshPicker = sceneGraph.findObject(dashboard, (obj) => obj instanceof SceneRefreshPicker); + if (refreshPicker instanceof SceneRefreshPicker) { + refreshPicker.setState({ refresh: '5s' }); + } + + 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 = setup(); + + 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 = setup(); + + 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 = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [ + { + id: 1, + title: 'Panel 1', + type: 'text', + }, + ], + version: 10, + templating: { + list: [ + { + type: 'groupby', + datasource: { + type: 'ds', + uid: 'ds-uid', + }, + name: 'GroupBy', + options: [ + { + text: 'Host', + value: 'host', + }, + { + text: 'Region', + value: 'region', + }, + ], + }, + ], + }, + }, + meta: {}, + }); + const initialSaveModel = transformSceneToSaveModel(dashboard); + dashboard.setInitialSaveModel(initialSaveModel); + + 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 adhocVar = { + id: 'adhoc', + name: 'adhoc', + label: 'Adhoc Label', + description: 'Adhoc Description', + type: 'adhoc', + datasource: { + uid: 'gdev-prometheus', + type: 'prometheus', + }, + filters: [], + baseFilters: [], + defaultKeys: [ + { + text: 'Host', + value: 'host', + }, + { + text: 'Region', + value: 'region', + }, + ], + } as VariableModel; + + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [ + { + id: 1, + title: 'Panel 1', + type: 'text', + }, + ], + version: 10, + templating: { + list: [adhocVar], + }, + }, + meta: {}, + }); + + const initialSaveModel = transformSceneToSaveModel(dashboard); + dashboard.setInitialSaveModel(initialSaveModel); + + 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 = setup(); + 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 Dashboard).panels![0]; + expect(panelSaveModel.title).toBe('changed title'); + }); + }); + + describe('tracking information', () => { + it('provides dashboard tracking information with no initial save model', () => { + const serializer = new V1DashboardSerializer(); + expect(serializer.getTrackingInformation()).toBe(undefined); + }); + + it('provides dashboard tracking information with from initial save model', () => { + const serializer = new V1DashboardSerializer(); + serializer.initialSaveModel = { + schemaVersion: 30, + version: 10, + uid: 'my-uid', + title: 'hello', + liveNow: true, + panels: [ + { + type: 'text', + }, + { + type: 'text', + }, + { + type: 'timeseries', + }, + ], + + templating: { + list: [ + { + type: 'query', + name: 'server', + }, + { + type: 'query', + name: 'host', + }, + { + type: 'textbox', + name: 'search', + }, + ], + }, + }; + + expect(serializer.getTrackingInformation()).toEqual({ + uid: 'my-uid', + title: 'hello', + schemaVersion: 30, + panels_count: 3, + panel_type_text_count: 2, + panel_type_timeseries_count: 1, + variable_type_query_count: 2, + variable_type_textbox_count: 1, + settings_nowdelay: undefined, + settings_livenow: true, + version_before_migration: 10, + }); + }); + }); + }); + + describe('v2 schema', () => { + 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(); + + expect(() => + serializer.onSaveComplete({} as DashboardV2Spec, { + id: 1, + uid: 'aa', + slug: 'slug', + url: 'url', + version: 2, + status: 'status', + }) + ).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 = {}) { + const dashboard = transformSaveModelToScene({ + dashboard: { + title: 'hello', + uid: 'my-uid', + schemaVersion: 30, + panels: [ + { + id: 1, + title: 'Panel 1', + type: 'text', + }, + ], + version: 10, + templating: { + list: [ + { + name: 'app', + type: 'custom', + current: { + text: 'app1', + value: 'app1', + }, + }, + ], + }, + }, + meta: {}, + }); + + const initialSaveModel = transformSceneToSaveModel(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 new file mode 100644 index 00000000000..5edefb6d1c6 --- /dev/null +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -0,0 +1,158 @@ +import { config } from '@grafana/runtime'; +import { Dashboard } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; +import { SaveDashboardAsOptions } from 'app/features/dashboard/components/SaveDashboard/types'; +import { getV1SchemaPanelCounts, getV1SchemaVariables } from 'app/features/dashboard/utils/tracking'; +import { SaveDashboardResponseDTO } from 'app/types'; + +import { getRawDashboardChanges } from '../saving/getDashboardChanges'; +import { DashboardChangeInfo } from '../saving/shared'; +import { DashboardScene } from '../scene/DashboardScene'; + +import { transformSceneToSaveModel } from './transformSceneToSaveModel'; +import { transformSceneToSaveModelSchemaV2 } from './transformSceneToSaveModelSchemaV2'; + +export interface DashboardSceneSerializerLike { + /** + * The save model which the dashboard scene was originally created from + */ + initialSaveModel?: T; + getSaveModel: (s: DashboardScene) => T; + getSaveAsModel: (s: DashboardScene, options: SaveDashboardAsOptions) => T; + getDashboardChangesFromScene: ( + scene: DashboardScene, + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + } + ) => DashboardChangeInfo; + onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void; + getTrackingInformation: () => DashboardTrackingInfo | undefined; +} + +interface DashboardTrackingInfo { + uid?: string; + title?: string; + schemaVersion: number; + version_before_migration?: number; + panels_count: number; + settings_nowdelay?: number; + settings_livenow?: boolean; +} + +export class V1DashboardSerializer implements DashboardSceneSerializerLike { + initialSaveModel?: Dashboard; + + getSaveModel(s: DashboardScene) { + return transformSceneToSaveModel(s); + } + + getSaveAsModel(s: DashboardScene, options: SaveDashboardAsOptions) { + const saveModel = this.getSaveModel(s); + + return { + ...saveModel, + id: null, + uid: '', + title: options.title || '', + description: options.description || '', + tags: options.isNew || options.copyTags ? saveModel.tags : [], + }; + } + + getDashboardChangesFromScene( + scene: DashboardScene, + options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + ) { + const changedSaveModel = this.getSaveModel(scene); + const changeInfo = getRawDashboardChanges( + this.initialSaveModel!, + changedSaveModel, + options.saveTimeRange, + options.saveVariables, + options.saveRefresh + ); + + const hasFolderChanges = scene.getInitialState()?.meta.folderUid !== scene.state.meta.folderUid; + + return { + ...changeInfo, + hasFolderChanges, + hasChanges: changeInfo.hasChanges || hasFolderChanges, + }; + } + + onSaveComplete(saveModel: Dashboard, result: SaveDashboardResponseDTO): void { + this.initialSaveModel = { + ...saveModel, + id: result.id, + uid: result.uid, + version: result.version, + }; + } + + getTrackingInformation(): DashboardTrackingInfo | undefined { + const panels = getV1SchemaPanelCounts(this.initialSaveModel?.panels || []); + const variables = getV1SchemaVariables(this.initialSaveModel?.templating?.list || []); + + if (this.initialSaveModel) { + return { + uid: this.initialSaveModel.uid, + title: this.initialSaveModel.title, + schemaVersion: this.initialSaveModel.schemaVersion, + version_before_migration: this.initialSaveModel.version, + panels_count: this.initialSaveModel.panels?.length || 0, + settings_nowdelay: undefined, + settings_livenow: !!this.initialSaveModel.liveNow, + ...panels, + ...variables, + }; + } + return undefined; + } +} + +export class V2DashboardSerializer implements DashboardSceneSerializerLike { + initialSaveModel?: DashboardV2Spec; + + getSaveModel(s: DashboardScene) { + return transformSceneToSaveModelSchemaV2(s); + } + + getSaveAsModel(s: DashboardScene, options: SaveDashboardAsOptions) { + throw new Error('Method not implemented.'); + // eslint-disable-next-line + return {} as DashboardV2Spec; + } + + getDashboardChangesFromScene(scene: DashboardScene) { + throw new Error('v2 schema: Method not implemented.'); + // eslint-disable-next-line + return {} as DashboardChangeInfo; + } + + onSaveComplete(saveModel: DashboardV2Spec, result: SaveDashboardResponseDTO): void { + throw new Error('v2 schema: Method not implemented.'); + } + + getTrackingInformation() { + throw new Error('v2 schema: Method not implemented.'); + return undefined; + } +} + +export function getDashboardSceneSerializer( + forceLegacy?: boolean +): 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) { + return new V2DashboardSerializer(); + } + + return new V1DashboardSerializer(); +} diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index bb0059effd2..92780e9fcde 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -57,7 +57,7 @@ type DeepPartial = T extends object } : T; -export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): Partial { +export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnapshot = false): DashboardV2Spec { const oldDash = scene.state; const timeRange = oldDash.$timeRange!.state; diff --git a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx index 30db2382027..c1a4b3edeea 100644 --- a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -90,9 +90,10 @@ export class JsonModelEditView extends SceneObjectBase i const { jsonText } = model.useState(); const onSave = async (overwrite: boolean) => { - const result = await onSaveDashboard(dashboard, JSON.parse(model.state.jsonText), { + const result = await onSaveDashboard(dashboard, { folderUid: dashboard.state.meta.folderUid, overwrite, + rawDashboardJSON: JSON.parse(model.state.jsonText), }); setIsSaving(true); diff --git a/public/app/features/dashboard/components/SaveDashboard/types.ts b/public/app/features/dashboard/components/SaveDashboard/types.ts index 2c2482dd683..204e9702852 100644 --- a/public/app/features/dashboard/components/SaveDashboard/types.ts +++ b/public/app/features/dashboard/components/SaveDashboard/types.ts @@ -1,4 +1,5 @@ import { Dashboard } from '@grafana/schema'; +import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen'; import { ObjectMeta } from 'app/features/apiserver/types'; import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { Diffs } from 'app/features/dashboard-scene/settings/version-history/utils'; @@ -18,8 +19,16 @@ export interface SaveDashboardOptions extends CloneOptions { makeEditable?: boolean; } +export interface SaveDashboardAsOptions { + saveAsCopy?: boolean; + isNew?: boolean; + copyTags?: boolean; + title?: string; + description?: string; +} + export interface SaveDashboardCommand { - dashboard: Dashboard; + dashboard: Dashboard | DashboardV2Spec; message?: string; folderUid?: string; overwrite?: boolean; diff --git a/public/app/features/dashboard/utils/tracking.ts b/public/app/features/dashboard/utils/tracking.ts index a9cca265776..847d0600c03 100644 --- a/public/app/features/dashboard/utils/tracking.ts +++ b/public/app/features/dashboard/utils/tracking.ts @@ -7,9 +7,9 @@ import { PanelModel } from '../state/PanelModel'; export function trackDashboardLoaded(dashboard: DashboardModel, duration?: number, versionBeforeMigration?: number) { // Count the different types of variables - const variables = getVariables(dashboard.templating.list); + const variables = getV1SchemaVariables(dashboard.templating.list); // Count the different types of panels - const panels = getPanelCounts(dashboard.panels); + const panels = getV1SchemaPanelCounts(dashboard.panels); DashboardInteractions.dashboardInitialized({ uid: dashboard.uid, @@ -28,28 +28,17 @@ export function trackDashboardLoaded(dashboard: DashboardModel, duration?: numbe } export function trackDashboardSceneLoaded(dashboard: DashboardScene, duration?: number) { - const initialSaveModel = dashboard.getInitialSaveModel(); - if (initialSaveModel) { - const panels = getPanelCounts(initialSaveModel.panels || []); - const variables = getVariables(initialSaveModel.templating?.list || []); - DashboardInteractions.dashboardInitialized({ - uid: initialSaveModel.uid, - title: initialSaveModel.title, - theme: undefined, - schemaVersion: initialSaveModel.schemaVersion, - version_before_migration: initialSaveModel.version, - panels_count: initialSaveModel.panels?.length || 0, - ...panels, - ...variables, - settings_nowdelay: undefined, - settings_livenow: !!initialSaveModel.liveNow, - duration, - isScene: true, - }); - } + const trackingInformation = dashboard.getTrackingInformation(); + + DashboardInteractions.dashboardInitialized({ + theme: undefined, + duration, + isScene: true, + ...trackingInformation, + }); } -function getPanelCounts(panels: Panel[] | PanelModel[]) { +export function getV1SchemaPanelCounts(panels: Panel[] | PanelModel[]) { return panels .map((p) => p.type) .reduce((r: Record, p) => { @@ -58,7 +47,7 @@ function getPanelCounts(panels: Panel[] | PanelModel[]) { }, {}); } -function getVariables(variableList: VariableModel[]) { +export function getV1SchemaVariables(variableList: VariableModel[]) { return variableList .map((v) => v.type) .reduce((r: Record, k) => { @@ -67,5 +56,5 @@ function getVariables(variableList: VariableModel[]) { }, {}); } -const variableName = (type: string) => `variable_type_${type}_count`; +export const variableName = (type: string) => `variable_type_${type}_count`; const panelName = (type: string) => `panel_type_${type}_count`;