Dashboard: Library Panels - Add ability to search by folder name (#106997)

* Dashboard: Add ability to search by folder name in library panels

* restore to main to fix linting issues

* restore from main to avoid go linting issues

* add logic to the writers.go that search by folder title if folder is not passed

* add missing left joing from the folder table

* Add extra logic to prevent folder searches without permission

* fix go linting issue about memory

* Add test when searching by folder name

* Refactor tests to include a bit more validation

* apply feedback and use SearchFolder from search folder service

* clean up comments

* Update pkg/services/libraryelements/database.go

Co-authored-by: Ezequiel Victorero <ezequiel.victorero@grafana.com>

* Fix logic of early return

* Extract into a function and remove the left join

* Apply feedback to be aligned with idiomatic go

* Apply suggestion from @evictorero

Co-authored-by: Ezequiel Victorero <ezequiel.victorero@grafana.com>

* fix liting

---------

Co-authored-by: Ezequiel Victorero <ezequiel.victorero@grafana.com>
This commit is contained in:
Alexa Vargas
2025-07-08 15:07:22 +02:00
committed by GitHub
co-authored by Ezequiel Victorero
parent 047349638d
commit e33047bdf1
5 changed files with 249 additions and 8 deletions
+51 -4
View File
@@ -426,6 +426,11 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
if folderFilter.parseError != nil {
return model.LibraryElementSearchResult{}, folderFilter.parseError
}
foldersWithMatchingTitles, err := getFoldersWithMatchingTitles(c, l, signedInUser, query)
if err != nil {
return model.LibraryElementSearchResult{}, err
}
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
if folderFilter.includeGeneralFolder {
@@ -434,7 +439,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
builder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()))
builder.Write(` WHERE le.org_id=? AND le.folder_id=0`, signedInUser.GetOrgID())
writeKindSQL(query, &builder)
writeSearchStringSQL(query, l.SQLStore, &builder)
writeSearchStringSQL(query, l.SQLStore, &builder, foldersWithMatchingTitles)
writeExcludeSQL(query, &builder)
writeTypeFilterSQL(typeFilter, &builder)
builder.Write(" ")
@@ -446,7 +451,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
builder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()))
builder.Write(` WHERE le.org_id=? AND le.folder_id<>0`, signedInUser.GetOrgID())
writeKindSQL(query, &builder)
writeSearchStringSQL(query, l.SQLStore, &builder)
writeSearchStringSQL(query, l.SQLStore, &builder, foldersWithMatchingTitles)
writeExcludeSQL(query, &builder)
writeTypeFilterSQL(typeFilter, &builder)
if err := folderFilter.writeFolderFilterSQL(false, &builder); err != nil {
@@ -525,7 +530,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
countBuilder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()))
countBuilder.Write(` WHERE le.org_id=? AND le.folder_id=0`, signedInUser.GetOrgID())
writeKindSQL(query, &countBuilder)
writeSearchStringSQL(query, l.SQLStore, &countBuilder)
writeSearchStringSQL(query, l.SQLStore, &countBuilder, foldersWithMatchingTitles)
writeExcludeSQL(query, &countBuilder)
writeTypeFilterSQL(typeFilter, &countBuilder)
countBuilder.Write(" ")
@@ -536,7 +541,7 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI
countBuilder.Write(getFromLibraryElementDTOWithMeta(l.SQLStore.GetDialect()))
countBuilder.Write(` WHERE le.org_id=? AND le.folder_id<>0`, signedInUser.GetOrgID())
writeKindSQL(query, &countBuilder)
writeSearchStringSQL(query, l.SQLStore, &countBuilder)
writeSearchStringSQL(query, l.SQLStore, &countBuilder, foldersWithMatchingTitles)
writeExcludeSQL(query, &countBuilder)
writeTypeFilterSQL(typeFilter, &countBuilder)
if err := folderFilter.writeFolderFilterSQL(true, &countBuilder); err != nil {
@@ -882,3 +887,45 @@ func contains(slice []string, element string) bool {
}
return false
}
func getFoldersWithMatchingTitles(c context.Context, l *LibraryElementService, signedInUser identity.Requester, query model.SearchLibraryElementsQuery) ([]string, error) {
if len(strings.TrimSpace(query.SearchString)) <= 0 {
return nil, nil
}
if l.features.IsEnabled(c, featuremgmt.FlagKubernetesClientDashboardsFolders) {
searchQuery := folder.SearchFoldersQuery{
OrgID: signedInUser.GetOrgID(),
Title: query.SearchString,
SignedInUser: signedInUser,
}
folderHits, err := l.folderService.SearchFolders(c, searchQuery)
if err != nil {
return nil, err
}
foldersWithMatchingTitles := make([]string, 0, len(folderHits))
for _, hit := range folderHits {
foldersWithMatchingTitles = append(foldersWithMatchingTitles, hit.UID)
}
return foldersWithMatchingTitles, nil
}
// Fallback to GetFolders
fs, err := l.folderService.GetFolders(c, folder.GetFoldersQuery{
OrgID: signedInUser.GetOrgID(),
SignedInUser: signedInUser,
})
if err != nil {
return nil, err
}
foldersWithMatchingTitles := make([]string, 0, len(fs))
for _, f := range fs {
if strings.Contains(strings.ToLower(f.Title), strings.ToLower(query.SearchString)) {
foldersWithMatchingTitles = append(foldersWithMatchingTitles, f.UID)
}
}
return foldersWithMatchingTitles, nil
}
@@ -1164,4 +1164,183 @@ func TestIntegration_GetAllLibraryElements(t *testing.T) {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
})
// Folder name search integration tests
scenarioWithPanel(t, "When searching by folder name, it should return panels in that folder",
func(t *testing.T, sc scenarioContext) {
// Create a panel in the existing folder
// nolint:staticcheck
command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Panel in ScenarioFolder")
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
// Search by folder name
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "ScenarioFolder")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
// Verify folder search finds both panels
require.Equal(t, int64(2), result.Result.TotalCount)
require.Equal(t, 2, len(result.Result.Elements))
require.Equal(t, 1, result.Result.Page)
require.Equal(t, 100, result.Result.PerPage)
for _, element := range result.Result.Elements {
require.Equal(t, int64(1), element.OrgID, "Should be in org 1")
require.Equal(t, sc.folder.UID, element.FolderUID, "Should have correct folder UID")
require.Equal(t, "ScenarioFolder", element.Meta.FolderName, "All panels should be in ScenarioFolder")
require.Equal(t, int64(model.PanelElement), element.Kind, "Should be a panel element")
require.Equal(t, "text", element.Type, "Should be text panel")
require.NotEmpty(t, element.UID, "Should have a UID")
require.NotEmpty(t, element.Name, "Should have a name")
require.Equal(t, int64(0), element.Meta.ConnectedDashboards, "Should have no connected dashboards")
}
})
scenarioWithPanel(t, "When searching by folder name that doesn't exist, it should return no results",
func(t *testing.T, sc scenarioContext) {
// Search by non-existent folder name
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "NonExistentFolder")
resp := sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
// Verify no results for non-existent folder
expected := libraryElementsSearch{
Result: libraryElementsSearchResult{
TotalCount: 0,
Elements: []libraryElement{},
Page: 1,
PerPage: 100,
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
require.Equal(t, 0, len(result.Result.Elements), "Should return empty elements array")
})
scenarioWithPanel(t, "When searching with folder filter applied, folder name search should be disabled",
func(t *testing.T, sc scenarioContext) {
// Create a panel in the existing folder
// nolint:staticcheck
command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Panel for folder filter test")
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
// Search with both searchString and folderFilterUIDs
// This should NOT search folder names, only panel names/descriptions within the specified folder
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "ScenarioFolder")
sc.reqContext.Req.Form.Add("folderFilterUIDs", sc.folder.UID)
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
// Folder name search disabled when folder filter applied
expected := libraryElementsSearch{
Result: libraryElementsSearchResult{
TotalCount: 0,
Elements: []libraryElement{},
Page: 1,
PerPage: 100,
},
}
if diff := cmp.Diff(expected, result, getCompareOptions()...); diff != "" {
t.Fatalf("Result mismatch (-want +got):\n%s", diff)
}
require.Equal(t, int64(0), result.Result.TotalCount, "Should not find panels by folder name when folder filter is applied")
require.Equal(t, 0, len(result.Result.Elements), "Should return empty elements array")
})
scenarioWithPanel(t, "When searching by partial folder name, it should return panels in matching folders",
func(t *testing.T, sc scenarioContext) {
// Create a panel in the existing folder
// nolint:staticcheck
command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Test Panel")
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
// Search by partial folder name
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "Scenario")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
// Verify partial folder name matching
require.Equal(t, int64(2), result.Result.TotalCount, "Should find exactly 2 panels by partial folder name")
require.Equal(t, 2, len(result.Result.Elements), "Should return 2 elements")
require.Equal(t, 1, result.Result.Page, "Should be on page 1")
require.Equal(t, 100, result.Result.PerPage, "Should have perPage 100")
for _, element := range result.Result.Elements {
require.Equal(t, int64(1), element.OrgID, "Should be in org 1")
require.Equal(t, sc.folder.UID, element.FolderUID, "Should have correct folder UID")
require.Contains(t, element.Meta.FolderName, "Scenario", "Folder name should contain 'Scenario'")
require.Equal(t, "ScenarioFolder", element.Meta.FolderName, "Should be in ScenarioFolder")
require.Equal(t, int64(model.PanelElement), element.Kind, "Should be a panel element")
require.NotEmpty(t, element.UID, "Should have a UID")
require.NotEmpty(t, element.Name, "Should have a name")
}
})
scenarioWithPanel(t, "When searching combines panel name and folder name matches, it should return both",
func(t *testing.T, sc scenarioContext) {
// Create a panel with a specific name
// nolint:staticcheck
command := getCreatePanelCommand(sc.folder.ID, sc.folder.UID, "Marketing Report Panel")
sc.reqContext.Req.Body = mockRequestBody(command)
resp := sc.service.createHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
// Search for "Marketing" which could match both panel name and folder name
err := sc.reqContext.Req.ParseForm()
require.NoError(t, err)
sc.reqContext.Req.Form.Add("searchString", "Marketing")
resp = sc.service.getAllHandler(sc.reqContext)
require.Equal(t, 200, resp.Status())
var result libraryElementsSearch
err = json.Unmarshal(resp.Body(), &result)
require.NoError(t, err)
// Verify panel name matching
require.Equal(t, int64(1), result.Result.TotalCount, "Should find exactly 1 panel with 'Marketing' in name")
require.Equal(t, 1, len(result.Result.Elements), "Should return 1 element")
require.Equal(t, 1, result.Result.Page, "Should be on page 1")
require.Equal(t, 100, result.Result.PerPage, "Should have perPage 100")
element := result.Result.Elements[0]
require.Equal(t, "Marketing Report Panel", element.Name, "Should find panel with correct name")
require.Equal(t, int64(1), element.OrgID, "Should be in org 1")
require.Equal(t, sc.folder.UID, element.FolderUID, "Should have correct folder UID")
require.Equal(t, "ScenarioFolder", element.Meta.FolderName, "Should be in ScenarioFolder")
require.Equal(t, int64(model.PanelElement), element.Kind, "Should be a panel element")
require.Equal(t, "text", element.Type, "Should be text panel")
require.NotEmpty(t, element.UID, "Should have a UID")
require.Equal(t, int64(0), element.Meta.ConnectedDashboards, "Should have no connected dashboards")
})
}
+17 -2
View File
@@ -58,13 +58,28 @@ func writeTypeFilterSQL(typeFilter []string, builder *db.SQLBuilder) {
}
}
func writeSearchStringSQL(query model.SearchLibraryElementsQuery, sqlStore db.DB, builder *db.SQLBuilder) {
func writeSearchStringSQL(query model.SearchLibraryElementsQuery, sqlStore db.DB, builder *db.SQLBuilder, foldersWithMatchingTitles []string) {
if len(strings.TrimSpace(query.SearchString)) > 0 {
// Search element names across all accessible folders
sql, param := sqlStore.GetDialect().LikeOperator("le.name", true, query.SearchString, true)
builder.Write(" AND ("+sql, param)
// Search element descriptions across all accessible folders
sql, param = sqlStore.GetDialect().LikeOperator("le.description", true, query.SearchString, true)
builder.Write(" OR "+sql+")", param)
builder.Write(" OR "+sql, param)
// Include ALL elements from folders whose titles match the search string
hasFolderFilter := len(strings.TrimSpace(query.FolderFilterUIDs)) > 0
if !hasFolderFilter && len(foldersWithMatchingTitles) > 0 {
folderUIDsSQL := "?" + strings.Repeat(",?", len(foldersWithMatchingTitles)-1)
params := make([]any, 0, len(foldersWithMatchingTitles))
for _, folderUID := range foldersWithMatchingTitles {
params = append(params, folderUID)
}
builder.Write(" OR le.folder_uid IN ("+folderUIDsSQL+")", params...)
}
builder.Write(")")
}
}
@@ -69,7 +69,7 @@ export const LibraryPanelsSearch = ({
onChange={setSearchQuery}
placeholder={t(
'library-panels.library-panels-search.placeholder-search-by-name-or-description',
'Search by name or description'
'Search by name, description or folder name'
)}
width={0}
escapeRegex={false}
+1 -1
View File
@@ -8495,7 +8495,7 @@
"usage-count_other": "Used on {{count}} dashboards"
},
"library-panels-search": {
"placeholder-search-by-name-or-description": "Search by name or description"
"placeholder-search-by-name-or-description": "Search by name, description or folder name"
},
"loading-indicator": {
"loading-library-panel": "Loading library panel..."