LibraryPanels: Load library panels in the frontend rather than the backend (#50560)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ryan McKinley
2022-10-26 15:38:20 -07:00
committed by GitHub
parent 0db946977b
commit 7346280316
21 changed files with 191 additions and 952 deletions

View File

@@ -3225,10 +3225,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"],
[0, 0, 0, "Do not use any type assertions.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"]
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
],
"public/app/features/dashboard/components/DashboardPrompt/DashboardPrompt.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@@ -3241,13 +3240,11 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "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"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Do not use any type assertions.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"]
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
],
"public/app/features/dashboard/components/DashboardRow/DashboardRow.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
@@ -3701,8 +3698,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "25"],
[0, 0, 0, "Do not use any type assertions.", "26"],
[0, 0, 0, "Unexpected any. Specify a different type.", "27"],
[0, 0, 0, "Unexpected any. Specify a different type.", "28"],
[0, 0, 0, "Unexpected any. Specify a different type.", "29"]
[0, 0, 0, "Do not use any type assertions.", "28"],
[0, 0, 0, "Unexpected any. Specify a different type.", "29"],
[0, 0, 0, "Unexpected any. Specify a different type.", "30"],
[0, 0, 0, "Unexpected any. Specify a different type.", "31"]
],
"public/app/features/dashboard/state/TimeModel.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@@ -14,6 +14,9 @@ export interface PanelModel<TOptions = any, TCustomFieldConfig = any> {
/** ID of the panel within the current dashboard */
id: number;
/** The panel type */
type: string;
/** Panel title */
title?: string;

View File

