LibraryPanels: Adds folder filter to manage library panel page (#33560)

* LibraryPanels: Adds folder filter

* Refactor: Adds folder filter to library search

* Refactor: splits huge function into smaller functions

* LibraryPanels: Adds Panels Page to Manage Folder tabs (#33618)

* Chore: adds tests to LibraryPanelsSearch

* Refactor: Adds reducer and tests

* Chore: changes GrafanaThemeV2

* Refactor: pulls everything behind the feature toggle

* Chore: removes clear icon from FolderFilter

* Chore: adds filter to SortPicker

* Refactor: using useAsync instead
This commit is contained in:
Hugo Häggmark 2021-05-04 13:59:40 +02:00 committed by GitHub
parent 918552d34b
commit c6d4d14a89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1002 additions and 105 deletions

View File

@ -167,12 +167,15 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Url: hs.Cfg.AppSubURL + "/dashboard/snapshots", Url: hs.Cfg.AppSubURL + "/dashboard/snapshots",
Icon: "camera", Icon: "camera",
}) })
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Library panels", if hs.Cfg.IsPanelLibraryEnabled() {
Id: "library-panels", dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Url: hs.Cfg.AppSubURL + "/library-panels", Text: "Library panels",
Icon: "library-panel", Id: "library-panels",
}) Url: hs.Cfg.AppSubURL + "/library-panels",
Icon: "library-panel",
})
}
} }
navTree = append(navTree, &dtos.NavLink{ navTree = append(navTree, &dtos.NavLink{

View File

@ -88,6 +88,7 @@ func (lps *LibraryPanelService) getAllHandler(c *models.ReqContext) response.Res
sortDirection: c.Query("sortDirection"), sortDirection: c.Query("sortDirection"),
panelFilter: c.Query("panelFilter"), panelFilter: c.Query("panelFilter"),
excludeUID: c.Query("excludeUid"), excludeUID: c.Query("excludeUid"),
folderFilter: c.Query("folderFilter"),
} }
libraryPanels, err := lps.getAllLibraryPanels(c, query) libraryPanels, err := lps.getAllLibraryPanels(c, query)
if err != nil { if err != nil {

View File

@ -1,16 +1,14 @@
package librarypanels package librarypanels
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@ -400,46 +398,28 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
if len(strings.TrimSpace(query.panelFilter)) > 0 { if len(strings.TrimSpace(query.panelFilter)) > 0 {
panelFilter = strings.Split(query.panelFilter, ",") panelFilter = strings.Split(query.panelFilter, ",")
} }
folderFilter := parseFolderFilter(query)
if folderFilter.parseError != nil {
return LibraryPanelSearchResult{}, folderFilter.parseError
}
err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error { err := lps.SQLStore.WithDbSession(c.Context.Req.Context(), func(session *sqlstore.DBSession) error {
builder := sqlstore.SQLBuilder{} builder := sqlstore.SQLBuilder{}
builder.Write(sqlStatmentLibrayPanelDTOWithMeta) if folderFilter.includeGeneralFolder {
builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId) builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
if len(strings.TrimSpace(query.searchString)) > 0 { builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId)
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writeSearchStringSQL(query, lps.SQLStore, &builder)
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writeExcludeSQL(query, &builder)
writePanelFilterSQL(panelFilter, &builder)
builder.Write(" UNION ")
} }
if len(strings.TrimSpace(query.excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
builder.Write(sql.String(), params...)
}
builder.Write(" UNION ")
builder.Write(sqlStatmentLibrayPanelDTOWithMeta) builder.Write(sqlStatmentLibrayPanelDTOWithMeta)
builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0") builder.Write(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0")
builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId) builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
if len(strings.TrimSpace(query.searchString)) > 0 { writeSearchStringSQL(query, lps.SQLStore, &builder)
builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writeExcludeSQL(query, &builder)
builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writePanelFilterSQL(panelFilter, &builder)
} if err := folderFilter.writeFolderFilterSQL(false, &builder); err != nil {
if len(strings.TrimSpace(query.excludeUID)) > 0 { return err
builder.Write(" AND lp.uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
builder.Write(sql.String(), params...)
} }
if c.SignedInUser.OrgRole != models.ROLE_ADMIN { if c.SignedInUser.OrgRole != models.ROLE_ADMIN {
builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW) builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW)
@ -449,10 +429,7 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
} else { } else {
builder.Write(" ORDER BY 1 ASC") builder.Write(" ORDER BY 1 ASC")
} }
if query.perPage != 0 { writePerPageSQL(query, lps.SQLStore, &builder)
offset := query.perPage * (query.page - 1)
builder.Write(lps.SQLStore.Dialect.LimitOffset(int64(query.perPage), int64(offset)))
}
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil { if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil {
return err return err
} }
@ -490,23 +467,13 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query
var panels []LibraryPanel var panels []LibraryPanel
countBuilder := sqlstore.SQLBuilder{} countBuilder := sqlstore.SQLBuilder{}
countBuilder.Write("SELECT * FROM library_panel") countBuilder.Write("SELECT * FROM library_panel AS lp")
countBuilder.Write(` WHERE org_id=?`, c.SignedInUser.OrgId) countBuilder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId)
if len(strings.TrimSpace(query.searchString)) > 0 { writeSearchStringSQL(query, lps.SQLStore, &countBuilder)
countBuilder.Write(" AND name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writeExcludeSQL(query, &countBuilder)
countBuilder.Write(" OR description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") writePanelFilterSQL(panelFilter, &countBuilder)
} if err := folderFilter.writeFolderFilterSQL(true, &countBuilder); err != nil {
if len(strings.TrimSpace(query.excludeUID)) > 0 { return err
countBuilder.Write(" AND uid <> ?", query.excludeUID)
}
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, v := range panelFilter {
params = append(params, v)
}
countBuilder.Write(sql.String(), params...)
} }
if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil { if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil {
return err return err

View File

@ -2,6 +2,7 @@ package librarypanels
import ( import (
"encoding/json" "encoding/json"
"strconv"
"testing" "testing"
"github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/search"
@ -372,6 +373,196 @@ func TestGetAllLibraryPanels(t *testing.T) {
} }
}) })
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to existing folders, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{})
command := getCreateCommand(newFolder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
folderFilter := strconv.FormatInt(newFolder.Id, 10)
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 1,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 2,
OrgID: 1,
FolderID: newFolder.Id,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel2",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel2",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to non existing folders, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
newFolder := createFolderWithACL(t, sc.sqlStore, "NewFolder", sc.user, []folderACLItem{})
command := getCreateCommand(newFolder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
folderFilter := "2020,2021"
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 0,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and folderFilter is set to General folder, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")
resp := sc.service.createHandler(sc.reqContext, command)
require.Equal(t, 200, resp.Status())
folderFilter := "0"
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("folderFilter", folderFilter)
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryPanelsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
var expected = libraryPanelsSearch{
Result: libraryPanelsSearchResult{
TotalCount: 0,
Page: 1,
PerPage: 100,
LibraryPanels: []libraryPanel{
{
ID: 1,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[0].UID,
Name: "Text - Library Panel",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[0].Meta.Created,
Updated: result.Result.LibraryPanels[0].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
{
ID: 2,
OrgID: 1,
FolderID: 1,
UID: result.Result.LibraryPanels[1].UID,
Name: "Text - Library Panel2",
Type: "text",
Description: "A description",
Model: map[string]interface{}{
"datasource": "${DS_GDEV-TESTDATA}",
"description": "A description",
"id": float64(1),
"title": "Text - Library Panel2",
"type": "text",
},
Version: 1,
Meta: LibraryPanelDTOMeta{
CanEdit: true,
ConnectedDashboards: 0,
Created: result.Result.LibraryPanels[1].Meta.Created,
Updated: result.Result.LibraryPanels[1].Meta.Updated,
CreatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
UpdatedBy: LibraryPanelDTOMetaUser{
ID: 1,
Name: UserInDbName,
AvatarUrl: UserInDbAvatar,
},
},
},
},
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and excludeUID is set, it should succeed and the result should be correct", scenarioWithLibraryPanel(t, "When an admin tries to get all library panels and two exist and excludeUID is set, it should succeed and the result should be correct",
func(t *testing.T, sc scenarioContext) { func(t *testing.T, sc scenarioContext) {
command := getCreateCommand(sc.folder.Id, "Text - Library Panel2") command := getCreateCommand(sc.folder.Id, "Text - Library Panel2")

View File

@ -146,4 +146,5 @@ type searchLibraryPanelsQuery struct {
sortDirection string sortDirection string
panelFilter string panelFilter string
excludeUID string excludeUID string
folderFilter string
} }

View File

@ -0,0 +1,102 @@
package librarypanels
import (
"bytes"
"strconv"
"strings"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
func writePerPageSQL(query searchLibraryPanelsQuery, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) {
if query.perPage != 0 {
offset := query.perPage * (query.page - 1)
builder.Write(sqlStore.Dialect.LimitOffset(int64(query.perPage), int64(offset)))
}
}
func writePanelFilterSQL(panelFilter []string, builder *sqlstore.SQLBuilder) {
if len(panelFilter) > 0 {
var sql bytes.Buffer
params := make([]interface{}, 0)
sql.WriteString(` AND lp.type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")")
for _, filter := range panelFilter {
params = append(params, filter)
}
builder.Write(sql.String(), params...)
}
}
func writeSearchStringSQL(query searchLibraryPanelsQuery, sqlStore *sqlstore.SQLStore, builder *sqlstore.SQLBuilder) {
if len(strings.TrimSpace(query.searchString)) > 0 {
builder.Write(" AND (lp.name "+sqlStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%")
builder.Write(" OR lp.description "+sqlStore.Dialect.LikeStr()+" ?)", "%"+query.searchString+"%")
}
}
func writeExcludeSQL(query searchLibraryPanelsQuery, builder *sqlstore.SQLBuilder) {
if len(strings.TrimSpace(query.excludeUID)) > 0 {
builder.Write(" AND lp.uid <> ?", query.excludeUID)
}
}
type FolderFilter struct {
includeGeneralFolder bool
folderIDs []string
parseError error
}
func parseFolderFilter(query searchLibraryPanelsQuery) FolderFilter {
var folderIDs []string
if len(strings.TrimSpace(query.folderFilter)) == 0 {
return FolderFilter{
includeGeneralFolder: true,
folderIDs: folderIDs,
parseError: nil,
}
}
includeGeneralFolder := false
folderIDs = strings.Split(query.folderFilter, ",")
for _, filter := range folderIDs {
folderID, err := strconv.ParseInt(filter, 10, 64)
if err != nil {
return FolderFilter{
includeGeneralFolder: false,
folderIDs: folderIDs,
parseError: err,
}
}
if isGeneralFolder(folderID) {
includeGeneralFolder = true
break
}
}
return FolderFilter{
includeGeneralFolder: includeGeneralFolder,
folderIDs: folderIDs,
parseError: nil,
}
}
func (f *FolderFilter) writeFolderFilterSQL(includeGeneral bool, builder *sqlstore.SQLBuilder) error {
var sql bytes.Buffer
params := make([]interface{}, 0)
for _, filter := range f.folderIDs {
folderID, err := strconv.ParseInt(filter, 10, 64)
if err != nil {
return err
}
if !includeGeneral && isGeneralFolder(folderID) {
continue
}
params = append(params, filter)
}
if len(params) > 0 {
sql.WriteString(` AND lp.folder_id IN (?` + strings.Repeat(",?", len(params)-1) + ")")
builder.Write(sql.String(), params...)
}
return nil
}

View File

@ -43,6 +43,8 @@ import { PanelRenderer } from './features/panel/PanelRenderer';
import { QueryRunner } from './features/query/state/QueryRunner'; import { QueryRunner } from './features/query/state/QueryRunner';
import { getTimeSrv } from './features/dashboard/services/TimeSrv'; import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl'; import { getVariablesUrlParams } from './features/variables/getAllVariableValuesForUrl';
import { SafeDynamicImport } from './core/components/DynamicImports/SafeDynamicImport';
import { featureToggledRoutes } from './routes/routes';
// add move to lodash for backward compatabilty with plugins // add move to lodash for backward compatabilty with plugins
// @ts-ignore // @ts-ignore
@ -66,6 +68,21 @@ export class GrafanaApp {
} }
init() { init() {
if (config.featureToggles.panelLibrary) {
featureToggledRoutes.push({
path: '/dashboards/f/:uid/:slug/library-panels',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "FolderLibraryPanelsPage"*/ 'app/features/folders/FolderLibraryPanelsPage')
),
});
featureToggledRoutes.push({
path: '/library-panels',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
),
});
}
initEchoSrv(); initEchoSrv();
addClassIfNoOverlayScrollbar(); addClassIfNoOverlayScrollbar();
setLocale(config.bootData.user.locale); setLocale(config.bootData.user.locale);

View File

@ -0,0 +1,106 @@
import React, { useCallback, useMemo, useState } from 'react';
import { css } from '@emotion/css';
import debounce from 'debounce-promise';
import { AsyncMultiSelect, Icon, resetSelectStyles, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { FolderInfo } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv';
export interface FolderFilterProps {
onChange: (folder: FolderInfo[]) => void;
maxMenuHeight?: number;
}
export function FolderFilter({ onChange: propsOnChange, maxMenuHeight }: FolderFilterProps): JSX.Element {
const styles = useStyles2(getStyles);
const [loading, setLoading] = useState(false);
const getOptions = useCallback((searchString: string) => getFoldersAsOptions(searchString, setLoading), []);
const debouncedLoadOptions = useMemo(() => debounce(getOptions, 300), [getOptions]);
const [value, setValue] = useState<Array<SelectableValue<FolderInfo>>>([]);
const onChange = useCallback(
(folders: Array<SelectableValue<FolderInfo>>) => {
const changedFolders = [];
for (const folder of folders) {
if (folder.value) {
changedFolders.push(folder.value);
}
}
propsOnChange(changedFolders);
setValue(folders);
},
[propsOnChange]
);
const selectOptions = {
defaultOptions: true,
isMulti: true,
noOptionsMessage: 'No folders found',
placeholder: 'Filter by folder',
styles: resetSelectStyles(),
maxMenuHeight,
value,
onChange,
};
return (
<div className={styles.container}>
{value.length > 0 && (
<span className={styles.clear} onClick={() => onChange([])}>
Clear folders
</span>
)}
<AsyncMultiSelect
{...selectOptions}
isLoading={loading}
loadOptions={debouncedLoadOptions}
prefix={<Icon name="filter" />}
aria-label="Folder filter"
/>
</div>
);
}
async function getFoldersAsOptions(searchString: string, setLoading: (loading: boolean) => void) {
setLoading(true);
const params = {
query: searchString,
type: 'dash-folder',
permission: 'View',
};
const searchHits = await getBackendSrv().search(params);
const options = searchHits.map((d) => ({ label: d.title, value: { id: d.id, title: d.title } }));
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
options.unshift({ label: 'General', value: { id: 0, title: 'General' } });
}
setLoading(false);
return options;
}
function getStyles(theme: GrafanaTheme2) {
return {
container: css`
label: container;
position: relative;
min-width: 180px;
flex-grow: 1;
`,
clear: css`
label: clear;
text-decoration: underline;
font-size: ${theme.spacing(1.5)};
position: absolute;
top: -${theme.spacing(2.75)};
right: 0;
cursor: pointer;
color: ${theme.colors.text.link};
&:hover {
color: ${theme.colors.text.maxContrast};
}
`,
};
}

View File

@ -6,9 +6,10 @@ import { css } from '@emotion/css';
export interface Props { export interface Props {
onChange: (plugins: PanelPluginMeta[]) => void; onChange: (plugins: PanelPluginMeta[]) => void;
maxMenuHeight?: number;
} }
export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element => { export const PanelTypeFilter = ({ onChange: propsOnChange, maxMenuHeight }: Props): JSX.Element => {
const plugins = useMemo<PanelPluginMeta[]>(() => { const plugins = useMemo<PanelPluginMeta[]>(() => {
return getAllPanelPluginMeta(); return getAllPanelPluginMeta();
}, []); }, []);
@ -43,7 +44,7 @@ export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element
noOptionsMessage: 'No Panel types found', noOptionsMessage: 'No Panel types found',
placeholder: 'Filter by type', placeholder: 'Filter by type',
styles: resetSelectStyles(), styles: resetSelectStyles(),
maxMenuHeight: 150, maxMenuHeight,
options, options,
value, value,
onChange, onChange,

View File

@ -1,6 +1,6 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { Select, Icon, IconName } from '@grafana/ui'; import { Icon, IconName, Select } from '@grafana/ui';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { DEFAULT_SORT } from 'app/features/search/constants'; import { DEFAULT_SORT } from 'app/features/search/constants';
import { SearchSrv } from '../../services/search_srv'; import { SearchSrv } from '../../services/search_srv';
@ -11,25 +11,27 @@ export interface Props {
onChange: (sortValue: SelectableValue) => void; onChange: (sortValue: SelectableValue) => void;
value?: string; value?: string;
placeholder?: string; placeholder?: string;
filter?: string[];
} }
const getSortOptions = () => { const getSortOptions = (filter?: string[]) => {
return searchSrv.getSortOptions().then(({ sortOptions }) => { return searchSrv.getSortOptions().then(({ sortOptions }) => {
return sortOptions.map((opt: any) => ({ label: opt.displayName, value: opt.name })); const filteredOptions = filter ? sortOptions.filter((o: any) => filter.includes(o.name)) : sortOptions;
return filteredOptions.map((opt: any) => ({ label: opt.displayName, value: opt.name }));
}); });
}; };
export const SortPicker: FC<Props> = ({ onChange, value, placeholder }) => { export const SortPicker: FC<Props> = ({ onChange, value, placeholder, filter }) => {
// Using sync Select and manual options fetching here since we need to find the selected option by value // Using sync Select and manual options fetching here since we need to find the selected option by value
const { loading, value: options } = useAsync<SelectableValue[]>(getSortOptions, []); const { loading, value: options } = useAsync<SelectableValue[]>(() => getSortOptions(filter), []);
const selected = options?.filter((opt) => opt.value === value); const selected = options?.find((opt) => opt.value === value);
return !loading ? ( return !loading ? (
<Select <Select
key={value} key={value}
width={25} width={25}
onChange={onChange} onChange={onChange}
value={selected?.length ? selected : null} value={selected ?? null}
options={options} options={options}
placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`} placeholder={placeholder ?? `Sort (Default ${DEFAULT_SORT.label})`}
prefix={<Icon name={(value?.includes('asc') ? 'sort-amount-up' : 'sort-amount-down') as IconName} />} prefix={<Icon name={(value?.includes('asc') ? 'sort-amount-up' : 'sort-amount-down') as IconName} />}

View File

@ -13,4 +13,4 @@ export const PANEL_BORDER = 2;
export const EDIT_PANEL_ID = 23763571993; export const EDIT_PANEL_ID = 23763571993;
export const DEFAULT_PER_PAGE_PAGINATION = 8; export const DEFAULT_PER_PAGE_PAGINATION = 40;

View File

@ -5,7 +5,6 @@ import { AppEvents, DataQueryErrorType, EventBusExtended } from '@grafana/data';
import { BackendSrv } from '../services/backend_srv'; import { BackendSrv } from '../services/backend_srv';
import { ContextSrv, User } from '../services/context_srv'; import { ContextSrv, User } from '../services/context_srv';
import { describe, expect } from '../../../test/lib/common';
import { BackendSrvRequest, FetchError } from '@grafana/runtime'; import { BackendSrvRequest, FetchError } from '@grafana/runtime';
import { TokenRevokedModal } from '../../features/users/TokenRevokedModal'; import { TokenRevokedModal } from '../../features/users/TokenRevokedModal';
import { ShowModalReactEvent } from '../../types/events'; import { ShowModalReactEvent } from '../../types/events';

View File

@ -141,12 +141,7 @@ export const AddPanelWidgetUnconnected: React.FC<Props> = ({ panel, dashboard })
{addPanelView ? 'Add panel from panel library' : 'Add panel'} {addPanelView ? 'Add panel from panel library' : 'Add panel'}
</AddPanelWidgetHandle> </AddPanelWidgetHandle>
{addPanelView ? ( {addPanelView ? (
<LibraryPanelsSearch <LibraryPanelsSearch onClick={onAddLibraryPanel} variant={LibraryPanelsSearchVariant.Tight} showPanelFilter />
onClick={onAddLibraryPanel}
perPage={40}
variant={LibraryPanelsSearchVariant.Tight}
showFilter
/>
) : ( ) : (
<div className={styles.actionsWrapper}> <div className={styles.actionsWrapper}>
<div className={styles.actionsRow}> <div className={styles.actionsRow}>

View File

@ -57,6 +57,7 @@ export const PanelTypeCard: React.FC<Props> = ({
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
aria-label="Delete button on panel type card"
/> />
)} )}
</div> </div>

View File

@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaRouteComponentProps } from '../../core/navigation/types';
import { StoreState } from '../../types';
import { getNavModel } from '../../core/selectors/navModel';
import { getLoadingNav } from './state/navModel';
import { LibraryPanelDTO } from '../library-panels/types';
import Page from '../../core/components/Page/Page';
import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
import { getFolderByUid } from './state/actions';
import { useAsync } from 'react-use';
export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {}
const mapStateToProps = (state: StoreState, props: OwnProps) => {
const uid = props.match.params.uid;
return {
navModel: getNavModel(state.navIndex, `folder-library-panels-${uid}`, getLoadingNav(1)),
folderUid: uid,
folder: state.folder,
};
};
const mapDispatchToProps = {
getFolderByUid,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export function FolderLibraryPanelsPage({ navModel, getFolderByUid, folderUid, folder }: Props): JSX.Element {
const { loading } = useAsync<void>(async () => await getFolderByUid(folderUid), [getFolderByUid, folderUid]);
const [selected, setSelected] = useState<LibraryPanelDTO | undefined>(undefined);
return (
<Page navModel={navModel}>
<Page.Contents isLoading={loading}>
<LibraryPanelsSearch
onClick={setSelected}
currentFolderId={folder.id}
showSecondaryActions
showSort
showPanelFilter
/>
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null}
</Page.Contents>
</Page>
);
}
export default connector(FolderLibraryPanelsPage);

View File

@ -1,5 +1,6 @@
import { FolderDTO } from 'app/types'; import { FolderDTO } from 'app/types';
import { NavModelItem, NavModel } from '@grafana/data'; import { NavModel, NavModelItem } from '@grafana/data';
import { getConfig } from '../../../core/config';
export function buildNavModel(folder: FolderDTO): NavModelItem { export function buildNavModel(folder: FolderDTO): NavModelItem {
const model = { const model = {
@ -40,6 +41,16 @@ export function buildNavModel(folder: FolderDTO): NavModelItem {
}); });
} }
if (getConfig().featureToggles.panelLibrary) {
model.children.push({
active: false,
icon: 'library-panel',
id: `folder-library-panels-${folder.uid}`,
text: 'Panels',
url: `${folder.url}/library-panels`,
});
}
return model; return model;
} }

View File

@ -25,7 +25,7 @@ export const LibraryPanelsPage: FC<Props> = ({ navModel }) => {
return ( return (
<Page navModel={navModel}> <Page navModel={navModel}>
<Page.Contents> <Page.Contents>
<LibraryPanelsSearch onClick={setSelected} showSecondaryActions showSort showFilter /> <LibraryPanelsSearch onClick={setSelected} showSecondaryActions showSort showPanelFilter showFolderFilter />
{selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null} {selected ? <OpenLibraryPanelModal onDismiss={() => setSelected(undefined)} libraryPanel={selected} /> : null}
</Page.Contents> </Page.Contents>
</Page> </Page>

View File

@ -0,0 +1,281 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { PanelPluginMeta, PluginType } from '@grafana/data';
import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSearch';
import * as api from '../../state/api';
import { LibraryPanelSearchResult } from '../../types';
import { backendSrv } from '../../../../core/services/backend_srv';
import * as viztypepicker from '../../../dashboard/components/VizTypePicker/VizTypePicker';
jest.mock('@grafana/runtime', () => ({
...((jest.requireActual('@grafana/runtime') as unknown) as object),
config: {
panels: {
timeseries: {
info: { logos: { small: '' } },
name: 'Time Series',
},
},
},
}));
jest.mock('debounce-promise', () => {
const debounce = (fn: any) => {
const debounced = () =>
Promise.resolve([
{ label: 'General', value: { id: 0, title: 'General' } },
{ label: 'Folder1', value: { id: 1, title: 'Folder1' } },
{ label: 'Folder2', value: { id: 2, title: 'Folder2' } },
]);
return debounced;
};
return debounce;
});
async function getTestContext(
propOverrides: Partial<LibraryPanelsSearchProps> = {},
searchResult: LibraryPanelSearchResult = { libraryPanels: [], perPage: 40, page: 1, totalCount: 0 }
) {
jest.clearAllMocks();
const pluginInfo: any = { logos: { small: '', large: '' } };
const graph: PanelPluginMeta = {
name: 'Graph',
id: 'graph',
info: pluginInfo,
baseUrl: '',
type: PluginType.panel,
module: '',
sort: 0,
};
const timeseries: PanelPluginMeta = {
name: 'Time Series',
id: 'timeseries',
info: pluginInfo,
baseUrl: '',
type: PluginType.panel,
module: '',
sort: 1,
};
const getSpy = jest
.spyOn(backendSrv, 'get')
.mockResolvedValue({ sortOptions: [{ displaName: 'Desc', name: 'alpha-desc' }] });
const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels').mockResolvedValue(searchResult);
const getAllPanelPluginMetaSpy = jest
.spyOn(viztypepicker, 'getAllPanelPluginMeta')
.mockReturnValue([graph, timeseries]);
const props: LibraryPanelsSearchProps = {
onClick: jest.fn(),
};
Object.assign(props, propOverrides);
const { rerender } = render(<LibraryPanelsSearch {...props} />);
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled());
expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1);
return { rerender, getLibraryPanelsSpy, getSpy, getAllPanelPluginMetaSpy };
}
describe('LibraryPanelsSearch', () => {
describe('when mounted with default options', () => {
it('should show input filter and library panels view', async () => {
await getTestContext();
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
});
describe('and user searches for library panel by name or description', () => {
it('should call api with correct params', async () => {
const { getLibraryPanelsSpy } = await getTestContext();
getLibraryPanelsSpy.mockClear();
await userEvent.type(screen.getByPlaceholderText(/search by name/i), 'a');
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled());
expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1);
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
searchString: 'a',
folderFilter: [],
page: 0,
panelFilter: [],
perPage: 40,
});
});
});
});
describe('when mounted with showSort', () => {
it('should show input filter and library panels view and sort', async () => {
await getTestContext({ showSort: true });
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
expect(screen.getByText(/sort \(default az\)/i)).toBeInTheDocument();
});
describe('and user changes sorting', () => {
it('should call api with correct params', async () => {
const { getLibraryPanelsSpy } = await getTestContext({ showSort: true });
getLibraryPanelsSpy.mockClear();
await userEvent.type(screen.getByText(/sort \(default az\)/i), 'Desc{enter}');
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
searchString: '',
sortDirection: 'alpha-desc',
folderFilter: [],
page: 0,
panelFilter: [],
perPage: 40,
});
});
});
});
describe('when mounted with showPanelFilter', () => {
it('should show input filter and library panels view and panel filter', async () => {
await getTestContext({ showPanelFilter: true });
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /panel type filter/i })).toBeInTheDocument();
});
describe('and user changes panel filter', () => {
it('should call api with correct params', async () => {
const { getLibraryPanelsSpy } = await getTestContext({ showPanelFilter: true });
getLibraryPanelsSpy.mockClear();
await userEvent.type(screen.getByRole('textbox', { name: /panel type filter/i }), 'Graph{enter}');
await userEvent.type(screen.getByRole('textbox', { name: /panel type filter/i }), 'Time Series{enter}');
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
searchString: '',
folderFilter: [],
page: 0,
panelFilter: ['graph', 'timeseries'],
perPage: 40,
});
});
});
});
describe('when mounted with showPanelFilter', () => {
it('should show input filter and library panels view and folder filter', async () => {
await getTestContext({ showFolderFilter: true });
expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument();
expect(screen.getByText(/no library panels found./i)).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /folder filter/i })).toBeInTheDocument();
});
describe('and user changes folder filter', () => {
it('should call api with correct params', async () => {
const { getLibraryPanelsSpy } = await getTestContext({ showFolderFilter: true });
getLibraryPanelsSpy.mockClear();
userEvent.click(screen.getByRole('textbox', { name: /folder filter/i }));
await userEvent.type(screen.getByRole('textbox', { name: /folder filter/i }), '{enter}', {
skipClick: true,
});
await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1));
expect(getLibraryPanelsSpy).toHaveBeenCalledWith({
searchString: '',
folderFilter: ['0'],
page: 0,
panelFilter: [],
perPage: 40,
});
});
});
});
describe('when mounted without showSecondaryActions and there is one panel', () => {
it('should show correct row and no delete button', async () => {
await getTestContext(
{},
{
page: 1,
totalCount: 1,
perPage: 40,
libraryPanels: [
{
id: 1,
name: 'Library Panel Name',
uid: 'uid',
description: 'Library Panel Description',
folderId: 0,
model: { type: 'timeseries', title: 'A title' },
type: 'timeseries',
orgId: 1,
version: 1,
meta: {
canEdit: true,
connectedDashboards: 0,
created: '2021-01-01 12:00:00',
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },
updated: '2021-01-01 12:00:00',
updatedBy: { id: 1, name: 'Admin', avatarUrl: '' },
},
},
],
}
);
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
expect(screen.queryByText(/no library panels found./i)).not.toBeInTheDocument();
expect(card()).toBeInTheDocument();
expect(within(card()).getByText(/library panel name/i)).toBeInTheDocument();
expect(within(card()).getByText(/library panel description/i)).toBeInTheDocument();
expect(within(card()).queryByLabelText(/delete button on panel type card/i)).not.toBeInTheDocument();
});
});
describe('when mounted with showSecondaryActions and there is one panel', () => {
it('should show correct row and delete button', async () => {
await getTestContext(
{ showSecondaryActions: true },
{
page: 1,
totalCount: 1,
perPage: 40,
libraryPanels: [
{
id: 1,
name: 'Library Panel Name',
uid: 'uid',
description: 'Library Panel Description',
folderId: 0,
model: { type: 'timeseries', title: 'A title' },
type: 'timeseries',
orgId: 1,
version: 1,
meta: {
canEdit: true,
connectedDashboards: 0,
created: '2021-01-01 12:00:00',
createdBy: { id: 1, name: 'Admin', avatarUrl: '' },
updated: '2021-01-01 12:00:00',
updatedBy: { id: 1, name: 'Admin', avatarUrl: '' },
},
},
],
}
);
const card = () => screen.getByLabelText(/plugin visualization item time series/i);
expect(screen.queryByText(/no library panels found./i)).not.toBeInTheDocument();
expect(card()).toBeInTheDocument();
expect(within(card()).getByText(/library panel name/i)).toBeInTheDocument();
expect(within(card()).getByText(/library panel description/i)).toBeInTheDocument();
expect(within(card()).getByLabelText(/delete button on panel type card/i)).toBeInTheDocument();
});
});
});

