From c9630329158418f56e214a26dd0502cf51fdfeb0 Mon Sep 17 00:00:00 2001
From: Will Assis <35489495+gassiss@users.noreply.github.com>
Date: Thu, 13 Feb 2025 17:56:29 -0300
Subject: [PATCH] search: handle "sharedwithme" use-case in both legacy/US
modes (#100286)
* handle "sharedwithme" use-case in both legacy/US modes
* display "Shared with me" as location in dashboard list
* fix missing "TotalHits" prop in mode 2
---
.../dashboard/legacysearcher/search_client.go | 2 +
.../legacysearcher/search_client_test.go | 1 +
pkg/registry/apis/dashboard/search.go | 166 ++++++++++--
pkg/registry/apis/dashboard/search_test.go | 244 +++++++++++++++---
.../apis/dashboard/v0alpha1/register.go | 2 +-
.../search/page/components/columns.tsx | 39 ++-
.../features/search/service/unified.test.ts | 10 +-
public/app/features/search/service/unified.ts | 23 +-
public/app/features/search/service/utils.ts | 4 +
9 files changed, 410 insertions(+), 81 deletions(-)
diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client.go b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
index 50ada42a4c5..71501c3fdcd 100644
--- a/pkg/registry/apis/dashboard/legacysearcher/search_client.go
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client.go
@@ -209,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
})
}
+ list.TotalHits = int64(len(list.Results.Rows))
+
return list, nil
}
diff --git a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
index 0a2e2d4ca5b..14dc870bbd4 100644
--- a/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
+++ b/pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
@@ -54,6 +54,7 @@ func TestDashboardSearchClient_Search(t *testing.T) {
require.NotNil(t, resp)
searchFields := resource.StandardSearchFields()
require.Equal(t, &resource.ResourceSearchResponse{
+ TotalHits: 2,
Results: &resource.ResourceTable{
Columns: []*resource.ResourceTableColumnDefinition{
searchFields.Field(resource.SEARCH_FIELD_TITLE),
diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go
index c4ad9532066..9edd79f5f83 100644
--- a/pkg/registry/apis/dashboard/search.go
+++ b/pkg/registry/apis/dashboard/search.go
@@ -3,6 +3,7 @@ package dashboard
import (
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/url"
"slices"
@@ -25,7 +26,10 @@ import (
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
+ "github.com/grafana/grafana/pkg/services/dashboards"
dashboardsearch "github.com/grafana/grafana/pkg/services/dashboards/service/search"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
+ foldermodel "github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/errhttp"
@@ -33,17 +37,19 @@ import (
// The DTO returns everything the UI needs in a single request
type SearchHandler struct {
- log log.Logger
- client func(context.Context) resource.ResourceIndexClient
- tracer trace.Tracer
+ log log.Logger
+ client func(context.Context) resource.ResourceIndexClient
+ tracer trace.Tracer
+ features featuremgmt.FeatureToggles
}
-func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient) *SearchHandler {
+func NewSearchHandler(tracer trace.Tracer, cfg *setting.Cfg, legacyDashboardSearcher resource.ResourceIndexClient, features featuremgmt.FeatureToggles) *SearchHandler {
searchClient := resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, unified.GetResourceClient, legacyDashboardSearcher)
return &SearchHandler{
- client: searchClient,
- log: log.New("grafana-apiserver.dashboards.search"),
- tracer: tracer,
+ client: searchClient,
+ log: log.New("grafana-apiserver.dashboards.search"),
+ tracer: tracer,
+ features: features,
}
}
@@ -252,19 +258,6 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}
searchRequest.Fields = fields
- // Add the folder constraint. Note this does not do recursive search
- folder := queryParams.Get("folder")
- if folder != "" {
- if folder == rootFolder {
- folder = "" // root folder is empty in the search index
- }
- searchRequest.Options.Fields = []*resource.Requirement{{
- Key: "folder",
- Operator: "=",
- Values: []string{folder},
- }}
- }
-
types := queryParams["type"]
var federate *resource.ResourceKey
switch len(types) {
@@ -329,7 +322,33 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}
// The names filter
- if names, ok := queryParams["name"]; ok {
+ names := queryParams["name"]
+
+ // Add the folder constraint. Note this does not do recursive search
+ folder := queryParams.Get("folder")
+ if folder == foldermodel.SharedWithMeFolderUID {
+ dashboardUIDs, err := s.getDashboardsUIDsSharedWithUser(ctx, user)
+ if err != nil {
+ errhttp.Write(ctx, err, w)
+ return
+ }
+
+ // hijacks the "name" query param to only search for shared dashboard UIDs
+ if len(dashboardUIDs) > 0 {
+ names = append(names, dashboardUIDs...)
+ }
+ } else if folder != "" {
+ if folder == rootFolder {
+ folder = "" // root folder is empty in the search index
+ }
+ searchRequest.Options.Fields = []*resource.Requirement{{
+ Key: "folder",
+ Operator: "=",
+ Values: []string{folder},
+ }}
+ }
+
+ if len(names) > 0 {
if searchRequest.Options.Fields == nil {
searchRequest.Options.Fields = []*resource.Requirement{}
}
@@ -378,3 +397,108 @@ func asResourceKey(ns string, k string) (*resource.ResourceKey, error) {
return key, nil
}
+
+func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, user identity.Requester) ([]string, error) {
+ if !s.features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering) {
+ return []string{}, nil
+ }
+
+ // gets dashboards that the user was granted read access to
+ permissions := user.GetPermissions()
+ dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
+ dashboardUids := make([]string, 0)
+ sharedDashboards := make([]string, 0)
+
+ for _, dashboardPermission := range dashboardPermissions {
+ if dashboardUid, found := strings.CutPrefix(dashboardPermission, dashboards.ScopeDashboardsPrefix); found {
+ if !slices.Contains(dashboardUids, dashboardUid) {
+ dashboardUids = append(dashboardUids, dashboardUid)
+ }
+ }
+ }
+
+ if len(dashboardUids) == 0 {
+ return sharedDashboards, nil
+ }
+
+ key, err := asResourceKey(user.GetNamespace(), dashboard.DASHBOARD_RESOURCE)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ dashboardSearchRequest := &resource.ResourceSearchRequest{
+ Fields: []string{"folder"},
+ Limit: int64(len(dashboardUids)),
+ Options: &resource.ListOptions{
+ Key: key,
+ Fields: []*resource.Requirement{{
+ Key: "name",
+ Operator: "in",
+ Values: dashboardUids,
+ }},
+ },
+ }
+ // get all dashboards user has access to, along with their parent folder uid
+ dashboardResult, err := s.client(ctx).Search(ctx, dashboardSearchRequest)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ folderUidIdx := -1
+ for i, col := range dashboardResult.Results.Columns {
+ if col.Name == "folder" {
+ folderUidIdx = i
+ }
+ }
+
+ if folderUidIdx == -1 {
+ return sharedDashboards, fmt.Errorf("Error retrieving folder information")
+ }
+
+ // populate list of unique folder UIDs in the list of dashboards user has read permissions
+ allFolders := make([]string, 0)
+ for _, dash := range dashboardResult.Results.Rows {
+ folderUid := string(dash.Cells[folderUidIdx])
+ if folderUid != "" && !slices.Contains(allFolders, folderUid) {
+ allFolders = append(allFolders, folderUid)
+ }
+ }
+
+ // only folders the user has access to will be returned here
+ folderKey, err := asResourceKey(user.GetNamespace(), folderv0alpha1.RESOURCE)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ folderSearchRequest := &resource.ResourceSearchRequest{
+ Fields: []string{"folder"},
+ Limit: int64(len(allFolders)),
+ Options: &resource.ListOptions{
+ Key: folderKey,
+ Fields: []*resource.Requirement{{
+ Key: "name",
+ Operator: "in",
+ Values: allFolders,
+ }},
+ },
+ }
+ foldersResult, err := s.client(ctx).Search(ctx, folderSearchRequest)
+ if err != nil {
+ return sharedDashboards, err
+ }
+
+ foldersWithAccess := make([]string, 0, len(foldersResult.Results.Rows))
+ for _, fold := range foldersResult.Results.Rows {
+ foldersWithAccess = append(foldersWithAccess, fold.Key.Name)
+ }
+
+ // add to sharedDashboards dashboards user has access to, but does NOT have access to it's parent folder
+ for _, dash := range dashboardResult.Results.Rows {
+ dashboardUid := dash.Key.Name
+ folderUid := string(dash.Cells[folderUidIdx])
+ if folderUid != "" && !slices.Contains(foldersWithAccess, folderUid) {
+ sharedDashboards = append(sharedDashboards, dashboardUid)
+ }
+ }
+ return sharedDashboards, nil
+}
diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go
index 91af78295a3..c92b39efdd0 100644
--- a/pkg/registry/apis/dashboard/search_test.go
+++ b/pkg/registry/apis/dashboard/search_test.go
@@ -12,6 +12,8 @@ import (
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
@@ -31,7 +33,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode0},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -59,7 +61,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode1},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -87,7 +89,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode2},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -115,7 +117,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode3},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -143,7 +145,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode4},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -171,7 +173,7 @@ func TestSearchFallback(t *testing.T) {
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
},
}
- searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient)
+ searchHandler := NewSearchHandler(tracing.NewNoopTracerService(), cfg, mockLegacyClient, nil)
searchHandler.client = resource.NewSearchClient(cfg, setting.UnifiedStorageConfigKeyDashboard, mockUnifiedCtxclient, mockLegacyClient)
rr := httptest.NewRecorder()
@@ -191,17 +193,19 @@ func TestSearchFallback(t *testing.T) {
}
func TestSearchHandler(t *testing.T) {
- // Create a mock client
- mockClient := &MockClient{}
-
- // Initialize the search handler with the mock client
- searchHandler := SearchHandler{
- log: log.New("test", "test"),
- client: func(context.Context) resource.ResourceIndexClient { return mockClient },
- tracer: tracing.NewNoopTracerService(),
- }
-
t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil)
req.Header.Add("content-type", "application/json")
@@ -219,6 +223,18 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search?field=field1", nil)
req.Header.Add("content-type", "application/json")
@@ -236,6 +252,18 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) {
+ // Create a mock client
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
@@ -253,6 +281,41 @@ func TestSearchHandler(t *testing.T) {
})
t.Run("Sort - default sort by resource then title", func(t *testing.T) {
+ rows := make([]*resource.ResourceTableRow, len(mockResults))
+ for i, r := range mockResults {
+ rows[i] = &resource.ResourceTableRow{
+ Key: &resource.ResourceKey{
+ Name: r.Name,
+ Resource: r.Resource,
+ },
+ Cells: [][]byte{
+ []byte(r.Value),
+ },
+ }
+ }
+
+ mockResponse := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {Name: resource.SEARCH_FIELD_TITLE},
+ },
+ Rows: rows,
+ },
+ }
+ // Create a mock client
+ mockClient := &MockClient{
+ MockResponses: []*resource.ResourceSearchResponse{mockResponse},
+ }
+
+ features := featuremgmt.WithFeatures()
+ // Initialize the search handler with the mock client
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/search", nil)
req.Header.Add("content-type", "application/json")
@@ -280,6 +343,125 @@ func TestSearchHandler(t *testing.T) {
})
}
+func TestSearchHandlerSharedDashboards(t *testing.T) {
+ t.Run("should bail out if FlagUnifiedStorageSearchPermissionFiltering is not enabled globally", func(t *testing.T) {
+ mockClient := &MockClient{}
+
+ features := featuremgmt.WithFeatures()
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
+ req.Header.Add("content-type", "application/json")
+ req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
+
+ searchHandler.DoSearch(rr, req)
+
+ assert.Equal(t, mockClient.CallCount, 1)
+ })
+
+ t.Run("should return the dashboards shared with the user", func(t *testing.T) {
+ // dashboardSearchRequest
+ mockResponse1 := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {
+ Name: "folder",
+ },
+ },
+ Rows: []*resource.ResourceTableRow{
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinroot",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{[]byte("")}, // root folder doesn't have uid
+ },
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinprivatefolder",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{
+ []byte("privatefolder"), // folder uid
+ },
+ },
+ {
+ Key: &resource.ResourceKey{
+ Name: "dashboardinpublicfolder",
+ Resource: "dashboard",
+ },
+ Cells: [][]byte{
+ []byte("publicfolder"), // folder uid
+ },
+ },
+ },
+ },
+ }
+
+ // folderSearchRequest
+ mockResponse2 := &resource.ResourceSearchResponse{
+ Results: &resource.ResourceTable{
+ Columns: []*resource.ResourceTableColumnDefinition{
+ {
+ Name: "folder",
+ },
+ },
+ Rows: []*resource.ResourceTableRow{
+ {
+ Key: &resource.ResourceKey{
+ Name: "publicfolder",
+ Resource: "folder",
+ },
+ Cells: [][]byte{
+ []byte(""), // root folder uid
+ },
+ },
+ },
+ },
+ }
+
+ mockClient := &MockClient{
+ MockResponses: []*resource.ResourceSearchResponse{mockResponse1, mockResponse2},
+ }
+
+ features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearchPermissionFiltering)
+ searchHandler := SearchHandler{
+ log: log.New("test", "test"),
+ client: func(context.Context) resource.ResourceIndexClient { return mockClient },
+ tracer: tracing.NewNoopTracerService(),
+ features: features,
+ }
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequest("GET", "/search?folder=sharedwithme", nil)
+ req.Header.Add("content-type", "application/json")
+ allPermissions := make(map[int64]map[string][]string)
+ permissions := make(map[string][]string)
+ permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
+ allPermissions[1] = permissions
+ req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
+
+ searchHandler.DoSearch(rr, req)
+
+ assert.Equal(t, mockClient.CallCount, 3)
+
+ // first call gets all dashboards user has permission for
+ firstCall := mockClient.MockCalls[0]
+ assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
+ // second call gets folders associated with the previous dashboards
+ secondCall := mockClient.MockCalls[1]
+ assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
+ // lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
+ // permission to read
+ thirdCall := mockClient.MockCalls[2]
+ assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
+ })
+}
+
// MockClient implements the ResourceIndexClient interface for testing
type MockClient struct {
resource.ResourceIndexClient
@@ -287,6 +469,10 @@ type MockClient struct {
// Capture the last SearchRequest for assertions
LastSearchRequest *resource.ResourceSearchRequest
+
+ MockResponses []*resource.ResourceSearchResponse
+ MockCalls []*resource.ResourceSearchRequest
+ CallCount int
}
type MockResult struct {
@@ -320,28 +506,16 @@ var mockResults = []MockResult{
func (m *MockClient) Search(ctx context.Context, in *resource.ResourceSearchRequest, opts ...grpc.CallOption) (*resource.ResourceSearchResponse, error) {
m.LastSearchRequest = in
+ m.MockCalls = append(m.MockCalls, in)
- rows := make([]*resource.ResourceTableRow, len(mockResults))
- for i, r := range mockResults {
- rows[i] = &resource.ResourceTableRow{
- Key: &resource.ResourceKey{
- Name: r.Name,
- Resource: r.Resource,
- },
- Cells: [][]byte{
- []byte(r.Value),
- },
- }
+ var response *resource.ResourceSearchResponse
+ if m.CallCount < len(m.MockResponses) {
+ response = m.MockResponses[m.CallCount]
}
- return &resource.ResourceSearchResponse{
- Results: &resource.ResourceTable{
- Columns: []*resource.ResourceTableColumnDefinition{
- {Name: resource.SEARCH_FIELD_TITLE},
- },
- Rows: rows,
- },
- }, nil
+ m.CallCount = m.CallCount + 1
+
+ return response, nil
}
func (m *MockClient) GetStats(ctx context.Context, in *resource.ResourceStatsRequest, opts ...grpc.CallOption) (*resource.ResourceStatsResponse, error) {
return nil, nil
diff --git a/pkg/registry/apis/dashboard/v0alpha1/register.go b/pkg/registry/apis/dashboard/v0alpha1/register.go
index c051bb7595f..b85e5f49329 100644
--- a/pkg/registry/apis/dashboard/v0alpha1/register.go
+++ b/pkg/registry/apis/dashboard/v0alpha1/register.go
@@ -82,7 +82,7 @@ func RegisterAPIService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
features: features,
accessControl: accessControl,
unified: unified,
- search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher),
+ search: dashboard.NewSearchHandler(tracing, cfg, legacyDashboardSearcher, features),
legacy: &dashboard.DashboardStorage{
Resource: dashboardv0alpha1.DashboardResourceInfo,
diff --git a/public/app/features/search/page/components/columns.tsx b/public/app/features/search/page/components/columns.tsx
index 05c4b2e6792..3f982658dd0 100644
--- a/public/app/features/search/page/components/columns.tsx
+++ b/public/app/features/search/page/components/columns.tsx
@@ -194,17 +194,34 @@ export const generateColumns = (
if (!info && p === 'general') {
info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' };
}
- return info ? (
-
-