LibraryPanels: Improves the Get All experience (#32028)

* LibraryPanels: Improves Get All Api

* Refactor: using useReducer instead of useState

* Refactor: adds Pagination to UI

* Tests: adds reducer tests

* Refactor: using Observable instead to avoid flickering

* Refactor: moves exclusion to backend instead

* Chore: changing back the perPage default value
This commit is contained in:
Hugo Häggmark
2021-03-18 11:19:41 +01:00
committed by GitHub
parent 0fbe7f7f52
commit f508a16a43
14 changed files with 838 additions and 164 deletions

View File

@@ -13,9 +13,11 @@ interface Props {
numberOfPages: number;
/** Callback function for fetching the selected page */
onNavigate: (toPage: number) => void;
/** When set to true and the pagination result is only one page it will not render the pagination at all */
hideWhenSinglePage?: boolean;
}
export const Pagination: React.FC<Props> = ({ currentPage, numberOfPages, onNavigate }) => {
export const Pagination: React.FC<Props> = ({ currentPage, numberOfPages, onNavigate, hideWhenSinglePage }) => {
const styles = getStyles();
const pages = [...new Array(numberOfPages).keys()];
@@ -71,6 +73,10 @@ export const Pagination: React.FC<Props> = ({ currentPage, numberOfPages, onNavi
return pagesToRender;
}, []);
if (hideWhenSinglePage && numberOfPages <= 1) {
return null;
}
return (
<div className={styles.container}>
<ol>

View File

@@ -81,7 +81,7 @@ 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.QueryInt64("limit"))
libraryPanels, err := lps.getAllLibraryPanels(c, c.QueryInt("perPage"), c.QueryInt("page"), c.Query("name"), c.Query("excludeUid"))
if err != nil {
return toLibraryPanelError(err, "Failed to get library panels")
}

View File

@@ -3,6 +3,7 @@ package librarypanels
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
@@ -365,60 +366,101 @@ func (lps *LibraryPanelService) getLibraryPanel(c *models.ReqContext, uid string
}
// getAllLibraryPanels gets all library panels.
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, limit int64) ([]LibraryPanelDTO, error) {
func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, perPage int, page int, name string, excludeUID string) (LibraryPanelSearchResult, error) {
libraryPanels := make([]LibraryPanelWithMeta, 0)
result := LibraryPanelSearchResult{}
if perPage <= 0 {
perPage = 100
}
if page <= 0 {
page = 1
}
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(excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", excludeUID)
}
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(excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", excludeUID)
}
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
}
if limit == 0 {
limit = 1000
if perPage != 0 {
offset := perPage * (page - 1)
builder.Write(lps.SQLStore.Dialect.LimitOffset(int64(perPage), int64(offset)))
}
builder.Write(lps.SQLStore.Dialect.Limit(limit))
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil {
return err
}
retDTOs := make([]LibraryPanelDTO, 0)
for _, panel := range libraryPanels {
retDTOs = append(retDTOs, LibraryPanelDTO{
ID: panel.ID,
OrgID: panel.OrgID,
FolderID: panel.FolderID,
UID: panel.UID,
Name: panel.Name,
Model: panel.Model,
Version: panel.Version,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: panel.ConnectedDashboards,
Created: panel.Created,
Updated: panel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: panel.CreatedBy,
Name: panel.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.CreatedByEmail),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: panel.UpdatedBy,
Name: panel.UpdatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.UpdatedByEmail),
},
},
})
}
var panels []LibraryPanel
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(excludeUID)) > 0 {
countBuilder.Write(" AND uid <> ?", excludeUID)
}
if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil {
return err
}
result = LibraryPanelSearchResult{
TotalCount: int64(len(panels)),
LibraryPanels: retDTOs,
Page: page,
PerPage: perPage,
}
return nil
})
retDTOs := make([]LibraryPanelDTO, 0)
for _, panel := range libraryPanels {
retDTOs = append(retDTOs, LibraryPanelDTO{
ID: panel.ID,
OrgID: panel.OrgID,
FolderID: panel.FolderID,
UID: panel.UID,
Name: panel.Name,
Model: panel.Model,
Version: panel.Version,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: panel.ConnectedDashboards,
Created: panel.Created,
Updated: panel.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: panel.CreatedBy,
Name: panel.CreatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.CreatedByEmail),
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: panel.UpdatedBy,
Name: panel.UpdatedByName,
AvatarUrl: dtos.GetGravatarUrl(panel.UpdatedByEmail),
},
},
})
}
return retDTOs, err
return result, err
}
// getConnectedDashboards gets all dashboards connected to a Library Panel.

