From c6d4d14a894b5f6b7ac7e42a5f3916cdd4e0b0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 4 May 2021 13:59:40 +0200 Subject: [PATCH] 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 --- pkg/api/index.go | 15 +- pkg/services/librarypanels/api.go | 1 + pkg/services/librarypanels/database.go | 83 ++---- .../librarypanels_get_all_test.go | 191 ++++++++++++ pkg/services/librarypanels/models.go | 1 + pkg/services/librarypanels/writers.go | 102 +++++++ public/app/app.ts | 17 ++ .../components/FolderFilter/FolderFilter.tsx | 106 +++++++ .../PanelTypeFilter/PanelTypeFilter.tsx | 5 +- .../app/core/components/Select/SortPicker.tsx | 16 +- public/app/core/constants.ts | 2 +- public/app/core/specs/backend_srv.test.ts | 1 - .../AddPanelWidget/AddPanelWidget.tsx | 7 +- .../VizTypePicker/PanelTypeCard.tsx | 1 + .../folders/FolderLibraryPanelsPage.tsx | 54 ++++ public/app/features/folders/state/navModel.ts | 13 +- .../library-panels/LibraryPanelsPage.tsx | 2 +- .../LibraryPanelsSearch.test.tsx | 281 ++++++++++++++++++ .../LibraryPanelsSearch.tsx | 62 +++- .../LibraryPanelsSearch/reducer.test.ts | 76 +++++ .../components/LibraryPanelsSearch/reducer.ts | 44 +++ .../LibraryPanelsView/LibraryPanelsView.tsx | 14 +- .../components/LibraryPanelsView/actions.ts | 2 + .../app/features/library-panels/state/api.ts | 3 + public/app/routes/routes.tsx | 8 +- 25 files changed, 1002 insertions(+), 105 deletions(-) create mode 100644 pkg/services/librarypanels/writers.go create mode 100644 public/app/core/components/FolderFilter/FolderFilter.tsx create mode 100644 public/app/features/folders/FolderLibraryPanelsPage.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx create mode 100644 public/app/features/library-panels/components/LibraryPanelsSearch/reducer.test.ts create mode 100644 public/app/features/library-panels/components/LibraryPanelsSearch/reducer.ts diff --git a/pkg/api/index.go b/pkg/api/index.go index 33538cfd700..bf4900a77c9 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -167,12 +167,15 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Url: hs.Cfg.AppSubURL + "/dashboard/snapshots", Icon: "camera", }) - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Library panels", - Id: "library-panels", - Url: hs.Cfg.AppSubURL + "/library-panels", - Icon: "library-panel", - }) + + if hs.Cfg.IsPanelLibraryEnabled() { + dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ + Text: "Library panels", + Id: "library-panels", + Url: hs.Cfg.AppSubURL + "/library-panels", + Icon: "library-panel", + }) + } } navTree = append(navTree, &dtos.NavLink{ diff --git a/pkg/services/librarypanels/api.go b/pkg/services/librarypanels/api.go index 2bbb82b1448..feaeffe1bae 100644 --- a/pkg/services/librarypanels/api.go +++ b/pkg/services/librarypanels/api.go @@ -88,6 +88,7 @@ func (lps *LibraryPanelService) getAllHandler(c *models.ReqContext) response.Res sortDirection: c.Query("sortDirection"), panelFilter: c.Query("panelFilter"), excludeUID: c.Query("excludeUid"), + folderFilter: c.Query("folderFilter"), } libraryPanels, err := lps.getAllLibraryPanels(c, query) if err != nil { diff --git a/pkg/services/librarypanels/database.go b/pkg/services/librarypanels/database.go index e65af258c26..055b937def7 100644 --- a/pkg/services/librarypanels/database.go +++ b/pkg/services/librarypanels/database.go @@ -1,16 +1,14 @@ package librarypanels import ( - "bytes" "encoding/json" "fmt" "strings" "time" - "github.com/grafana/grafana/pkg/services/search" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore" "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 { 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 { builder := sqlstore.SQLBuilder{} - builder.Write(sqlStatmentLibrayPanelDTOWithMeta) - builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId) - if len(strings.TrimSpace(query.searchString)) > 0 { - builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") - builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") + if folderFilter.includeGeneralFolder { + builder.Write(sqlStatmentLibrayPanelDTOWithMeta) + builder.Write(` WHERE lp.org_id=? AND lp.folder_id=0`, c.SignedInUser.OrgId) + writeSearchStringSQL(query, lps.SQLStore, &builder) + 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(" INNER JOIN dashboard AS dashboard on lp.folder_id = dashboard.id AND lp.folder_id<>0") builder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId) - if len(strings.TrimSpace(query.searchString)) > 0 { - builder.Write(" AND lp.name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") - builder.Write(" OR lp.description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") - } - if len(strings.TrimSpace(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...) + writeSearchStringSQL(query, lps.SQLStore, &builder) + writeExcludeSQL(query, &builder) + writePanelFilterSQL(panelFilter, &builder) + if err := folderFilter.writeFolderFilterSQL(false, &builder); err != nil { + return err } if c.SignedInUser.OrgRole != models.ROLE_ADMIN { builder.WriteDashboardPermissionFilter(c.SignedInUser, models.PERMISSION_VIEW) @@ -449,10 +429,7 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query } else { builder.Write(" ORDER BY 1 ASC") } - if query.perPage != 0 { - offset := query.perPage * (query.page - 1) - builder.Write(lps.SQLStore.Dialect.LimitOffset(int64(query.perPage), int64(offset))) - } + writePerPageSQL(query, lps.SQLStore, &builder) if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryPanels); err != nil { return err } @@ -490,23 +467,13 @@ func (lps *LibraryPanelService) getAllLibraryPanels(c *models.ReqContext, query var panels []LibraryPanel countBuilder := sqlstore.SQLBuilder{} - countBuilder.Write("SELECT * FROM library_panel") - countBuilder.Write(` WHERE org_id=?`, c.SignedInUser.OrgId) - if len(strings.TrimSpace(query.searchString)) > 0 { - countBuilder.Write(" AND name "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") - countBuilder.Write(" OR description "+lps.SQLStore.Dialect.LikeStr()+" ?", "%"+query.searchString+"%") - } - if len(strings.TrimSpace(query.excludeUID)) > 0 { - countBuilder.Write(" AND uid <> ?", query.excludeUID) - } - if len(panelFilter) > 0 { - var sql bytes.Buffer - params := make([]interface{}, 0) - sql.WriteString(` AND type IN (?` + strings.Repeat(",?", len(panelFilter)-1) + ")") - for _, v := range panelFilter { - params = append(params, v) - } - countBuilder.Write(sql.String(), params...) + countBuilder.Write("SELECT * FROM library_panel AS lp") + countBuilder.Write(` WHERE lp.org_id=?`, c.SignedInUser.OrgId) + writeSearchStringSQL(query, lps.SQLStore, &countBuilder) + writeExcludeSQL(query, &countBuilder) + writePanelFilterSQL(panelFilter, &countBuilder) + if err := folderFilter.writeFolderFilterSQL(true, &countBuilder); err != nil { + return err } if err := session.SQL(countBuilder.GetSQLString(), countBuilder.GetParams()...).Find(&panels); err != nil { return err diff --git a/pkg/services/librarypanels/librarypanels_get_all_test.go b/pkg/services/librarypanels/librarypanels_get_all_test.go index d27aacb7237..96ba32b5b73 100644 --- a/pkg/services/librarypanels/librarypanels_get_all_test.go +++ b/pkg/services/librarypanels/librarypanels_get_all_test.go @@ -2,6 +2,7 @@ package librarypanels import ( "encoding/json" + "strconv" "testing" "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", func(t *testing.T, sc scenarioContext) { command := getCreateCommand(sc.folder.Id, "Text - Library Panel2") diff --git a/pkg/services/librarypanels/models.go b/pkg/services/librarypanels/models.go index af046b412d8..40706f61b2c 100644 --- a/pkg/services/librarypanels/models.go +++ b/pkg/services/librarypanels/models.go @@ -146,4 +146,5 @@ type searchLibraryPanelsQuery struct { sortDirection string panelFilter string excludeUID string + folderFilter string } diff --git a/pkg/services/librarypanels/writers.go b/pkg/services/librarypanels/writers.go new file mode 100644 index 00000000000..0070438ab26 --- /dev/null +++ b/pkg/services/librarypanels/writers.go @@ -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 +} diff --git a/public/app/app.ts b/public/app/app.ts index a7d51c29ab9..00cdf9f84ed 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -43,6 +43,8 @@ import { PanelRenderer } from './features/panel/PanelRenderer'; import { QueryRunner } from './features/query/state/QueryRunner'; import { getTimeSrv } from './features/dashboard/services/TimeSrv'; 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 // @ts-ignore @@ -66,6 +68,21 @@ export class GrafanaApp { } 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(); addClassIfNoOverlayScrollbar(); setLocale(config.bootData.user.locale); diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx new file mode 100644 index 00000000000..5fa1e4fd2b5 --- /dev/null +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -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>>([]); + const onChange = useCallback( + (folders: Array>) => { + 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 ( +
+ {value.length > 0 && ( + onChange([])}> + Clear folders + + )} + } + aria-label="Folder filter" + /> +
+ ); +} + +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}; + } + `, + }; +} diff --git a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx index 0058f8d3c7e..48042e81eca 100644 --- a/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx +++ b/public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx @@ -6,9 +6,10 @@ import { css } from '@emotion/css'; export interface Props { 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(() => { return getAllPanelPluginMeta(); }, []); @@ -43,7 +44,7 @@ export const PanelTypeFilter = ({ onChange: propsOnChange }: Props): JSX.Element noOptionsMessage: 'No Panel types found', placeholder: 'Filter by type', styles: resetSelectStyles(), - maxMenuHeight: 150, + maxMenuHeight, options, value, onChange, diff --git a/public/app/core/components/Select/SortPicker.tsx b/public/app/core/components/Select/SortPicker.tsx index 589a76bea8a..266c6a393b1 100644 --- a/public/app/core/components/Select/SortPicker.tsx +++ b/public/app/core/components/Select/SortPicker.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; 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 { DEFAULT_SORT } from 'app/features/search/constants'; import { SearchSrv } from '../../services/search_srv'; @@ -11,25 +11,27 @@ export interface Props { onChange: (sortValue: SelectableValue) => void; value?: string; placeholder?: string; + filter?: string[]; } -const getSortOptions = () => { +const getSortOptions = (filter?: string[]) => { 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 = ({ onChange, value, placeholder }) => { +export const SortPicker: FC = ({ onChange, value, placeholder, filter }) => { // Using sync Select and manual options fetching here since we need to find the selected option by value - const { loading, value: options } = useAsync(getSortOptions, []); + const { loading, value: options } = useAsync(() => getSortOptions(filter), []); - const selected = options?.filter((opt) => opt.value === value); + const selected = options?.find((opt) => opt.value === value); return !loading ? (