View File

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react'; import React, { useReducer } from 'react';
import { HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui'; import { HorizontalGroup, useStyles2, VerticalGroup } from '@grafana/ui';
import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
@ -8,6 +8,16 @@ import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/Pan
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView'; import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants'; import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants';
import { LibraryPanelDTO } from '../../types'; import { LibraryPanelDTO } from '../../types';
import { FolderFilter } from '../../../../core/components/FolderFilter/FolderFilter';
import { FolderInfo } from '../../../../types';
import {
folderFilterChanged,
initialLibraryPanelsSearchState,
libraryPanelsSearchReducer,
panelFilterChanged,
searchChanged,
sortChanged,
} from './reducer';
export enum LibraryPanelsSearchVariant { export enum LibraryPanelsSearchVariant {
Tight = 'tight', Tight = 'tight',
@ -18,9 +28,11 @@ export interface LibraryPanelsSearchProps {
onClick: (panel: LibraryPanelDTO) => void; onClick: (panel: LibraryPanelDTO) => void;
variant?: LibraryPanelsSearchVariant; variant?: LibraryPanelsSearchVariant;
showSort?: boolean; showSort?: boolean;
showFilter?: boolean; showPanelFilter?: boolean;
showFolderFilter?: boolean;
showSecondaryActions?: boolean; showSecondaryActions?: boolean;
currentPanelId?: string; currentPanelId?: string;
currentFolderId?: number;
perPage?: number; perPage?: number;
} }
@ -28,26 +40,44 @@ export const LibraryPanelsSearch = ({
onClick, onClick,
variant = LibraryPanelsSearchVariant.Spacious, variant = LibraryPanelsSearchVariant.Spacious,
currentPanelId, currentPanelId,
currentFolderId,
perPage = DEFAULT_PER_PAGE_PAGINATION, perPage = DEFAULT_PER_PAGE_PAGINATION,
showFilter = false, showPanelFilter = false,
showFolderFilter = false,
showSort = false, showSort = false,
showSecondaryActions = false, showSecondaryActions = false,
}: LibraryPanelsSearchProps): JSX.Element => { }: LibraryPanelsSearchProps): JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const [sortDirection, setSortDirection] = useState<string | undefined>(undefined);
const [panelFilter, setPanelFilter] = useState<string[]>([]);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const onSortChange = useCallback((sort: SelectableValue<string>) => setSortDirection(sort.value), []); const [{ sortDirection, panelFilter, folderFilter, searchQuery }, dispatch] = useReducer(libraryPanelsSearchReducer, {
const onFilterChange = useCallback((plugins: PanelPluginMeta[]) => setPanelFilter(plugins.map((p) => p.id)), []); ...initialLibraryPanelsSearchState,
folderFilter: currentFolderId ? [currentFolderId.toString(10)] : [],
});
const onFilterChange = (searchString: string) => dispatch(searchChanged(searchString));
const onSortChange = (sorting: SelectableValue<string>) => dispatch(sortChanged(sorting));
const onFolderFilterChange = (folders: FolderInfo[]) => dispatch(folderFilterChanged(folders));
const onPanelFilterChange = (plugins: PanelPluginMeta[]) => dispatch(panelFilterChanged(plugins));
if (variant === LibraryPanelsSearchVariant.Spacious) { if (variant === LibraryPanelsSearchVariant.Spacious) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<VerticalGroup spacing="lg"> <VerticalGroup spacing="lg">
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name'} width={0} /> <FilterInput
<HorizontalGroup spacing="sm" justify={showSort && showFilter ? 'space-between' : 'flex-end'}> value={searchQuery}
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />} onChange={onFilterChange}
{showFilter && <PanelTypeFilter onChange={onFilterChange} />} placeholder={'Search by name or description'}
width={0}
/>
<HorizontalGroup
spacing="sm"
justify={(showSort && showPanelFilter) || showFolderFilter ? 'space-between' : 'flex-end'}
>
{showSort && (
<SortPicker value={sortDirection} onChange={onSortChange} filter={['alpha-asc', 'alpha-desc']} />
)}
<HorizontalGroup spacing="sm" justify={showFolderFilter && showPanelFilter ? 'space-between' : 'flex-end'}>
{showFolderFilter && <FolderFilter onChange={onFolderFilterChange} />}
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} />}
</HorizontalGroup>
</HorizontalGroup> </HorizontalGroup>
<div className={styles.libraryPanelsView}> <div className={styles.libraryPanelsView}>
<LibraryPanelsView <LibraryPanelsView
@ -55,6 +85,7 @@ export const LibraryPanelsSearch = ({
searchString={searchQuery} searchString={searchQuery}
sortDirection={sortDirection} sortDirection={sortDirection}
panelFilter={panelFilter} panelFilter={panelFilter}
folderFilter={folderFilter}
currentPanelId={currentPanelId} currentPanelId={currentPanelId}
showSecondaryActions={showSecondaryActions} showSecondaryActions={showSecondaryActions}
perPage={perPage} perPage={perPage}
@ -70,11 +101,12 @@ export const LibraryPanelsSearch = ({
<VerticalGroup spacing="xs"> <VerticalGroup spacing="xs">
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<div className={styles.tightFilter}> <div className={styles.tightFilter}>
<FilterInput value={searchQuery} onChange={setSearchQuery} placeholder={'Search by name'} width={0} /> <FilterInput value={searchQuery} onChange={onFilterChange} placeholder={'Search by name'} width={0} />
</div> </div>
<div className={styles.tightSortFilter}> <div className={styles.tightSortFilter}>
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />} {showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
{showFilter && <PanelTypeFilter onChange={onFilterChange} />} {showFolderFilter && <FolderFilter onChange={onFolderFilterChange} maxMenuHeight={200} />}
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} maxMenuHeight={200} />}
</div> </div>
</div> </div>
<div className={styles.libraryPanelsView}> <div className={styles.libraryPanelsView}>
@ -83,6 +115,7 @@ export const LibraryPanelsSearch = ({
searchString={searchQuery} searchString={searchQuery}
sortDirection={sortDirection} sortDirection={sortDirection}
panelFilter={panelFilter} panelFilter={panelFilter}
folderFilter={folderFilter}
currentPanelId={currentPanelId} currentPanelId={currentPanelId}
showSecondaryActions={showSecondaryActions} showSecondaryActions={showSecondaryActions}
perPage={perPage} perPage={perPage}
@ -99,6 +132,7 @@ function getStyles(theme: GrafanaTheme2) {
width: 100%; width: 100%;
overflow-y: auto; overflow-y: auto;
padding: ${theme.spacing(1)}; padding: ${theme.spacing(1)};
min-height: 400px;
`, `,
buttonRow: css` buttonRow: css`
display: flex; display: flex;

View File

@ -0,0 +1,76 @@
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import {
folderFilterChanged,
initialLibraryPanelsSearchState,
libraryPanelsSearchReducer,
LibraryPanelsSearchState,
panelFilterChanged,
searchChanged,
sortChanged,
} from './reducer';
describe('libraryPanelsSearchReducer', () => {
describe('when searchChanged is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(searchChanged('searching for'))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
searchQuery: 'searching for',
});
});
});
describe('when sortChanged is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(sortChanged({ label: 'Ascending', value: 'asc' }))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
sortDirection: 'asc',
});
});
});
describe('when panelFilterChanged is dispatched', () => {
it('then state should be correct', () => {
const plugins: any = [
{ id: 'graph', name: 'Graph' },
{ id: 'timeseries', name: 'Time Series' },
];
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(panelFilterChanged(plugins))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
panelFilter: ['graph', 'timeseries'],
});
});
});
describe('when folderFilterChanged is dispatched', () => {
it('then state should be correct', () => {
const folders: any = [
{ id: 0, name: 'General' },
{ id: 1, name: 'Folder' },
];
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(folderFilterChanged(folders))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
folderFilter: ['0', '1'],
});
});
});
});