View File

@@ -16,11 +16,20 @@ func TestGetAllLibraryPanels(t *testing.T) {
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsResult
var result libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.NotNil(t, result.Result)
require.Equal(t, 0, len(result.Result))
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 0,
LibraryPanels: []libraryPanel{},
Page: 1,
PerPage: 100,
},
}
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, it should succeed",
@@ -32,68 +41,132 @@ func TestGetAllLibraryPanels(t *testing.T) {
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsResult
var result libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsResult{
Result: []libraryPanel{
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result[0].UID,
Name: "Text - Library Panel",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result[0].Meta.Created,
Updated: result.Result[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 2,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
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",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"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,
},
},
},
},
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result[1].UID,
Name: "Text - Library Panel2",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"id": float64(1),
"title": "Text - Library Panel2",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result[1].Meta.Created,
Updated: result.Result[1].Meta.Updated,
CreatedBy: 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",
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("excludeUid", sc.initialResult.Result.UID)
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: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel2",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"id": float64(1),
"title": "Text - Library Panel2",
"type": "text",
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
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,
},
},
},
},
@@ -104,6 +177,246 @@ func TestGetAllLibraryPanels(t *testing.T) {
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and perPage is 1, 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("perPage", "1")
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: 1,
LibraryPanels: []libraryPanel{
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"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 perPage is 1 and page is 2, 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("perPage", "1")
sc.reqContext.Req.Form.Add("page", "2")
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: 2,
PerPage: 1,
LibraryPanels: []libraryPanel{
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel2",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"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 perPage is 1 and page is 1 and name 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)
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("name", "panel2")
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: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel2",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"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 perPage is 1 and page is 3 and name 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)
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", "3")
sc.reqContext.Req.Form.Add("name", "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: 3,
PerPage: 1,
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 perPage is 1 and page is 3 and name 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)
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", "3")
sc.reqContext.Req.Form.Add("name", "monkey")
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: 3,
PerPage: 1,
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 but only one is connected, it should succeed and return correct connected dashboards",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
@@ -121,11 +434,11 @@ func TestGetAllLibraryPanels(t *testing.T) {
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var results libraryPanelsResult
var results libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &results)
require.NoError(t, err)
require.Equal(t, int64(0), results.Result[0].Meta.ConnectedDashboards)
require.Equal(t, int64(2), results.Result[1].Meta.ConnectedDashboards)
require.Equal(t, int64(0), results.Result.LibraryPanels[0].Meta.ConnectedDashboards)
require.Equal(t, int64(2), results.Result.LibraryPanels[1].Meta.ConnectedDashboards)
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels in a different org, none should be returned",
@@ -133,22 +446,31 @@ func TestGetAllLibraryPanels(t *testing.T) {
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsResult
var result libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.Equal(t, 1, len(result.Result))
require.Equal(t, int64(1), result.Result[0].FolderID)
require.Equal(t, "Text - Library Panel", result.Result[0].Name)
require.Equal(t, 1, len(result.Result.LibraryPanels))
require.Equal(t, int64(1), result.Result.LibraryPanels[0].FolderID)
require.Equal(t, "Text - Library Panel", result.Result.LibraryPanels[0].Name)
sc.reqContext.SignedInUser.OrgId = 2
sc.reqContext.SignedInUser.OrgRole = models.ROLE_ADMIN
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
result = libraryPanelsResult{}
result = libraryPanelsSearch{}
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.NotNil(t, result.Result)
require.Equal(t, 0, len(result.Result))
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 0,
LibraryPanels: []libraryPanel{},
Page: 1,
PerPage: 100,
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
}

View File

@@ -343,10 +343,10 @@ func TestLibraryPanelPermissions(t *testing.T) {
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var actual libraryPanelsResult
var actual libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &actual)
require.NoError(t, err)
require.Equal(t, testCase.panels, len(actual.Result))
require.Equal(t, testCase.panels, len(actual.Result.LibraryPanels))
for _, folderIndex := range testCase.folderIndexes {
var folderID = int64(folderIndex + 2) // testScenario creates one folder and general folder doesn't count
var foundResult libraryPanel
@@ -359,7 +359,7 @@ func TestLibraryPanelPermissions(t *testing.T) {
}
require.NotEmpty(t, foundResult)
for _, result := range actual.Result {
for _, result := range actual.Result.LibraryPanels {
if result.FolderID == folderID {
actualResult = result
break
@@ -386,11 +386,11 @@ func TestLibraryPanelPermissions(t *testing.T) {
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var actual libraryPanelsResult
var actual libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &actual)
require.NoError(t, err)
require.Equal(t, 1, len(actual.Result))
if diff := cmp.Diff(result.Result, actual.Result[0], getCompareOptions()...); diff != "" {
require.Equal(t, 1, len(actual.Result.LibraryPanels))
if diff := cmp.Diff(result.Result, actual.Result.LibraryPanels[0], getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})

View File

@@ -646,11 +646,11 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) {
func(t *testing.T, sc scenarioContext) {
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsResult
var result libraryPanelsSearch
err := json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.NotNil(t, result.Result)
require.Equal(t, 1, len(result.Result))
require.Equal(t, 1, len(result.Result.LibraryPanels))
err = sc.service.DeleteLibraryPanelsInFolder(sc.reqContext, sc.folder.Uid)
require.NoError(t, err)
@@ -659,7 +659,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) {
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
require.NotNil(t, result.Result)
require.Equal(t, 0, len(result.Result))
require.Equal(t, 0, len(result.Result.LibraryPanels))
})
}
@@ -678,8 +678,15 @@ type libraryPanelResult struct {
Result libraryPanel `json:"result"`
}
type libraryPanelsResult struct {
Result []libraryPanel `json:"result"`
type libraryPanelsSearch struct {
Result libraryPanelsSearchResult `json:"result"`
}
type libraryPanelsSearchResult struct {
TotalCount int64 `json:"totalCount"`
LibraryPanels []libraryPanel `json:"libraryPanels"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
type libraryPanelDashboardsResult struct {

View File

@@ -58,6 +58,14 @@ type LibraryPanelDTO struct {
Meta LibraryPanelDTOMeta `json:"meta"`
}
// LibraryPanelSearchResult is the search result for library panels.
type LibraryPanelSearchResult struct {
TotalCount int64 `json:"totalCount"`
LibraryPanels []LibraryPanelDTO `json:"libraryPanels"`
Page int `json:"page"`
PerPage int `json:"perPage"`
}
// LibraryPanelDTOMeta is the meta information for LibraryPanelDTO.
type LibraryPanelDTOMeta struct {
CanEdit bool `json:"canEdit"`

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Icon, IconButton, ConfirmModal, Tooltip, useStyles, Card } from '@grafana/ui';
import { Card, ConfirmModal, Icon, IconButton, Tooltip, useStyles } from '@grafana/ui';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { LibraryPanelDTO } from '../../types';
@@ -7,7 +7,7 @@ import { LibraryPanelDTO } from '../../types';
export interface LibraryPanelCardProps {
libraryPanel: LibraryPanelDTO;
onClick?: (panel: LibraryPanelDTO) => void;
onDelete?: () => void;
onDelete?: (panel: LibraryPanelDTO) => void;
showSecondaryActions?: boolean;
formatDate?: (dateString: string) => string;
}
@@ -24,7 +24,7 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
const [showDeletionModal, setShowDeletionModal] = useState(false);
const onDeletePanel = () => {
onDelete?.();
onDelete?.(libraryPanel);
setShowDeletionModal(false);
};

View File

@@ -1,12 +1,13 @@
import React, { useEffect, useState } from 'react';
import React, { FormEvent, useMemo, useReducer } from 'react';
import { useDebounce } from 'react-use';
import { css, cx } from 'emotion';
import { Button, Icon, Input, stylesFactory, useStyles } from '@grafana/ui';
import { DateTimeInput, GrafanaTheme } from '@grafana/data';
import { Button, Icon, Input, Pagination, stylesFactory, useStyles } from '@grafana/ui';
import { DateTimeInput, GrafanaTheme, LoadingState } from '@grafana/data';
import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard';
import { deleteLibraryPanel, getLibraryPanels } from '../../state/api';
import { LibraryPanelDTO } from '../../types';
import { changePage, changeSearchString, initialLibraryPanelsViewState, libraryPanelsViewReducer } from './reducer';
import { asyncDispatcher, deleteLibraryPanel, searchForLibraryPanels } from './actions';
interface LibraryPanelViewProps {
className?: string;
@@ -28,39 +29,24 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
currentPanelId: currentPanel,
}) => {
const styles = useStyles(getPanelViewStyles);
const [searchString, setSearchString] = useState('');
// Deliberately not using useAsync here as we want to be able to update libraryPanels without
// making an additional API request (for example when a user deletes a library panel and we want to update the view to reflect that)
const [libraryPanels, setLibraryPanels] = useState<LibraryPanelDTO[] | undefined>(undefined);
useEffect(() => {
getLibraryPanels().then((panels) => {
setLibraryPanels(panels);
});
}, []);
const [filteredItems, setFilteredItems] = useState(libraryPanels);
useDebounce(
() => {
setFilteredItems(
libraryPanels?.filter(
(v) => v.name.toLowerCase().includes(searchString.toLowerCase()) && v.uid !== currentPanel
)
);
},
300,
[searchString, libraryPanels, currentPanel]
);
const onDeletePanel = async (uid: string) => {
try {
await deleteLibraryPanel(uid);
const panelIndex = libraryPanels!.findIndex((panel) => panel.uid === uid);
setLibraryPanels([...libraryPanels!.slice(0, panelIndex), ...libraryPanels!.slice(panelIndex + 1)]);
} catch (err) {
throw err;
}
};
const [
{ libraryPanels, searchString, page, perPage, numberOfPages, loadingState, currentPanelId },
dispatch,
] = useReducer(libraryPanelsViewReducer, {
...initialLibraryPanelsViewState,
currentPanelId: currentPanel,
});
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
useDebounce(() => asyncDispatch(searchForLibraryPanels({ searchString, page, perPage, currentPanelId })), 300, [
searchString,
page,
asyncDispatch,
]);
const onSearchChange = (event: FormEvent<HTMLInputElement>) =>
asyncDispatch(changeSearchString({ searchString: event.currentTarget.value }));
const onDelete = ({ uid }: LibraryPanelDTO) =>
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
const onPageChange = (page: number) => asyncDispatch(changePage({ page }));
return (
<div className={cx(styles.container, className)}>
@@ -70,21 +56,21 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
prefix={<Icon name="search" />}
value={searchString}
autoFocus
onChange={(e) => setSearchString(e.currentTarget.value)}
></Input>
onChange={onSearchChange}
/>
{/* <Select placeholder="Filter by" onChange={() => {}} width={35} /> */}
</div>
<div className={styles.libraryPanelList}>
{libraryPanels === undefined ? (
{loadingState === LoadingState.Loading ? (
<p>Loading library panels...</p>
) : filteredItems?.length! < 1 ? (
) : libraryPanels.length < 1 ? (
<p>No library panels found.</p>
) : (
filteredItems?.map((item, i) => (
libraryPanels?.map((item, i) => (
<LibraryPanelCard
key={`shared-panel=${i}`}
libraryPanel={item}
onDelete={() => onDeletePanel(item.uid)}
onDelete={onDelete}
onClick={onClickCard}
formatDate={formatDate}
showSecondaryActions={showSecondaryActions}
@@ -94,6 +80,17 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
))
)}
</div>
{libraryPanels.length ? (
<div className={styles.pagination}>
<Pagination
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage={true}
/>
</div>
) : null}
{onCreateNewPanel && (
<Button icon="plus" className={styles.newPanelButton} onClick={onCreateNewPanel}>
Create a new reusable panel
@@ -111,6 +108,7 @@ const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
flex-wrap: nowrap;
gap: ${theme.spacing.sm};
height: 100%;
overflow-y: auto;
`,
libraryPanelList: css`
display: flex;
@@ -124,5 +122,8 @@ const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
margin-top: 10px;
align-self: flex-start;
`,
pagination: css`
align-self: center;
`,
};
});

View File

@@ -0,0 +1,60 @@
import { Dispatch } from 'react';
import { AnyAction } from '@reduxjs/toolkit';
import { from, merge, of, Subscription, timer } from 'rxjs';
import { catchError, finalize, mapTo, mergeMap, share, takeUntil } from 'rxjs/operators';
import { deleteLibraryPanel as apiDeleteLibraryPanel, getLibraryPanels } from '../../state/api';
import { initialLibraryPanelsViewState, initSearch, LibraryPanelsViewState, searchCompleted } from './reducer';
type DispatchResult = (dispatch: Dispatch<AnyAction>) => void;
type SearchArgs = Pick<LibraryPanelsViewState, 'searchString' | 'perPage' | 'page' | 'currentPanelId'>;
export function searchForLibraryPanels(args: SearchArgs): DispatchResult {
return function (dispatch) {
const subscription = new Subscription();
const dataObservable = from(
getLibraryPanels({
name: args.searchString,
perPage: args.perPage,
page: args.page,
})
).pipe(
mergeMap(({ perPage, libraryPanels, page, totalCount }) =>
of(searchCompleted({ libraryPanels, page, perPage, totalCount }))
),
catchError((err) => {
console.error(err);
return of(searchCompleted({ ...initialLibraryPanelsViewState, page: args.page, perPage: args.perPage }));
}),
finalize(() => subscription.unsubscribe()), // make sure we unsubscribe
share()
);
subscription.add(
// If 50ms without a response dispatch a loading state
// mapTo will translate the timer event into a loading state
// takeUntil will cancel the timer emit when first response is received on the dataObservable
merge(timer(50).pipe(mapTo(initSearch()), takeUntil(dataObservable)), dataObservable).subscribe(dispatch)
);
};
}
export function deleteLibraryPanel(uid: string, args: SearchArgs): DispatchResult {
return async function (dispatch) {
try {
await apiDeleteLibraryPanel(uid);
searchForLibraryPanels(args)(dispatch);
} catch (e) {
console.error(e);
}
};
}
export function asyncDispatcher(dispatch: Dispatch<AnyAction>) {
return function (action: any) {
if (action instanceof Function) {
return action(dispatch);
}
return dispatch(action);
};
}

View File

@@ -0,0 +1,144 @@
import { LoadingState } from '@grafana/data';
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import {
changePage,
changeSearchString,
initialLibraryPanelsViewState,
initSearch,
libraryPanelsViewReducer,
LibraryPanelsViewState,
searchCompleted,
} from './reducer';
import { LibraryPanelDTO } from '../../types';
describe('libraryPanelsViewReducer', () => {
describe('when initSearch is dispatched', () => {
it('then the state should be correct', () => {
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(initSearch())
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
loadingState: LoadingState.Loading,
});
});
});
describe('when searchCompleted is dispatched', () => {
it('then the state should be correct', () => {
const payload = {
perPage: 10,
page: 3,
libraryPanels: getLibraryPanelMocks(2),
totalCount: 200,
};
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(searchCompleted(payload))
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
perPage: 10,
page: 3,
libraryPanels: payload.libraryPanels,
totalCount: 200,
loadingState: LoadingState.Done,
numberOfPages: 20,
});
});
describe('and page is greater than the current number of pages', () => {
it('then the state should be correct', () => {
const payload = {
perPage: 10,
page: 21,
libraryPanels: getLibraryPanelMocks(2),
totalCount: 200,
};
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(searchCompleted(payload))
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
perPage: 10,
page: 20,
libraryPanels: payload.libraryPanels,
totalCount: 200,
loadingState: LoadingState.Done,
numberOfPages: 20,
});
});
});
});
describe('when changeSearchString is dispatched', () => {
it('then the state should be correct', () => {
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(changeSearchString({ searchString: 'a search string' }))
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
searchString: 'a search string',
});
});
});
describe('when changePage is dispatched', () => {
it('then the state should be correct', () => {
reducerTester<LibraryPanelsViewState>()
.givenReducer(libraryPanelsViewReducer, { ...initialLibraryPanelsViewState })
.whenActionIsDispatched(changePage({ page: 42 }))
.thenStateShouldEqual({
...initialLibraryPanelsViewState,
page: 42,
});
});
});
});
function getLibraryPanelMocks(count: number): LibraryPanelDTO[] {
const mocks: LibraryPanelDTO[] = [];
for (let i = 0; i < count; i++) {
mocks.push(
mockLibraryPanel({
uid: i.toString(10),
id: i,
name: `Test Panel ${i}`,
})
);
}
return mocks;
}
function mockLibraryPanel({
uid = '1',
id = 1,
orgId = 1,
folderId = 0,
name = 'Test Panel',
model = { type: 'text', title: 'Test Panel' },
meta = {
canEdit: true,
connectedDashboards: 0,
created: '2021-01-01T00:00:00',
createdBy: { id: 1, name: 'User X', avatarUrl: '/avatar/abc' },
updated: '2021-01-02T00:00:00',
updatedBy: { id: 2, name: 'User Y', avatarUrl: '/avatar/xyz' },
},
version = 1,
}: Partial<LibraryPanelDTO> = {}): LibraryPanelDTO {
return {
uid,
id,
orgId,
folderId,
name,
model,
version,
meta,
};
}

