LibraryPanels: Adds panel type filter and sorting (#33425)

* Wip: inital commit

* Chore: updating api

* Refactor: adds description search and sorting

* Refactor: adds panel filtering

* Refactor: limits the height of select

* Tests: updates snapshot

* Refactor: small UI improvements
This commit is contained in:
Hugo Häggmark 2021-04-28 09:18:13 +02:00 committed by GitHub
parent eaf7decf5a
commit 20ee0e9601
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 756 additions and 119 deletions

View File

@ -81,7 +81,15 @@ func (lps *LibraryPanelService) getHandler(c *models.ReqContext) response.Respon
// getAllHandler handles GET /api/library-panels/.
func (lps *LibraryPanelService) getAllHandler(c *models.ReqContext) response.Response {
libraryPanels, err := lps.getAllLibraryPanels(c, c.QueryInt("perPage"), c.QueryInt("page"), c.Query("name"), c.Query("excludeUid"))
query := searchLibraryPanelsQuery{
perPage: c.QueryInt("perPage"),
page: c.QueryInt("page"),
searchString: c.Query("searchString"),
sortDirection: c.Query("sortDirection"),
panelFilter: c.Query("panelFilter"),
excludeUID: c.Query("excludeUid"),
}
libraryPanels, err := lps.getAllLibraryPanels(c, query)
if err != nil {
return toLibraryPanelError(err, "Failed to get library panels")
}

View File

@ -1,11 +1,14 @@
package librarypanels
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/sqlstore"
@ -15,7 +18,7 @@ import (
var (
sqlStatmentLibrayPanelDTOWithMeta = `
SELECT DISTINCT
lp.id, lp.org_id, lp.folder_id, lp.uid, lp.name, lp.type, lp.description, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by, lp.version
lp.name, lp.id, lp.org_id, lp.folder_id, lp.uid, lp.type, lp.description, lp.model, lp.created, lp.created_by, lp.updated, lp.updated_by, lp.version
, 0 AS can_edit
, u1.login AS created_by_name
, u1.email AS created_by_email
@ -384,42 +387,71 @@ func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string
}
// getAllLibraryPanels gets all library panels.
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, perPage int, page int, name string, excludeUID string) (LibraryPanelSearchResult, error) {
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query searchLibraryPanelsQuery) (LibraryPanelSearchResult, error) {
libraryPanels := make([]LibraryPanelWithMeta, 0)
result := LibraryPanelSearchResult{}
if perPage <= 0 {
perPage = 100
if query.perPage <= 0 {
query.perPage = 100
}
if page <= 0 {
page = 1
if query.page <= 0 {
query.page = 1
}
var panelFilter []string
if len(strings.TrimSpace(query.panelFilter)) > 0 {
panelFilter = strings.Split(query.panelFilter, ",")
}
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
builder := sqlstore.SQLBuilder{}
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
if len(strings.TrimSpace(name)) > 0 {
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+name+"%")
if len(strings.TrimSpace(query.searchString)) > 0 {
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
}
if len(strings.TrimSpace(excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", excludeUID)
if len(strings.TrimSpace(query.excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
builder.Write(sql.String(), params...)
}
builder.Write(" UNION ")
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0")
builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
if len(strings.TrimSpace(name)) > 0 {
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+name+"%")
if len(strings.TrimSpace(query.searchString)) > 0 {
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
}
if len(strings.TrimSpace(excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", excludeUID)
if len(strings.TrimSpace(query.excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
builder.Write(sql.String(), params...)
}
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
}
if perPage != 0 {
offset := perPage * (page - 1)
builder.Write(lps.SQLStore.Dialect.LimitOffset(int64(perPage), int64(offset)))
if query.sortDirection == search.SortAlphaDesc.Name {
builder.Write(" ORDER BY 1 DESC")
} else {
builder.Write(" ORDER BY 1 ASC")
}
if query.perPage != 0 {
offset := query.perPage * (query.page - 1)
builder.Write(lps.SQLStore.Dialect.LimitOffset(int64(query.perPage), int64(offset)))
}
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil {
return err
@ -460,11 +492,21 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, perPag
countBuilder := sqlstore.SQLBuilder{}
countBuilder.Write("SELECT * FROM library_panel")
countBuilder.Write(` WHERE org_id=?`, c.SignedInUser.OrgId)
if len(strings.TrimSpace(name)) > 0 {
countBuilder.Write(" AND name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+name+"%")
if len(strings.TrimSpace(query.searchString)) > 0 {
countBuilder.Write(" AND name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
countBuilder.Write(" OR description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
}
if len(strings.TrimSpace(excludeUID)) > 0 {
countBuilder.Write(" AND uid <> ?", excludeUID)
if len(strings.TrimSpace(query.excludeUID)) > 0 {
countBuilder.Write(" AND uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
countBuilder.Write(sql.String(), params...)
}
if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil {
return err
@ -473,8 +515,8 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, perPag
result = LibraryPanelSearchResult{
TotalCount: int64(len(panels)),
LibraryPanels: retDTOs,
Page: page,
PerPage: perPage,
Page: query.page,
PerPage: query.perPage,
}
return nil

View File

@ -4,6 +4,8 @@ import (
"encoding/json"
"testing"
"github.com/grafana/grafana/pkg/services/search"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/require"
@ -124,6 +126,252 @@ func TestGetAllLibraryPanels(t *testing.T) {
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and sort desc is set, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("sortDirection", search.SortAlphaDesc.Name)
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 2,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel2",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel2",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[1].UID,
Name: "Text - Library Panel",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and panelFilter is set to existing types, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Gauge - Library Panel",
"type": "gauge",
"description": "Gauge description"
}
`))
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
command = getCreateCommandWithModel(sc.folder.Id, "BarGauge - Library Panel", []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "BarGauge - Library Panel",
"type": "bargauge",
"description": "BarGauge description"
}
`))
resp = sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("panelFilter", "bargauge,gauge")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 2,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 3,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "BarGauge - Library Panel",
Type: "bargauge",
Description: "BarGauge description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "BarGauge description",
"id": float64(1),
"title": "BarGauge - Library Panel",
"type": "bargauge",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[1].UID,
Name: "Gauge - Library Panel",
Type: "gauge",
Description: "Gauge description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"id": float64(1),
"title": "Gauge - Library Panel",
"type": "gauge",
"description": "Gauge description",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and panelFilter is set to non existing type, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommandWithModel(sc.folder.Id, "Gauge - Library Panel", []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Gauge - Library Panel",
"type": "gauge",
"description": "Gauge description"
}
`))
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("panelFilter", "unknown1,unknown2")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 0,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and excludeUID is set, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
@ -311,7 +559,182 @@ func TestGetAllLibraryPanels(t *testing.T) {
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 1 and name is panel2, it should succeed and the result should be correct",
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in the description, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommandWithModel(sc.folder.Id, "Text - Library Panel2", []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Text - Library Panel",
"type": "text",
"description": "Some other d e s c r i p t i o n"
}
`))
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("perPage", "1")
sc.reqContext.Req.Form.Add("page", "1")
sc.reqContext.Req.Form.Add("searchString", "description")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 1,
Page: 1,
PerPage: 1,
LibraryPanels: []libraryPanel{
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and searchString exists in both name and description, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommandWithModel(sc.folder.Id, "Some Other", []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
"title": "Text - Library Panel",
"type": "text",
"description": "A Library Panel"
}
`))
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "Library Panel")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 2,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Some Other",
Type: "text",
Description: "A Library Panel",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A Library Panel",
"id": float64(1),
"title": "Some Other",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[1].UID,
Name: "Text - Library Panel",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 1 and searchString is panel2, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
@ -321,7 +744,7 @@ func TestGetAllLibraryPanels(t *testing.T) {
require.NoError(t, err)
sc.reqContext.Req.Form.Add("perPage", "1")
sc.reqContext.Req.Form.Add("page", "1")
sc.reqContext.Req.Form.Add("name", "panel2")
sc.reqContext.Req.Form.Add("searchString", "panel2")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
@ -375,7 +798,7 @@ func TestGetAllLibraryPanels(t *testing.T) {
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and name is panel, it should succeed and the result should be correct",
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and searchString is panel, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
@ -385,7 +808,7 @@ func TestGetAllLibraryPanels(t *testing.T) {
require.NoError(t, err)
sc.reqContext.Req.Form.Add("perPage", "1")
sc.reqContext.Req.Form.Add("page", "3")
sc.reqContext.Req.Form.Add("name", "panel")
sc.reqContext.Req.Form.Add("searchString", "panel")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
@ -405,7 +828,7 @@ func TestGetAllLibraryPanels(t *testing.T) {
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and name does not exist, it should succeed and the result should be correct",
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1 and page is 3 and searchString does not exist, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
@ -415,7 +838,7 @@ func TestGetAllLibraryPanels(t *testing.T) {
require.NoError(t, err)
sc.reqContext.Req.Form.Add("perPage", "1")
sc.reqContext.Req.Form.Add("page", "3")
sc.reqContext.Req.Form.Add("name", "monkey")
sc.reqContext.Req.Form.Add("searchString", "monkey")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())

View File

@ -720,10 +720,7 @@ func overrideLibraryPanelServiceInRegistry(cfg *setting.Cfg) LibraryPanelService
}
func getCreateCommand(folderID int64, name string) createLibraryPanelCommand {
command := createLibraryPanelCommand{
FolderID: folderID,
Name: name,
Model: []byte(`
command := getCreateCommandWithModel(folderID, name, []byte(`
{
"datasource": "${DS_GDEV-TESTDATA}",
"id": 1,
@ -731,7 +728,16 @@ func getCreateCommand(folderID int64, name string) createLibraryPanelCommand {
"type": "text",
"description": "A description"
}
`),
`))
return command
}
func getCreateCommandWithModel(folderID int64, name string, model []byte) createLibraryPanelCommand {
command := createLibraryPanelCommand{
FolderID: folderID,
Name: name,
Model: model,
}
return command

View File

@ -137,3 +137,13 @@ type patchLibraryPanelCommand struct {
Model json.RawMessage `json:"model"`
Version int64 `json:"version" binding:"Required"`
}
// searchLibraryPanelsQuery is the query used for searching for LibraryPanels
type searchLibraryPanelsQuery struct {
perPage int
page int
searchString string
sortDirection string
panelFilter string
excludeUID string
}

View File

@ -60,8 +60,8 @@ type SearchService struct {
func (s *SearchService) Init() error {
s.Bus.AddHandler(s.searchHandler)
s.sortOptions = map[string]SortOption{
sortAlphaAsc.Name: sortAlphaAsc,
sortAlphaDesc.Name: sortAlphaDesc,
SortAlphaAsc.Name: SortAlphaAsc,
SortAlphaDesc.Name: SortAlphaDesc,
}
return nil

View File

@ -7,7 +7,7 @@ import (
)
var (
sortAlphaAsc = SortOption{
SortAlphaAsc = SortOption{
Name: "alpha-asc",
DisplayName: "Alphabetically (AZ)",
Description: "Sort results in an alphabetically ascending order",
@ -16,7 +16,7 @@ var (
searchstore.TitleSorter{},
},
}
sortAlphaDesc = SortOption{
SortAlphaDesc = SortOption{
Name: "alpha-desc",
DisplayName: "Alphabetically (ZA)",
Description: "Sort results in an alphabetically descending order",

View File

@ -0,0 +1,87 @@
import React, { useCallback, useMemo, useState } from 'react';
import { GrafanaThemeV2, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { getAllPanelPluginMeta } from '../../../features/dashboard/components/VizTypePicker/VizTypePicker';
import { Icon, resetSelectStyles, Select, useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
export interface Props {
onChange: (plugins: PanelPluginMeta[]) => void;
}
export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element => {
const plugins = useMemo<PanelPluginMeta[]>(() => {
return getAllPanelPluginMeta();
}, []);
const options = useMemo(
() =>
plugins
.map((p) => ({ label: p.name, imgUrl: p.info.logos.small, value: p }))
.sort((a, b) => a.label?.localeCompare(b.label)),
[plugins]
);
const [value, setValue] = useState<Array<SelectableValue<PanelPluginMeta>>>([]);
const onChange = useCallback(
(plugins: Array<SelectableValue<PanelPluginMeta>>) => {
const changedPlugins = [];
for (const plugin of plugins) {
if (plugin.value) {
changedPlugins.push(plugin.value);
}
}
propsOnChange(changedPlugins);
setValue(plugins);
},
[propsOnChange]
);
const styles = useStyles2(getStyles);
const selectOptions = {
defaultOptions: true,
getOptionLabel: (i: any) => i.label,
getOptionValue: (i: any) => i.value,
isMulti: true,
noOptionsMessage: 'No Panel types found',
placeholder: 'Filter by Panel type',
styles: resetSelectStyles(),
maxMenuHeight: 150,
options,
value,
onChange,
};
return (
<div className={styles.container}>
{value.length > 0 && (
<span className={styles.clear} onClick={() => onChange([])}>
Clear types
</span>
)}
<Select {...selectOptions} prefix={<Icon name="table" />} aria-label="Panel Type filter" />
</div>
);
};
function getStyles(theme: GrafanaThemeV2) {
return {
container: css`
label: container;
position: relative;
min-width: 180px;
flex-grow: 1;
`,
clear: css`
label: clear;
text-decoration: underline;
font-size: ${theme.spacing(1.5)};
position: absolute;
top: -${theme.spacing(2.75)};
right: 0;
cursor: pointer;
color: ${theme.colors.text.link};
&:hover {
color: ${theme.colors.text.maxContrast};
}
`,
};
}

View File

@ -1,5 +1,4 @@
import React, { useMemo, useState } from 'react';
import { connect, MapDispatchToProps } from 'react-redux';
import { css, cx, keyframes } from '@emotion/css';
import { chain, cloneDeep, defaults, find, sortBy } from 'lodash';
import tinycolor from 'tinycolor2';
@ -10,13 +9,13 @@ import { GrafanaTheme } from '@grafana/data';
import config from 'app/core/config';
import store from 'app/core/store';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { addPanel } from 'app/features/dashboard/state/reducers';
import { DashboardModel, PanelModel } from '../../state';
import { LibraryPanelsView } from '../../../library-panels/components/LibraryPanelsView/LibraryPanelsView';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { LibraryPanelDTO } from '../../../library-panels/types';
import { toPanelModelLibraryPanel } from '../../../library-panels/utils';
import { LibraryPanelsSearch } from '../../../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { connect, MapDispatchToProps } from 'react-redux';
export type PanelPluginInfo = { id: any; defaults: { gridPos: { w: any; h: any }; title: any } };
@ -56,7 +55,6 @@ const getCopiedPanelPlugins = () => {
export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard }) => {
const [addPanelView, setAddPanelView] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const onCancelAddPanel = (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
@ -140,17 +138,7 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
{addPanelView ? 'Add panel from panel library' : 'Add panel'}
</AddPanelWidgetHandle>
{addPanelView ? (
<>
<div className={styles.panelSearchInput}>
<FilterInput width={0} value={searchQuery} onChange={setSearchQuery} placeholder={'Search global panels'} />
</div>
<LibraryPanelsView
className={styles.libraryPanelsWrapper}
onClickCard={(panel) => onAddLibraryPanel(panel)}
showSecondaryActions={false}
searchString={searchQuery}
/>
</>
<LibraryPanelsSearch onClick={onAddLibraryPanel} perPage={3} />
) : (
<div className={styles.actionsWrapper}>
<div className={styles.actionsRow}>
@ -226,10 +214,6 @@ const getStyles = (theme: GrafanaTheme) => {
`;
return {
panelSearchInput: css`
padding-left: ${theme.spacing.sm};
padding-right: ${theme.spacing.sm};
`,
wrapper: css`
overflow: hidden;
outline: 2px dotted transparent;
@ -272,9 +256,6 @@ const getStyles = (theme: GrafanaTheme) => {
padding: 0 ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm};
height: 100%;
`,
libraryPanelsWrapper: css`
padding: ${theme.spacing.sm};
`,
headerRow: css`
display: flex;
align-items: center;

View File

@ -12,9 +12,7 @@ exports[`Render should render component 1`] = `
"actionsWrapper": "css-gxxmom",
"backButton": "css-1cdxa9p",
"headerRow": "css-3sdqvi",
"libraryPanelsWrapper": "css-18m13of",
"noMargin": "css-u023fv",
"panelSearchInput": "css-2ug8g3",
"wrapper": "css-e4b3m6",
}
}

View File

@ -1,15 +1,11 @@
import React, { FC, useState } from 'react';
import React, { FC } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
import { StoreState } from '../../types';
import { getNavModel } from '../../core/selectors/navModel';
import Page from '../../core/components/Page/Page';
import { LibraryPanelsView } from './components/LibraryPanelsView/LibraryPanelsView';
import { useAsync } from 'react-use';
import { getLibraryPanels } from './state/api';
import PageActionBar from '../../core/components/PageActionBar/PageActionBar';
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { LibraryPanelsSearch } from './components/LibraryPanelsSearch/LibraryPanelsSearch';
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'library-panels'),
@ -22,28 +18,15 @@ interface OwnProps extends GrafanaRouteComponentProps {}
type Props = OwnProps & ConnectedProps<typeof connector>;
export const LibraryPanelsPage: FC<Props> = ({ navModel }) => {
const [searchQuery, setSearchQuery] = useState('');
const { value: searchResult, loading } = useAsync(async () => {
return getLibraryPanels();
});
const hasLibraryPanels = Boolean(searchResult?.libraryPanels.length);
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
{hasLibraryPanels && (
<PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} placeholder={'Search by name'} />
)}
<LibraryPanelsView
onClickCard={() => undefined}
searchString={searchQuery}
currentPanelId={undefined}
showSecondaryActions={true}
perPage={DEFAULT_PER_PAGE_PAGINATION}
/>
<Page.Contents>
<LibraryPanelsSearch onClick={noop} showSecondaryActions showSort showFilter />
</Page.Contents>
</Page>
);
};
function noop() {}
export default connect(mapStateToProps)(LibraryPanelsPage);

View File

@ -0,0 +1,71 @@
import React, { useCallback, useState } from 'react';
import { HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
import { GrafanaThemeV2, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { css } from '@emotion/css';
import { FilterInput } from '../../../../core/components/FilterInput/FilterInput';
import { SortPicker } from '../../../../core/components/Select/SortPicker';
import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/PanelTypeFilter';
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants';
import { LibraryPanelDTO } from '../../types';
export interface LibraryPanelsSearchProps {
onClick: (panel: LibraryPanelDTO) => void;
showSort?: boolean;
showFilter?: boolean;
showSecondaryActions?: boolean;
currentPanelId?: string;
perPage?: number;
}
export const LibraryPanelsSearch = ({
onClick,
currentPanelId,
perPage = DEFAULT_PER_PAGE_PAGINATION,
showFilter = false,
showSort = false,
showSecondaryActions = false,
}: LibraryPanelsSearchProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const [sortDirection, setSortDirection] = useState<string | undefined>(undefined);
const [panelFilter, setPanelFilter] = useState<string[]>([]);
const styles = useStyles2(getStyles);
const onSortChange = useCallback((sort: SelectableValue<string>) => setSortDirection(sort.value), []);
const onFilterChange = useCallback((plugins: PanelPluginMeta[]) => setPanelFilter(plugins.map((p) => p.id)), []);
return (
<div className={styles.container}>
<VerticalGroup spacing={showSort || showFilter ? 'lg' : 'xs'}>
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name'} width={0} />
<HorizontalGroup spacing="sm" justify={showSort && showFilter ? 'space-between' : 'flex-end'}>
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
{showFilter && <PanelTypeFilter onChange={onFilterChange} />}
</HorizontalGroup>
<div className={styles.libraryPanelsView}>
<LibraryPanelsView
onClickCard={onClick}
searchString={searchQuery}
sortDirection={sortDirection}
panelFilter={panelFilter}
currentPanelId={currentPanelId}
showSecondaryActions={showSecondaryActions}
perPage={perPage}
/>
</div>
</VerticalGroup>
</div>
);
};
function getStyles(theme: GrafanaThemeV2) {
return {
container: css`
width: 100%;
overflow-y: auto;
padding: ${theme.spacing(1)};
`,
libraryPanelsView: css`
width: 100%;
`,
};
}

View File

@ -15,6 +15,8 @@ interface LibraryPanelViewProps {
showSecondaryActions?: boolean;
currentPanelId?: string;
searchString: string;
sortDirection?: string;
panelFilter?: string[];
perPage?: number;
}
@ -22,6 +24,8 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
className,
onClickCard,
searchString,
sortDirection,
panelFilter,
showSecondaryActions,
currentPanelId: currentPanel,
perPage: propsPerPage = 40,
@ -36,11 +40,14 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
}
);
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
useDebounce(() => asyncDispatch(searchForLibraryPanels({ searchString, page, perPage, currentPanelId })), 300, [
searchString,
page,
asyncDispatch,
]);
useDebounce(
() =>
asyncDispatch(
searchForLibraryPanels({ searchString, sortDirection, panelFilter, page, perPage, currentPanelId })
),
300,
[searchString, sortDirection, panelFilter, page, asyncDispatch]
);
const onDelete = ({ uid }: LibraryPanelDTO) =>
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
const onPageChange = (page: number) => asyncDispatch(changePage({ page }));
@ -51,7 +58,7 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
{loadingState === LoadingState.Loading ? (
<p>Loading library panels...</p>
) : libraryPanels.length < 1 ? (
<p>No library panels found.</p>
<p className={styles.noPanelsFound}>No library panels found.</p>
) : (
libraryPanels?.map((item, i) => (
<LibraryPanelCard
@ -101,5 +108,9 @@ const getPanelViewStyles = (theme: GrafanaTheme) => {
align-self: center;
margin-top: ${theme.spacing.sm};
`,
noPanelsFound: css`
label: noPanelsFound;
min-height: 200px;
`,
};
};

View File

@ -11,6 +11,8 @@ interface SearchArgs {
perPage: number;
page: number;
searchString: string;
sortDirection?: string;
panelFilter?: string[];
currentPanelId?: string;
}
@ -19,10 +21,12 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult {
const subscription = new Subscription();
const dataObservable = from(
getLibraryPanels({
name: args.searchString,
searchString: args.searchString,
perPage: args.perPage,
page: args.page,
excludeUid: args.currentPanelId,
sortDirection: args.sortDirection,
panelFilter: args.panelFilter,
})
).pipe(
mergeMap(({ perPage, libraryPanels, page, totalCount }) =>

View File

@ -1,8 +1,8 @@
import React, { FC, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { Button, useStyles } from '@grafana/ui';
import { GrafanaThemeV2, PanelPluginMeta } from '@grafana/data';
import { Button, useStyles2, VerticalGroup } from '@grafana/ui';
import { PanelModel } from 'app/features/dashboard/state';
import { AddLibraryPanelModal } from '../AddLibraryPanelModal/AddLibraryPanelModal';
@ -13,6 +13,7 @@ import { toPanelModelLibraryPanel } from '../../utils';
import { changePanelPlugin } from 'app/features/dashboard/state/actions';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { ChangeLibraryPanelModal } from '../ChangeLibraryPanelModal/ChangeLibraryPanelModal';
import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/PanelTypeFilter';
interface Props {
panel: PanelModel;
@ -20,9 +21,16 @@ interface Props {
}
export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
const styles = useStyles(getStyles);
const styles = useStyles2(getStyles);
const [showingAddPanelModal, setShowingAddPanelModal] = useState(false);
const [changeToPanel, setChangeToPanel] = useState<LibraryPanelDTO | undefined>(undefined);
const [panelFilter, setPanelFilter] = useState<string[]>([]);
const onPanelFilterChange = useCallback(
(plugins: PanelPluginMeta[]) => {
setPanelFilter(plugins.map((p) => p.id));
},
[setPanelFilter]
);
const dashboard = getDashboardSrv().getCurrent();
const dispatch = useDispatch();
@ -64,21 +72,26 @@ export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
};
return (
<div className={styles.box}>
<VerticalGroup spacing="md">
{!panel.libraryPanel && (
<div className={styles.addButtonWrapper}>
<VerticalGroup align="center">
<Button icon="plus" onClick={onAddToPanelLibrary} variant="secondary" fullWidth>
Add current panel to library
</Button>
</div>
</VerticalGroup>
)}
<LibraryPanelsView
currentPanelId={panel.libraryPanel?.uid}
searchString={searchQuery}
onClickCard={onChangeLibraryPanel}
showSecondaryActions
/>
<PanelTypeFilter onChange={onPanelFilterChange} />
<div className={styles.libraryPanelsView}>
<LibraryPanelsView
currentPanelId={panel.libraryPanel?.uid}
searchString={searchQuery}
panelFilter={panelFilter}
onClickCard={onChangeLibraryPanel}
showSecondaryActions
/>
</div>
{showingAddPanelModal && (
<AddLibraryPanelModal
@ -92,20 +105,14 @@ export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
{changeToPanel && (
<ChangeLibraryPanelModal panel={panel} onDismiss={onDismissChangeToPanel} onConfirm={useLibraryPanel} />
)}
</div>
</VerticalGroup>
);
};
const getStyles = (theme: GrafanaTheme) => {
const getStyles = (theme: GrafanaThemeV2) => {
return {
box: css``,
addButtonWrapper: css`
padding-bottom: ${theme.spacing.md};
text-align: center;
`,
panelLibraryTitle: css`
display: flex;
gap: 10px;
libraryPanelsView: css`
width: 100%;
`,
};
};

View File

@ -2,20 +2,26 @@ import { getBackendSrv } from '@grafana/runtime';
import { LibraryPanelDTO, LibraryPanelSearchResult, PanelModelWithLibraryPanel } from '../types';
export interface GetLibraryPanelsOptions {
name?: string;
searchString?: string;
perPage?: number;
page?: number;
excludeUid?: string;
sortDirection?: string;
panelFilter?: string[];
}
export async function getLibraryPanels({
name = '',
searchString = '',
perPage = 100,
page = 1,
excludeUid = '',
sortDirection = '',
panelFilter = [],
}: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> {
const params = new URLSearchParams();
params.append('name', name);
params.append('searchString', searchString);
params.append('sortDirection', sortDirection);
params.append('panelFilter', panelFilter.join(','));
params.append('excludeUid', excludeUid);
params.append('perPage', perPage.toString(10));
params.append('page', page.toString(10));