mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -13,9 +13,11 @@ interface Props {
|
|||||||
numberOfPages: number;
|
numberOfPages: number;
|
||||||
/** Callback function for fetching the selected page */
|
/** Callback function for fetching the selected page */
|
||||||
onNavigate: (toPage: number) => void;
|
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 styles = getStyles();
|
||||||
const pages = [...new Array(numberOfPages).keys()];
|
const pages = [...new Array(numberOfPages).keys()];
|
||||||
|
|
||||||
@@ -71,6 +73,10 @@ export const Pagination: React.FC<Props> = ({ currentPage, numberOfPages, onNavi
|
|||||||
return pagesToRender;
|
return pagesToRender;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (hideWhenSinglePage && numberOfPages <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<ol>
|
<ol>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (lps *LibraryPanelService) getHandler(c *models.ReqContext) response.Respon
|
|||||||
|
|
||||||
// getAllHandler handles GET /api/library-panels/.
|
// getAllHandler handles GET /api/library-panels/.
|
||||||
func (lps *LibraryPanelService) getAllHandler(c *models.ReqContext) response.Response {
|
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 {
|
if err != nil {
|
||||||
return toLibraryPanelError(err, "Failed to get library panels")
|
return toLibraryPanelError(err, "Failed to get library panels")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package librarypanels
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"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.
|
// 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)
|
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 {
|
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
|
||||||
builder := sqlstore.SQLBuilder{}
|
builder := sqlstore.SQLBuilder{}
|
||||||
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
|
builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
|
||||||
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
|
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(" 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(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 {
|
if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
|
||||||
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
|
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
|
||||||
}
|
}
|
||||||
if limit == 0 {
|
if perPage != 0 {
|
||||||
limit = 1000
|
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 {
|
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
retDTOs := make([]LibraryPanelDTO, 0)
|
return result, err
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConnectedDashboards gets all dashboards connected to a Library Panel.
|
// getConnectedDashboards gets all dashboards connected to a Library Panel.
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ func TestGetAllLibraryPanels(t *testing.T) {
|
|||||||
resp := sc.service.getAllHandler(sc.reqContext)
|
resp := sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
|
|
||||||
var result libraryPanelsResult
|
var result libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &result)
|
err := json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result.Result)
|
var expected = libraryPanelsSearch{
|
||||||
require.Equal(t, 0, len(result.Result))
|
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",
|
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)
|
resp = sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
|
|
||||||
var result libraryPanelsResult
|
var result libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &result)
|
err := json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
var expected = libraryPanelsResult{
|
var expected = libraryPanelsSearch{
|
||||||
Result: []libraryPanel{
|
Result: libraryPanelsSearchResult{
|
||||||
{
|
TotalCount: 2,
|
||||||
ID: 1,
|
Page: 1,
|
||||||
OrgID: 1,
|
PerPage: 100,
|
||||||
FolderID: 1,
|
LibraryPanels: []libraryPanel{
|
||||||
UID: result.Result[0].UID,
|
{
|
||||||
Name: "Text - Library Panel",
|
ID: 1,
|
||||||
Model: map[string]interface{}{
|
OrgID: 1,
|
||||||
"datasource": "${DS_GDEV-TESTDATA}",
|
FolderID: 1,
|
||||||
"id": float64(1),
|
UID: result.Result.LibraryPanels[0].UID,
|
||||||
"title": "Text - Library Panel",
|
Name: "Text - Library Panel",
|
||||||
"type": "text",
|
Model: map[string]interface{}{
|
||||||
},
|
"datasource": "${DS_GDEV-TESTDATA}",
|
||||||
Version: 1,
|
"id": float64(1),
|
||||||
Meta: LibraryPanelDTOMeta{
|
"title": "Text - Library Panel",
|
||||||
CanEdit: true,
|
"type": "text",
|
||||||
ConnectedDashboards: 0,
|
|
||||||
Created: result.Result[0].Meta.Created,
|
|
||||||
Updated: result.Result[0].Meta.Updated,
|
|
||||||
CreatedBy: LibraryPanelDTOMetaUser{
|
|
||||||
ID: 1,
|
|
||||||
Name: UserInDbName,
|
|
||||||
AvatarUrl: UserInDbAvatar,
|
|
||||||
},
|
},
|
||||||
UpdatedBy: LibraryPanelDTOMetaUser{
|
Version: 1,
|
||||||
ID: 1,
|
Meta: LibraryPanelDTOMeta{
|
||||||
Name: UserInDbName,
|
CanEdit: true,
|
||||||
AvatarUrl: UserInDbAvatar,
|
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,
|
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
|
||||||
FolderID: 1,
|
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||||
UID: result.Result[1].UID,
|
}
|
||||||
Name: "Text - Library Panel2",
|
})
|
||||||
Model: map[string]interface{}{
|
|
||||||
"datasource": "${DS_GDEV-TESTDATA}",
|
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",
|
||||||
"id": float64(1),
|
func(t *testing.T, sc scenarioContext) {
|
||||||
"title": "Text - Library Panel2",
|
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
|
||||||
"type": "text",
|
resp := sc.service.createHandler(sc.reqContext, command)
|
||||||
},
|
require.Equal(t, 200, resp.Status())
|
||||||
Version: 1,
|
|
||||||
Meta: LibraryPanelDTOMeta{
|
err := sc.reqContext.Req.ParseForm()
|
||||||
CanEdit: true,
|
require.NoError(t, err)
|
||||||
ConnectedDashboards: 0,
|
sc.reqContext.Req.Form.Add("excludeUid", sc.initialResult.Result.UID)
|
||||||
Created: result.Result[1].Meta.Created,
|
resp = sc.service.getAllHandler(sc.reqContext)
|
||||||
Updated: result.Result[1].Meta.Updated,
|
require.Equal(t, 200, resp.Status())
|
||||||
CreatedBy: LibraryPanelDTOMetaUser{
|
|
||||||
ID: 1,
|
var result libraryPanelsSearch
|
||||||
Name: UserInDbName,
|
err = json.Unmarshal(resp.Body(), &result)
|
||||||
AvatarUrl: UserInDbAvatar,
|
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{
|
Version: 1,
|
||||||
ID: 1,
|
Meta: LibraryPanelDTOMeta{
|
||||||
Name: UserInDbName,
|
CanEdit: true,
|
||||||
AvatarUrl: UserInDbAvatar,
|
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",
|
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) {
|
func(t *testing.T, sc scenarioContext) {
|
||||||
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
|
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
|
||||||
@@ -121,11 +434,11 @@ func TestGetAllLibraryPanels(t *testing.T) {
|
|||||||
resp = sc.service.getAllHandler(sc.reqContext)
|
resp = sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
|
|
||||||
var results libraryPanelsResult
|
var results libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &results)
|
err := json.Unmarshal(resp.Body(), &results)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(0), results.Result[0].Meta.ConnectedDashboards)
|
require.Equal(t, int64(0), results.Result.LibraryPanels[0].Meta.ConnectedDashboards)
|
||||||
require.Equal(t, int64(2), results.Result[1].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",
|
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)
|
resp := sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
|
|
||||||
var result libraryPanelsResult
|
var result libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &result)
|
err := json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 1, len(result.Result))
|
require.Equal(t, 1, len(result.Result.LibraryPanels))
|
||||||
require.Equal(t, int64(1), result.Result[0].FolderID)
|
require.Equal(t, int64(1), result.Result.LibraryPanels[0].FolderID)
|
||||||
require.Equal(t, "Text - Library Panel", result.Result[0].Name)
|
require.Equal(t, "Text - Library Panel", result.Result.LibraryPanels[0].Name)
|
||||||
|
|
||||||
sc.reqContext.SignedInUser.OrgId = 2
|
sc.reqContext.SignedInUser.OrgId = 2
|
||||||
sc.reqContext.SignedInUser.OrgRole = models.ROLE_ADMIN
|
sc.reqContext.SignedInUser.OrgRole = models.ROLE_ADMIN
|
||||||
resp = sc.service.getAllHandler(sc.reqContext)
|
resp = sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
|
|
||||||
result = libraryPanelsResult{}
|
result = libraryPanelsSearch{}
|
||||||
err = json.Unmarshal(resp.Body(), &result)
|
err = json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result.Result)
|
var expected = libraryPanelsSearch{
|
||||||
require.Equal(t, 0, len(result.Result))
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,10 +343,10 @@ func TestLibraryPanelPermissions(t *testing.T) {
|
|||||||
|
|
||||||
resp := sc.service.getAllHandler(sc.reqContext)
|
resp := sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
var actual libraryPanelsResult
|
var actual libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &actual)
|
err := json.Unmarshal(resp.Body(), &actual)
|
||||||
require.NoError(t, err)
|
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 {
|
for _, folderIndex := range testCase.folderIndexes {
|
||||||
var folderID = int64(folderIndex + 2) // testScenario creates one folder and general folder doesn't count
|
var folderID = int64(folderIndex + 2) // testScenario creates one folder and general folder doesn't count
|
||||||
var foundResult libraryPanel
|
var foundResult libraryPanel
|
||||||
@@ -359,7 +359,7 @@ func TestLibraryPanelPermissions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NotEmpty(t, foundResult)
|
require.NotEmpty(t, foundResult)
|
||||||
|
|
||||||
for _, result := range actual.Result {
|
for _, result := range actual.Result.LibraryPanels {
|
||||||
if result.FolderID == folderID {
|
if result.FolderID == folderID {
|
||||||
actualResult = result
|
actualResult = result
|
||||||
break
|
break
|
||||||
@@ -386,11 +386,11 @@ func TestLibraryPanelPermissions(t *testing.T) {
|
|||||||
|
|
||||||
resp = sc.service.getAllHandler(sc.reqContext)
|
resp = sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
var actual libraryPanelsResult
|
var actual libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &actual)
|
err := json.Unmarshal(resp.Body(), &actual)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 1, len(actual.Result))
|
require.Equal(t, 1, len(actual.Result.LibraryPanels))
|
||||||
if diff := cmp.Diff(result.Result, actual.Result[0], getCompareOptions()...); diff != "" {
|
if diff := cmp.Diff(result.Result, actual.Result.LibraryPanels[0], getCompareOptions()...); diff != "" {
|
||||||
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -646,11 +646,11 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) {
|
|||||||
func(t *testing.T, sc scenarioContext) {
|
func(t *testing.T, sc scenarioContext) {
|
||||||
resp := sc.service.getAllHandler(sc.reqContext)
|
resp := sc.service.getAllHandler(sc.reqContext)
|
||||||
require.Equal(t, 200, resp.Status())
|
require.Equal(t, 200, resp.Status())
|
||||||
var result libraryPanelsResult
|
var result libraryPanelsSearch
|
||||||
err := json.Unmarshal(resp.Body(), &result)
|
err := json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result.Result)
|
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)
|
err = sc.service.DeleteLibraryPanelsInFolder(sc.reqContext, sc.folder.Uid)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -659,7 +659,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) {
|
|||||||
err = json.Unmarshal(resp.Body(), &result)
|
err = json.Unmarshal(resp.Body(), &result)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, result.Result)
|
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"`
|
Result libraryPanel `json:"result"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type libraryPanelsResult struct {
|
type libraryPanelsSearch struct {
|
||||||
Result []libraryPanel `json:"result"`
|
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 {
|
type libraryPanelDashboardsResult struct {
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ type LibraryPanelDTO struct {
|
|||||||
Meta LibraryPanelDTOMeta `json:"meta"`
|
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.
|
// LibraryPanelDTOMeta is the meta information for LibraryPanelDTO.
|
||||||
type LibraryPanelDTOMeta struct {
|
type LibraryPanelDTOMeta struct {
|
||||||
CanEdit bool `json:"canEdit"`
|
CanEdit bool `json:"canEdit"`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { css } from 'emotion';
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { LibraryPanelDTO } from '../../types';
|
import { LibraryPanelDTO } from '../../types';
|
||||||
@@ -7,7 +7,7 @@ import { LibraryPanelDTO } from '../../types';
|
|||||||
export interface LibraryPanelCardProps {
|
export interface LibraryPanelCardProps {
|
||||||
libraryPanel: LibraryPanelDTO;
|
libraryPanel: LibraryPanelDTO;
|
||||||
onClick?: (panel: LibraryPanelDTO) => void;
|
onClick?: (panel: LibraryPanelDTO) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: (panel: LibraryPanelDTO) => void;
|
||||||
showSecondaryActions?: boolean;
|
showSecondaryActions?: boolean;
|
||||||
formatDate?: (dateString: string) => string;
|
formatDate?: (dateString: string) => string;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export const LibraryPanelCard: React.FC<LibraryPanelCardProps & { children?: JSX
|
|||||||
const [showDeletionModal, setShowDeletionModal] = useState(false);
|
const [showDeletionModal, setShowDeletionModal] = useState(false);
|
||||||
|
|
||||||
const onDeletePanel = () => {
|
const onDeletePanel = () => {
|
||||||
onDelete?.();
|
onDelete?.(libraryPanel);
|
||||||
setShowDeletionModal(false);
|
setShowDeletionModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { FormEvent, useMemo, useReducer } from 'react';
|
||||||
import { useDebounce } from 'react-use';
|
import { useDebounce } from 'react-use';
|
||||||
import { css, cx } from 'emotion';
|
import { css, cx } from 'emotion';
|
||||||
import { Button, Icon, Input, stylesFactory, useStyles } from '@grafana/ui';
|
import { Button, Icon, Input, Pagination, stylesFactory, useStyles } from '@grafana/ui';
|
||||||
import { DateTimeInput, GrafanaTheme } from '@grafana/data';
|
import { DateTimeInput, GrafanaTheme, LoadingState } from '@grafana/data';
|
||||||
|
|
||||||
import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard';
|
import { LibraryPanelCard } from '../LibraryPanelCard/LibraryPanelCard';
|
||||||
import { deleteLibraryPanel, getLibraryPanels } from '../../state/api';
|
|
||||||
import { LibraryPanelDTO } from '../../types';
|
import { LibraryPanelDTO } from '../../types';
|
||||||
|
import { changePage, changeSearchString, initialLibraryPanelsViewState, libraryPanelsViewReducer } from './reducer';
|
||||||
|
import { asyncDispatcher, deleteLibraryPanel, searchForLibraryPanels } from './actions';
|
||||||
|
|
||||||
interface LibraryPanelViewProps {
|
interface LibraryPanelViewProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -28,39 +29,24 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
|
|||||||
currentPanelId: currentPanel,
|
currentPanelId: currentPanel,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles(getPanelViewStyles);
|
const styles = useStyles(getPanelViewStyles);
|
||||||
const [searchString, setSearchString] = useState('');
|
const [
|
||||||
|
{ libraryPanels, searchString, page, perPage, numberOfPages, loadingState, currentPanelId },
|
||||||
// Deliberately not using useAsync here as we want to be able to update libraryPanels without
|
dispatch,
|
||||||
// making an additional API request (for example when a user deletes a library panel and we want to update the view to reflect that)
|
] = useReducer(libraryPanelsViewReducer, {
|
||||||
const [libraryPanels, setLibraryPanels] = useState<LibraryPanelDTO[] | undefined>(undefined);
|
...initialLibraryPanelsViewState,
|
||||||
useEffect(() => {
|
currentPanelId: currentPanel,
|
||||||
getLibraryPanels().then((panels) => {
|
});
|
||||||
setLibraryPanels(panels);
|
const asyncDispatch = useMemo(() => asyncDispatcher(dispatch), [dispatch]);
|
||||||
});
|
useDebounce(() => asyncDispatch(searchForLibraryPanels({ searchString, page, perPage, currentPanelId })), 300, [
|
||||||
}, []);
|
searchString,
|
||||||
|
page,
|
||||||
const [filteredItems, setFilteredItems] = useState(libraryPanels);
|
asyncDispatch,
|
||||||
useDebounce(
|
]);
|
||||||
() => {
|
const onSearchChange = (event: FormEvent<HTMLInputElement>) =>
|
||||||
setFilteredItems(
|
asyncDispatch(changeSearchString({ searchString: event.currentTarget.value }));
|
||||||
libraryPanels?.filter(
|
const onDelete = ({ uid }: LibraryPanelDTO) =>
|
||||||
(v) => v.name.toLowerCase().includes(searchString.toLowerCase()) && v.uid !== currentPanel
|
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));
|
||||||
)
|
const onPageChange = (page: number) => asyncDispatch(changePage({ page }));
|
||||||
);
|
|
||||||
},
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(styles.container, className)}>
|
<div className={cx(styles.container, className)}>
|
||||||
@@ -70,21 +56,21 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
|
|||||||
prefix={<Icon name="search" />}
|
prefix={<Icon name="search" />}
|
||||||
value={searchString}
|
value={searchString}
|
||||||
autoFocus
|
autoFocus
|
||||||
onChange={(e) => setSearchString(e.currentTarget.value)}
|
onChange={onSearchChange}
|
||||||
></Input>
|
/>
|
||||||
{/* <Select placeholder="Filter by" onChange={() => {}} width={35} /> */}
|
{/* <Select placeholder="Filter by" onChange={() => {}} width={35} /> */}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.libraryPanelList}>
|
<div className={styles.libraryPanelList}>
|
||||||
{libraryPanels === undefined ? (
|
{loadingState === LoadingState.Loading ? (
|
||||||
<p>Loading library panels...</p>
|
<p>Loading library panels...</p>
|
||||||
) : filteredItems?.length! < 1 ? (
|
) : libraryPanels.length < 1 ? (
|
||||||
<p>No library panels found.</p>
|
<p>No library panels found.</p>
|
||||||
) : (
|
) : (
|
||||||
filteredItems?.map((item, i) => (
|
libraryPanels?.map((item, i) => (
|
||||||
<LibraryPanelCard
|
<LibraryPanelCard
|
||||||
key={`shared-panel=${i}`}
|
key={`shared-panel=${i}`}
|
||||||
libraryPanel={item}
|
libraryPanel={item}
|
||||||
onDelete={() => onDeletePanel(item.uid)}
|
onDelete={onDelete}
|
||||||
onClick={onClickCard}
|
onClick={onClickCard}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
showSecondaryActions={showSecondaryActions}
|
showSecondaryActions={showSecondaryActions}
|
||||||
@@ -94,6 +80,17 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{libraryPanels.length ? (
|
||||||
|
<div className={styles.pagination}>
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
numberOfPages={numberOfPages}
|
||||||
|
onNavigate={onPageChange}
|
||||||
|
hideWhenSinglePage={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{onCreateNewPanel && (
|
{onCreateNewPanel && (
|
||||||
<Button icon="plus" className={styles.newPanelButton} onClick={onCreateNewPanel}>
|
<Button icon="plus" className={styles.newPanelButton} onClick={onCreateNewPanel}>
|
||||||
Create a new reusable panel
|
Create a new reusable panel
|
||||||
@@ -111,6 +108,7 @@ const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: ${theme.spacing.sm};
|
gap: ${theme.spacing.sm};
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
`,
|
`,
|
||||||
libraryPanelList: css`
|
libraryPanelList: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -124,5 +122,8 @@ const getPanelViewStyles = stylesFactory((theme: GrafanaTheme) => {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
`,
|
`,
|
||||||
|
pagination: css`
|
||||||
|
align-self: center;
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { LibraryPanelDTO, PanelModelWithLibraryPanel } from '../types';
|
import { LibraryPanelDTO, LibraryPanelSearchResult, PanelModelWithLibraryPanel } from '../types';
|
||||||
|
|
||||||
export async function getLibraryPanels(): Promise<LibraryPanelDTO[]> {
|
export interface GetLibraryPanelsOptions {
|
||||||
const { result } = await getBackendSrv().get(`/api/library-panels`);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { PanelModel } from '../dashboard/state';
|
import { PanelModel } from '../dashboard/state';
|
||||||
|
|
||||||
|
export interface LibraryPanelSearchResult {
|
||||||
|
totalCount: number;
|
||||||
|
libraryPanels: LibraryPanelDTO[];
|
||||||
|
perPage: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LibraryPanelDTO {
|
export interface LibraryPanelDTO {
|
||||||
id: number;
|
id: number;
|
||||||
orgId: number;
|
orgId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user