View File

@@ -0,0 +1,59 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { LoadingState } from '@grafana/data';
import { LibraryPanelDTO } from '../../types';
export interface LibraryPanelsViewState {
loadingState: LoadingState;
libraryPanels: LibraryPanelDTO[];
searchString: string;
totalCount: number;
perPage: number;
page: number;
numberOfPages: number;
currentPanelId?: string;
}
export const initialLibraryPanelsViewState: LibraryPanelsViewState = {
loadingState: LoadingState.NotStarted,
libraryPanels: [],
searchString: '',
totalCount: 0,
perPage: 10,
page: 1,
numberOfPages: 0,
currentPanelId: undefined,
};
const libraryPanelsViewSlice = createSlice({
name: 'libraryPanels/view',
initialState: initialLibraryPanelsViewState,
reducers: {
initSearch: (state) => {
state.loadingState = LoadingState.Loading;
},
searchCompleted: (
state,
action: PayloadAction<
Omit<LibraryPanelsViewState, 'currentPanelId' | 'searchString' | 'loadingState' | 'numberOfPages'>
>
) => {
const { libraryPanels, page, perPage, totalCount } = action.payload;
state.libraryPanels = libraryPanels;
state.perPage = perPage;
state.totalCount = totalCount;
state.loadingState = LoadingState.Done;
state.numberOfPages = Math.ceil(totalCount / perPage);
state.page = page > state.numberOfPages ? page - 1 : page;
},
changeSearchString: (state, action: PayloadAction<Pick<LibraryPanelsViewState, 'searchString'>>) => {
state.searchString = action.payload.searchString;
},
changePage: (state, action: PayloadAction<Pick<LibraryPanelsViewState, 'page'>>) => {
state.page = action.payload.page;
},
},
});
export const libraryPanelsViewReducer = libraryPanelsViewSlice.reducer;
export const { initSearch, searchCompleted, changeSearchString, changePage } = libraryPanelsViewSlice.actions;