View File

@ -0,0 +1,44 @@
import { AnyAction } from 'redux';
import { createAction } from '@reduxjs/toolkit';
import { PanelPluginMeta, SelectableValue } from '@grafana/data';
import { FolderInfo } from '../../../../types';
export interface LibraryPanelsSearchState {
searchQuery: string;
sortDirection?: string;
panelFilter: string[];
folderFilter: string[];
}
export const initialLibraryPanelsSearchState: LibraryPanelsSearchState = {
searchQuery: '',
panelFilter: [],
folderFilter: [],
sortDirection: undefined,
};
export const searchChanged = createAction<string>('libraryPanels/search/searchChanged');
export const sortChanged = createAction<SelectableValue<string>>('libraryPanels/search/sortChanged');
export const panelFilterChanged = createAction<PanelPluginMeta[]>('libraryPanels/search/panelFilterChanged');
export const folderFilterChanged = createAction<FolderInfo[]>('libraryPanels/search/folderFilterChanged');
export const libraryPanelsSearchReducer = (state: LibraryPanelsSearchState, action: AnyAction) => {
if (searchChanged.match(action)) {
return { ...state, searchQuery: action.payload };
}
if (sortChanged.match(action)) {
return { ...state, sortDirection: action.payload.value };
}
if (panelFilterChanged.match(action)) {
return { ...state, panelFilter: action.payload.map((p) => p.id) };
}
if (folderFilterChanged.match(action)) {
return { ...state, folderFilter: action.payload.map((f) => String(f.id!)) };
}
return state;
};

