From af1979692bc5343bbe3542edb48b3cb1fea4d8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 13 Sep 2021 09:13:43 +0200 Subject: [PATCH 01/10] Chore: adds tests to reducer --- .../manage-dashboards/state/reducers.test.ts | 89 +++++++++++++++++++ .../manage-dashboards/state/reducers.ts | 2 +- 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 public/app/features/manage-dashboards/state/reducers.test.ts diff --git a/public/app/features/manage-dashboards/state/reducers.test.ts b/public/app/features/manage-dashboards/state/reducers.test.ts new file mode 100644 index 000000000000..4e942ecbde24 --- /dev/null +++ b/public/app/features/manage-dashboards/state/reducers.test.ts @@ -0,0 +1,89 @@ +import { reducerTester } from '../../../../test/core/redux/reducerTester'; +import { + clearDashboard, + DashboardSource, + DataSourceInput, + importDashboardReducer, + ImportDashboardState, + initialImportDashboardState, + InputType, + setGcomDashboard, + setInputs, + setJsonDashboard, +} from './reducers'; + +describe('importDashboardReducer', () => { + describe('when setGcomDashboard action is dispatched', () => { + it('then resulting state should be correct', () => { + reducerTester() + .givenReducer(importDashboardReducer, { ...initialImportDashboardState }) + .whenActionIsDispatched( + setGcomDashboard({ json: { id: 1, title: 'Imported' }, updatedAt: '2001-01-01', orgName: 'Some Org' }) + ) + .thenStateShouldEqual({ + ...initialImportDashboardState, + dashboard: { + title: 'Imported', + id: null, + }, + meta: { updatedAt: '2001-01-01', orgName: 'Some Org' }, + source: DashboardSource.Gcom, + isLoaded: true, + }); + }); + }); + + describe('when setJsonDashboard action is dispatched', () => { + it('then resulting state should be correct', () => { + reducerTester() + .givenReducer(importDashboardReducer, { ...initialImportDashboardState, source: DashboardSource.Gcom }) + .whenActionIsDispatched(setJsonDashboard({ id: 1, title: 'Imported' })) + .thenStateShouldEqual({ + ...initialImportDashboardState, + dashboard: { + title: 'Imported', + id: null, + }, + source: DashboardSource.Json, + isLoaded: true, + }); + }); + }); + + describe('when clearDashboard action is dispatched', () => { + it('then resulting state should be correct', () => { + reducerTester() + .givenReducer(importDashboardReducer, { + ...initialImportDashboardState, + dashboard: { + title: 'Imported', + id: null, + }, + isLoaded: true, + }) + .whenActionIsDispatched(clearDashboard()) + .thenStateShouldEqual({ + ...initialImportDashboardState, + dashboard: {}, + isLoaded: false, + }); + }); + }); + + describe('when setInputs action is dispatched', () => { + it('then resulting state should be correct', () => { + reducerTester() + .givenReducer(importDashboardReducer, { ...initialImportDashboardState }) + .whenActionIsDispatched( + setInputs([{ type: InputType.DataSource }, { type: InputType.Constant }, { type: 'temp' }]) + ) + .thenStateShouldEqual({ + ...initialImportDashboardState, + inputs: { + dataSources: [{ type: InputType.DataSource }] as DataSourceInput[], + constants: [{ type: InputType.Constant }] as DataSourceInput[], + }, + }); + }); + }); +}); diff --git a/public/app/features/manage-dashboards/state/reducers.ts b/public/app/features/manage-dashboards/state/reducers.ts index cbcb92adfab4..3e86d06bc1ec 100644 --- a/public/app/features/manage-dashboards/state/reducers.ts +++ b/public/app/features/manage-dashboards/state/reducers.ts @@ -45,7 +45,7 @@ export interface ImportDashboardState { isLoaded: boolean; } -const initialImportDashboardState: ImportDashboardState = { +export const initialImportDashboardState: ImportDashboardState = { meta: { updatedAt: '', orgName: '' }, dashboard: {}, source: DashboardSource.Json, From e227b1806c5019c168b038893851d9e473172832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Mon, 13 Sep 2021 09:22:48 +0200 Subject: [PATCH 02/10] Refactor: rewrite state --- .../manage-dashboards/state/reducers.ts | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/public/app/features/manage-dashboards/state/reducers.ts b/public/app/features/manage-dashboards/state/reducers.ts index 3e86d06bc1ec..3cabf27451ae 100644 --- a/public/app/features/manage-dashboards/state/reducers.ts +++ b/public/app/features/manage-dashboards/state/reducers.ts @@ -1,4 +1,4 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, Draft, PayloadAction } from '@reduxjs/toolkit'; import { DataSourceInstanceSettings } from '@grafana/data'; export enum DashboardSource { @@ -57,43 +57,34 @@ const importDashboardSlice = createSlice({ name: 'manageDashboards', initialState: initialImportDashboardState, reducers: { - setGcomDashboard: (state, action: PayloadAction): ImportDashboardState => { - return { - ...state, - dashboard: { - ...action.payload.json, - id: null, - }, - meta: { updatedAt: action.payload.updatedAt, orgName: action.payload.orgName }, - source: DashboardSource.Gcom, - isLoaded: true, + setGcomDashboard: (state: Draft, action: PayloadAction) => { + state.dashboard = { + ...action.payload.json, + id: null, }; + state.meta = { updatedAt: action.payload.updatedAt, orgName: action.payload.orgName }; + state.source = DashboardSource.Gcom; + state.isLoaded = true; }, - setJsonDashboard: (state, action: PayloadAction): ImportDashboardState => { - return { - ...state, - dashboard: { - ...action.payload, - id: null, - }, - source: DashboardSource.Json, - isLoaded: true, + setJsonDashboard: (state: Draft, action: PayloadAction) => { + state.dashboard = { + ...action.payload, + id: null, }; + state.meta = initialImportDashboardState.meta; + state.source = DashboardSource.Json; + state.isLoaded = true; }, - clearDashboard: (state): ImportDashboardState => { - return { - ...state, - dashboard: {}, - isLoaded: false, - }; + clearDashboard: (state: Draft) => { + state.dashboard = {}; + state.isLoaded = false; }, - setInputs: (state, action: PayloadAction): ImportDashboardState => ({ - ...state, - inputs: { + setInputs: (state: Draft, action: PayloadAction) => { + state.inputs = { dataSources: action.payload.filter((p) => p.type === InputType.DataSource), constants: action.payload.filter((p) => p.type === InputType.Constant), - }, - }), + }; + }, }, }); From 6178e54656ea458289b74aea4248fd93a4f47f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 14 Sep 2021 08:29:47 +0200 Subject: [PATCH 03/10] Refactor: adds library panels to export --- .../DashExportModal/DashboardExporter.test.ts | 49 ++++++++++++++++++- .../DashExportModal/DashboardExporter.ts | 32 ++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index a8175b0a1dd3..06282d3c00e1 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -1,12 +1,13 @@ import { find } from 'lodash'; import config from 'app/core/config'; -import { DashboardExporter } from './DashboardExporter'; +import { DashboardExporter, LibraryElement } from './DashboardExporter'; import { DashboardModel } from '../../state/DashboardModel'; import { PanelPluginMeta } from '@grafana/data'; import { variableAdapters } from '../../../variables/adapters'; import { createConstantVariableAdapter } from '../../../variables/constant/adapter'; import { createQueryVariableAdapter } from '../../../variables/query/adapter'; import { createDataSourceVariableAdapter } from '../../../variables/datasource/adapter'; +import { LibraryElementKind } from '../../../library-panels/types'; function getStub(arg: string) { return Promise.resolve(stubs[arg || 'gfdb']); @@ -84,6 +85,15 @@ describe('given dashboard with repeated panels', () => { targets: [{ datasource: 'other' }], }, { id: 9, datasource: '$ds' }, + { + id: 17, + datasource: '$ds', + type: 'graph', + libraryPanel: { + name: 'Library Panel 2', + uid: 'ah8NqyDPs', + }, + }, { id: 2, repeat: 'apps', @@ -110,6 +120,15 @@ describe('given dashboard with repeated panels', () => { type: 'heatmap', }, { id: 15, repeat: null, repeatPanelId: 14 }, + { + id: 16, + datasource: 'gfdb', + type: 'graph', + libraryPanel: { + name: 'Library Panel', + uid: 'jL6MrxCMz', + }, + }, ], }, ], @@ -149,7 +168,7 @@ describe('given dashboard with repeated panels', () => { }); it('should replace datasource refs in collapsed row', () => { - const panel = exported.panels[5].panels[0]; + const panel = exported.panels[6].panels[0]; expect(panel.datasource).toBe('${DS_GFDB}'); }); @@ -236,6 +255,32 @@ describe('given dashboard with repeated panels', () => { const require: any = find(exported.__requires, { name: 'OtherDB_2' }); expect(require.id).toBe('other2'); }); + + it('should add library panels as elements', () => { + const element: LibraryElement = exported.__elements.find((element: LibraryElement) => element.uid === 'ah8NqyDPs'); + expect(element.name).toBe('Library Panel 2'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + id: 17, + datasource: '$ds', + type: 'graph', + fieldConfig: { + defaults: {}, + overrides: [], + }, + }); + }); + + it('should add library panels in collapsed rows as elements', () => { + const element: LibraryElement = exported.__elements.find((element: LibraryElement) => element.uid === 'jL6MrxCMz'); + expect(element.name).toBe('Library Panel'); + expect(element.kind).toBe(LibraryElementKind.Panel); + expect(element.model).toEqual({ + id: 16, + datasource: '${DS_GFDB}', + type: 'graph', + }); + }); }); // Stub responses diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index c828a3dcb460..67e18e0d0283 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -7,6 +7,8 @@ import { PanelPluginMeta } from '@grafana/data'; import { getDataSourceSrv } from '@grafana/runtime'; import { VariableOption, VariableRefresh } from '../../../variables/types'; import { isConstant, isQuery } from '../../../variables/guard'; +import { LibraryElementKind } from '../../../library-panels/types'; +import { isPanelModelLibraryPanel } from '../../../library-panels/guard'; interface Input { name: string; @@ -36,6 +38,13 @@ interface DataSources { }; } +export interface LibraryElement { + name: string; + uid: string; + model: any; + kind: LibraryElementKind; +} + export class DashboardExporter { makeExportable(dashboard: DashboardModel) { // clean up repeated rows and panels, @@ -55,6 +64,7 @@ export class DashboardExporter { const datasources: DataSources = {}; const promises: Array> = []; const variableLookup: { [key: string]: any } = {}; + const libraryPanels: Map = new Map(); for (const variable of saveModel.getVariables()) { variableLookup[variable.name] = variable; @@ -132,6 +142,16 @@ export class DashboardExporter { } }; + const processLibraryPanels = (panel: any) => { + if (isPanelModelLibraryPanel(panel)) { + const { libraryPanel, ...model } = panel; + const { name, uid } = libraryPanel; + if (!libraryPanels.has(uid)) { + libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model }); + } + } + }; + // check up panel data sources for (const panel of saveModel.panels) { processPanel(panel); @@ -174,6 +194,17 @@ export class DashboardExporter { inputs.push(value); }); + // we need to process all panels again after all the promises are resolved + // so all data sources, variables and targets have been templateized when we process library panels + for (const panel of saveModel.panels) { + processLibraryPanels(panel); + if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) { + for (const rowPanel of panel.panels) { + processLibraryPanels(rowPanel); + } + } + } + // templatize constants for (const variable of saveModel.getVariables()) { if (isConstant(variable)) { @@ -199,6 +230,7 @@ export class DashboardExporter { // make inputs and requires a top thing const newObj: { [key: string]: {} } = {}; newObj['__inputs'] = inputs; + newObj['__elements'] = [...libraryPanels.values()]; newObj['__requires'] = sortBy(requires, ['id']); defaults(newObj, saveModel); From 72bd6d47bbda2237c5f5553351b08ae4729d17b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 14 Sep 2021 12:28:59 +0200 Subject: [PATCH 04/10] wip --- .../DashExportModal/DashboardExporter.test.ts | 10 +- .../DashExportModal/DashboardExporter.ts | 4 +- .../LibraryPanelCard/LibraryPanelCard.tsx | 10 +- .../app/features/library-panels/state/api.ts | 14 +- .../components/ImportDashboardForm.tsx | 10 +- .../ImportDashboardLibraryPanelsList.tsx | 77 ++++++++++ .../components/ImportDashboardOverview.tsx | 4 +- .../manage-dashboards/state/actions.ts | 131 ++++++++++++++++++ .../manage-dashboards/state/reducers.test.ts | 47 ++++++- .../manage-dashboards/state/reducers.ts | 27 +++- 10 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 public/app/features/manage-dashboards/components/ImportDashboardLibraryPanelsList.tsx diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts index 06282d3c00e1..4fd6ff27e72d 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.test.ts @@ -1,6 +1,6 @@ import { find } from 'lodash'; import config from 'app/core/config'; -import { DashboardExporter, LibraryElement } from './DashboardExporter'; +import { DashboardExporter, LibraryElementExport } from './DashboardExporter'; import { DashboardModel } from '../../state/DashboardModel'; import { PanelPluginMeta } from '@grafana/data'; import { variableAdapters } from '../../../variables/adapters'; @@ -257,7 +257,9 @@ describe('given dashboard with repeated panels', () => { }); it('should add library panels as elements', () => { - const element: LibraryElement = exported.__elements.find((element: LibraryElement) => element.uid === 'ah8NqyDPs'); + const element: LibraryElementExport = exported.__elements.find( + (element: LibraryElementExport) => element.uid === 'ah8NqyDPs' + ); expect(element.name).toBe('Library Panel 2'); expect(element.kind).toBe(LibraryElementKind.Panel); expect(element.model).toEqual({ @@ -272,7 +274,9 @@ describe('given dashboard with repeated panels', () => { }); it('should add library panels in collapsed rows as elements', () => { - const element: LibraryElement = exported.__elements.find((element: LibraryElement) => element.uid === 'jL6MrxCMz'); + const element: LibraryElementExport = exported.__elements.find( + (element: LibraryElementExport) => element.uid === 'jL6MrxCMz' + ); expect(element.name).toBe('Library Panel'); expect(element.kind).toBe(LibraryElementKind.Panel); expect(element.model).toEqual({ diff --git a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts index 67e18e0d0283..5ec69704fcf3 100644 --- a/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts +++ b/public/app/features/dashboard/components/DashExportModal/DashboardExporter.ts @@ -38,7 +38,7 @@ interface DataSources { }; } -export interface LibraryElement { +export interface LibraryElementExport { name: string; uid: string; model: any; @@ -64,7 +64,7 @@ export class DashboardExporter { const datasources: DataSources = {}; const promises: Array> = []; const variableLookup: { [key: string]: any } = {}; - const libraryPanels: Map = new Map(); + const libraryPanels: Map = new Map(); for (const variable of saveModel.getVariables()) { variableLookup[variable.name] = variable; diff --git a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx index 7295c880dd6f..c3b0f294628b 100644 --- a/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx +++ b/public/app/features/library-panels/components/LibraryPanelCard/LibraryPanelCard.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { ReactElement, useState } from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, Link, useStyles2 } from '@grafana/ui'; @@ -37,7 +37,7 @@ export const LibraryPanelCard: React.FC onClick(libraryPanel)} + onClick={() => onClick?.(libraryPanel)} onDelete={showSecondaryActions ? () => setShowDeletionModal(true) : undefined} > @@ -57,9 +57,13 @@ interface FolderLinkProps { libraryPanel: LibraryElementDTO; } -function FolderLink({ libraryPanel }: FolderLinkProps): JSX.Element { +function FolderLink({ libraryPanel }: FolderLinkProps): ReactElement | null { const styles = useStyles2(getStyles); + if (!libraryPanel.meta.folderUid && !libraryPanel.meta.folderName) { + return null; + } + if (!libraryPanel.meta.folderUid) { return ( diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index cc8cfb3f3aa0..6f70625da1ec 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -7,6 +7,7 @@ import { } from '../types'; import { DashboardSearchHit } from '../../search/types'; import { getBackendSrv } from '../../../core/services/backend_srv'; +import { lastValueFrom } from 'rxjs'; export interface GetLibraryPanelsOptions { searchString?: string; @@ -43,9 +44,16 @@ export async function getLibraryPanels({ return result; } -export async function getLibraryPanel(uid: string): Promise { - const { result } = await getBackendSrv().get(`/api/library-elements/${uid}`); - return result; +export async function getLibraryPanel(uid: string, isHandled = false): Promise { + const response = await lastValueFrom( + getBackendSrv().fetch<{ result: LibraryElementDTO }>({ + method: 'GET', + url: `/api/library-elements/${uid}`, + showSuccessAlert: !isHandled, + showErrorAlert: !isHandled, + }) + ); + return response.data.result; } export async function getLibraryPanelByName(name: string): Promise { diff --git a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx index 7c679382e628..2c23d5b11279 100644 --- a/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx +++ b/public/app/features/manage-dashboards/components/ImportDashboardForm.tsx @@ -1,20 +1,22 @@ import React, { FC, useEffect, useState } from 'react'; import { Button, + Field, FormAPI, + FormFieldErrors, FormsOnSubmit, HorizontalGroup, - FormFieldErrors, Input, - Field, InputControl, Legend, } from '@grafana/ui'; import { DataSourcePicker } from '@grafana/runtime'; +import { selectors } from '@grafana/e2e-selectors'; + import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { DashboardInput, DashboardInputs, DataSourceInput, ImportDashboardDTO } from '../state/reducers'; import { validateTitle, validateUid } from '../utils/validation'; -import { selectors } from '@grafana/e2e-selectors'; +import { ImportDashboardLibraryPanelsList } from './ImportDashboardLibraryPanelsList'; interface Props extends Pick, 'register' | 'errors' | 'control' | 'getValues' | 'watch'> { uidReset: boolean; @@ -41,6 +43,7 @@ export const ImportDashboardForm: FC = ({ }) => { const [isSubmitted, setSubmitted] = useState(false); const watchDataSources = watch('dataSources'); + const watchFolder = watch('folder'); /* This useEffect is needed for overwriting a dashboard. It @@ -136,6 +139,7 @@ export const ImportDashboardForm: FC = ({ ); })} +