diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index ea91df4fe79..96c5f82cd22 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -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 +} diff --git a/pkg/services/libraryelements/libraryelements_get_all_test.go b/pkg/services/libraryelements/libraryelements_get_all_test.go index 3c61d08b8ab..970730399c9 100644 --- a/pkg/services/libraryelements/libraryelements_get_all_test.go +++ b/pkg/services/libraryelements/libraryelements_get_all_test.go @@ -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") + }) } diff --git a/pkg/services/libraryelements/writers.go b/pkg/services/libraryelements/writers.go index c35dedcd737..39bfadbb312 100644 --- a/pkg/services/libraryelements/writers.go +++ b/pkg/services/libraryelements/writers.go @@ -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(")") } } diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx index d4588f0ccff..9c6ca73c898 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx @@ -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} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4334abe4952..4f517a706ed 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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..."