mirror of
https://github.com/grafana/grafana.git
synced 2025-12-22 20:54:34 +08:00
Compare commits
3 Commits
docs/add-t
...
folders/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d83ad6e2a | ||
|
|
726ac07372 | ||
|
|
688d6746c9 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -262,3 +262,5 @@ public/mockServiceWorker.js
|
|||||||
|
|
||||||
# Ignore grafana/hippocampus local cache folder
|
# Ignore grafana/hippocampus local cache folder
|
||||||
.hippo
|
.hippo
|
||||||
|
|
||||||
|
.cache
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ type DashboardHit struct {
|
|||||||
Folder string `json:"folder,omitempty"`
|
Folder string `json:"folder,omitempty"`
|
||||||
// The resource is managed
|
// The resource is managed
|
||||||
ManagedBy ManagedBy `json:"managedBy,omitzero,omitempty"`
|
ManagedBy ManagedBy `json:"managedBy,omitzero,omitempty"`
|
||||||
|
// Owner references set on the resource metadata
|
||||||
|
OwnerReferences []metav1.OwnerReference `json:"ownerReferences,omitempty"`
|
||||||
// Stick untyped extra fields in this object (including the sort value)
|
// Stick untyped extra fields in this object (including the sort value)
|
||||||
Field *common.Unstructured `json:"field,omitzero,omitempty"`
|
Field *common.Unstructured `json:"field,omitzero,omitempty"`
|
||||||
// When using "real" search, this is the score
|
// When using "real" search, this is the score
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ const injectedRtkApi = api
|
|||||||
sort: queryArg.sort,
|
sort: queryArg.sort,
|
||||||
limit: queryArg.limit,
|
limit: queryArg.limit,
|
||||||
explain: queryArg.explain,
|
explain: queryArg.explain,
|
||||||
|
owner: queryArg.owner,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
providesTags: ['Search'],
|
providesTags: ['Search'],
|
||||||
@@ -606,6 +607,8 @@ export type GetSearchApiArg = {
|
|||||||
type?: 'folder' | 'dashboard';
|
type?: 'folder' | 'dashboard';
|
||||||
/** search/list within a folder (not recursive) */
|
/** search/list within a folder (not recursive) */
|
||||||
folder?: string;
|
folder?: string;
|
||||||
|
/** filter by owner reference name or UID */
|
||||||
|
owner?: string;
|
||||||
/** count distinct terms for selected fields */
|
/** count distinct terms for selected fields */
|
||||||
facet?: string[];
|
facet?: string[];
|
||||||
/** tag query filter */
|
/** tag query filter */
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
unstructuredv1 "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/kube-openapi/pkg/common"
|
"k8s.io/kube-openapi/pkg/common"
|
||||||
"k8s.io/kube-openapi/pkg/spec3"
|
"k8s.io/kube-openapi/pkg/spec3"
|
||||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||||
@@ -38,19 +39,21 @@ import (
|
|||||||
|
|
||||||
// The DTO returns everything the UI needs in a single request
|
// The DTO returns everything the UI needs in a single request
|
||||||
type SearchHandler struct {
|
type SearchHandler struct {
|
||||||
log log.Logger
|
log log.Logger
|
||||||
client resourcepb.ResourceIndexClient
|
client resourcepb.ResourceIndexClient
|
||||||
tracer trace.Tracer
|
resourceClient resource.ResourceClient
|
||||||
features featuremgmt.FeatureToggles
|
tracer trace.Tracer
|
||||||
|
features featuremgmt.FeatureToggles
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyDashboardSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
func NewSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyDashboardSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *SearchHandler {
|
||||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher, features)
|
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), dashboardv0alpha1.DashboardResourceInfo.GroupResource(), resourceClient, legacyDashboardSearcher, features)
|
||||||
return &SearchHandler{
|
return &SearchHandler{
|
||||||
client: searchClient,
|
client: searchClient,
|
||||||
log: log.New("grafana-apiserver.dashboards.search"),
|
resourceClient: resourceClient,
|
||||||
tracer: tracer,
|
log: log.New("grafana-apiserver.dashboards.search"),
|
||||||
features: features,
|
tracer: tracer,
|
||||||
|
features: features,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,6 +332,9 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.addOwnerReferences(ctx, result, &parsedResults)
|
||||||
|
s.filterByOwner(queryParams.Get("owner"), &parsedResults)
|
||||||
|
|
||||||
s.write(w, parsedResults)
|
s.write(w, parsedResults)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +495,66 @@ func (s *SearchHandler) write(w http.ResponseWriter, obj any) {
|
|||||||
_ = json.NewEncoder(w).Encode(obj)
|
_ = json.NewEncoder(w).Encode(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SearchHandler) filterByOwner(owner string, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||||
|
if owner == "" || parsedResults == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]dashboardv0alpha1.DashboardHit, 0, len(parsedResults.Hits))
|
||||||
|
for _, hit := range parsedResults.Hits {
|
||||||
|
for _, ref := range hit.OwnerReferences {
|
||||||
|
if ref.Name == owner || string(ref.UID) == owner {
|
||||||
|
filtered = append(filtered, hit)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedResults.Hits = filtered
|
||||||
|
parsedResults.TotalHits = int64(len(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SearchHandler) addOwnerReferences(ctx context.Context, searchResult *resourcepb.ResourceSearchResponse, parsedResults *dashboardv0alpha1.SearchResults) {
|
||||||
|
if s.resourceClient == nil || searchResult == nil || parsedResults == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResult.Results == nil || len(searchResult.Results.Rows) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResult.Results.Rows) != len(parsedResults.Hits) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, row := range searchResult.Results.Rows {
|
||||||
|
key := row.Key
|
||||||
|
if key == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
readResp, err := s.resourceClient.Read(ctx, &resourcepb.ReadRequest{Key: key})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Debug("failed to read resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if readResp == nil || readResp.Error != nil || len(readResp.Value) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var obj unstructuredv1.Unstructured
|
||||||
|
if err := obj.UnmarshalJSON(readResp.Value); err != nil {
|
||||||
|
s.log.Debug("failed to unmarshal resource while adding owner references", "resource", key.Resource, "name", key.Name, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if refs := obj.GetOwnerReferences(); len(refs) > 0 {
|
||||||
|
parsedResults.Hits[i].OwnerReferences = refs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Given a namespace and type convert it to a search key
|
// Given a namespace and type convert it to a search key
|
||||||
func asResourceKey(ns string, k string) (*resourcepb.ResourceKey, error) {
|
func asResourceKey(ns string, k string) (*resourcepb.ResourceKey, error) {
|
||||||
key, err := resource.AsResourceKey(ns, k)
|
key, err := resource.AsResourceKey(ns, k)
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
dashboardv0alpha1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
@@ -338,13 +339,184 @@ func TestSearchHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
p := &v0alpha1.SearchResults{}
|
p := &dashboardv0alpha1.SearchResults{}
|
||||||
err := json.NewDecoder(resp.Body).Decode(p)
|
err := json.NewDecoder(resp.Body).Decode(p)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, len(mockResults), len(p.Hits))
|
assert.Equal(t, len(mockResults), len(p.Hits))
|
||||||
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
|
assert.Equal(t, mockResults[2].Value, p.Hits[0].Title)
|
||||||
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
|
assert.Equal(t, mockResults[1].Value, p.Hits[3].Title)
|
||||||
})
|
})
|
||||||
|
t.Run("Adds owner references from resource store", func(t *testing.T) {
|
||||||
|
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||||
|
Results: &resourcepb.ResourceTable{
|
||||||
|
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||||
|
{Name: resource.SEARCH_FIELD_TITLE},
|
||||||
|
},
|
||||||
|
Rows: []*resourcepb.ResourceTableRow{
|
||||||
|
{
|
||||||
|
Key: &resourcepb.ResourceKey{
|
||||||
|
Namespace: "test",
|
||||||
|
Group: dashboardv0alpha1.GROUP,
|
||||||
|
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||||
|
Name: "d1",
|
||||||
|
},
|
||||||
|
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerRefs := []metav1.OwnerReference{
|
||||||
|
{
|
||||||
|
APIVersion: "team.grafana.app/v1beta1",
|
||||||
|
Kind: "Team",
|
||||||
|
Name: "team-1",
|
||||||
|
UID: "uid-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
value, err := json.Marshal(map[string]any{
|
||||||
|
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||||
|
"kind": "Dashboard",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "d1",
|
||||||
|
"namespace": "test",
|
||||||
|
"ownerReferences": ownerRefs,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"title": "Dashboard 1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockClient := &MockClient{
|
||||||
|
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||||
|
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||||
|
"d1": {
|
||||||
|
Value: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
|
searchHandler := SearchHandler{
|
||||||
|
log: log.New("test", "ownerRefs"),
|
||||||
|
client: mockClient,
|
||||||
|
resourceClient: mockClient,
|
||||||
|
tracer: tracing.NewNoopTracerService(),
|
||||||
|
features: features,
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/search", nil)
|
||||||
|
req.Header.Add("content-type", "application/json")
|
||||||
|
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||||
|
|
||||||
|
searchHandler.DoSearch(rr, req)
|
||||||
|
|
||||||
|
var result dashboardv0alpha1.SearchResults
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||||
|
|
||||||
|
require.Len(t, result.Hits, 1)
|
||||||
|
require.Len(t, result.Hits[0].OwnerReferences, 1)
|
||||||
|
assert.Equal(t, ownerRefs[0].Name, result.Hits[0].OwnerReferences[0].Name)
|
||||||
|
assert.Equal(t, ownerRefs[0].Kind, result.Hits[0].OwnerReferences[0].Kind)
|
||||||
|
})
|
||||||
|
t.Run("Filters by owner param", func(t *testing.T) {
|
||||||
|
mockResponse := &resourcepb.ResourceSearchResponse{
|
||||||
|
Results: &resourcepb.ResourceTable{
|
||||||
|
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||||
|
{Name: resource.SEARCH_FIELD_TITLE},
|
||||||
|
},
|
||||||
|
Rows: []*resourcepb.ResourceTableRow{
|
||||||
|
{
|
||||||
|
Key: &resourcepb.ResourceKey{
|
||||||
|
Namespace: "test",
|
||||||
|
Group: dashboardv0alpha1.GROUP,
|
||||||
|
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||||
|
Name: "d1",
|
||||||
|
},
|
||||||
|
Cells: [][]byte{[]byte("Dashboard 1")},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: &resourcepb.ResourceKey{
|
||||||
|
Namespace: "test",
|
||||||
|
Group: dashboardv0alpha1.GROUP,
|
||||||
|
Resource: dashboardv0alpha1.DASHBOARD_RESOURCE,
|
||||||
|
Name: "d2",
|
||||||
|
},
|
||||||
|
Cells: [][]byte{[]byte("Dashboard 2")},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TotalHits: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerRefs := []metav1.OwnerReference{
|
||||||
|
{
|
||||||
|
APIVersion: "team.grafana.app/v1beta1",
|
||||||
|
Kind: "Team",
|
||||||
|
Name: "team-1",
|
||||||
|
UID: "uid-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d1Value, err := json.Marshal(map[string]any{
|
||||||
|
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||||
|
"kind": "Dashboard",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "d1",
|
||||||
|
"namespace": "test",
|
||||||
|
"ownerReferences": ownerRefs,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"title": "Dashboard 1",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
d2Value, err := json.Marshal(map[string]any{
|
||||||
|
"apiVersion": dashboardv0alpha1.APIVERSION,
|
||||||
|
"kind": "Dashboard",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": "d2",
|
||||||
|
"namespace": "test",
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"title": "Dashboard 2",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mockClient := &MockClient{
|
||||||
|
MockResponses: []*resourcepb.ResourceSearchResponse{mockResponse},
|
||||||
|
ReadResponses: map[string]*resourcepb.ReadResponse{
|
||||||
|
"d1": {Value: d1Value},
|
||||||
|
"d2": {Value: d2Value},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
|
searchHandler := SearchHandler{
|
||||||
|
log: log.New("test", "ownerRefs-filter"),
|
||||||
|
client: mockClient,
|
||||||
|
resourceClient: mockClient,
|
||||||
|
tracer: tracing.NewNoopTracerService(),
|
||||||
|
features: features,
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/search?owner=team-1", nil)
|
||||||
|
req.Header.Add("content-type", "application/json")
|
||||||
|
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"}))
|
||||||
|
|
||||||
|
searchHandler.DoSearch(rr, req)
|
||||||
|
|
||||||
|
var result dashboardv0alpha1.SearchResults
|
||||||
|
require.NoError(t, json.NewDecoder(rr.Body).Decode(&result))
|
||||||
|
|
||||||
|
require.Len(t, result.Hits, 1)
|
||||||
|
assert.Equal(t, int64(1), result.TotalHits)
|
||||||
|
assert.Equal(t, "d1", result.Hits[0].Name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchHandlerSharedDashboards(t *testing.T) {
|
func TestSearchHandlerSharedDashboards(t *testing.T) {
|
||||||
@@ -376,7 +548,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
p := &v0alpha1.SearchResults{}
|
p := &dashboardv0alpha1.SearchResults{}
|
||||||
err := json.NewDecoder(resp.Body).Decode(p)
|
err := json.NewDecoder(resp.Body).Decode(p)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, len(p.Hits))
|
assert.Equal(t, 0, len(p.Hits))
|
||||||
@@ -466,7 +638,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
p := &v0alpha1.SearchResults{}
|
p := &dashboardv0alpha1.SearchResults{}
|
||||||
err := json.NewDecoder(resp.Body).Decode(p)
|
err := json.NewDecoder(resp.Body).Decode(p)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, 0, len(p.Hits))
|
assert.Equal(t, 0, len(p.Hits))
|
||||||
@@ -597,7 +769,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
p := &v0alpha1.SearchResults{}
|
p := &dashboardv0alpha1.SearchResults{}
|
||||||
err := json.NewDecoder(resp.Body).Decode(p)
|
err := json.NewDecoder(resp.Body).Decode(p)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
|
assert.Equal(t, len(mockResponse3.Results.Rows), len(p.Hits))
|
||||||
@@ -1017,6 +1189,12 @@ type MockClient struct {
|
|||||||
MockResponses []*resourcepb.ResourceSearchResponse
|
MockResponses []*resourcepb.ResourceSearchResponse
|
||||||
MockCalls []*resourcepb.ResourceSearchRequest
|
MockCalls []*resourcepb.ResourceSearchRequest
|
||||||
CallCount int
|
CallCount int
|
||||||
|
|
||||||
|
ReadResponses map[string]*resourcepb.ReadResponse
|
||||||
|
DefaultReadResponse *resourcepb.ReadResponse
|
||||||
|
LastReadRequests []*resourcepb.ReadRequest
|
||||||
|
ReadError error
|
||||||
|
LastReadErrorRequest *resourcepb.ReadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
type MockResult struct {
|
type MockResult struct {
|
||||||
@@ -1080,6 +1258,22 @@ func (m *MockClient) Update(ctx context.Context, in *resourcepb.UpdateRequest, o
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
|
func (m *MockClient) Read(ctx context.Context, in *resourcepb.ReadRequest, opts ...grpc.CallOption) (*resourcepb.ReadResponse, error) {
|
||||||
|
m.LastReadRequests = append(m.LastReadRequests, in)
|
||||||
|
if m.ReadError != nil {
|
||||||
|
m.LastReadErrorRequest = in
|
||||||
|
return nil, m.ReadError
|
||||||
|
}
|
||||||
|
|
||||||
|
if in != nil && in.Key != nil && m.ReadResponses != nil {
|
||||||
|
if resp, ok := m.ReadResponses[in.Key.Name]; ok {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.DefaultReadResponse != nil {
|
||||||
|
return m.DefaultReadResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
|
func (m *MockClient) GetBlob(ctx context.Context, in *resourcepb.GetBlobRequest, opts ...grpc.CallOption) (*resourcepb.GetBlobResponse, error) {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useEffect, useMemo } from 'react';
|
|||||||
import { AppEvents } from '@grafana/data';
|
import { AppEvents } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
import { config, getAppEvents } from '@grafana/runtime';
|
import { config, getAppEvents } from '@grafana/runtime';
|
||||||
import { DisplayList, iamAPIv0alpha1, useLazyGetDisplayMappingQuery } from 'app/api/clients/iam/v0alpha1';
|
import {
|
||||||
|
API_GROUP as IAM_API_GROUP,
|
||||||
|
API_VERSION as IAM_API_VERSION,
|
||||||
|
DisplayList,
|
||||||
|
iamAPIv0alpha1,
|
||||||
|
useLazyGetDisplayMappingQuery,
|
||||||
|
} from 'app/api/clients/iam/v0alpha1';
|
||||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||||
import {
|
import {
|
||||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||||
@@ -56,6 +62,8 @@ import {
|
|||||||
ReplaceFolderApiArg,
|
ReplaceFolderApiArg,
|
||||||
useGetAffectedItemsQuery,
|
useGetAffectedItemsQuery,
|
||||||
FolderInfo,
|
FolderInfo,
|
||||||
|
ObjectMeta,
|
||||||
|
OwnerReference,
|
||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
function getFolderUrl(uid: string, title: string): string {
|
function getFolderUrl(uid: string, title: string): string {
|
||||||
@@ -66,6 +74,10 @@ function getFolderUrl(uid: string, title: string): string {
|
|||||||
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CombinedFolder = FolderDTO & {
|
||||||
|
ownerReferences?: OwnerReference[];
|
||||||
|
};
|
||||||
|
|
||||||
const combineFolderResponses = (
|
const combineFolderResponses = (
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
legacyFolder: FolderDTO,
|
legacyFolder: FolderDTO,
|
||||||
@@ -75,7 +87,7 @@ const combineFolderResponses = (
|
|||||||
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
const updatedBy = folder.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||||
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
const createdBy = folder.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||||
|
|
||||||
const newData: FolderDTO = {
|
const newData: CombinedFolder = {
|
||||||
canAdmin: legacyFolder.canAdmin,
|
canAdmin: legacyFolder.canAdmin,
|
||||||
canDelete: legacyFolder.canDelete,
|
canDelete: legacyFolder.canDelete,
|
||||||
canEdit: legacyFolder.canEdit,
|
canEdit: legacyFolder.canEdit,
|
||||||
@@ -84,6 +96,7 @@ const combineFolderResponses = (
|
|||||||
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
createdBy: (createdBy && userDisplay?.display[userDisplay?.keys.indexOf(createdBy)]?.displayName) || 'Anonymous',
|
||||||
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
updatedBy: (updatedBy && userDisplay?.display[userDisplay?.keys.indexOf(updatedBy)]?.displayName) || 'Anonymous',
|
||||||
...appPlatformFolderToLegacyFolder(folder),
|
...appPlatformFolderToLegacyFolder(folder),
|
||||||
|
ownerReferences: folder.metadata.ownerReferences || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parents.length) {
|
if (parents.length) {
|
||||||
@@ -101,7 +114,7 @@ const combineFolderResponses = (
|
|||||||
return newData;
|
return newData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getFolderByUidFacade(uid: string): Promise<FolderDTO> {
|
export async function getFolderByUidFacade(uid: string) {
|
||||||
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid);
|
||||||
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
|
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
|
||||||
|
|
||||||
@@ -216,7 +229,7 @@ export function useGetFolderQueryFacade(uid?: string) {
|
|||||||
|
|
||||||
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
// Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy
|
||||||
// api client.
|
// api client.
|
||||||
let newData: FolderDTO | undefined = undefined;
|
let newData: CombinedFolder | undefined = undefined;
|
||||||
if (
|
if (
|
||||||
resultFolder.data &&
|
resultFolder.data &&
|
||||||
resultParents.data &&
|
resultParents.data &&
|
||||||
@@ -359,14 +372,36 @@ export function useCreateFolder() {
|
|||||||
return legacyHook;
|
return legacyHook;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createFolderAppPlatform = async (folder: NewFolder) => {
|
const createFolderAppPlatform = async (payload: NewFolder & { createAsTeamFolder?: boolean; teamUid?: string }) => {
|
||||||
const payload: CreateFolderApiArg = {
|
const { createAsTeamFolder, teamUid, ...folder } = payload;
|
||||||
|
const slugifiedTitle = kbn.slugifyForUrl(folder.title);
|
||||||
|
|
||||||
|
const metadataName = `team-${slugifiedTitle}`;
|
||||||
|
const partialMetadata: ObjectMeta =
|
||||||
|
createAsTeamFolder && teamUid
|
||||||
|
? {
|
||||||
|
name: metadataName,
|
||||||
|
ownerReferences: [
|
||||||
|
{
|
||||||
|
apiVersion: `${IAM_API_GROUP}/${IAM_API_VERSION}`,
|
||||||
|
kind: 'Team',
|
||||||
|
name: folder.title,
|
||||||
|
uid: teamUid,
|
||||||
|
controller: true,
|
||||||
|
blockOwnerDeletion: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: { generateName: 'f' };
|
||||||
|
|
||||||
|
const apiPayload: CreateFolderApiArg = {
|
||||||
folder: {
|
folder: {
|
||||||
spec: {
|
spec: {
|
||||||
title: folder.title,
|
title: folder.title,
|
||||||
|
description: 'Testing a description',
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
generateName: 'f',
|
...partialMetadata,
|
||||||
annotations: {
|
annotations: {
|
||||||
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
...(folder.parentUid && { [AnnoKeyFolder]: folder.parentUid }),
|
||||||
},
|
},
|
||||||
@@ -375,7 +410,7 @@ export function useCreateFolder() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await createFolder(payload);
|
const result = await createFolder(apiPayload);
|
||||||
refresh({ childrenOf: folder.parentUid });
|
refresh({ childrenOf: folder.parentUid });
|
||||||
deletedDashboardsCache.clear();
|
deletedDashboardsCache.clear();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { FolderRepo } from './FolderRepo';
|
|||||||
import { getDOMId, NestedFolderList } from './NestedFolderList';
|
import { getDOMId, NestedFolderList } from './NestedFolderList';
|
||||||
import Trigger from './Trigger';
|
import Trigger from './Trigger';
|
||||||
import { useFoldersQuery } from './useFoldersQuery';
|
import { useFoldersQuery } from './useFoldersQuery';
|
||||||
|
import { useTeamOwnedFolder } from './useTeamOwnedFolder';
|
||||||
import { useTreeInteractions } from './useTreeInteractions';
|
import { useTreeInteractions } from './useTreeInteractions';
|
||||||
import { getRootFolderItem } from './utils';
|
import { getRootFolderItem } from './utils';
|
||||||
|
|
||||||
@@ -82,7 +83,10 @@ export function NestedFolderPicker({
|
|||||||
id,
|
id,
|
||||||
}: NestedFolderPickerProps) {
|
}: NestedFolderPickerProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const selectedFolder = useGetFolderQueryFacade(value);
|
const { folder: teamFolder } = useTeamOwnedFolder();
|
||||||
|
const effectiveValue = value || teamFolder?.name;
|
||||||
|
const selectedFolder = useGetFolderQueryFacade(effectiveValue);
|
||||||
|
|
||||||
// user might not have access to the folder, but they have access to the dashboard
|
// user might not have access to the folder, but they have access to the dashboard
|
||||||
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
||||||
// but doesn't have access to the folder
|
// but doesn't have access to the folder
|
||||||
@@ -112,6 +116,12 @@ export function NestedFolderPicker({
|
|||||||
rootFolderItem,
|
rootFolderItem,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value === undefined && teamFolder && onChange) {
|
||||||
|
onChange(teamFolder.name, teamFolder.title);
|
||||||
|
}
|
||||||
|
}, [onChange, teamFolder, value]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!search) {
|
if (!search) {
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
@@ -209,6 +219,23 @@ export function NestedFolderPicker({
|
|||||||
[search, fetchFolderPage]
|
[search, fetchFolderPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teamFolderTreeItem = useMemo(() => {
|
||||||
|
if (!teamFolder) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: false,
|
||||||
|
level: 0,
|
||||||
|
item: {
|
||||||
|
kind: 'folder' as const,
|
||||||
|
title: teamFolder.title,
|
||||||
|
uid: teamFolder.name,
|
||||||
|
parentUID: teamFolder.folder,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [teamFolder]);
|
||||||
|
|
||||||
const flatTree = useMemo(() => {
|
const flatTree = useMemo(() => {
|
||||||
let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = [];
|
let flatTree: Array<DashboardsTreeItem<DashboardViewItemWithUIItems>> = [];
|
||||||
|
|
||||||
@@ -229,6 +256,10 @@ export function NestedFolderPicker({
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (teamFolderTreeItem) {
|
||||||
|
flatTree = [teamFolderTreeItem, ...flatTree];
|
||||||
|
}
|
||||||
|
|
||||||
// It's not super optimal to filter these in an additional iteration, but
|
// It's not super optimal to filter these in an additional iteration, but
|
||||||
// these options are used infrequently that its not a big deal
|
// these options are used infrequently that its not a big deal
|
||||||
if (!showRootFolder || excludeUIDs?.length) {
|
if (!showRootFolder || excludeUIDs?.length) {
|
||||||
@@ -246,7 +277,7 @@ export function NestedFolderPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return flatTree;
|
return flatTree;
|
||||||
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder]);
|
}, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder, teamFolderTreeItem]);
|
||||||
|
|
||||||
const isItemLoaded = useCallback(
|
const isItemLoaded = useCallback(
|
||||||
(itemIndex: number) => {
|
(itemIndex: number) => {
|
||||||
@@ -276,7 +307,7 @@ export function NestedFolderPicker({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let label = selectedFolder.data?.title;
|
let label = selectedFolder.data?.title;
|
||||||
if (value === '') {
|
if (!label) {
|
||||||
label = t('browse-dashboards.folder-picker.root-title', 'Dashboards');
|
label = t('browse-dashboards.folder-picker.root-title', 'Dashboards');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +393,7 @@ export function NestedFolderPicker({
|
|||||||
|
|
||||||
<NestedFolderList
|
<NestedFolderList
|
||||||
items={flatTree}
|
items={flatTree}
|
||||||
selectedFolder={value}
|
selectedFolder={effectiveValue}
|
||||||
focusedItemIndex={focusedItemIndex}
|
focusedItemIndex={focusedItemIndex}
|
||||||
onFolderExpand={handleFolderExpand}
|
onFolderExpand={handleFolderExpand}
|
||||||
onFolderSelect={handleFolderSelect}
|
onFolderSelect={handleFolderSelect}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { TeamDto } from '@grafana/api-clients/rtkq/legacy';
|
||||||
|
import { useGetSearchQuery } from 'app/api/clients/dashboard/v0alpha1';
|
||||||
|
import { api as profileApi } from 'app/features/profile/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first folder owned by any team the current user belongs to.
|
||||||
|
* Uses the dashboard v0alpha1 search API with the `owner` filter.
|
||||||
|
*/
|
||||||
|
export function useTeamOwnedFolder() {
|
||||||
|
const [teams, setTeams] = useState<TeamDto[] | null>(null);
|
||||||
|
const [teamError, setTeamError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
profileApi
|
||||||
|
.loadTeams()
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTeams(result);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTeamError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const owner = useMemo(() => {
|
||||||
|
if (!teams || teams.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const firstTeam = teams[0];
|
||||||
|
// Prefer UID if available, otherwise fallback to name
|
||||||
|
return firstTeam.uid ?? firstTeam.name;
|
||||||
|
}, [teams]);
|
||||||
|
|
||||||
|
const searchArgs =
|
||||||
|
owner !== undefined
|
||||||
|
? {
|
||||||
|
owner,
|
||||||
|
type: 'folder' as const,
|
||||||
|
// Now with the dummy backend this wouldn't work as we filter for owner after we get results
|
||||||
|
// from the DB and this would be only applied to the DB call.
|
||||||
|
// limit: 1,
|
||||||
|
}
|
||||||
|
: skipToken;
|
||||||
|
|
||||||
|
const { data, isFetching, error: searchError } = useGetSearchQuery(searchArgs);
|
||||||
|
|
||||||
|
const folder = data?.hits?.[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
folder,
|
||||||
|
isLoading: (teams === null && !teamError) || isFetching,
|
||||||
|
error: teamError ?? searchError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* eslint-disable @grafana/i18n/no-untranslated-strings */
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Box, Button, Combobox, ComboboxOption, Divider, Stack, Text } from '@grafana/ui';
|
||||||
|
import { OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||||
|
import { useListTeamQuery, API_GROUP, API_VERSION } from 'app/api/clients/iam/v0alpha1';
|
||||||
|
import { useDispatch } from 'app/types/store';
|
||||||
|
|
||||||
|
import { TeamOwnerReference } from './OwnerReference';
|
||||||
|
import { SupportedResource, useAddOwnerReference, useGetOwnerReferences } from './hooks';
|
||||||
|
|
||||||
|
const TeamSelector = ({ onChange }: { onChange: (ownerRef: OwnerReference) => void }) => {
|
||||||
|
const { data: teams } = useListTeamQuery({});
|
||||||
|
const teamsOptions = teams?.items.map((team) => ({
|
||||||
|
label: team.spec.title,
|
||||||
|
value: team.metadata.name!,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
options={teamsOptions}
|
||||||
|
onChange={(team: ComboboxOption<string>) => {
|
||||||
|
onChange({
|
||||||
|
apiVersion: `${API_GROUP}/${API_VERSION}`,
|
||||||
|
kind: 'Team',
|
||||||
|
name: team.label,
|
||||||
|
uid: team.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManageOwnerReferences = ({
|
||||||
|
resource,
|
||||||
|
resourceId,
|
||||||
|
}: {
|
||||||
|
resource: SupportedResource;
|
||||||
|
resourceId: string;
|
||||||
|
}) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const [addingNewReference, setAddingNewReference] = useState(false);
|
||||||
|
const [pendingReference, setPendingReference] = useState<OwnerReference | null>(null);
|
||||||
|
const ownerReferences = useGetOwnerReferences({ resource, resourceId });
|
||||||
|
const [trigger, result] = useAddOwnerReference({ resource, resourceId });
|
||||||
|
|
||||||
|
const addOwnerReference = (ownerReference: OwnerReference) => {
|
||||||
|
trigger(ownerReference);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack direction="column">
|
||||||
|
<Text variant="h3">Owned by:</Text>
|
||||||
|
<Box>
|
||||||
|
{ownerReferences
|
||||||
|
.filter((ownerReference) => ownerReference.kind === 'Team')
|
||||||
|
.map((ownerReference) => (
|
||||||
|
<>
|
||||||
|
<TeamOwnerReference key={ownerReference.uid} ownerReference={ownerReference} />
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
{addingNewReference && (
|
||||||
|
<Box paddingBottom={2}>
|
||||||
|
<Text variant="h3">Add new owner reference:</Text>
|
||||||
|
<TeamSelector
|
||||||
|
onChange={(ownerReference) => {
|
||||||
|
setPendingReference(ownerReference);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
addOwnerReference(pendingReference);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => setAddingNewReference(true)}>Add new owner reference</Button>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||||
|
import { useGetTeamMembersQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||||
|
import { Stack, Text, Avatar, Link, Tooltip } from '@grafana/ui';
|
||||||
|
|
||||||
|
export const getGravatarUrl = (text: string) => {
|
||||||
|
// todo
|
||||||
|
return `avatar/bd38b9ecaf6169ca02b848f60a44cb95`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TeamOwnerReference = ({ ownerReference }: { ownerReference: OwnerReference }) => {
|
||||||
|
const { data: teamMembers } = useGetTeamMembersQuery({ name: ownerReference.uid });
|
||||||
|
|
||||||
|
const avatarURL = getGravatarUrl(ownerReference.name);
|
||||||
|
|
||||||
|
const membersTooltip = (
|
||||||
|
<>
|
||||||
|
<Stack gap={1} direction="column">
|
||||||
|
<Text>Team members:</Text>
|
||||||
|
{teamMembers?.items?.map((member) => (
|
||||||
|
<div key={member.identity.name}>
|
||||||
|
<Avatar src={member.avatarURL} /> {member.displayName}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/org/teams/edit/${ownerReference.uid}/members`} key={ownerReference.uid}>
|
||||||
|
<Tooltip content={membersTooltip}>
|
||||||
|
<Stack gap={1} alignItems="center">
|
||||||
|
<Avatar src={avatarURL} alt={ownerReference.name} /> {ownerReference.name}
|
||||||
|
</Stack>
|
||||||
|
</Tooltip>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
55
public/app/core/components/OwnerReferences/hooks.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useReplaceFolderMutation } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||||
|
import { folderAPIv1beta1, OwnerReference } from 'app/api/clients/folder/v1beta1';
|
||||||
|
import { useDispatch } from 'app/types/store';
|
||||||
|
|
||||||
|
const getReferencesEndpointMap = {
|
||||||
|
Folder: (resourceId: string) => folderAPIv1beta1.endpoints.getFolder.initiate({ name: resourceId }),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SupportedResource = keyof typeof getReferencesEndpointMap;
|
||||||
|
|
||||||
|
export const useGetOwnerReferences = ({
|
||||||
|
resource,
|
||||||
|
resourceId,
|
||||||
|
}: {
|
||||||
|
resource: SupportedResource;
|
||||||
|
resourceId: string;
|
||||||
|
}) => {
|
||||||
|
const [ownerReferences, setOwnerReferences] = useState<OwnerReference[]>([]);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const endpointAction = getReferencesEndpointMap[resource];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(endpointAction(resourceId)).then(({ data }) => {
|
||||||
|
if (data?.metadata?.ownerReferences) {
|
||||||
|
setOwnerReferences(data.metadata.ownerReferences);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [dispatch, endpointAction, resourceId]);
|
||||||
|
|
||||||
|
return ownerReferences;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAddOwnerReference = ({ resource, resourceId }: { resource: SupportedResource; resourceId: string }) => {
|
||||||
|
const [replaceFolder, result] = useReplaceFolderMutation();
|
||||||
|
return [
|
||||||
|
(ownerReference: OwnerReference) =>
|
||||||
|
replaceFolder({
|
||||||
|
name: resourceId,
|
||||||
|
|
||||||
|
folder: {
|
||||||
|
status: {},
|
||||||
|
metadata: {
|
||||||
|
name: resourceId,
|
||||||
|
ownerReferences: [ownerReference],
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
title: resourceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
result,
|
||||||
|
] as const;
|
||||||
|
};
|
||||||
@@ -6,8 +6,9 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { config, reportInteraction } from '@grafana/runtime';
|
import { config, reportInteraction } from '@grafana/runtime';
|
||||||
import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui';
|
import { LinkButton, FilterInput, useStyles2, Text, Stack, Box, Divider } from '@grafana/ui';
|
||||||
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
|
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
import { useDispatch } from 'app/types/store';
|
import { useDispatch } from 'app/types/store';
|
||||||
@@ -146,6 +147,19 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ownerReferences = folderDTO && 'ownerReferences' in folderDTO && (
|
||||||
|
<Box>
|
||||||
|
{folderDTO.ownerReferences
|
||||||
|
?.filter((ref) => ref.kind === 'Team')
|
||||||
|
.map((ref) => (
|
||||||
|
<Stack key={ref.uid} direction="row">
|
||||||
|
<Text>Owned by team:</Text>
|
||||||
|
<TeamOwnerReference ownerReference={ref} />
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
navId="dashboards/browse"
|
navId="dashboards/browse"
|
||||||
@@ -153,7 +167,8 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
onEditTitle={showEditTitle ? onEditTitle : undefined}
|
||||||
renderTitle={renderTitle}
|
renderTitle={renderTitle}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<Stack alignItems="center">
|
||||||
|
{ownerReferences}
|
||||||
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
{config.featureToggles.restoreDashboards && hasAdminRights && (
|
||||||
<LinkButton
|
<LinkButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -173,7 +188,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
|||||||
isReadOnlyRepo={isReadOnlyRepo}
|
isReadOnlyRepo={isReadOnlyRepo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</Stack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Page.Contents className={styles.pageContents}>
|
<Page.Contents className={styles.pageContents}>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export async function listFolders(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return folders.map((item) => ({
|
const result = folders.map((item) => ({
|
||||||
kind: 'folder',
|
kind: 'folder',
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
@@ -40,6 +40,20 @@ export async function listFolders(
|
|||||||
// URLs from the backend come with subUrlPrefix already included, so match that behaviour here
|
// URLs from the backend come with subUrlPrefix already included, so match that behaviour here
|
||||||
url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid),
|
url: isSharedWithMe(item.uid) ? undefined : getFolderURL(item.uid),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (!parentUID) {
|
||||||
|
// result.unshift({
|
||||||
|
// kind: 'folder',
|
||||||
|
// uid: 'teamfolders',
|
||||||
|
// title: 'Team folders',
|
||||||
|
// parentTitle,
|
||||||
|
// parentUID,
|
||||||
|
// managedBy: undefined,
|
||||||
|
// url: undefined,
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise<DashboardViewItem[]> {
|
export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise<DashboardViewItem[]> {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function CheckboxCell({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSharedWithMe(item.uid)) {
|
if (isSharedWithMe(item.uid) || item.uid === 'teamfolders') {
|
||||||
return <CheckboxSpacer />;
|
return <CheckboxSpacer />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import InfiniteLoader from 'react-window-infinite-loader';
|
|||||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { Avatar, useStyles2 } from '@grafana/ui';
|
||||||
|
import { TeamOwnerReference } from 'app/core/components/OwnerReferences/OwnerReference';
|
||||||
import { DashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -102,8 +103,27 @@ export function DashboardsTree({
|
|||||||
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
Header: t('browse-dashboards.dashboards-tree.tags-column', 'Tags'),
|
||||||
Cell: (props: DashboardsTreeCellProps) => <TagsCell {...props} onTagClick={onTagClick} />,
|
Cell: (props: DashboardsTreeCellProps) => <TagsCell {...props} onTagClick={onTagClick} />,
|
||||||
};
|
};
|
||||||
|
const ownerReferencesColumn: DashboardsTreeColumn = {
|
||||||
|
id: 'ownerReferences',
|
||||||
|
width: 2,
|
||||||
|
Header: 'Owner',
|
||||||
|
Cell: ({ row: { original: data } }) => {
|
||||||
|
const ownerReferences = data.item.ownerReferences;
|
||||||
|
if (!ownerReferences) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{ownerReferences.map((ownerReference) => {
|
||||||
|
return <TeamOwnerReference ownerReference={ownerReference} key={ownerReference.uid} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
const canSelect = canSelectItems(permissions);
|
const canSelect = canSelectItems(permissions);
|
||||||
const columns = [canSelect && checkboxColumn, nameColumn, tagsColumns].filter(isTruthy);
|
const columns = [canSelect && checkboxColumn, nameColumn, ownerReferencesColumn, tagsColumns].filter(isTruthy);
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}, [onFolderClick, onTagClick, permissions]);
|
}, [onFolderClick, onTagClick, permissions]);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { locationService, reportInteraction } from '@grafana/runtime';
|
|||||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
|
||||||
import { appEvents } from 'app/core/app_events';
|
import { appEvents } from 'app/core/app_events';
|
||||||
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
import { Permissions } from 'app/core/components/AccessControl/Permissions';
|
||||||
|
import { ManageOwnerReferences } from 'app/core/components/OwnerReferences/ManageOwnerReferences';
|
||||||
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
import { RepoType } from 'app/features/provisioning/Wizard/types';
|
||||||
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
|
||||||
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
|
||||||
@@ -30,6 +31,7 @@ interface Props {
|
|||||||
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||||
|
const [showManageOwnersDrawer, setShowManageOwnersDrawer] = useState(false);
|
||||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||||
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
|
||||||
const [moveFolder] = useMoveFolderMutationFacade();
|
const [moveFolder] = useMoveFolderMutationFacade();
|
||||||
@@ -126,14 +128,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
|
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
|
||||||
|
const manageOwnersLabel = t('browse-dashboards.folder-actions-button.manage-folder-owners', 'Manage folder owners');
|
||||||
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
|
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
|
||||||
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
|
||||||
|
|
||||||
|
const showManageOwners = canViewPermissions && !isProvisionedFolder;
|
||||||
|
|
||||||
const menu = (
|
const menu = (
|
||||||
<Menu>
|
<Menu>
|
||||||
{canViewPermissions && !isProvisionedFolder && (
|
{canViewPermissions && !isProvisionedFolder && (
|
||||||
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
<MenuItem onClick={() => setShowPermissionsDrawer(true)} label={managePermissionsLabel} />
|
||||||
)}
|
)}
|
||||||
|
{showManageOwners && <MenuItem onClick={() => setShowManageOwnersDrawer(true)} label={manageOwnersLabel} />}
|
||||||
{canMoveFolder && !isReadOnlyRepo && (
|
{canMoveFolder && !isReadOnlyRepo && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
onClick={isProvisionedFolder ? handleShowMoveProvisionedFolderDrawer : showMoveModal}
|
||||||
@@ -180,6 +186,16 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
|
|||||||
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
<Permissions resource="folders" resourceId={folder.uid} canSetPermissions={canSetPermissions} />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
{showManageOwnersDrawer && (
|
||||||
|
<Drawer
|
||||||
|
title={t('browse-dashboards.action.manage-permissions-button', 'Manage owners')}
|
||||||
|
subtitle={folder.title}
|
||||||
|
onClose={() => setShowManageOwnersDrawer(false)}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<ManageOwnerReferences resource="Folder" resourceId={folder.uid} />
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
{showDeleteProvisionedFolderDrawer && (
|
{showDeleteProvisionedFolderDrawer && (
|
||||||
<Drawer
|
<Drawer
|
||||||
title={
|
title={
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
|
||||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
import { createAsyncThunk } from 'app/types/store';
|
import { createAsyncThunk } from 'app/types/store';
|
||||||
@@ -53,6 +54,10 @@ export const refreshParents = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hackGetOwnerRefs = async () => {
|
||||||
|
return getBackendSrv().get('/apis/folder.grafana.app/v1beta1/namespaces/default/folders');
|
||||||
|
};
|
||||||
|
|
||||||
export const refetchChildren = createAsyncThunk(
|
export const refetchChildren = createAsyncThunk(
|
||||||
'browseDashboards/refetchChildren',
|
'browseDashboards/refetchChildren',
|
||||||
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise<RefetchChildrenResult> => {
|
||||||
@@ -66,6 +71,7 @@ export const refetchChildren = createAsyncThunk(
|
|||||||
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
let fetchKind: DashboardViewItemKind | undefined = 'folder';
|
||||||
|
|
||||||
let children = await listFolders(uid, undefined, page, pageSize);
|
let children = await listFolders(uid, undefined, page, pageSize);
|
||||||
|
|
||||||
let lastPageOfKind = children.length < pageSize;
|
let lastPageOfKind = children.length < pageSize;
|
||||||
|
|
||||||
// If we've loaded all folders, load the first page of dashboards.
|
// If we've loaded all folders, load the first page of dashboards.
|
||||||
@@ -136,6 +142,16 @@ export const fetchNextChildrenPage = createAsyncThunk(
|
|||||||
? await listFolders(uid, undefined, page, pageSize)
|
? await listFolders(uid, undefined, page, pageSize)
|
||||||
: await listDashboards(uid, page, pageSize);
|
: await listDashboards(uid, page, pageSize);
|
||||||
|
|
||||||
|
const foldersWithOwnerRefs = await hackGetOwnerRefs();
|
||||||
|
|
||||||
|
children.forEach((child) => {
|
||||||
|
const ownerRefs = foldersWithOwnerRefs.items.find((folder) => folder.metadata.name === child.uid)?.metadata
|
||||||
|
.ownerReferences;
|
||||||
|
if (ownerRefs) {
|
||||||
|
child.ownerReferences = ownerRefs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let lastPageOfKind = children.length < pageSize;
|
let lastPageOfKind = children.length < pageSize;
|
||||||
|
|
||||||
// If we've loaded all folders, load the first page of dashboards.
|
// If we've loaded all folders, load the first page of dashboards.
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export function createFlatTree(
|
|||||||
|
|
||||||
const items = [thisItem, ...mappedChildren];
|
const items = [thisItem, ...mappedChildren];
|
||||||
|
|
||||||
if (isSharedWithMe(thisItem.item.uid)) {
|
if (isSharedWithMe(thisItem.item.uid) || thisItem.item.uid === 'teamfolders') {
|
||||||
items.push({
|
items.push({
|
||||||
item: {
|
item: {
|
||||||
kind: 'ui',
|
kind: 'ui',
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { FormEvent } from 'react';
|
import { FormEvent, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useListTeamQuery } from '@grafana/api-clients/rtkq/iam/v0alpha1';
|
||||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
import { Button, Checkbox, Stack, RadioButtonGroup, useStyles2, Combobox } from '@grafana/ui';
|
||||||
import { SortPicker } from 'app/core/components/Select/SortPicker';
|
import { SortPicker } from 'app/core/components/Select/SortPicker';
|
||||||
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
import { TagFilter, TermCount } from 'app/core/components/TagFilter/TagFilter';
|
||||||
|
|
||||||
@@ -76,10 +77,25 @@ export const ActionRow = ({
|
|||||||
? [SearchLayout.Folders]
|
? [SearchLayout.Folders]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const teams = useListTeamQuery({});
|
||||||
|
|
||||||
|
const teamOptions = useMemo(() => {
|
||||||
|
return teams.data?.items.map((team) => ({
|
||||||
|
label: team.spec.title,
|
||||||
|
value: team.metadata.name || '',
|
||||||
|
}));
|
||||||
|
}, [teams.data?.items]);
|
||||||
return (
|
return (
|
||||||
<Stack justifyContent="space-between" alignItems="center">
|
<Stack justifyContent="space-between" alignItems="center">
|
||||||
<Stack gap={2} alignItems="center">
|
<Stack gap={2} alignItems="center">
|
||||||
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
<TagFilter isClearable={false} tags={state.tag} tagOptions={getTagOptions} onChange={onTagFilterChange} />
|
||||||
|
<Combobox
|
||||||
|
prefixIcon="user-arrows"
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Filter by owner"
|
||||||
|
options={teamOptions || []}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
{config.featureToggles.panelTitleSearch && (
|
{config.featureToggles.panelTitleSearch && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
data-testid="include-panels"
|
data-testid="include-panels"
|
||||||
@@ -99,6 +115,13 @@ export const ActionRow = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* <div className={styles.checkboxWrapper}>
|
||||||
|
<Checkbox
|
||||||
|
label={t('search.actions.owned-by-me', 'My team folders')}
|
||||||
|
onChange={onStarredFilterChange}
|
||||||
|
value={state.teamFolders}
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
{state.datasource && (
|
{state.datasource && (
|
||||||
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
<Button icon="times" variant="secondary" onClick={() => onDatasourceChange(undefined)}>
|
||||||
<Trans i18nKey="search.actions.remove-datasource-filter">
|
<Trans i18nKey="search.actions.remove-datasource-filter">
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr
|
|||||||
|
|
||||||
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
async function loadLocationInfo(): Promise<Record<string, LocationInfo>> {
|
||||||
// TODO: use proper pagination for search.
|
// TODO: use proper pagination for search.
|
||||||
const uri = `${searchURI}?type=folders&limit=100000`;
|
const uri = `${searchURI}?type=folder&limit=100000`;
|
||||||
const rsp = getBackendSrv()
|
const rsp = getBackendSrv()
|
||||||
.get<SearchAPIResponse>(uri)
|
.get<SearchAPIResponse>(uri)
|
||||||
.then((rsp) => {
|
.then((rsp) => {
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
export function getIconForItem(item: DashboardViewItemWithUIItems, isOpen?: boolean): IconName {
|
||||||
if (item && isSharedWithMe(item.uid)) {
|
if (item.uid === 'teamfolders') {
|
||||||
return 'users-alt';
|
return 'users-alt';
|
||||||
|
}
|
||||||
|
if (item && isSharedWithMe(item.uid)) {
|
||||||
|
return 'share-alt';
|
||||||
} else {
|
} else {
|
||||||
return getIconForKind(item.kind, isOpen);
|
return getIconForKind(item.kind, isOpen);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
|
|
||||||
|
import { OwnerReference } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||||
import { WithAccessControlMetadata } from '@grafana/data';
|
import { WithAccessControlMetadata } from '@grafana/data';
|
||||||
|
|
||||||
import { ManagerKind } from '../apiserver/types';
|
import { ManagerKind } from '../apiserver/types';
|
||||||
@@ -83,6 +84,7 @@ export interface DashboardViewItem {
|
|||||||
sortMeta?: number | string; // value sorted by
|
sortMeta?: number | string; // value sorted by
|
||||||
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
sortMetaName?: string; // name of the value being sorted e.g. 'Views'
|
||||||
managedBy?: ManagerKind;
|
managedBy?: ManagerKind;
|
||||||
|
ownerReferences?: OwnerReference[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchAction extends Action {
|
export interface SearchAction extends Action {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { UserEvent } from '@testing-library/user-event';
|
|||||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||||
import { render, screen, waitFor } from 'test/test-utils';
|
import { render, screen, waitFor } from 'test/test-utils';
|
||||||
|
|
||||||
import { setBackendSrv } from '@grafana/runtime';
|
import { config, setBackendSrv } from '@grafana/runtime';
|
||||||
import { setupMockServer } from '@grafana/test-utils/server';
|
import { setupMockServer } from '@grafana/test-utils/server';
|
||||||
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
import { MOCK_TEAMS } from '@grafana/test-utils/unstable';
|
||||||
import { backendSrv } from 'app/core/services/backend_srv';
|
import { backendSrv } from 'app/core/services/backend_srv';
|
||||||
@@ -27,9 +27,15 @@ const setup = async () => {
|
|||||||
return view;
|
return view;
|
||||||
};
|
};
|
||||||
|
|
||||||
const attemptCreateTeam = async (user: UserEvent, teamName?: string, teamEmail?: string) => {
|
const attemptCreateTeam = async (
|
||||||
|
user: UserEvent,
|
||||||
|
teamName?: string,
|
||||||
|
teamEmail?: string,
|
||||||
|
createTeamFolder?: boolean
|
||||||
|
) => {
|
||||||
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
|
teamName && (await user.type(screen.getByRole('textbox', { name: /name/i }), teamName));
|
||||||
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
|
teamEmail && (await user.type(screen.getByLabelText(/email/i), teamEmail));
|
||||||
|
createTeamFolder && (await user.click(screen.getByLabelText(/auto-create a team folder/i)));
|
||||||
await user.click(screen.getByRole('button', { name: /create/i }));
|
await user.click(screen.getByRole('button', { name: /create/i }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,4 +78,22 @@ describe('Create team', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('team folders enabled', () => {
|
||||||
|
const originalFeatureToggles = config.featureToggles;
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles = { ...originalFeatureToggles, teamFolders: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
config.featureToggles = originalFeatureToggles;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders team folder checkbox', async () => {
|
||||||
|
const { user } = await setup();
|
||||||
|
await attemptCreateTeam(user, MOCK_TEAMS[0].spec.title, undefined, true);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/edit team page/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
|
|||||||
|
|
||||||
import { NavModelItem } from '@grafana/data';
|
import { NavModelItem } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { locationService } from '@grafana/runtime';
|
import { config, locationService } from '@grafana/runtime';
|
||||||
import { Button, Field, Input, FieldSet, Stack } from '@grafana/ui';
|
import { Button, Field, Input, FieldSet, Stack, Checkbox, Alert } from '@grafana/ui';
|
||||||
import { extractErrorMessage } from 'app/api/utils';
|
import { extractErrorMessage } from 'app/api/utils';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||||
@@ -16,34 +16,42 @@ import { TeamDTO } from 'app/types/teams';
|
|||||||
|
|
||||||
import { useCreateTeam } from './hooks';
|
import { useCreateTeam } from './hooks';
|
||||||
|
|
||||||
const pageNav: NavModelItem = {
|
type NewTeamForm = TeamDTO & { createTeamFolder?: boolean };
|
||||||
icon: 'users-alt',
|
|
||||||
id: 'team-new',
|
|
||||||
text: 'New team',
|
|
||||||
subTitle: 'Create a new team. Teams let you grant permissions to a group of users.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CreateTeam = (): JSX.Element => {
|
export const CreateTeam = (): JSX.Element => {
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
|
icon: 'users-alt',
|
||||||
|
id: 'team-new',
|
||||||
|
text: t('teams.create-team.page-title', 'New team'),
|
||||||
|
subTitle: t(
|
||||||
|
'teams.create-team.page-subtitle',
|
||||||
|
'Create a new team. Teams let you grant permissions to a group of users.'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const teamFoldersEnabled = config.featureToggles.teamFolders;
|
||||||
|
const showRolesPicker = contextSrv.licensedAccessControlEnabled();
|
||||||
const currentOrgId = contextSrv.user.orgId;
|
const currentOrgId = contextSrv.user.orgId;
|
||||||
|
|
||||||
const notifyApp = useAppNotification();
|
const notifyApp = useAppNotification();
|
||||||
const [createTeamTrigger] = useCreateTeam();
|
const [createTeamTrigger, createResponse] = useCreateTeam();
|
||||||
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
|
||||||
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
const [{ roleOptions }] = useRoleOptions(currentOrgId);
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
register,
|
register,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<TeamDTO>();
|
} = useForm<NewTeamForm>();
|
||||||
|
|
||||||
const createTeam = async (formModel: TeamDTO) => {
|
const createTeam = async (formModel: NewTeamForm) => {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await createTeamTrigger(
|
const { data, error } = await createTeamTrigger(
|
||||||
{
|
{
|
||||||
email: formModel.email || '',
|
email: formModel.email || '',
|
||||||
name: formModel.name,
|
name: formModel.name,
|
||||||
},
|
},
|
||||||
pendingRoles
|
pendingRoles,
|
||||||
|
formModel.createTeamFolder
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
const errorMessage = error ? extractErrorMessage(error) : undefined;
|
||||||
@@ -73,11 +81,11 @@ const CreateTeam = (): JSX.Element => {
|
|||||||
label={t('teams.create-team.label-name', 'Name')}
|
label={t('teams.create-team.label-name', 'Name')}
|
||||||
required
|
required
|
||||||
invalid={!!errors.name}
|
invalid={!!errors.name}
|
||||||
error="Team name is required"
|
error={t('teams.create-team.error-name-required', 'Team name is required')}
|
||||||
>
|
>
|
||||||
<Input {...register('name', { required: true })} id="team-name" />
|
<Input {...register('name', { required: true })} id="team-name" />
|
||||||
</Field>
|
</Field>
|
||||||
{contextSrv.licensedAccessControlEnabled() && (
|
{showRolesPicker && (
|
||||||
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
<Field noMargin label={t('teams.create-team.label-role', 'Role')}>
|
||||||
<TeamRolePicker
|
<TeamRolePicker
|
||||||
teamId={0}
|
teamId={0}
|
||||||
@@ -106,8 +114,37 @@ const CreateTeam = (): JSX.Element => {
|
|||||||
placeholder="email@test.com"
|
placeholder="email@test.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{teamFoldersEnabled && (
|
||||||
|
<Field
|
||||||
|
noMargin
|
||||||
|
label={t('teams.create-team.team-folder', 'Team folder')}
|
||||||
|
description={t(
|
||||||
|
'teams.create-team.description-team-folder',
|
||||||
|
'This creates a folder associated with the team, where users can add resources like dashboards and schedules with the right permissions.'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
{...register('createTeamFolder')}
|
||||||
|
id="team-folder"
|
||||||
|
label={t(
|
||||||
|
'teams.create-team.team-folder-label-autocreate-a-team-folder',
|
||||||
|
'Auto-create a team folder'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
{Boolean(createResponse.error) && (
|
||||||
|
<Alert title={t('teams.create-team.error-title', 'Error creating team')} severity="error">
|
||||||
|
<Trans i18nKey="teams.create-team.error-message">
|
||||||
|
We were unable to create your new team. Please try again later or contact support.
|
||||||
|
</Trans>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<div>{extractErrorMessage(createResponse.error)}</div>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<Button type="submit" variant="primary">
|
<Button type="submit" variant="primary">
|
||||||
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
<Trans i18nKey="teams.create-team.create">Create</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
23
public/app/features/teams/OwnedResources.tsx
Normal file
23
public/app/features/teams/OwnedResources.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useListFolderQuery } from '@grafana/api-clients/rtkq/folder/v1beta1';
|
||||||
|
import { Stack, Text, Link, Icon } from '@grafana/ui';
|
||||||
|
import { Team } from 'app/types/teams';
|
||||||
|
|
||||||
|
export const OwnedResources = ({ team }: { team: Team }) => {
|
||||||
|
const { data } = useListFolderQuery({});
|
||||||
|
const ownedFolders = data?.items.filter((folder) =>
|
||||||
|
folder.metadata.ownerReferences?.some((ref) => ref.uid === team.uid)
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Stack gap={1} direction="column">
|
||||||
|
<Text variant="h3">Owned folders:</Text>
|
||||||
|
{ownedFolders &&
|
||||||
|
ownedFolders.map((folder) => (
|
||||||
|
<div key={folder.metadata.uid}>
|
||||||
|
<Link href={`/dashboards/f/${folder.metadata.name}`}>
|
||||||
|
<Icon name="folder" /> <Text>{folder.spec.title}</Text>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,6 +11,7 @@ import { contextSrv } from 'app/core/services/context_srv';
|
|||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
import { StoreState, useSelector } from 'app/types/store';
|
import { StoreState, useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { OwnedResources } from './OwnedResources';
|
||||||
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
|
||||||
import TeamPermissions from './TeamPermissions';
|
import TeamPermissions from './TeamPermissions';
|
||||||
import TeamSettings from './TeamSettings';
|
import TeamSettings from './TeamSettings';
|
||||||
@@ -26,9 +27,10 @@ enum PageTypes {
|
|||||||
Members = 'members',
|
Members = 'members',
|
||||||
Settings = 'settings',
|
Settings = 'settings',
|
||||||
GroupSync = 'groupsync',
|
GroupSync = 'groupsync',
|
||||||
|
Resources = 'resources',
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGES = ['members', 'settings', 'groupsync'];
|
const PAGES = ['members', 'settings', 'groupsync', 'resources'];
|
||||||
|
|
||||||
const pageNavSelector = createSelector(
|
const pageNavSelector = createSelector(
|
||||||
[
|
[
|
||||||
@@ -59,24 +61,30 @@ const TeamPages = memo(() => {
|
|||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
|
const currentPage = PAGES.includes(pageName) ? pageName : PAGES[0];
|
||||||
|
|
||||||
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team!);
|
if (!team) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team);
|
||||||
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
|
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||||
AccessControlAction.ActionTeamsPermissionsRead,
|
AccessControlAction.ActionTeamsPermissionsRead,
|
||||||
team!
|
team
|
||||||
);
|
);
|
||||||
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
|
||||||
AccessControlAction.ActionTeamsPermissionsWrite,
|
AccessControlAction.ActionTeamsPermissionsWrite,
|
||||||
team!
|
team
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (currentPage) {
|
switch (currentPage) {
|
||||||
case PageTypes.Members:
|
case PageTypes.Members:
|
||||||
if (canReadTeamPermissions) {
|
if (canReadTeamPermissions) {
|
||||||
return <TeamPermissions team={team!} />;
|
return <TeamPermissions team={team} />;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
case PageTypes.Settings:
|
case PageTypes.Settings:
|
||||||
return canReadTeam && <TeamSettings team={team!} />;
|
return canReadTeam && <TeamSettings team={team} />;
|
||||||
|
case PageTypes.Resources:
|
||||||
|
return canReadTeam && <OwnedResources team={team} />;
|
||||||
case PageTypes.GroupSync:
|
case PageTypes.GroupSync:
|
||||||
if (isSyncEnabled.current) {
|
if (isSyncEnabled.current) {
|
||||||
if (canReadTeamPermissions) {
|
if (canReadTeamPermissions) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
|
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { Button, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
import { Button, Divider, Field, FieldSet, Input, Stack } from '@grafana/ui';
|
||||||
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker';
|
||||||
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
import { useRoleOptions } from 'app/core/components/RolePicker/hooks';
|
||||||
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
|
||||||
@@ -97,6 +97,7 @@ const TeamSettings = ({ team }: Props) => {
|
|||||||
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
<Trans i18nKey="teams.team-settings.save">Save team details</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<Divider />
|
||||||
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
<SharedPreferences resourceUri={`teams/${team.id}`} disabled={!canWriteTeamSettings} preferenceType="team" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { skipToken } from '@reduxjs/toolkit/query';
|
import { skipToken } from '@reduxjs/toolkit/query';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks';
|
||||||
import {
|
import {
|
||||||
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
useSearchTeamsQuery as useLegacySearchTeamsQuery,
|
||||||
useCreateTeamMutation,
|
useCreateTeamMutation,
|
||||||
@@ -127,14 +128,16 @@ export const useDeleteTeam = () => {
|
|||||||
export const useCreateTeam = () => {
|
export const useCreateTeam = () => {
|
||||||
const [createTeam, response] = useCreateTeamMutation();
|
const [createTeam, response] = useCreateTeamMutation();
|
||||||
const [setTeamRoles] = useSetTeamRolesMutation();
|
const [setTeamRoles] = useSetTeamRolesMutation();
|
||||||
|
const [createFolder] = useCreateFolder();
|
||||||
|
|
||||||
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[]) => {
|
const trigger = async (team: CreateTeamCommand, pendingRoles?: Role[], createTeamFolder?: boolean) => {
|
||||||
const mutationResult = await createTeam({
|
const mutationResult = await createTeam({
|
||||||
createTeamCommand: team,
|
createTeamCommand: team,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data } = mutationResult;
|
const { data } = mutationResult;
|
||||||
|
|
||||||
|
// Add any pending roles to the team
|
||||||
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
if (data && data.teamId && pendingRoles && pendingRoles.length) {
|
||||||
await contextSrv.fetchUserPermissions();
|
await contextSrv.fetchUserPermissions();
|
||||||
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
|
if (contextSrv.licensedAccessControlEnabled() && canUpdateRoles()) {
|
||||||
@@ -147,6 +150,14 @@ export const useCreateTeam = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data && data.teamId && createTeamFolder) {
|
||||||
|
await createFolder({
|
||||||
|
title: team.name,
|
||||||
|
createAsTeamFolder: true,
|
||||||
|
teamUid: data.uid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return mutationResult;
|
return mutationResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ export function buildNavModel(team: Team): NavModelItem {
|
|||||||
url: `org/teams/edit/${team.uid}/members`,
|
url: `org/teams/edit/${team.uid}/members`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
navModel.children!.push({
|
||||||
|
active: false,
|
||||||
|
icon: 'folder',
|
||||||
|
id: `team-resources-${team.uid}`,
|
||||||
|
text: 'Resources',
|
||||||
|
url: `org/teams/edit/${team.uid}/resources`,
|
||||||
|
});
|
||||||
|
|
||||||
const teamGroupSync: NavModelItem = {
|
const teamGroupSync: NavModelItem = {
|
||||||
active: false,
|
active: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user