View File

@ -17,6 +17,7 @@ interface LibraryPanelViewProps {
searchString: string; searchString: string;
sortDirection?: string; sortDirection?: string;
panelFilter?: string[]; panelFilter?: string[];
folderFilter?: string[];
perPage?: number; perPage?: number;
} }
@ -26,6 +27,7 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
searchString, searchString,
sortDirection, sortDirection,
panelFilter, panelFilter,
folderFilter,
showSecondaryActions, showSecondaryActions,
currentPanelId: currentPanel, currentPanelId: currentPanel,
perPage: propsPerPage = 40, perPage: propsPerPage = 40,
@ -43,10 +45,18 @@ export const LibraryPanelsView: React.FC<LibraryPanelViewProps> = ({
useDebounce( useDebounce(
() => () =>
asyncDispatch( asyncDispatch(
searchForLibraryPanels({ searchString, sortDirection, panelFilter, page, perPage, currentPanelId }) searchForLibraryPanels({
searchString,
sortDirection,
panelFilter,
folderFilter,
page,
perPage,
currentPanelId,
})
), ),
300, 300,
[searchString, sortDirection, panelFilter, page, asyncDispatch] [searchString, sortDirection, panelFilter, folderFilter, page, asyncDispatch]
); );
const onDelete = ({ uid }: LibraryPanelDTO) => const onDelete = ({ uid }: LibraryPanelDTO) =>
asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage })); asyncDispatch(deleteLibraryPanel(uid, { searchString, page, perPage }));