View File

@@ -1,8 +1,26 @@
import { getBackendSrv } from '@grafana/runtime';
import { LibraryPanelDTO, PanelModelWithLibraryPanel } from '../types';
import { LibraryPanelDTO, LibraryPanelSearchResult, PanelModelWithLibraryPanel } from '../types';
export async function getLibraryPanels(): Promise<LibraryPanelDTO[]> {
const { result } = await getBackendSrv().get(`/api/library-panels`);
export interface GetLibraryPanelsOptions {
name?: string;
perPage?: number;
page?: number;
excludeUid?: string;
}
export async function getLibraryPanels({
name = '',
perPage = 100,
page = 1,
excludeUid = '',
}: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> {
const params = new URLSearchParams();
params.append('name', name);
params.append('excludeUid', excludeUid);
params.append('perPage', perPage.toString(10));
params.append('page', page.toString(10));
const { result } = await getBackendSrv().get(`/api/library-panels?${params.toString()}`);
return result;
}

View File

@@ -1,5 +1,12 @@
import { PanelModel } from '../dashboard/state';
export interface LibraryPanelSearchResult {
totalCount: number;
libraryPanels: LibraryPanelDTO[];
perPage: number;
page: number;
}
export interface LibraryPanelDTO {
id: number;
orgId: number;