@@ -214,12 +214,6 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
// make sure db version is in sync with json model version
dash.Data.Set("version", dash.Version)
// load library panels JSON for this dashboard
err = hs.LibraryPanelService.LoadLibraryPanelsForDashboard(c.Req.Context(), dash)
if err != nil {
return response.Error(500, "Error while loading library panels", err)
}
if hs.QueryLibraryService != nil && !hs.QueryLibraryService.IsDisabled() {
if err := hs.QueryLibraryService.UpdateDashboardQueries(c.Req.Context(), c.SignedInUser, dash); err != nil {
return response.Error(500, "Error while loading saved queries", err)
@@ -431,12 +425,6 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa
allowUiUpdate = hs.ProvisioningService.GetAllowUIUpdatesFromConfig(provisioningData.Name)
}
// clean up all unnecessary library panels JSON properties so we store a minimum JSON
err = hs.LibraryPanelService.CleanLibraryPanelsForDashboard(dash)
if err != nil {
return response.Error(500, "Error while cleaning library panels", err)
}
dashItem := &dashboards.SaveDashboardDTO{
Dashboard: dash,
Message: cmd.Message,

View File

@@ -1241,14 +1241,6 @@ func (s mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboard
type mockLibraryPanelService struct {
}
func (m *mockLibraryPanelService) LoadLibraryPanelsForDashboard(c context.Context, dash *models.Dashboard) error {
return nil
}
func (m *mockLibraryPanelService) CleanLibraryPanelsForDashboard(dash *models.Dashboard) error {
return nil
}
func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser *user.SignedInUser, dash *models.Dashboard) error {
return nil
}

View File

@@ -61,18 +61,19 @@ type LibraryElementWithMeta struct {
// LibraryElementDTO is the frontend DTO for entities.
type LibraryElementDTO struct {
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
FolderID int64 `json:"folderId"`
FolderUID string `json:"folderUid"`
UID string `json:"uid"`
Name string `json:"name"`
Kind int64 `json:"kind"`
Type string `json:"type"`
Description string `json:"description"`
Model json.RawMessage `json:"model"`
Version int64 `json:"version"`
Meta LibraryElementDTOMeta `json:"meta"`
ID int64 `json:"id"`
OrgID int64 `json:"orgId"`
FolderID int64 `json:"folderId"`
FolderUID string `json:"folderUid"`
UID string `json:"uid"`
Name string `json:"name"`
Kind int64 `json:"kind"`
Type string `json:"type"`
Description string `json:"description"`
Model json.RawMessage `json:"model"`
Version int64 `json:"version"`
Meta LibraryElementDTOMeta `json:"meta"`
SchemaVersion int64 `json:"schemaVersion,omitempty"`
}
// LibraryElementSearchResult is the search result for entities.

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -29,8 +28,6 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
// Service is a service for operating on library panels.
type Service interface {
LoadLibraryPanelsForDashboard(c context.Context, dash *models.Dashboard) error
CleanLibraryPanelsForDashboard(dash *models.Dashboard) error
ConnectLibraryPanelsForDashboard(c context.Context, signedInUser *user.SignedInUser, dash *models.Dashboard) error
ImportLibraryPanelsForDashboard(c context.Context, signedInUser *user.SignedInUser, libraryPanels *simplejson.Json, panels []interface{}, folderID int64) error
}
@@ -49,160 +46,6 @@ type LibraryPanelService struct {
log log.Logger
}
// LoadLibraryPanelsForDashboard loops through all panels in dashboard JSON and replaces any library panel JSON
// with JSON stored for library panel in db.
func (lps *LibraryPanelService) LoadLibraryPanelsForDashboard(c context.Context, dash *models.Dashboard) error {
elements, err := lps.LibraryElementService.GetElementsForDashboard(c, dash.Id)
if err != nil {
return err
}
return loadLibraryPanelsRecursively(elements, dash.Data)
}
func loadLibraryPanelsRecursively(elements map[string]libraryelements.LibraryElementDTO, parent *simplejson.Json) error {
panels := parent.Get("panels").MustArray()
for i, panel := range panels {
panelAsJSON := simplejson.NewFromAny(panel)
libraryPanel := panelAsJSON.Get("libraryPanel")
panelType := panelAsJSON.Get("type").MustString()
if !isLibraryPanelOrRow(libraryPanel, panelType) {
continue
}
// we have a row
if panelType == "row" {
err := loadLibraryPanelsRecursively(elements, panelAsJSON)
if err != nil {
return err
}
continue
}
// we have a library panel
UID := libraryPanel.Get("uid").MustString()
if len(UID) == 0 {
return errLibraryPanelHeaderUIDMissing
}
elementInDB, ok := elements[UID]
if !ok {
elem := parent.Get("panels").GetIndex(i)
gridPos := panelAsJSON.Get("gridPos").MustMap()
if gridPos == nil {
elem.Del("gridPos")
} else {
elem.Set("gridPos", gridPos)
}
elem.Set("id", panelAsJSON.Get("id").MustInt64())
elem.Set("libraryPanel", map[string]interface{}{
"uid": UID,
})
continue
}
if models.LibraryElementKind(elementInDB.Kind) != models.PanelElement {
continue
}
// we have a match between what is stored in db and in dashboard json
libraryPanelModel, err := elementInDB.Model.MarshalJSON()
if err != nil {
return fmt.Errorf("could not marshal library panel JSON: %w", err)
}
libraryPanelModelAsJSON, err := simplejson.NewJson(libraryPanelModel)
if err != nil {
return fmt.Errorf("could not convert library panel to simplejson model: %w", err)
}
// set the library panel json as the new panel json in dashboard json
parent.Get("panels").SetIndex(i, libraryPanelModelAsJSON.Interface())
// set dashboard specific props
elem := parent.Get("panels").GetIndex(i)
gridPos := panelAsJSON.Get("gridPos").MustMap()
if gridPos == nil {
elem.Del("gridPos")
} else {
elem.Set("gridPos", gridPos)
}
elem.Set("id", panelAsJSON.Get("id").MustInt64())
elem.Set("libraryPanel", map[string]interface{}{
"uid": elementInDB.UID,
"name": elementInDB.Name,
"type": elementInDB.Type,
"description": elementInDB.Description,
"version": elementInDB.Version,
"meta": map[string]interface{}{
"folderName": elementInDB.Meta.FolderName,
"folderUid": elementInDB.Meta.FolderUID,
"connectedDashboards": elementInDB.Meta.ConnectedDashboards,
"created": elementInDB.Meta.Created,
"updated": elementInDB.Meta.Updated,
"createdBy": map[string]interface{}{
"id": elementInDB.Meta.CreatedBy.ID,
"name": elementInDB.Meta.CreatedBy.Name,
"avatarUrl": elementInDB.Meta.CreatedBy.AvatarURL,
},
"updatedBy": map[string]interface{}{
"id": elementInDB.Meta.UpdatedBy.ID,
"name": elementInDB.Meta.UpdatedBy.Name,
"avatarUrl": elementInDB.Meta.UpdatedBy.AvatarURL,
},
},
})
}
return nil
}
// CleanLibraryPanelsForDashboard loops through all panels in dashboard JSON and cleans up any library panel JSON so that
// only the necessary JSON properties remain when storing the dashboard JSON.
func (lps *LibraryPanelService) CleanLibraryPanelsForDashboard(dash *models.Dashboard) error {
return cleanLibraryPanelsRecursively(dash.Data)
}
func cleanLibraryPanelsRecursively(parent *simplejson.Json) error {
panels := parent.Get("panels").MustArray()
for i, panel := range panels {
panelAsJSON := simplejson.NewFromAny(panel)
libraryPanel := panelAsJSON.Get("libraryPanel")
panelType := panelAsJSON.Get("type").MustString()
if !isLibraryPanelOrRow(libraryPanel, panelType) {
continue
}
// we have a row
if panelType == "row" {
err := cleanLibraryPanelsRecursively(panelAsJSON)
if err != nil {
return err
}
continue
}
// we have a library panel
UID := libraryPanel.Get("uid").MustString()
if len(UID) == 0 {
return errLibraryPanelHeaderUIDMissing
}
// keep only the necessary JSON properties, the rest of the properties should be safely stored in library_panels table
gridPos := panelAsJSON.Get("gridPos").MustMap()
ID := panelAsJSON.Get("id").MustInt64(int64(i))
parent.Get("panels").SetIndex(i, map[string]interface{}{
"id": ID,
"gridPos": gridPos,
"libraryPanel": map[string]interface{}{
"uid": UID,
},
})
}
return nil
}
// ConnectLibraryPanelsForDashboard loops through all panels in dashboard JSON and connects any library panels to the dashboard.
func (lps *LibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser *user.SignedInUser, dash *models.Dashboard) error {
panels := dash.Data.Get("panels").MustArray()

View File

@@ -35,697 +35,6 @@ import (
const userInDbName = "user_in_db"
const userInDbAvatar = "/avatar/402d08de060496d6b6874495fe20f5ad"
func TestLoadLibraryPanelsForDashboard(t *testing.T) {
scenarioWithLibraryPanel(t, "When an admin tries to load a dashboard with a library panel, it should copy JSON properties from library panel.",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
"name": sc.initialResult.Result.Name,
},
},
},
}
dash := models.Dashboard{
Title: "Testing LoadLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err := sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.Id)
require.NoError(t, err)
err = sc.service.LoadLibraryPanelsForDashboard(sc.ctx, dashInDB)
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"title": "Testing LoadLibraryPanelsForDashboard",
"uid": dashInDB.Uid,
"version": dashInDB.Version,
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
"name": sc.initialResult.Result.Name,
"type": sc.initialResult.Result.Type,
"description": sc.initialResult.Result.Description,
"version": sc.initialResult.Result.Version,
"meta": map[string]interface{}{
"folderName": "ScenarioFolder",
"folderUid": sc.folder.Uid,
"connectedDashboards": int64(1),
"created": sc.initialResult.Result.Meta.Created,
"updated": sc.initialResult.Result.Meta.Updated,
"createdBy": map[string]interface{}{
"id": sc.initialResult.Result.Meta.CreatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
"updatedBy": map[string]interface{}{
"id": sc.initialResult.Result.Meta.UpdatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
},
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to load a dashboard with library panels inside and outside of rows, it should copy JSON properties from library panels",
func(t *testing.T, sc scenarioContext) {
cmd := libraryelements.CreateLibraryElementCommand{
FolderID: sc.initialResult.Result.FolderID,
Name: "Outside row",
Model: []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Text - Library Panel",
"type": "text",
"description": "A description"
}
`),
Kind: int64(models.PanelElement),
}
outsidePanel, err := sc.elementService.CreateElement(sc.ctx, sc.user, cmd)
require.NoError(t, err)
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"collapsed": true,
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 6,
},
"id": int64(2),
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": int64(3),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 7,
},
},
map[string]interface{}{
"id": int64(4),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 13,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
"title": "Inside row",
"type": "text",
},
},
},
map[string]interface{}{
"id": int64(5),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 19,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"uid": outsidePanel.UID,
},
"title": "Outside row",
"type": "text",
},
},
}
dash := models.Dashboard{
Title: "Testing LoadLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{outsidePanel.UID, sc.initialResult.Result.UID}, dashInDB.Id)
require.NoError(t, err)
err = sc.service.LoadLibraryPanelsForDashboard(sc.ctx, dashInDB)
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"title": "Testing LoadLibraryPanelsForDashboard",
"uid": dashInDB.Uid,
"version": dashInDB.Version,
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"collapsed": true,
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 6,
},
"id": int64(2),
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": int64(3),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 7,
},
},
map[string]interface{}{
"id": int64(4),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 13,
},
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
"name": sc.initialResult.Result.Name,
"type": sc.initialResult.Result.Type,
"description": sc.initialResult.Result.Description,
"version": sc.initialResult.Result.Version,
"meta": map[string]interface{}{
"folderName": "ScenarioFolder",
"folderUid": sc.folder.Uid,
"connectedDashboards": int64(1),
"created": sc.initialResult.Result.Meta.Created,
"updated": sc.initialResult.Result.Meta.Updated,
"createdBy": map[string]interface{}{
"id": sc.initialResult.Result.Meta.CreatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
"updatedBy": map[string]interface{}{
"id": sc.initialResult.Result.Meta.UpdatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
},
},
"title": "Text - Library Panel",
"type": "text",
},
},
},
map[string]interface{}{
"id": int64(5),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 19,
},
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"libraryPanel": map[string]interface{}{
"uid": outsidePanel.UID,
"name": outsidePanel.Name,
"type": outsidePanel.Type,
"description": outsidePanel.Description,
"version": outsidePanel.Version,
"meta": map[string]interface{}{
"folderName": "ScenarioFolder",
"folderUid": sc.folder.Uid,
"connectedDashboards": int64(1),
"created": outsidePanel.Meta.Created,
"updated": outsidePanel.Meta.Updated,
"createdBy": map[string]interface{}{
"id": outsidePanel.Meta.CreatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
"updatedBy": map[string]interface{}{
"id": outsidePanel.Meta.UpdatedBy.ID,
"name": userInDbName,
"avatarUrl": userInDbAvatar,
},
},
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to load a dashboard with a library panel without uid, it should fail",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"name": sc.initialResult.Result.Name,
},
},
},
}
dash := models.Dashboard{
Title: "Testing LoadLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err := sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.Id)
require.NoError(t, err)
err = sc.service.LoadLibraryPanelsForDashboard(sc.ctx, dashInDB)
require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error())
})
scenarioWithLibraryPanel(t, "When an admin tries to load a dashboard with a library panel that is not connected, it should set correct JSON and continue",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
},
},
}
dash := models.Dashboard{
Title: "Testing LoadLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err := sc.service.LoadLibraryPanelsForDashboard(sc.ctx, dashInDB)
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"title": "Testing LoadLibraryPanelsForDashboard",
"uid": dashInDB.Uid,
"version": dashInDB.Version,
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
}
func TestCleanLibraryPanelsForDashboard(t *testing.T) {
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should just keep the correct JSON properties in library panel",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
dash := models.Dashboard{
Title: "Testing CleanLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err := sc.service.CleanLibraryPanelsForDashboard(dashInDB)
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"title": "Testing CleanLibraryPanelsForDashboard",
"uid": dashInDB.Uid,
"version": dashInDB.Version,
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with library panels inside and outside of rows, it should just keep the correct JSON properties",
func(t *testing.T, sc scenarioContext) {
cmd := libraryelements.CreateLibraryElementCommand{
FolderID: sc.initialResult.Result.FolderID,
Name: "Outside row",
Model: []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Text - Library Panel",
"type": "text",
"description": "A description"
}
`),
Kind: int64(models.PanelElement),
}
outsidePanel, err := sc.elementService.CreateElement(sc.ctx, sc.user, cmd)
require.NoError(t, err)
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"collapsed": true,
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 6,
},
"id": int64(2),
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": int64(3),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 7,
},
},
map[string]interface{}{
"id": int64(4),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 13,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
"title": "Inside row",
"type": "text",
},
},
},
map[string]interface{}{
"id": int64(5),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 19,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"uid": outsidePanel.UID,
},
"title": "Outside row",
"type": "text",
},
},
}
dash := models.Dashboard{
Title: "Testing CleanLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err = sc.service.CleanLibraryPanelsForDashboard(dashInDB)
require.NoError(t, err)
expectedJSON := map[string]interface{}{
"title": "Testing CleanLibraryPanelsForDashboard",
"uid": dashInDB.Uid,
"version": dashInDB.Version,
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"collapsed": true,
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 6,
},
"id": int64(2),
"type": "row",
"panels": []interface{}{
map[string]interface{}{
"id": int64(3),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 7,
},
},
map[string]interface{}{
"id": int64(4),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 13,
},
"libraryPanel": map[string]interface{}{
"uid": sc.initialResult.Result.UID,
},
},
},
},
map[string]interface{}{
"id": int64(5),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 19,
},
"libraryPanel": map[string]interface{}{
"uid": outsidePanel.UID,
},
},
},
}
expected := simplejson.NewFromAny(expectedJSON)
if diff := cmp.Diff(expected.Interface(), dash.Data.Interface(), getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel without uid, it should fail",
func(t *testing.T, sc scenarioContext) {
dashJSON := map[string]interface{}{
"panels": []interface{}{
map[string]interface{}{
"id": int64(1),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 0,
"y": 0,
},
},
map[string]interface{}{
"id": int64(2),
"gridPos": map[string]interface{}{
"h": 6,
"w": 6,
"x": 6,
"y": 0,
},
"datasource": "${DS_GDEV-TESTDATA}",
"libraryPanel": map[string]interface{}{
"name": sc.initialResult.Result.Name,
},
"title": "Text - Library Panel",
"type": "text",
},
},
}
dash := models.Dashboard{
Title: "Testing CleanLibraryPanelsForDashboard",
Data: simplejson.NewFromAny(dashJSON),
}
dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.Id)
err := sc.service.CleanLibraryPanelsForDashboard(dashInDB)
require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error())
})
}
func TestConnectLibraryPanelsForDashboard(t *testing.T) {
scenarioWithLibraryPanel(t, "When an admin tries to store a dashboard with a library panel, it should connect the two",
func(t *testing.T, sc scenarioContext) {

View File

@@ -19,7 +19,6 @@ import {
LibraryPanelsSearchVariant,
} from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { LibraryElementDTO } from '../../../library-panels/types';
import { toPanelModelLibraryPanel } from '../../../library-panels/utils';
import { DashboardModel, PanelModel } from '../../state';
export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } };
@@ -116,7 +115,7 @@ export const AddPanelWidgetUnconnected = ({ panel, dashboard }: Props) => {
const newPanel: PanelModel = {
...panelInfo.model,
gridPos,
libraryPanel: toPanelModelLibraryPanel(panelInfo),
libraryPanel: panelInfo,
};
dashboard.addPanel(newPanel);

View File

@@ -40,6 +40,20 @@ jest.mock('@grafana/runtime', () => ({
},
}));
jest.mock('app/features/library-panels/state/api', () => ({
getLibraryPanel: jest.fn().mockReturnValue(
Promise.resolve({
model: {
type: 'graph',
datasource: {
type: 'testdb',
uid: '${DS_GFDB}',
},
},
})
),
}));
variableAdapters.register(createQueryVariableAdapter());
variableAdapters.register(createConstantVariableAdapter());
variableAdapters.register(createDataSourceVariableAdapter());
@@ -146,8 +160,6 @@ describe('given dashboard with repeated panels', () => {
{ id: 9, datasource: { uid: '$ds', type: 'other2' } },
{
id: 17,
datasource: { uid: '$ds', type: 'other2' },
type: 'graph',
libraryPanel: {
name: 'Library Panel 2',
uid: 'ah8NqyDPs',
@@ -181,8 +193,8 @@ describe('given dashboard with repeated panels', () => {
{ id: 15, repeat: null, repeatPanelId: 14 },
{
id: 16,
datasource: { uid: 'gfdb', type: 'testdb' },
type: 'graph',
// datasource: { uid: 'gfdb', type: 'testdb' },
// type: 'graph',
libraryPanel: {
name: 'Library Panel',
uid: 'jL6MrxCMz',
@@ -218,6 +230,18 @@ describe('given dashboard with repeated panels', () => {
} as PanelPluginMeta;
dash = new DashboardModel(dash, {}, () => dash.templating.list);
// init library panels
dash.getPanelById(17).initLibraryPanel({
uid: 'ah8NqyDPs',
name: 'Library Panel 2',
model: {
datasource: { type: 'other2', uid: '$ds' },
targets: [{ refId: 'A', datasource: { type: 'other2', uid: '$ds' } }],
type: 'graph',
},
});
const exporter = new DashboardExporter();
exporter.makeExportable(dash).then((clean) => {
exported = clean;

View File

@@ -4,6 +4,7 @@ import { DataSourceRef, PanelPluginMeta } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import config from 'app/core/config';
import { PanelModel } from 'app/features/dashboard/state';
import { getLibraryPanel } from 'app/features/library-panels/state/api';
import { isPanelModelLibraryPanel } from '../../../library-panels/guard';
import { LibraryElementKind } from '../../../library-panels/types';
@@ -167,10 +168,15 @@ export class DashboardExporter {
}
};
const processLibraryPanels = (panel: any) => {
const processLibraryPanels = async (panel: PanelModel) => {
if (isPanelModelLibraryPanel(panel)) {
const { libraryPanel, ...model } = panel;
const { name, uid } = libraryPanel;
const { name, uid } = panel.libraryPanel;
let model = panel.libraryPanel.model;
if (!model) {
const libPanel = await getLibraryPanel(uid, true);
model = libPanel.model;
}
const { gridPos, id, ...rest } = model;
if (!libraryPanels.has(uid)) {
libraryPanels.set(uid, { name, uid, kind: LibraryElementKind.Panel, model: rest });
@@ -221,11 +227,11 @@ export class DashboardExporter {
// 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);
for (const panel of dashboard.panels) {
await processLibraryPanels(panel);
if (panel.collapsed !== undefined && panel.collapsed === true && panel.panels) {
for (const rowPanel of panel.panels) {
processLibraryPanels(rowPanel);
await processLibraryPanels(rowPanel);
}
}
}

View File

@@ -165,7 +165,7 @@ export function ignoreChanges(current: DashboardModel, original: object | null)
/**
* Remove stuff that should not count in diff
*/
function cleanDashboardFromIgnoredChanges(dashData: any) {
function cleanDashboardFromIgnoredChanges(dashData: unknown) {
// need to new up the domain model class to get access to expand / collapse row logic
const model = new DashboardModel(dashData);
@@ -193,7 +193,7 @@ function cleanDashboardFromIgnoredChanges(dashData: any) {
return dash;
}
export function hasChanges(current: DashboardModel, original: any) {
export function hasChanges(current: DashboardModel, original: unknown) {
if (current.hasUnsavedChanges()) {
return true;
}

View File

@@ -69,12 +69,15 @@ export class DashboardPanelUnconnected extends PureComponent<Props> {
}
};
render() {
const { dashboard, panel, isViewing, isEditing, width, height, lazy, plugin } = this.props;
renderPanel = (isInView: boolean) => {
const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props;
const renderPanelChrome = (isInView: boolean) =>
plugin &&
(plugin.angularPanelCtrl ? (
if (!plugin) {
return null;
}
if (plugin && plugin.angularPanelCtrl) {
return (
<PanelChromeAngular
plugin={plugin}
panel={panel}
@@ -85,26 +88,33 @@ export class DashboardPanelUnconnected extends PureComponent<Props> {
width={width}
height={height}
/>
) : (
<PanelStateWrapper
plugin={plugin}
panel={panel}
dashboard={dashboard}
isViewing={isViewing}
isEditing={isEditing}
isInView={isInView}
width={width}
height={height}
onInstanceStateChange={this.onInstanceStateChange}
/>
));
);
}
return (
<PanelStateWrapper
plugin={plugin}
panel={panel}
dashboard={dashboard}
isViewing={isViewing}
isEditing={isEditing}
isInView={isInView}
width={width}
height={height}
onInstanceStateChange={this.onInstanceStateChange}
/>
);
};
render() {
const { width, height, lazy } = this.props;
return lazy ? (
<LazyLoader width={width} height={height} onChange={this.onVisibilityChange} onLoad={this.onPanelLoad}>
{({ isInView }) => renderPanelChrome(isInView)}
{this.renderPanel}
</LazyLoader>
) : (
renderPanelChrome(true)
this.renderPanel(true)
);
}
}

View File

@@ -278,6 +278,20 @@ export class DashboardModel implements TimeModel {
this.isSnapshotTruthy() || !(panel.type === 'add-panel' || panel.repeatPanelId || panel.repeatedByRow)
)
.map((panel) => {
// Clean libarary panels on save
if (panel.libraryPanel) {
const { id, title, libraryPanel, gridPos } = panel;
return {
id,
title,
gridPos,
libraryPanel: {
uid: libraryPanel.uid,
name: libraryPanel.name,
},
};
}
// If we save while editing we should include the panel in edit mode instead of the
// unmodified source panel
if (this.panelInEdit && this.panelInEdit.id === panel.id) {

View File

@@ -29,7 +29,7 @@ import {
RenderEvent,
} from 'app/types/events';
import { PanelModelLibraryPanel } from '../../library-panels/types';
import { LibraryElementDTO, LibraryPanelRef } from '../../library-panels/types';
import { PanelQueryRunner } from '../../query/state/PanelQueryRunner';
import { getVariablesUrlParams } from '../../variables/getAllVariableValuesForUrl';
import { getTimeSrv } from '../services/TimeSrv';
@@ -171,7 +171,7 @@ export class PanelModel implements DataConfigSource, IPanelModel {
links?: DataLink[];
declare transparent: boolean;
libraryPanel?: { uid: undefined; name: string; version?: number } | PanelModelLibraryPanel;
libraryPanel?: LibraryPanelRef | LibraryElementDTO;
autoMigrateFrom?: string;
@@ -659,6 +659,19 @@ export class PanelModel implements DataConfigSource, IPanelModel {
getDisplayTitle(): string {
return this.replaceVariables(this.title, undefined, 'text');
}
initLibraryPanel(libPanel: LibraryElementDTO) {
for (const [key, val] of Object.entries(libPanel.model)) {
switch (key) {
case 'id':
case 'gridPos':
case 'libraryPanel': // recursive?
continue;
}
(this as any)[key] = val; // :grimmice:
}
this.libraryPanel = libPanel;
}
}
function getPluginVersion(plugin: PanelPlugin): string {

View File

@@ -27,7 +27,7 @@ export const AddLibraryPanelContents = ({ panel, initialFolderId, onDismiss }: A
const { saveLibraryPanel } = usePanelSave();
const onCreate = useCallback(() => {
panel.libraryPanel = { uid: undefined, name: panelName };
panel.libraryPanel = { uid: '', name: panelName };
saveLibraryPanel(panel, folderId!).then((res) => {
if (!(res instanceof Error)) {
onDismiss();

View File

@@ -4,7 +4,6 @@ import React from 'react';
import { DateTimeInput, GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui';
import { isPanelModelLibraryPanel } from '../../guard';
import { PanelModelWithLibraryPanel } from '../../types';
interface Props {
@@ -15,28 +14,29 @@ interface Props {
export const LibraryPanelInformation = ({ panel, formatDate }: Props) => {
const styles = useStyles(getStyles);
if (!isPanelModelLibraryPanel(panel)) {
const meta = panel.libraryPanel?.meta;
if (!meta) {
return null;
}
return (
<div className={styles.info}>
<div className={styles.libraryPanelInfo}>
{`Used on ${panel.libraryPanel.meta.connectedDashboards} `}
{panel.libraryPanel.meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}
{`Used on ${meta.connectedDashboards} `}
{meta.connectedDashboards === 1 ? 'dashboard' : 'dashboards'}
</div>
<div className={styles.libraryPanelInfo}>
Last edited on {formatDate?.(panel.libraryPanel.meta.updated, 'L') ?? panel.libraryPanel.meta.updated} by
{panel.libraryPanel.meta.updatedBy.avatarUrl && (
Last edited on {formatDate?.(meta.updated, 'L') ?? meta.updated} by
{meta.updatedBy.avatarUrl && (
<img
width="22"
height="22"
className={styles.userAvatar}
src={panel.libraryPanel.meta.updatedBy.avatarUrl}
alt={`Avatar for ${panel.libraryPanel.meta.updatedBy.name}`}
src={meta.updatedBy.avatarUrl}
alt={`Avatar for ${meta.updatedBy.name}`}
/>
)}
{panel.libraryPanel.meta.updatedBy.name}
{meta.updatedBy.name}
</div>
</div>
);

View File

@@ -1,5 +1,7 @@
import { lastValueFrom } from 'rxjs';
import { DashboardModel } from 'app/features/dashboard/state';
import { getBackendSrv } from '../../../core/services/backend_srv';
import { DashboardSearchItem } from '../../search/types';
import {
@@ -54,7 +56,18 @@ export async function getLibraryPanel(uid: string, isHandled = false): Promise<L
showErrorAlert: !isHandled,
})
);
return response.data.result;
// kinda heavy weight migration process!!!
const { result } = response.data;
const dash = new DashboardModel({
schemaVersion: 35, // should be saved in the library panel
panels: [result.model],
});
const model = dash.panels[0].getSaveModel(); // migrated panel
dash.destroy(); // kill event listeners
return {
...result,
model,
};
}
export async function getLibraryPanelByName(name: string): Promise<LibraryElementDTO[]> {
@@ -76,9 +89,9 @@ export async function addLibraryPanel(
}
export async function updateLibraryPanel(panelSaveModel: PanelModelWithLibraryPanel): Promise<LibraryElementDTO> {
const { uid, name, version } = panelSaveModel.libraryPanel;
const { libraryPanel, ...model } = panelSaveModel;
const { uid, name, version } = libraryPanel;
const kind = LibraryElementKind.Panel;
const model = panelSaveModel;
const { result } = await getBackendSrv().patch(`/api/library-elements/${uid}`, {
name,
model,

View File

@@ -59,10 +59,13 @@ export interface LibraryElementDTOMetaUser {
avatarUrl: string;
}
export type PanelModelLibraryPanel = Pick<LibraryElementDTO, 'uid' | 'name' | 'meta' | 'version'>;
export interface LibraryPanelRef {
name: string;
uid: string;
}
export interface PanelModelWithLibraryPanel extends PanelModel {
libraryPanel: PanelModelLibraryPanel;
libraryPanel: LibraryElementDTO;
}
export type DispatchResult = (dispatch: Dispatch<AnyAction>) => void;

View File

@@ -3,7 +3,7 @@ import { AppNotification } from '../../types';
import { PanelModel } from '../dashboard/state';
import { addLibraryPanel, updateLibraryPanel } from './state/api';
import { LibraryElementDTO, PanelModelLibraryPanel } from './types';
import { LibraryElementDTO } from './types';
export function createPanelLibraryErrorNotification(message: string): AppNotification {
return createErrorNotification(message);
@@ -13,11 +13,6 @@ export function createPanelLibrarySuccessNotification(message: string): AppNotif
return createSuccessNotification(message);
}
export function toPanelModelLibraryPanel(libraryPanelDto: LibraryElementDTO): PanelModelLibraryPanel {
const { uid, name, meta, version } = libraryPanelDto;
return { uid, name, meta, version };
}
export async function saveAndRefreshLibraryPanel(panel: PanelModel, folderId: number): Promise<LibraryElementDTO> {
const panelSaveModel = toPanelSaveModel(panel);
const savedPanel = await saveOrUpdateLibraryPanel(panelSaveModel, folderId);
@@ -42,7 +37,7 @@ function updatePanelModelWithUpdate(panel: PanelModel, updated: LibraryElementDT
panel.restoreModel({
...updated.model,
configRev: 0, // reset config rev, since changes have been saved
libraryPanel: toPanelModelLibraryPanel(updated),
libraryPanel: updated,
title: panel.title,
});
panel.hasSavedPanelEditChange = true;
@@ -54,7 +49,7 @@ function saveOrUpdateLibraryPanel(panel: any, folderId: number): Promise<Library
return Promise.reject();
}
if (panel.libraryPanel && panel.libraryPanel.uid === undefined) {
if (panel.libraryPanel && panel.libraryPanel.uid === '') {
return addLibraryPanel(panel, folderId!);
}

View File

@@ -1,8 +1,8 @@
import { DataTransformerConfig, FieldConfigSource } from '@grafana/data';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { getPanelOptionsWithDefaults } from 'app/features/dashboard/state/getPanelOptionsWithDefaults';
import { getLibraryPanel } from 'app/features/library-panels/state/api';
import { LibraryElementDTO } from 'app/features/library-panels/types';
import { toPanelModelLibraryPanel } from 'app/features/library-panels/utils';
import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError';
import { loadPanelPlugin } from 'app/features/plugins/admin/state/actions';
import { ThunkResult } from 'app/types';
@@ -18,6 +18,12 @@ import {
export function initPanelState(panel: PanelModel): ThunkResult<void> {
return async (dispatch, getStore) => {
if (panel.libraryPanel?.uid && !('model' in panel.libraryPanel)) {
// this will call init with a loaded libary panel if it loads succesfully
dispatch(loadLibraryPanelAndUpdate(panel));
return;
}
let pluginToLoad = panel.type;
let plugin = getStore().plugins.panels[pluginToLoad];
@@ -118,7 +124,7 @@ export function changeToLibraryPanel(panel: PanelModel, libraryPanel: LibraryEle
...libraryPanel.model,
gridPos: panel.gridPos,
id: panel.id,
libraryPanel: toPanelModelLibraryPanel(libraryPanel),
libraryPanel: libraryPanel,
});
// a new library panel usually means new queries, clear any current result
@@ -155,3 +161,22 @@ export function changeToLibraryPanel(panel: PanelModel, libraryPanel: LibraryEle
panel.events.publish(PanelOptionsChangedEvent);
};
}
export function loadLibraryPanelAndUpdate(panel: PanelModel): ThunkResult<void> {
return async (dispatch) => {
const uid = panel.libraryPanel!.uid!;
try {
const libPanel = await getLibraryPanel(uid, true);
panel.initLibraryPanel(libPanel);
dispatch(initPanelState(panel));
} catch (ex) {
console.log('ERROR: ', ex);
dispatch(
panelModelAndPluginReady({
key: panel.key,
plugin: getPanelPluginNotFound('Unable to load library panel: ' + uid, false),
})
);
}
};
}

View File

@@ -40,6 +40,7 @@ describe('textPanelMigrationHandler', () => {
it('then should just pass options through', () => {
const panel: PanelModel<PanelOptions> = {
id: 1,
type: 'text',
fieldConfig: {} as unknown as FieldConfigSource,
options: {
content: `# Title
@@ -65,6 +66,7 @@ describe('textPanelMigrationHandler', () => {
const mode = 'text' as unknown as TextMode;
const panel: PanelModel<PanelOptions> = {
id: 1,
type: 'text',
fieldConfig: {} as unknown as FieldConfigSource,
options: {
content: `# Title