View File

@ -13,6 +13,7 @@ interface SearchArgs {
searchString: string; searchString: string;
sortDirection?: string; sortDirection?: string;
panelFilter?: string[]; panelFilter?: string[];
folderFilter?: string[];
currentPanelId?: string; currentPanelId?: string;
} }
@ -27,6 +28,7 @@ export function searchForLibraryPanels(args: SearchArgs): DispatchResult {
excludeUid: args.currentPanelId, excludeUid: args.currentPanelId,
sortDirection: args.sortDirection, sortDirection: args.sortDirection,
panelFilter: args.panelFilter, panelFilter: args.panelFilter,
folderFilter: args.folderFilter,
}) })
).pipe( ).pipe(
mergeMap(({ perPage, libraryPanels, page, totalCount }) => mergeMap(({ perPage, libraryPanels, page, totalCount }) =>

View File

@ -9,6 +9,7 @@ export interface GetLibraryPanelsOptions {
excludeUid?: string; excludeUid?: string;
sortDirection?: string; sortDirection?: string;
panelFilter?: string[]; panelFilter?: string[];
folderFilter?: string[];
} }
export async function getLibraryPanels({ export async function getLibraryPanels({
@ -18,11 +19,13 @@ export async function getLibraryPanels({
excludeUid = '', excludeUid = '',
sortDirection = '', sortDirection = '',
panelFilter = [], panelFilter = [],
folderFilter = [],
}: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> { }: GetLibraryPanelsOptions = {}): Promise<LibraryPanelSearchResult> {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append('searchString', searchString); params.append('searchString', searchString);
params.append('sortDirection', sortDirection); params.append('sortDirection', sortDirection);
params.append('panelFilter', panelFilter.join(',')); params.append('panelFilter', panelFilter.join(','));
params.append('folderFilter', folderFilter.join(','));
params.append('excludeUid', excludeUid); params.append('excludeUid', excludeUid);
params.append('perPage', perPage.toString(10)); params.append('perPage', perPage.toString(10));
params.append('page', page.toString(10)); params.append('page', page.toString(10));

View File

@ -11,6 +11,7 @@ import { Redirect } from 'react-router-dom';
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage'; import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
export const extraRoutes: RouteDescriptor[] = []; export const extraRoutes: RouteDescriptor[] = [];
export const featureToggledRoutes: RouteDescriptor[] = [];
export function getAppRoutes(): RouteDescriptor[] { export function getAppRoutes(): RouteDescriptor[] {
return [ return [
@ -465,12 +466,6 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage') () => import(/* webpackChunkName: "PlaylistEditPage"*/ 'app/features/playlist/PlaylistEditPage')
), ),
}, },
{
path: '/library-panels',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
),
},
{ {
path: '/sandbox/benchmarks', path: '/sandbox/benchmarks',
component: SafeDynamicImport( component: SafeDynamicImport(
@ -478,6 +473,7 @@ export function getAppRoutes(): RouteDescriptor[] {
), ),
}, },
...extraRoutes, ...extraRoutes,
...featureToggledRoutes,
{ {
path: '/*', path: '/*',
component: ErrorPage, component: ErrorPage,