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
pull/100685/head
Will Assis 4 months ago committed by GitHub
parent e2081c3e0c
commit c963032915
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      pkg/registry/apis/dashboard/legacysearcher/search_client.go
  2. 1
      pkg/registry/apis/dashboard/legacysearcher/search_client_test.go
  3. 166
      pkg/registry/apis/dashboard/search.go
  4. 244
      pkg/registry/apis/dashboard/search_test.go
  5. 2
      pkg/registry/apis/dashboard/v0alpha1/register.go
  6. 39
      public/app/features/search/page/components/columns.tsx
  7. 10
      public/app/features/search/service/unified.test.ts
  8. 23
      public/app/features/search/service/unified.ts
  9. 4
      public/app/features/search/service/utils.ts

@ -209,6 +209,8 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resource.Resour
})
}
list.TotalHits = int64(len(list.Results.Rows))
return list, nil
}

@ -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),

@ -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
}

@ -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

@ -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,

@ -194,17 +194,34 @@ export const generateColumns = (
if (!info && p === 'general') {
info = { kind: 'folder', url: '/dashboards', name: 'Dashboards' };
}
return info ? (
<a key={p} href={info.url} className={styles.locationItem}>
<Icon name={getIconForKind(info.kind)} />
<Text variant="body" truncate>
{info.name}
</Text>
</a>
) : (
<span key={p}>{p}</span>
);
if (info) {
const content = (
<>
<Icon name={getIconForKind(info.kind)} />
<Text variant="body" truncate>
{info.name}
</Text>
</>
);
if (info.url) {
return (
<a key={p} href={info.url} className={styles.locationItem}>
{content}
</a>
);
}
return (
<div key={p} className={styles.locationItem}>
{content}
</div>
);
}
return <span key={p}>{p}</span>;
})}
</div>
)}

@ -115,9 +115,6 @@ describe('Unified Storage Searcher', () => {
.mockResolvedValueOnce(mockResults)
.mockResolvedValueOnce(mockFolders);
const consoleWarn = jest.fn();
jest.spyOn(console, 'warn').mockImplementationOnce(consoleWarn);
const query: SearchQuery = {
query: 'test',
limit: 50,
@ -127,14 +124,15 @@ describe('Unified Storage Searcher', () => {
const response = await searcher.search(query);
expect(response.view.length).toBe(1);
expect(response.view.get(0).title).toBe('DB 2');
expect(response.view.length).toBe(2);
expect(response.view.get(0).title).toBe('DB 1');
expect(response.view.get(0).folder).toBe('sharedwithme');
expect(response.view.get(1).title).toBe('DB 2');
const df = response.view.dataFrame;
const locationInfo = df.meta?.custom?.locationInfo;
expect(locationInfo).toBeDefined();
expect(locationInfo?.folder2.name).toBe('Folder 2');
expect(consoleWarn).toHaveBeenCalled();
expect(mockSearcher.search).toHaveBeenCalledTimes(3);
});

@ -204,15 +204,19 @@ export class UnifiedSearcher implements GrafanaSearcher {
if (!hasMissing) {
return rsp;
}
// we still have results here with folders we can't find
// filter the results since we probably don't have access to that folder
const locationInfo = await this.locationInfo;
const hits = rsp.hits.filter((hit) => {
if (hit.folder === undefined || locationInfo[hit.folder] !== undefined) {
return true;
const hits = rsp.hits.map((hit) => {
if (hit.folder === undefined) {
return { ...hit, location: 'general', folder: 'general' };
}
// this means user has permission to see this dashboard, but not the folder contents
if (locationInfo[hit.folder] === undefined) {
return { ...hit, location: 'sharedwithme', folder: 'sharedwithme' };
}
console.warn('Dropping search hit with missing folder', hit);
return false;
return hit;
});
const totalHits = rsp.totalHits - (rsp.hits.length - hits.length);
return { ...rsp, hits, totalHits };
@ -370,6 +374,11 @@ async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
name: 'Dashboards',
url: '/dashboards',
}, // share location info with everyone
sharedwithme: {
kind: 'sharedwithme',
name: 'Shared with me',
url: '',
},
};
for (const hit of rsp.hits) {
locationInfo[hit.name] = {

@ -49,6 +49,10 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
return isOpen ? 'folder-open' : 'folder';
}
if (kind === 'sharedwithme') {
return 'users-alt';
}
return 'question-circle';
}

Loading…
Cancel
Save