mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
LibraryPanels: Adds folder filter to manage library panel page (#33560)
* LibraryPanels: Adds folder filter * Refactor: Adds folder filter to library search * Refactor: splits huge function into smaller functions * LibraryPanels: Adds Panels Page to Manage Folder tabs (#33618) * Chore: adds tests to LibraryPanelsSearch * Refactor: Adds reducer and tests * Chore: changes GrafanaThemeV2 * Refactor: pulls everything behind the feature toggle * Chore: removes clear icon from FolderFilter * Chore: adds filter to SortPicker * Refactor: using useAsync instead
This commit is contained in:
parent
918552d34b
commit
c6d4d14a89
@ -167,12 +167,15 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
|
|||||||
Url: hs.Cfg.AppSubURL + "/dashboard/snapshots",
|
Url: hs.Cfg.AppSubURL + "/dashboard/snapshots",
|
||||||
Icon: "camera",
|
Icon: "camera",
|
||||||
})
|
})
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
|
||||||
Text: "Library panels",
|
if hs.Cfg.IsPanelLibraryEnabled() {
|
||||||
Id: "library-panels",
|
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
||||||
Url: hs.Cfg.AppSubURL + "/library-panels",
|
Text: "Library panels",
|
||||||
Icon: "library-panel",
|
Id: "library-panels",
|
||||||
})
|
Url: hs.Cfg.AppSubURL + "/library-panels",
|
||||||
|
Icon: "library-panel",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navTree = append(navTree, &dtos.NavLink{
|
navTree = append(navTree, &dtos.NavLink{
|
||||||
|
@ -88,6 +88,7 @@ func (lps *LibraryPanelService) getAllHandler(c *models.ReqContext) response.Res
|
|||||||
sortDirection: c.Query("sortDirection"),
|
sortDirection: c.Query("sortDirection"),
|
||||||
panelFilter: c.Query("panelFilter"),
|
panelFilter: c.Query("panelFilter"),
|
||||||
excludeUID: c.Query("excludeUid"),
|
excludeUID: c.Query("excludeUid"),
|
||||||
|
folderFilter: c.Query("folderFilter"),
|
||||||
}
|
}
|
||||||
libraryPanels, err := lps.getAllLibraryPanels(c, query)
|
libraryPanels, err := lps.getAllLibraryPanels(c, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
package librarypanels
|
package librarypanels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/search"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/search"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
@ -400,46 +398,28 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
|
|||||||
if len(strings.TrimSpace(query.panelFilter)) > 0 {
|
if len(strings.TrimSpace(query.panelFilter)) > 0 {
|
||||||
panelFilter = strings.Split(query.panelFilter, ",")
|
panelFilter = strings.Split(query.panelFilter, ",")
|
||||||
}
|
}
|
||||||
|
folderFilter := parseFolderFilter(query)
|
||||||
|
if folderFilter.parseError != nil {
|
||||||
|
return LibraryPanelSearchResult{}, folderFilter.parseError
|
||||||
|
}
|
||||||
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
|
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
|
||||||
builder := sqlstore.SQLBuilder{}
|
builder := sqlstore.SQLBuilder{}
|
||||||
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
|
if folderFilter.includeGeneralFolder {
|
||||||
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
|
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
|
||||||
if len(strings.TrimSpace(query.searchString)) > 0 {
|
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
|
||||||
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writeSearchStringSQL(query, lps.SQLStore, &builder)
|
||||||
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writeExcludeSQL(query, &builder)
|
||||||
|
writePanelFilterSQL(panelFilter, &builder)
|
||||||
|
builder.Write(" UNION ")
|
||||||
}
|
}
|
||||||
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(sqlStatmentLibrayPanelDTOWithMeta)
|
||||||
builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0")
|
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)
|
builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
|
||||||
if len(strings.TrimSpace(query.searchString)) > 0 {
|
writeSearchStringSQL(query, lps.SQLStore, &builder)
|
||||||
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writeExcludeSQL(query, &builder)
|
||||||
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writePanelFilterSQL(panelFilter, &builder)
|
||||||
}
|
if err := folderFilter.writeFolderFilterSQL(false, &builder); err != nil {
|
||||||
if len(strings.TrimSpace(query.excludeUID)) > 0 {
|
return err
|
||||||
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 {
|
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
|
||||||
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
|
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
|
||||||
@ -449,10 +429,7 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
|
|||||||
} else {
|
} else {
|
||||||
builder.Write(" ORDER BY 1 ASC")
|
builder.Write(" ORDER BY 1 ASC")
|
||||||
}
|
}
|
||||||
if query.perPage != 0 {
|
writePerPageSQL(query, lps.SQLStore, &builder)
|
||||||
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 {
|
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -490,23 +467,13 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
|
|||||||
|
|
||||||
var panels []LibraryPanel
|
var panels []LibraryPanel
|
||||||
countBuilder := sqlstore.SQLBuilder{}
|
countBuilder := sqlstore.SQLBuilder{}
|
||||||
countBuilder.Write("SELECT * FROM library_panel")
|
countBuilder.Write("SELECT * FROM library_panel AS lp")
|
||||||
countBuilder.Write(` WHERE org_id=?`, c.SignedInUser.OrgId)
|
countBuilder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
|
||||||
if len(strings.TrimSpace(query.searchString)) > 0 {
|
writeSearchStringSQL(query, lps.SQLStore, &countBuilder)
|
||||||
countBuilder.Write(" AND name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writeExcludeSQL(query, &countBuilder)
|
||||||
countBuilder.Write(" OR description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
writePanelFilterSQL(panelFilter, &countBuilder)
|
||||||
}
|
if err := folderFilter.writeFolderFilterSQL(true, &countBuilder); err != nil {
|
||||||
if len(strings.TrimSpace(query.excludeUID)) > 0 {
|
return err
|
||||||
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 {
|
if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -2,6 +2,7 @@ package librarypanels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/search"
|
"github.com/grafana/grafana/pkg/services/search"
|
||||||
@ -372,6 +373,196 @@ func TestGetAllLibraryPanels(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to existing folders, it should succeed and the result should be correct",
|
||||||
|
func(t *testing.T, sc scenarioContext) {
|
||||||
|
newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{})
|
||||||
|
command := getCreateCommand(newFolder.Id, "Text - Library Panel2")
|
||||||
|
resp := sc.service.createHandler(sc.reqContext, command)
|
||||||
|
require.Equal(t, 200, resp.Status())
|
||||||
|
folderFilter := strconv.FormatInt(newFolder.Id, 10)
|
||||||
|
|
||||||
|
err := sc.reqContext.Req.ParseForm()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
|
||||||
|
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: 100,
|
||||||
|
LibraryPanels: []libraryPanel{
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
OrgID: 1,
|
||||||
|
FolderID: newFolder.Id,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
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 folderFilter is set to non existing folders, it should succeed and the result should be correct",
|
||||||
|
func(t *testing.T, sc scenarioContext) {
|
||||||
|
newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{})
|
||||||
|
command := getCreateCommand(newFolder.Id, "Text - Library Panel2")
|
||||||
|
resp := sc.service.createHandler(sc.reqContext, command)
|
||||||
|
require.Equal(t, 200, resp.Status())
|
||||||
|
folderFilter := "2020,2021"
|
||||||
|
|
||||||
|
err := sc.reqContext.Req.ParseForm()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
|
||||||
|
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 folderFilter is set to General folder, 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())
|
||||||
|
folderFilter := "0"
|
||||||
|
|
||||||
|
err := sc.reqContext.Req.ParseForm()
|
||||||
|
require.NoError(t, err)
|
||||||
|
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
|
||||||
|
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{
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
OrgID: 1,
|
||||||
|
FolderID: 1,
|
||||||
|
UID: result.Result.LibraryPanels[1].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[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 excludeUID is set, it should succeed and the result should be correct",
|
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) {
|
func(t *testing.T, sc scenarioContext) {
|
||||||
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
|
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
|
||||||
|
@ -146,4 +146,5 @@ type searchLibraryPanelsQuery struct {
|
|||||||
sortDirection string
|
sortDirection string
|
||||||
panelFilter string
|
panelFilter string
|
||||||
excludeUID string
|
excludeUID string
|
||||||
|
folderFilter string
|
||||||
}
|
}
|
||||||
|
102
pkg/services/librarypanels/writers.go
Normal file
102
pkg/services/librarypanels/writers.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package librarypanels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writePerPageSQL(query searchLibraryPanelsQuery, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) {
|
||||||
|
if query.perPage != 0 {
|
||||||
|
offset := query.perPage * (query.page - 1)
|
||||||
|
builder.Write(sqlStore.Dialect.LimitOffset(int64(query.perPage), int64(offset)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePanelFilterSQL(panelFilter []string, builder *sqlstore.SQLBuilder) {
|
||||||
|
if len(panelFilter) > 0 {
|
||||||
|
var sql bytes.Buffer
|
||||||
|
params := make([]interface{}, 0)
|
||||||
|
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
|
||||||
|
for _, filter := range panelFilter {
|
||||||
|
params = append(params, filter)
|
||||||
|
}
|
||||||
|
builder.Write(sql.String(), params...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSearchStringSQL(query searchLibraryPanelsQuery, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) {
|
||||||
|
if len(strings.TrimSpace(query.searchString)) > 0 {
|
||||||
|
builder.Write(" AND (lp.name "+sqlStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
|
||||||
|
builder.Write(" OR lp.description "+sqlStore.Dialect.LikeStr()+" ?)", "%"+query.searchString+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExcludeSQL(query searchLibraryPanelsQuery, builder *sqlstore.SQLBuilder) {
|
||||||
|
if len(strings.TrimSpace(query.excludeUID)) > 0 {
|
||||||
|
builder.Write(" AND lp.uid <> ?", query.excludeUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FolderFilter struct {
|
||||||
|
includeGeneralFolder bool
|
||||||
|
folderIDs []string
|
||||||
|
parseError error
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFolderFilter(query searchLibraryPanelsQuery) FolderFilter {
|
||||||
|
var folderIDs []string
|
||||||
|
if len(strings.TrimSpace(query.folderFilter)) == 0 {
|
||||||
|
return FolderFilter{
|
||||||
|
includeGeneralFolder: true,
|
||||||
|
folderIDs: folderIDs,
|
||||||
|
parseError: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
includeGeneralFolder := false
|
||||||
|
folderIDs = strings.Split(query.folderFilter, ",")
|
||||||
|
for _, filter := range folderIDs {
|
||||||
|
folderID, err := strconv.ParseInt(filter, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return FolderFilter{
|
||||||
|
includeGeneralFolder: false,
|
||||||
|
folderIDs: folderIDs,
|
||||||
|
parseError: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isGeneralFolder(folderID) {
|
||||||
|
includeGeneralFolder = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FolderFilter{
|
||||||
|
includeGeneralFolder: includeGeneralFolder,
|
||||||
|
folderIDs: folderIDs,
|
||||||
|
parseError: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FolderFilter) writeFolderFilterSQL(includeGeneral bool, builder *sqlstore.SQLBuilder) error {
|
||||||
|
var sql bytes.Buffer
|
||||||
|
params := make([]interface{}, 0)
|
||||||
|
for _, filter := range f.folderIDs {
|
||||||
|
folderID, err := strconv.ParseInt(filter, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !includeGeneral && isGeneralFolder(folderID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params = append(params, filter)
|
||||||
|
}
|
||||||
|
if len(params) > 0 {
|
||||||
|
sql.WriteString(` AND lp.folder_id IN (?` + strings.Repeat(",?", len(params)-1) + ")")
|
||||||
|
builder.Write(sql.String(), params...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
@ -43,6 +43,8 @@ import { PanelRenderer } from './features/panel/PanelRenderer';
|
|||||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||||
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
||||||
import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl';
|
import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl';
|
||||||
|
import { SafeDynamicImport } from './core/components/DynamicImports/SafeDynamicImport';
|
||||||
|
import { featureToggledRoutes } from './routes/routes';
|
||||||
|
|
||||||
// add move to lodash for backward compatabilty with plugins
|
// add move to lodash for backward compatabilty with plugins
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -66,6 +68,21 @@ export class GrafanaApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if (config.featureToggles.panelLibrary) {
|
||||||
|
featureToggledRoutes.push({
|
||||||
|
path: '/dashboards/f/:uid/:slug/library-panels',
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() => import(/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/folders/FolderLibraryPanelsPage')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
featureToggledRoutes.push({
|
||||||
|
path: '/library-panels',
|
||||||
|
component: SafeDynamicImport(
|
||||||
|
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initEchoSrv();
|
initEchoSrv();
|
||||||
addClassIfNoOverlayScrollbar();
|
addClassIfNoOverlayScrollbar();
|
||||||
setLocale(config.bootData.user.locale);
|
setLocale(config.bootData.user.locale);
|
||||||
|
106
public/app/core/components/FolderFilter/FolderFilter.tsx
Normal file
106
public/app/core/components/FolderFilter/FolderFilter.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import debounce from 'debounce-promise';
|
||||||
|
import { AsyncMultiSelect, Icon, resetSelectStyles, useStyles2 } from '@grafana/ui';
|
||||||
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
import { FolderInfo } from 'app/types';
|
||||||
|
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||||
|
|
||||||
|
export interface FolderFilterProps {
|
||||||
|
onChange: (folder: FolderInfo[]) => void;
|
||||||
|
maxMenuHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderFilter({ onChange: propsOnChange, maxMenuHeight }: FolderFilterProps): JSX.Element {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const getOptions = useCallback((searchString: string) => getFoldersAsOptions(searchString, setLoading), []);
|
||||||
|
const debouncedLoadOptions = useMemo(() => debounce(getOptions, 300), [getOptions]);
|
||||||
|
const [value, setValue] = useState<Array<SelectableValue<FolderInfo>>>([]);
|
||||||
|
const onChange = useCallback(
|
||||||
|
(folders: Array<SelectableValue<FolderInfo>>) => {
|
||||||
|
const changedFolders = [];
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (folder.value) {
|
||||||
|
changedFolders.push(folder.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
propsOnChange(changedFolders);
|
||||||
|
setValue(folders);
|
||||||
|
},
|
||||||
|
[propsOnChange]
|
||||||
|
);
|
||||||
|
const selectOptions = {
|
||||||
|
defaultOptions: true,
|
||||||
|
isMulti: true,
|
||||||
|
noOptionsMessage: 'No folders found',
|
||||||
|
placeholder: 'Filter by folder',
|
||||||
|
styles: resetSelectStyles(),
|
||||||
|
maxMenuHeight,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{value.length > 0 && (
|
||||||
|
<span className={styles.clear} onClick={() => onChange([])}>
|
||||||
|
Clear folders
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<AsyncMultiSelect
|
||||||
|
{...selectOptions}
|
||||||
|
isLoading={loading}
|
||||||
|
loadOptions={debouncedLoadOptions}
|
||||||
|
prefix={<Icon name="filter" />}
|
||||||
|
aria-label="Folder filter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFoldersAsOptions(searchString: string, setLoading: (loading: boolean) => void) {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
query: searchString,
|
||||||
|
type: 'dash-folder',
|
||||||
|
permission: 'View',
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchHits = await getBackendSrv().search(params);
|
||||||
|
const options = searchHits.map((d) => ({ label: d.title, value: { id: d.id, title: d.title } }));
|
||||||
|
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
|
||||||
|
options.unshift({ label: 'General', value: { id: 0, title: 'General' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
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};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
@ -6,9 +6,10 @@ import { css } from '@emotion/css';
|
|||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: (plugins: PanelPluginMeta[]) => void;
|
onChange: (plugins: PanelPluginMeta[]) => void;
|
||||||
|
maxMenuHeight?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element => {
|
export const PanelTypeFilter = ({ onChange: propsOnChange, maxMenuHeight }: Props): JSX.Element => {
|
||||||
const plugins = useMemo<PanelPluginMeta[]>(() => {
|
const plugins = useMemo<PanelPluginMeta[]>(() => {
|
||||||
return getAllPanelPluginMeta();
|
return getAllPanelPluginMeta();
|
||||||
}, []);
|
}, []);
|
||||||
@ -43,7 +44,7 @@ export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element
|
|||||||
noOptionsMessage: 'No Panel types found',
|
noOptionsMessage: 'No Panel types found',
|
||||||
placeholder: 'Filter by type',
|
placeholder: 'Filter by type',
|
||||||
styles: resetSelectStyles(),
|
styles: resetSelectStyles(),
|
||||||
maxMenuHeight: 150,
|
maxMenuHeight,
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { FC } from 'react';
|
import React, { FC } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
import { Select, Icon, IconName } from '@grafana/ui';
|
import { Icon, IconName, Select } from '@grafana/ui';
|
||||||
import { SelectableValue } from '@grafana/data';
|
import { SelectableValue } from '@grafana/data';
|
||||||
import { DEFAULT_SORT } from 'app/features/search/constants';
|
import { DEFAULT_SORT } from 'app/features/search/constants';
|
||||||
import { SearchSrv } from '../../services/search_srv';
|
import { SearchSrv } from '../../services/search_srv';
|
||||||
@ -11,25 +11,27 @@ export interface Props {
|
|||||||
onChange: (sortValue: SelectableValue) => void;
|
onChange: (sortValue: SelectableValue) => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
filter?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSortOptions = () => {
|
const getSortOptions = (filter?: string[]) => {
|
||||||
return searchSrv.getSortOptions().then(({ sortOptions }) => {
|
return searchSrv.getSortOptions().then(({ sortOptions }) => {
|
||||||
return sortOptions.map((opt: any) => ({ label: opt.displayName, value: opt.name }));
|
const filteredOptions = filter ? sortOptions.filter((o: any) => filter.includes(o.name)) : sortOptions;
|
||||||
|
return filteredOptions.map((opt: any) => ({ label: opt.displayName, value: opt.name }));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => {
|
export const SortPicker: FC<Props> = ({ onChange, value, placeholder, filter }) => {
|
||||||
// Using sync Select and manual options fetching here since we need to find the selected option by value
|
// Using sync Select and manual options fetching here since we need to find the selected option by value
|
||||||
const { loading, value: options } = useAsync<SelectableValue[]>(getSortOptions, []);
|
const { loading, value: options } = useAsync<SelectableValue[]>(() => getSortOptions(filter), []);
|
||||||
|
|
||||||
const selected = options?.filter((opt) => opt.value === value);
|
const selected = options?.find((opt) => opt.value === value);
|
||||||
return !loading ? (
|
return !loading ? (
|
||||||
<Select
|
<Select
|
||||||
key={value}
|
key={value}
|
||||||
width={25}
|
width={25}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
value={selected?.length ? selected : null}
|
value={selected ?? null}
|
||||||
options={options}
|
options={options}
|
||||||
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
|
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
|
||||||
prefix={<Icon name={(value?.includes('asc') ? 'sort-amount-up' : 'sort-amount-down') as IconName} />}
|
prefix={<Icon name={(value?.includes('asc') ? 'sort-amount-up' : 'sort-amount-down') as IconName} />}
|
||||||
|
@ -13,4 +13,4 @@ export const PANEL_BORDER = 2;
|
|||||||
|
|
||||||
export const EDIT_PANEL_ID = 23763571993;
|
export const EDIT_PANEL_ID = 23763571993;
|
||||||
|
|
||||||
export const DEFAULT_PER_PAGE_PAGINATION = 8;
|
export const DEFAULT_PER_PAGE_PAGINATION = 40;
|
||||||
|
@ -5,7 +5,6 @@ import { AppEvents, DataQueryErrorType, EventBusExtended } from '@grafana/data';
|
|||||||
|
|
||||||
import { BackendSrv } from '../services/backend_srv';
|
import { BackendSrv } from '../services/backend_srv';
|
||||||
import { ContextSrv, User } from '../services/context_srv';
|
import { ContextSrv, User } from '../services/context_srv';
|
||||||
import { describe, expect } from '../../../test/lib/common';
|
|
||||||
import { BackendSrvRequest, FetchError } from '@grafana/runtime';
|
import { BackendSrvRequest, FetchError } from '@grafana/runtime';
|
||||||
import { TokenRevokedModal } from '../../features/users/TokenRevokedModal';
|
import { TokenRevokedModal } from '../../features/users/TokenRevokedModal';
|
||||||
import { ShowModalReactEvent } from '../../types/events';
|
import { ShowModalReactEvent } from '../../types/events';
|
||||||
|
@ -141,12 +141,7 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
|
|||||||
{addPanelView ? 'Add panel from panel library' : 'Add panel'}
|
{addPanelView ? 'Add panel from panel library' : 'Add panel'}
|
||||||
</AddPanelWidgetHandle>
|
</AddPanelWidgetHandle>
|
||||||
{addPanelView ? (
|
{addPanelView ? (
|
||||||
<LibraryPanelsSearch
|
<LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter />
|
||||||
onClick={onAddLibraryPanel}
|
|
||||||
perPage={40}
|
|
||||||
variant={LibraryPanelsSearchVariant.Tight}
|
|
||||||
showFilter
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.actionsWrapper}>
|
<div className={styles.actionsWrapper}>
|
||||||
<div className={styles.actionsRow}>
|
<div className={styles.actionsRow}>
|
||||||
|
@ -57,6 +57,7 @@ export const PanelTypeCard: React.FC<Props> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
|
aria-label="Delete button on panel type card"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
54
public/app/features/folders/FolderLibraryPanelsPage.tsx
Normal file
54
public/app/features/folders/FolderLibraryPanelsPage.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React, { useState } 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 { getLoadingNav } from './state/navModel';
|
||||||
|
import { LibraryPanelDTO } from '../library-panels/types';
|
||||||
|
import Page from '../../core/components/Page/Page';
|
||||||
|
import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
|
||||||
|
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
|
||||||
|
import { getFolderByUid } from './state/actions';
|
||||||
|
import { useAsync } from 'react-use';
|
||||||
|
|
||||||
|
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
|
||||||
|
|
||||||
|
const mapStateToProps = (state: StoreState, props: OwnProps) => {
|
||||||
|
const uid = props.match.params.uid;
|
||||||
|
return {
|
||||||
|
navModel: getNavModel(state.navIndex, `folder-library-panels-${uid}`, getLoadingNav(1)),
|
||||||
|
folderUid: uid,
|
||||||
|
folder: state.folder,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
getFolderByUid,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||||
|
|
||||||
|
export function FolderLibraryPanelsPage({ navModel, getFolderByUid, folderUid, folder }: Props): JSX.Element {
|
||||||
|
const { loading } = useAsync<void>(async () => await getFolderByUid(folderUid), [getFolderByUid, folderUid]);
|
||||||
|
const [selected, setSelected] = useState<LibraryPanelDTO | undefined>(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navModel={navModel}>
|
||||||
|
<Page.Contents isLoading={loading}>
|
||||||
|
<LibraryPanelsSearch
|
||||||
|
onClick={setSelected}
|
||||||
|
currentFolderId={folder.id}
|
||||||
|
showSecondaryActions
|
||||||
|
showSort
|
||||||
|
showPanelFilter
|
||||||
|
/>
|
||||||
|
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null}
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connector(FolderLibraryPanelsPage);
|
@ -1,5 +1,6 @@
|
|||||||
import { FolderDTO } from 'app/types';
|
import { FolderDTO } from 'app/types';
|
||||||
import { NavModelItem, NavModel } from '@grafana/data';
|
import { NavModel, NavModelItem } from '@grafana/data';
|
||||||
|
import { getConfig } from '../../../core/config';
|
||||||
|
|
||||||
export function buildNavModel(folder: FolderDTO): NavModelItem {
|
export function buildNavModel(folder: FolderDTO): NavModelItem {
|
||||||
const model = {
|
const model = {
|
||||||
@ -40,6 +41,16 @@ export function buildNavModel(folder: FolderDTO): NavModelItem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getConfig().featureToggles.panelLibrary) {
|
||||||
|
model.children.push({
|
||||||
|
active: false,
|
||||||
|
icon: 'library-panel',
|
||||||
|
id: `folder-library-panels-${folder.uid}`,
|
||||||
|
text: 'Panels',
|
||||||
|
url: `${folder.url}/library-panels`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ export const LibraryPanelsPage: FC<Props> = ({ navModel }) => {
|
|||||||
return (
|
return (
|
||||||
<Page navModel={navModel}>
|
<Page navModel={navModel}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<LibraryPanelsSearch onClick={setSelected} showSecondaryActions showSort showFilter />
|
<LibraryPanelsSearch onClick={setSelected} showSecondaryActions showSort showPanelFilter showFolderFilter />
|
||||||
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null}
|
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null}
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
|
@ -0,0 +1,281 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { within } from '@testing-library/dom';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { PanelPluginMeta, PluginType } from '@grafana/data';
|
||||||
|
|
||||||
|
import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSearch';
|
||||||
|
import * as api from '../../state/api';
|
||||||
|
import { LibraryPanelSearchResult } from '../../types';
|
||||||
|
import { backendSrv } from '../../../../core/services/backend_srv';
|
||||||
|
import * as viztypepicker from '../../../dashboard/components/VizTypePicker/VizTypePicker';
|
||||||
|
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
...((jest.requireActual('@grafana/runtime') as unknown) as object),
|
||||||
|
config: {
|
||||||
|
panels: {
|
||||||
|
timeseries: {
|
||||||
|
info: { logos: { small: '' } },
|
||||||
|
name: 'Time Series',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('debounce-promise', () => {
|
||||||
|
const debounce = (fn: any) => {
|
||||||
|
const debounced = () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{ label: 'General', value: { id: 0, title: 'General' } },
|
||||||
|
{ label: 'Folder1', value: { id: 1, title: 'Folder1' } },
|
||||||
|
{ label: 'Folder2', value: { id: 2, title: 'Folder2' } },
|
||||||
|
]);
|
||||||
|
return debounced;
|
||||||
|
};
|
||||||
|
|
||||||
|
return debounce;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getTestContext(
|
||||||
|
propOverrides: Partial<LibraryPanelsSearchProps> = {},
|
||||||
|
searchResult: LibraryPanelSearchResult = { libraryPanels: [], perPage: 40, page: 1, totalCount: 0 }
|
||||||
|
) {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
const pluginInfo: any = { logos: { small: '', large: '' } };
|
||||||
|
const graph: PanelPluginMeta = {
|
||||||
|
name: 'Graph',
|
||||||
|
id: 'graph',
|
||||||
|
info: pluginInfo,
|
||||||
|
baseUrl: '',
|
||||||
|
type: PluginType.panel,
|
||||||
|
module: '',
|
||||||
|
sort: 0,
|
||||||
|
};
|
||||||
|
const timeseries: PanelPluginMeta = {
|
||||||
|
name: 'Time Series',
|
||||||
|
id: 'timeseries',
|
||||||
|
info: pluginInfo,
|
||||||
|
baseUrl: '',
|
||||||
|
type: PluginType.panel,
|
||||||
|
module: '',
|
||||||
|
sort: 1,
|
||||||
|
};
|
||||||
|
const getSpy = jest
|
||||||
|
.spyOn(backendSrv, 'get')
|
||||||
|
.mockResolvedValue({ sortOptions: [{ displaName: 'Desc', name: 'alpha-desc' }] });
|
||||||
|
const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels').mockResolvedValue(searchResult);
|
||||||
|
const getAllPanelPluginMetaSpy = jest
|
||||||
|
.spyOn(viztypepicker, 'getAllPanelPluginMeta')
|
||||||
|
.mockReturnValue([graph, timeseries]);
|
||||||
|
|
||||||
|
const props: LibraryPanelsSearchProps = {
|
||||||
|
onClick: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(props, propOverrides);
|
||||||
|
const { rerender } = render(<LibraryPanelsSearch {...props} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled());
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
return { rerender, getLibraryPanelsSpy, getSpy, getAllPanelPluginMetaSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LibraryPanelsSearch', () => {
|
||||||
|
describe('when mounted with default options', () => {
|
||||||
|
it('should show input filter and library panels view', async () => {
|
||||||
|
await getTestContext();
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user searches for library panel by name or description', () => {
|
||||||
|
it('should call api with correct params', async () => {
|
||||||
|
const { getLibraryPanelsSpy } = await getTestContext();
|
||||||
|
getLibraryPanelsSpy.mockClear();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByPlaceholderText(/search by name/i), 'a');
|
||||||
|
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled());
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
|
||||||
|
searchString: 'a',
|
||||||
|
folderFilter: [],
|
||||||
|
page: 0,
|
||||||
|
panelFilter: [],
|
||||||
|
perPage: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when mounted with showSort', () => {
|
||||||
|
it('should show input filter and library panels view and sort', async () => {
|
||||||
|
await getTestContext({ showSort: true });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/sort \(default a–z\)/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user changes sorting', () => {
|
||||||
|
it('should call api with correct params', async () => {
|
||||||
|
const { getLibraryPanelsSpy } = await getTestContext({ showSort: true });
|
||||||
|
getLibraryPanelsSpy.mockClear();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByText(/sort \(default a–z\)/i), 'Desc{enter}');
|
||||||
|
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
|
||||||
|
searchString: '',
|
||||||
|
sortDirection: 'alpha-desc',
|
||||||
|
folderFilter: [],
|
||||||
|
page: 0,
|
||||||
|
panelFilter: [],
|
||||||
|
perPage: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when mounted with showPanelFilter', () => {
|
||||||
|
it('should show input filter and library panels view and panel filter', async () => {
|
||||||
|
await getTestContext({ showPanelFilter: true });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /panel type filter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user changes panel filter', () => {
|
||||||
|
it('should call api with correct params', async () => {
|
||||||
|
const { getLibraryPanelsSpy } = await getTestContext({ showPanelFilter: true });
|
||||||
|
getLibraryPanelsSpy.mockClear();
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /panel type filter/i }), 'Graph{enter}');
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /panel type filter/i }), 'Time Series{enter}');
|
||||||
|
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
|
||||||
|
searchString: '',
|
||||||
|
folderFilter: [],
|
||||||
|
page: 0,
|
||||||
|
panelFilter: ['graph', 'timeseries'],
|
||||||
|
perPage: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when mounted with showPanelFilter', () => {
|
||||||
|
it('should show input filter and library panels view and folder filter', async () => {
|
||||||
|
await getTestContext({ showFolderFilter: true });
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('textbox', { name: /folder filter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user changes folder filter', () => {
|
||||||
|
it('should call api with correct params', async () => {
|
||||||
|
const { getLibraryPanelsSpy } = await getTestContext({ showFolderFilter: true });
|
||||||
|
getLibraryPanelsSpy.mockClear();
|
||||||
|
|
||||||
|
userEvent.click(screen.getByRole('textbox', { name: /folder filter/i }));
|
||||||
|
await userEvent.type(screen.getByRole('textbox', { name: /folder filter/i }), '{enter}', {
|
||||||
|
skipClick: true,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
|
||||||
|
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
|
||||||
|
searchString: '',
|
||||||
|
folderFilter: ['0'],
|
||||||
|
page: 0,
|
||||||
|
panelFilter: [],
|
||||||
|
perPage: 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when mounted without showSecondaryActions and there is one panel', () => {
|
||||||
|
it('should show correct row and no delete button', async () => {
|
||||||
|
await getTestContext(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
totalCount: 1,
|
||||||
|
perPage: 40,
|
||||||
|
libraryPanels: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Library Panel Name',
|
||||||
|
uid: 'uid',
|
||||||
|
description: 'Library Panel Description',
|
||||||
|
folderId: 0,
|
||||||
|
model: { type: 'timeseries', title: 'A title' },
|
||||||
|
type: 'timeseries',
|
||||||
|
orgId: 1,
|
||||||
|
version: 1,
|
||||||
|
meta: {
|
||||||
|
canEdit: true,
|
||||||
|
connectedDashboards: 0,
|
||||||
|
created: '2021-01-01 12:00:00',
|
||||||
|
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },
|
||||||
|
updated: '2021-01-01 12:00:00',
|
||||||
|
updatedBy: { id: 1, name: 'Admin', avatarUrl: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/no library panels found./i)).not.toBeInTheDocument();
|
||||||
|
expect(card()).toBeInTheDocument();
|
||||||
|
expect(within(card()).getByText(/library panel name/i)).toBeInTheDocument();
|
||||||
|
expect(within(card()).getByText(/library panel description/i)).toBeInTheDocument();
|
||||||
|
expect(within(card()).queryByLabelText(/delete button on panel type card/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when mounted with showSecondaryActions and there is one panel', () => {
|
||||||
|
it('should show correct row and delete button', async () => {
|
||||||
|
await getTestContext(
|
||||||
|
{ showSecondaryActions: true },
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
totalCount: 1,
|
||||||
|
perPage: 40,
|
||||||
|
libraryPanels: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Library Panel Name',
|
||||||
|
uid: 'uid',
|
||||||
|
description: 'Library Panel Description',
|
||||||
|
folderId: 0,
|
||||||
|
model: { type: 'timeseries', title: 'A title' },
|
||||||
|
type: 'timeseries',
|
||||||
|
orgId: 1,
|
||||||
|
version: 1,
|
||||||
|
meta: {
|
||||||
|
canEdit: true,
|
||||||
|
connectedDashboards: 0,
|
||||||
|
created: '2021-01-01 12:00:00',
|
||||||
|
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },
|
||||||
|
updated: '2021-01-01 12:00:00',
|
||||||
|
updatedBy: { id: 1, name: 'Admin', avatarUrl: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/no library panels found./i)).not.toBeInTheDocument();
|
||||||
|
expect(card()).toBeInTheDocument();
|
||||||
|
expect(within(card()).getByText(/library panel name/i)).toBeInTheDocument();
|
||||||
|
expect(within(card()).getByText(/library panel description/i)).toBeInTheDocument();
|
||||||
|
expect(within(card()).getByLabelText(/delete button on panel type card/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useReducer } from 'react';
|
||||||
import { HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
|
import { HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
|
||||||
import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
@ -8,6 +8,16 @@ import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/Pan
|
|||||||
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
|
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
|
||||||
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants';
|
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants';
|
||||||
import { LibraryPanelDTO } from '../../types';
|
import { LibraryPanelDTO } from '../../types';
|
||||||
|
import { FolderFilter } from '../../../../core/components/FolderFilter/FolderFilter';
|
||||||
|
import { FolderInfo } from '../../../../types';
|
||||||
|
import {
|
||||||
|
folderFilterChanged,
|
||||||
|
initialLibraryPanelsSearchState,
|
||||||
|
libraryPanelsSearchReducer,
|
||||||
|
panelFilterChanged,
|
||||||
|
searchChanged,
|
||||||
|
sortChanged,
|
||||||
|
} from './reducer';
|
||||||
|
|
||||||
export enum LibraryPanelsSearchVariant {
|
export enum LibraryPanelsSearchVariant {
|
||||||
Tight = 'tight',
|
Tight = 'tight',
|
||||||
@ -18,9 +28,11 @@ export interface LibraryPanelsSearchProps {
|
|||||||
onClick: (panel: LibraryPanelDTO) => void;
|
onClick: (panel: LibraryPanelDTO) => void;
|
||||||
variant?: LibraryPanelsSearchVariant;
|
variant?: LibraryPanelsSearchVariant;
|
||||||
showSort?: boolean;
|
showSort?: boolean;
|
||||||
showFilter?: boolean;
|
showPanelFilter?: boolean;
|
||||||
|
showFolderFilter?: boolean;
|
||||||
showSecondaryActions?: boolean;
|
showSecondaryActions?: boolean;
|
||||||
currentPanelId?: string;
|
currentPanelId?: string;
|
||||||
|
currentFolderId?: number;
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,26 +40,44 @@ export const LibraryPanelsSearch = ({
|
|||||||
onClick,
|
onClick,
|
||||||
variant = LibraryPanelsSearchVariant.Spacious,
|
variant = LibraryPanelsSearchVariant.Spacious,
|
||||||
currentPanelId,
|
currentPanelId,
|
||||||
|
currentFolderId,
|
||||||
perPage = DEFAULT_PER_PAGE_PAGINATION,
|
perPage = DEFAULT_PER_PAGE_PAGINATION,
|
||||||
showFilter = false,
|
showPanelFilter = false,
|
||||||
|
showFolderFilter = false,
|
||||||
showSort = false,
|
showSort = false,
|
||||||
showSecondaryActions = false,
|
showSecondaryActions = false,
|
||||||
}: LibraryPanelsSearchProps): JSX.Element => {
|
}: LibraryPanelsSearchProps): JSX.Element => {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [sortDirection, setSortDirection] = useState<string | undefined>(undefined);
|
|
||||||
const [panelFilter, setPanelFilter] = useState<string[]>([]);
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const onSortChange = useCallback((sort: SelectableValue<string>) => setSortDirection(sort.value), []);
|
const [{ sortDirection, panelFilter, folderFilter, searchQuery }, dispatch] = useReducer(libraryPanelsSearchReducer, {
|
||||||
const onFilterChange = useCallback((plugins: PanelPluginMeta[]) => setPanelFilter(plugins.map((p) => p.id)), []);
|
...initialLibraryPanelsSearchState,
|
||||||
|
folderFilter: currentFolderId ? [currentFolderId.toString(10)] : [],
|
||||||
|
});
|
||||||
|
const onFilterChange = (searchString: string) => dispatch(searchChanged(searchString));
|
||||||
|
const onSortChange = (sorting: SelectableValue<string>) => dispatch(sortChanged(sorting));
|
||||||
|
const onFolderFilterChange = (folders: FolderInfo[]) => dispatch(folderFilterChanged(folders));
|
||||||
|
const onPanelFilterChange = (plugins: PanelPluginMeta[]) => dispatch(panelFilterChanged(plugins));
|
||||||
|
|
||||||
if (variant === LibraryPanelsSearchVariant.Spacious) {
|
if (variant === LibraryPanelsSearchVariant.Spacious) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<VerticalGroup spacing="lg">
|
<VerticalGroup spacing="lg">
|
||||||
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name'} width={0} />
|
<FilterInput
|
||||||
<HorizontalGroup spacing="sm" justify={showSort && showFilter ? 'space-between' : 'flex-end'}>
|
value={searchQuery}
|
||||||
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
|
onChange={onFilterChange}
|
||||||
{showFilter && <PanelTypeFilter onChange={onFilterChange} />}
|
placeholder={'Search by name or description'}
|
||||||
|
width={0}
|
||||||
|
/>
|
||||||
|
<HorizontalGroup
|
||||||
|
spacing="sm"
|
||||||
|
justify={(showSort && showPanelFilter) || showFolderFilter ? 'space-between' : 'flex-end'}
|
||||||
|
>
|
||||||
|
{showSort && (
|
||||||
|
<SortPicker value={sortDirection} onChange={onSortChange} filter={['alpha-asc', 'alpha-desc']} />
|
||||||
|
)}
|
||||||
|
<HorizontalGroup spacing="sm" justify={showFolderFilter && showPanelFilter ? 'space-between' : 'flex-end'}>
|
||||||
|
{showFolderFilter && <FolderFilter onChange={onFolderFilterChange} />}
|
||||||
|
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} />}
|
||||||
|
</HorizontalGroup>
|
||||||
</HorizontalGroup>
|
</HorizontalGroup>
|
||||||
<div className={styles.libraryPanelsView}>
|
<div className={styles.libraryPanelsView}>
|
||||||
<LibraryPanelsView
|
<LibraryPanelsView
|
||||||
@ -55,6 +85,7 @@ export const LibraryPanelsSearch = ({
|
|||||||
searchString={searchQuery}
|
searchString={searchQuery}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
panelFilter={panelFilter}
|
panelFilter={panelFilter}
|
||||||
|
folderFilter={folderFilter}
|
||||||
currentPanelId={currentPanelId}
|
currentPanelId={currentPanelId}
|
||||||
showSecondaryActions={showSecondaryActions}
|
showSecondaryActions={showSecondaryActions}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
@ -70,11 +101,12 @@ export const LibraryPanelsSearch = ({
|
|||||||
<VerticalGroup spacing="xs">
|
<VerticalGroup spacing="xs">
|
||||||
<div className={styles.buttonRow}>
|
<div className={styles.buttonRow}>
|
||||||
<div className={styles.tightFilter}>
|
<div className={styles.tightFilter}>
|
||||||
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name'} width={0} />
|
<FilterInput value={searchQuery} onChange={onFilterChange} placeholder={'Search by name'} width={0} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.tightSortFilter}>
|
<div className={styles.tightSortFilter}>
|
||||||
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
|
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
|
||||||
{showFilter && <PanelTypeFilter onChange={onFilterChange} />}
|
{showFolderFilter && <FolderFilter onChange={onFolderFilterChange} maxMenuHeight={200} />}
|
||||||
|
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} maxMenuHeight={200} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.libraryPanelsView}>
|
<div className={styles.libraryPanelsView}>
|
||||||
@ -83,6 +115,7 @@ export const LibraryPanelsSearch = ({
|
|||||||
searchString={searchQuery}
|
searchString={searchQuery}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
panelFilter={panelFilter}
|
panelFilter={panelFilter}
|
||||||
|
folderFilter={folderFilter}
|
||||||
currentPanelId={currentPanelId}
|
currentPanelId={currentPanelId}
|
||||||
showSecondaryActions={showSecondaryActions}
|
showSecondaryActions={showSecondaryActions}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
@ -99,6 +132,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: ${theme.spacing(1)};
|
padding: ${theme.spacing(1)};
|
||||||
|
min-height: 400px;
|
||||||
`,
|
`,
|
||||||
buttonRow: css`
|
buttonRow: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
|
||||||
|
import {
|
||||||
|
folderFilterChanged,
|
||||||
|
initialLibraryPanelsSearchState,
|
||||||
|
libraryPanelsSearchReducer,
|
||||||
|
LibraryPanelsSearchState,
|
||||||
|
panelFilterChanged,
|
||||||
|
searchChanged,
|
||||||
|
sortChanged,
|
||||||
|
} from './reducer';
|
||||||
|
|
||||||
|
describe('libraryPanelsSearchReducer', () => {
|
||||||
|
describe('when searchChanged is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester<LibraryPanelsSearchState>()
|
||||||
|
.givenReducer(libraryPanelsSearchReducer, {
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
})
|
||||||
|
.whenActionIsDispatched(searchChanged('searching for'))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
searchQuery: 'searching for',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when sortChanged is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
reducerTester<LibraryPanelsSearchState>()
|
||||||
|
.givenReducer(libraryPanelsSearchReducer, {
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
})
|
||||||
|
.whenActionIsDispatched(sortChanged({ label: 'Ascending', value: 'asc' }))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
sortDirection: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when panelFilterChanged is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const plugins: any = [
|
||||||
|
{ id: 'graph', name: 'Graph' },
|
||||||
|
{ id: 'timeseries', name: 'Time Series' },
|
||||||
|
];
|
||||||
|
reducerTester<LibraryPanelsSearchState>()
|
||||||
|
.givenReducer(libraryPanelsSearchReducer, {
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
})
|
||||||
|
.whenActionIsDispatched(panelFilterChanged(plugins))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
panelFilter: ['graph', 'timeseries'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when folderFilterChanged is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const folders: any = [
|
||||||
|
{ id: 0, name: 'General' },
|
||||||
|
{ id: 1, name: 'Folder' },
|
||||||
|
];
|
||||||
|
reducerTester<LibraryPanelsSearchState>()
|
||||||
|
.givenReducer(libraryPanelsSearchReducer, {
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
})
|
||||||
|
.whenActionIsDispatched(folderFilterChanged(folders))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
...initialLibraryPanelsSearchState,
|
||||||
|
folderFilter: ['0', '1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,44 @@
|
|||||||
|
import { AnyAction } from 'redux';
|
||||||
|
import { createAction } from '@reduxjs/toolkit';
|
||||||
|
import { PanelPluginMeta, SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
import { FolderInfo } from '../../../../types';
|
||||||
|
|
||||||
|
export interface LibraryPanelsSearchState {
|
||||||
|
searchQuery: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
panelFilter: string[];
|
||||||
|
folderFilter: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialLibraryPanelsSearchState: LibraryPanelsSearchState = {
|
||||||
|
searchQuery: '',
|
||||||
|
panelFilter: [],
|
||||||
|
folderFilter: [],
|
||||||
|
sortDirection: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchChanged = createAction<string>('libraryPanels/search/searchChanged');
|
||||||
|
export const sortChanged = createAction<SelectableValue<string>>('libraryPanels/search/sortChanged');
|
||||||
|
export const panelFilterChanged = createAction<PanelPluginMeta[]>('libraryPanels/search/panelFilterChanged');
|
||||||
|
export const folderFilterChanged = createAction<FolderInfo[]>('libraryPanels/search/folderFilterChanged');
|
||||||
|
|
||||||
|
export const libraryPanelsSearchReducer = (state: LibraryPanelsSearchState, action: AnyAction) => {
|
||||||
|
if (searchChanged.match(action)) {
|
||||||
|
return { ...state, searchQuery: action.payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortChanged.match(action)) {
|
||||||
|
return { ...state, sortDirection: action.payload.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelFilterChanged.match(action)) {
|
||||||
|
return { ...state, panelFilter: action.payload.map((p) => p.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderFilterChanged.match(action)) {
|
||||||
|
return { ...state, folderFilter: action.payload.map((f) => String(f.id!)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
@ -17,6 +17,7 @@ interface LibraryPanelViewProps {
|
|||||||
searchString: string;
|
searchString: string;
|
||||||
sortDirection?: string;
|
sortDirection?: string;
|
||||||
panelFilter?: string[];
|
panelFilter?: string[];
|
||||||
|
folderFilter?: string[];
|
||||||
perPage?: number;
|
perPage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,6 +27,7 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
|
|||||||
searchString,
|
searchString,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
panelFilter,
|
panelFilter,
|
||||||
|
folderFilter,
|
||||||
showSecondaryActions,
|
showSecondaryActions,
|
||||||
currentPanelId: currentPanel,
|
currentPanelId: currentPanel,
|
||||||
perPage: propsPerPage = 40,
|
perPage: propsPerPage = 40,
|
||||||
@ -43,10 +45,18 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
|
|||||||
useDebounce(
|
useDebounce(
|
||||||
() =>
|
() =>
|
||||||
asyncDispatch(
|
asyncDispatch(
|
||||||
searchForLibraryPanels({ searchString, sortDirection, panelFilter, page, perPage, currentPanelId })
|
searchForLibraryPanels({
|
||||||
|
searchString,
|
||||||
|
sortDirection,
|
||||||
|
panelFilter,
|
||||||
|
folderFilter,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
currentPanelId,
|
||||||
|
})
|
||||||
),
|
),
|
||||||
300,
|
300,
|
||||||
[searchString, sortDirection, panelFilter, page, asyncDispatch]
|
[searchString, sortDirection, panelFilter, folderFilter, page, asyncDispatch]
|
||||||
);
|
);
|
||||||
const onDelete = ({ uid }: LibraryPanelDTO) =>
|
const onDelete = ({ uid }: LibraryPanelDTO) =>
|
||||||
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
|
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
|
||||||
|
@ -13,6 +13,7 @@ interface SearchArgs {
|
|||||||
searchString: string;
|
searchString: string;
|
||||||
sortDirection?: string;
|
sortDirection?: string;
|
||||||
panelFilter?: string[];
|
panelFilter?: string[];
|
||||||
|
folderFilter?: string[];
|
||||||
currentPanelId?: string;
|
currentPanelId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult {
|
|||||||
excludeUid: args.currentPanelId,
|
excludeUid: args.currentPanelId,
|
||||||
sortDirection: args.sortDirection,
|
sortDirection: args.sortDirection,
|
||||||
panelFilter: args.panelFilter,
|
panelFilter: args.panelFilter,
|
||||||
|
folderFilter: args.folderFilter,
|
||||||
})
|
})
|
||||||
).pipe(
|
).pipe(
|
||||||
mergeMap(({ perPage, libraryPanels, page, totalCount }) =>
|
mergeMap(({ perPage, libraryPanels, page, totalCount }) =>
|
||||||
|
@ -9,6 +9,7 @@ export interface GetLibraryPanelsOptions {
|
|||||||
excludeUid?: string;
|
excludeUid?: string;
|
||||||
sortDirection?: string;
|
sortDirection?: string;
|
||||||
panelFilter?: string[];
|
panelFilter?: string[];
|
||||||
|
folderFilter?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLibraryPanels({
|
export async function getLibraryPanels({
|
||||||
@ -18,11 +19,13 @@ export async function getLibraryPanels({
|
|||||||
excludeUid = '',
|
excludeUid = '',
|
||||||
sortDirection = '',
|
sortDirection = '',
|
||||||
panelFilter = [],
|
panelFilter = [],
|
||||||
|
folderFilter = [],
|
||||||
}: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> {
|
}: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('searchString', searchString);
|
params.append('searchString', searchString);
|
||||||
params.append('sortDirection', sortDirection);
|
params.append('sortDirection', sortDirection);
|
||||||
params.append('panelFilter', panelFilter.join(','));
|
params.append('panelFilter', panelFilter.join(','));
|
||||||
|
params.append('folderFilter', folderFilter.join(','));
|
||||||
params.append('excludeUid', excludeUid);
|
params.append('excludeUid', excludeUid);
|
||||||
params.append('perPage', perPage.toString(10));
|
params.append('perPage', perPage.toString(10));
|
||||||
params.append('page', page.toString(10));
|
params.append('page', page.toString(10));
|
||||||
|
@ -11,6 +11,7 @@ import { Redirect } from 'react-router-dom';
|
|||||||
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
|
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
|
||||||
|
|
||||||
export const extraRoutes: RouteDescriptor[] = [];
|
export const extraRoutes: RouteDescriptor[] = [];
|
||||||
|
export const featureToggledRoutes: RouteDescriptor[] = [];
|
||||||
|
|
||||||
export function getAppRoutes(): RouteDescriptor[] {
|
export function getAppRoutes(): RouteDescriptor[] {
|
||||||
return [
|
return [
|
||||||
@ -465,12 +466,6 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage')
|
() => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/library-panels',
|
|
||||||
component: SafeDynamicImport(
|
|
||||||
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/sandbox/benchmarks',
|
path: '/sandbox/benchmarks',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
@ -478,6 +473,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
...extraRoutes,
|
...extraRoutes,
|
||||||
|
...featureToggledRoutes,
|
||||||
{
|
{
|
||||||
path: '/*',
|
path: '/*',
|
||||||
component: ErrorPage,
|
component: ErrorPage,
|
||||||
|
Loading…
Reference in New Issue
Block a user