Nested folders: Remove feature flag (#109212)

This commit is contained in:
Stephanie Hingtgen
2025-08-06 10:07:23 +03:00
committed by GitHub
parent fc13a1ae0c
commit 6b86277ecf
45 changed files with 511 additions and 1101 deletions
@@ -29,7 +29,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `featureHighlights` | Highlight Grafana Enterprise features | |
| `correlations` | Correlations page | Yes |
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
| `nestedFolders` | Enable folder nesting | Yes |
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes |
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes |
| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes |
@@ -80,11 +80,6 @@ export interface FeatureToggles {
*/
mysqlAnsiQuotes?: boolean;
/**
* Enable folder nesting
* @default true
*/
nestedFolders?: boolean;
/**
* Rule backtesting API for alerting
*/
alertingBacktesting?: boolean;
+38 -88
View File
@@ -14,10 +14,8 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
)
@@ -71,41 +69,32 @@ func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response {
permission = dashboardaccess.PERMISSION_EDIT
}
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagNestedFolders) {
q := &folder.GetChildrenQuery{
OrgID: c.GetOrgID(),
Limit: c.QueryInt64("limit"),
Page: c.QueryInt64("page"),
UID: c.Query("parentUid"),
Permission: permission,
SignedInUser: c.SignedInUser,
}
folders, err := hs.folderService.GetChildren(c.Req.Context(), q)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
hits := make([]dtos.FolderSearchHit, 0)
for _, f := range folders {
hits = append(hits, dtos.FolderSearchHit{
ID: f.ID, // nolint:staticcheck
UID: f.UID,
Title: f.Title,
ParentUID: f.ParentUID,
ManagedBy: f.ManagedBy,
})
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolders).Inc()
}
return response.JSON(http.StatusOK, hits)
q := &folder.GetChildrenQuery{
OrgID: c.GetOrgID(),
Limit: c.QueryInt64("limit"),
Page: c.QueryInt64("page"),
UID: c.Query("parentUid"),
Permission: permission,
SignedInUser: c.SignedInUser,
}
hits, err := hs.searchFolders(c, permission)
folders, err := hs.folderService.GetChildren(c.Req.Context(), q)
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
hits := make([]dtos.FolderSearchHit, 0)
for _, f := range folders {
hits = append(hits, dtos.FolderSearchHit{
ID: f.ID, // nolint:staticcheck
UID: f.UID,
Title: f.Title,
ParentUID: f.ParentUID,
ManagedBy: f.ManagedBy,
})
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolders).Inc()
}
return response.JSON(http.StatusOK, hits)
}
@@ -214,30 +203,25 @@ func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response {
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagNestedFolders) {
cmd := folder.MoveFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
var err error
cmd.OrgID = c.GetOrgID()
cmd.UID = web.Params(c.Req)[":uid"]
cmd.SignedInUser = c.SignedInUser
theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd)
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err)
}
folderDTO, err := hs.newToFolderDto(c, theFolder)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
cmd := folder.MoveFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
result := map[string]string{}
result["message"] = "To use this service, you need to activate nested folder feature."
return response.JSON(http.StatusNotFound, result)
var err error
cmd.OrgID = c.GetOrgID()
cmd.UID = web.Params(c.Req)[":uid"]
cmd.SignedInUser = c.SignedInUser
theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd)
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err)
}
folderDTO, err := hs.newToFolderDto(c, theFolder)
if err != nil {
return response.Err(err)
}
return response.JSON(http.StatusOK, folderDTO)
}
// swagger:route PUT /folders/{folder_uid} folders updateFolder
@@ -396,10 +380,6 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde
return dtos.Folder{}, err
}
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagNestedFolders) {
return folderDTO, nil
}
parents, err := hs.folderService.GetParents(ctx, folder.GetParentsQuery{UID: f.UID, OrgID: f.OrgID})
if err != nil {
// log the error instead of failing
@@ -445,36 +425,6 @@ func (hs *HTTPServer) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.
return metadata, nil
}
func (hs *HTTPServer) searchFolders(c *contextmodel.ReqContext, permission dashboardaccess.PermissionType) ([]dtos.FolderSearchHit, error) {
searchQuery := search.Query{
SignedInUser: c.SignedInUser,
DashboardIds: make([]int64, 0),
FolderIds: make([]int64, 0), // nolint:staticcheck
Limit: c.QueryInt64("limit"),
OrgId: c.GetOrgID(),
Type: "dash-folder",
Permission: permission,
Page: c.QueryInt64("page"),
}
hits, err := hs.SearchService.SearchHandler(c.Req.Context(), &searchQuery)
if err != nil {
return nil, err
}
folderHits := make([]dtos.FolderSearchHit, 0)
for _, hit := range hits {
folderHits = append(folderHits, dtos.FolderSearchHit{
ID: hit.ID, // nolint:staticcheck
UID: hit.UID,
Title: hit.Title,
})
metrics.MFolderIDsAPICount.WithLabelValues(metrics.SearchFolders).Inc()
}
return folderHits, nil
}
// swagger:parameters getFolders
type GetFoldersParams struct {
// Limit the maximum number of folders to return
+23 -53
View File
@@ -107,80 +107,50 @@ func BenchmarkFolderListAndSearch(b *testing.B) {
features featuremgmt.FeatureToggles
}{
{
desc: "impl=default nested_folders=on get root folders",
desc: "impl=default get root folders",
url: "/api/folders",
expectedLen: LEVEL0_FOLDER_NUM + 1, // for shared with me folder
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
features: featuremgmt.WithFeatures(),
},
{
desc: "impl=default nested_folders=on get subfolders",
desc: "impl=default get subfolders",
url: "/api/folders?parentUid=folder0",
expectedLen: LEVEL1_FOLDER_NUM,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
},
{
desc: "impl=default nested_folders=on list all inherited dashboards",
url: "/api/search?type=dash-db&limit=5000",
expectedLen: withLimit(all),
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
},
{
desc: "impl=permissionsFilterRemoveSubquery nested_folders=on list all inherited dashboards",
url: "/api/search?type=dash-db&limit=5000",
expectedLen: withLimit(all),
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
{
desc: "impl=default nested_folders=on search for pattern",
url: "/api/search?type=dash-db&query=dashboard_0_0&limit=5000",
expectedLen: withLimit(1 + LEVEL1_DASHBOARD_NUM + LEVEL2_FOLDER_NUM*LEVEL2_DASHBOARD_NUM),
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
},
{
desc: "impl=permissionsFilterRemoveSubquery nested_folders=on search for pattern",
url: "/api/search?type=dash-db&query=dashboard_0_0&limit=5000",
expectedLen: withLimit(1 + LEVEL1_DASHBOARD_NUM + LEVEL2_FOLDER_NUM*LEVEL2_DASHBOARD_NUM),
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
{
desc: "impl=default nested_folders=on search for specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0_0_0",
expectedLen: 1,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
},
{
desc: "impl=permissionsFilterRemoveSubquery nested_folders=on search for specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0_0_0",
expectedLen: 1,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
{
desc: "impl=default nested_folders=off get root folders",
url: "/api/folders?limit=5000",
expectedLen: withLimit(LEVEL0_FOLDER_NUM),
features: featuremgmt.WithFeatures(),
},
{
desc: "impl=default nested_folders=off list all dashboards",
desc: "impl=defaultlist all inherited dashboards",
url: "/api/search?type=dash-db&limit=5000",
expectedLen: withLimit(LEVEL0_FOLDER_NUM * LEVEL0_DASHBOARD_NUM),
expectedLen: withLimit(all),
features: featuremgmt.WithFeatures(),
},
{
desc: "impl=permissionsFilterRemoveSubquery nested_folders=off list all dashboards",
desc: "impl=permissionsFilterRemoveSubquery list all inherited dashboards",
url: "/api/search?type=dash-db&limit=5000",
expectedLen: withLimit(LEVEL0_FOLDER_NUM * LEVEL0_DASHBOARD_NUM),
expectedLen: withLimit(all),
features: featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
{
desc: "impl=default nested_folders=off search specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0",
desc: "impl=default search for pattern",
url: "/api/search?type=dash-db&query=dashboard_0_0&limit=5000",
expectedLen: withLimit(1 + LEVEL1_DASHBOARD_NUM + LEVEL2_FOLDER_NUM*LEVEL2_DASHBOARD_NUM),
features: featuremgmt.WithFeatures(),
},
{
desc: "impl=permissionsFilterRemoveSubquery search for pattern",
url: "/api/search?type=dash-db&query=dashboard_0_0&limit=5000",
expectedLen: withLimit(1 + LEVEL1_DASHBOARD_NUM + LEVEL2_FOLDER_NUM*LEVEL2_DASHBOARD_NUM),
features: featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
{
desc: "impl=default search for specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0_0_0",
expectedLen: 1,
features: featuremgmt.WithFeatures(),
},
{
desc: "impl=permissionsFilterRemoveSubquery nested_folders=off search specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0",
desc: "impl=permissionsFilterRemoveSubquery search for specific dashboard",
url: "/api/search?type=dash-db&query=dashboard_0_0_0_0",
expectedLen: 1,
features: featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery),
},
+2 -31
View File
@@ -43,7 +43,6 @@ func TestFoldersCreateAPIEndpoint(t *testing.T) {
expectedFolder *folder.Folder
expectedFolderSvcError error
permissions []accesscontrol.Permission
withNestedFolders bool
input string
}
tcs := []testCase{
@@ -119,10 +118,6 @@ func TestFoldersCreateAPIEndpoint(t *testing.T) {
srv := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
if tc.withNestedFolders {
hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
}
hs.folderService = folderService
hs.folderPermissionsService = folderPermService
hs.accesscontrolService = actest.FakeService{}
@@ -255,7 +250,7 @@ func testDescription(description string, expectedErr error) string {
func TestHTTPServer_FolderMetadata(t *testing.T) {
folderService := &foldertest.FakeService{}
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
features := featuremgmt.WithFeatures()
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.folderService = folderService
@@ -292,7 +287,6 @@ func TestHTTPServer_FolderMetadata(t *testing.T) {
t.Run("Should attach access control metadata to folder response with permissions cascading from nested folders", func(t *testing.T) {
folderService.ExpectedFolder = &folder.Folder{UID: "folderUid"}
folderService.ExpectedFolders = []*folder.Folder{{UID: "parentUid"}}
features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
defer func() {
features = featuremgmt.WithFeatures()
folderService.ExpectedFolders = nil
@@ -386,7 +380,6 @@ func TestFolderMoveAPIEndpoint(t *testing.T) {
for _, tc := range tcs {
srv := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
hs.folderService = folderService
})
@@ -423,7 +416,6 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
type testCase struct {
description string
URL string
features featuremgmt.FeatureToggles
expectedCode int
expectedParentUIDs []string
expectedParentOrgIDs []int64
@@ -435,7 +427,6 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
description: "get folder by UID should return parent folders if nested folder are enabled",
URL: "/api/folders/uid",
expectedCode: http.StatusOK,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
expectedParentUIDs: []string{"parent", "subfolder"},
expectedParentOrgIDs: []int64{0, 0},
expectedParentTitles: []string{"parent title", "subfolder title"},
@@ -449,7 +440,6 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
description: "get folder by UID should return parent folders redacted if nested folder are enabled and user does not have read access to parent folders",
URL: "/api/folders/uid",
expectedCode: http.StatusOK,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
expectedParentUIDs: []string{REDACTED, REDACTED},
expectedParentOrgIDs: []int64{0, 0},
expectedParentTitles: []string{REDACTED, REDACTED},
@@ -461,7 +451,6 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
description: "get folder by UID should return some parent folder titles and some parent folders as redacted if nested folder are enabled and user only has read access to some parent folders",
URL: "/api/folders/uid",
expectedCode: http.StatusOK,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
expectedParentUIDs: []string{REDACTED, "subfolder"},
expectedParentOrgIDs: []int64{0, 0},
expectedParentTitles: []string{REDACTED, "subfolder title"},
@@ -470,24 +459,11 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("subfolder")},
},
},
{
description: "get folder by UID should not return parent folders if nested folder are disabled",
URL: "/api/folders/uid",
expectedCode: http.StatusOK,
features: featuremgmt.WithFeatures(),
expectedParentUIDs: []string{},
expectedParentOrgIDs: []int64{0, 0},
expectedParentTitles: []string{},
permissions: []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersProvider.GetResourceAllScope()},
},
},
}
for _, tc := range tcs {
srv := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.Features = tc.features
hs.folderService = folderService
})
@@ -619,8 +595,6 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
},
}
featuresArr := []any{featuremgmt.FlagNestedFolders}
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.folderService = &foldertest.FakeService{
@@ -634,9 +608,7 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
hs.userService = &usertest.FakeUserService{
ExpectedUser: testuser,
}
hs.Features = featuremgmt.WithFeatures(
featuresArr...,
)
hs.Features = featuremgmt.WithFeatures()
hs.clientConfigProvider = mockClientConfigProvider{
host: folderApiServerMock.URL,
}
@@ -697,7 +669,6 @@ func TestSetDefaultPermissionsWhenCreatingFolder(t *testing.T) {
srv := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
hs.folderService = folderService
hs.folderPermissionsService = folderPermService
hs.accesscontrolService = actest.FakeService{}
+1 -1
View File
@@ -162,7 +162,7 @@ func TestLegacyAuthorizer(t *testing.T) {
},
}
authz := newLegacyAuthorizer(acimpl.ProvideAccessControl(featuremgmt.WithFeatures("nestedFolders")))
authz := newLegacyAuthorizer(acimpl.ProvideAccessControl(featuremgmt.WithFeatures()))
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+1 -1
View File
@@ -153,7 +153,7 @@ func (s *folderStorage) setDefaultFolderPermissions(ctx context.Context, orgID i
})
}
isNested := parentUID != ""
if !isNested || !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
if !isNested {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
+2 -7
View File
@@ -147,10 +147,7 @@ func (s *Service) getUserPermissions(ctx context.Context, user identity.Requeste
permissions = append(permissions, basicRole.Permissions...)
}
}
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
permissions = append(permissions, SharedWithMeFolderPermission)
}
permissions = append(permissions, SharedWithMeFolderPermission)
// we don't care about the error here, if this fails we get 0 and no
// permission assigned to user will be returned, only for org role.
@@ -232,9 +229,7 @@ func (s *Service) getUserDirectPermissions(ctx context.Context, user identity.Re
}
permissions = s.actionResolver.ExpandActionSets(permissions)
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
permissions = append(permissions, SharedWithMeFolderPermission)
}
permissions = append(permissions, SharedWithMeFolderPermission)
return permissions, nil
}
@@ -933,7 +933,8 @@ func TestIntegrationService_SaveExternalServiceRole(t *testing.T) {
// Check that the permissions and assignment are stored correctly
perms, errGetPerms := ac.getUserPermissions(ctx, &user.SignedInUser{OrgID: r.cmd.AssignmentOrgID, UserID: 2}, accesscontrol.Options{})
require.NoError(t, errGetPerms)
assert.ElementsMatch(t, r.cmd.Permissions, perms)
// shared with me is added by default for all users in pkg/services/accesscontrol/acimpl/service.go
assert.Equal(t, append([]accesscontrol.Permission{{Action: "folders:read", Scope: "folders:uid:sharedwithme"}}, r.cmd.Permissions...), perms)
}
})
}
@@ -989,7 +990,8 @@ func TestIntegrationService_DeleteExternalServiceRole(t *testing.T) {
// Check that the permissions and assignment are removed correctly
perms, errGetPerms := ac.getUserPermissions(ctx, &user.SignedInUser{OrgID: tt.initCmd.AssignmentOrgID, UserID: 2}, accesscontrol.Options{})
require.NoError(t, errGetPerms)
assert.Empty(t, perms)
// shared with me is added by default for all users in pkg/services/accesscontrol/acimpl/service.go
assert.Equal(t, []accesscontrol.Permission{{Action: "folders:read", Scope: "folders:uid:sharedwithme"}}, perms)
}
})
}
+7 -4
View File
@@ -13,6 +13,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
@@ -831,10 +832,9 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F
if len(query.FolderUIDs) > 0 {
filters = append(filters, searchstore.FolderUIDFilter{
Dialect: d.store.GetDialect(),
OrgID: orgID,
UIDs: query.FolderUIDs,
NestedFoldersEnabled: d.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders),
Dialect: d.store.GetDialect(),
OrgID: orgID,
UIDs: query.FolderUIDs,
})
}
@@ -900,6 +900,9 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F
if item.Term != "" {
item.Tags = append(item.Tags, item.Term)
}
if item.FolderTitle != "" {
item.FolderSlug = slugify.Slugify(item.FolderTitle)
}
seen[item.ID] = len(uniqueRes)
uniqueRes = append(uniqueRes, item)
}
@@ -12,6 +12,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
"github.com/grafana/grafana/pkg/services/user"
@@ -30,13 +31,16 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) {
var flder, dashInRoot, childDash *dashboards.Dashboard
var currentUser *user.SignedInUser
var dashboardStore dashboards.Store
var folderStore *folderimpl.FolderStoreImpl
setup := func() {
sqlStore, cfg = db.InitTestDBWithCfg(t)
var err error
dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
flder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp")
folderStore = folderimpl.ProvideStore(sqlStore)
require.NoError(t, err)
flder = insertTestDashFolder(t, dashboardStore, folderStore, "1 test dash folder", 1, 0, "", "prod", "webapp")
dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, "", false, "prod", "webapp")
childDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, flder.ID, flder.UID, false, "prod", "webapp")
insertTestDashboard(t, dashboardStore, "test dash 45", 1, flder.ID, flder.UID, false, "prod")
@@ -132,8 +136,8 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) {
sqlStore, cfg = db.InitTestDBWithCfg(t)
var err error
require.NoError(t, err)
folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod")
folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, "", true, "prod")
folder1 = insertTestDashFolder(t, dashboardStore, folderStore, "1 test dash folder", 1, 0, "", "prod")
folder2 = insertTestDashFolder(t, dashboardStore, folderStore, "2 test dash folder", 1, 0, "", "prod")
dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, "", false, "prod")
childDash1 = insertTestDashboard(t, dashboardStore, "child dash 1", 1, folder1.ID, folder1.UID, false, "prod")
childDash2 = insertTestDashboard(t, dashboardStore, "child dash 2", 1, folder2.ID, folder2.UID, false, "prod")
@@ -190,8 +194,8 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) {
})
t.Run("and a dashboard is moved from folder with acl to the folder without an acl", func(t *testing.T) {
setup2()
moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, childDash2.FolderUID)
currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID), dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}, dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}}}
moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, childDash2.UID)
currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID), dashboards.ScopeDashboardsProvider.GetResourceScopeUID(folder2.UID), dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}, dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}}}
actest.AddUserPermissionToDB(t, sqlStore, currentUser)
t.Run("should return folder without acl and its children", func(t *testing.T) {
@@ -202,7 +206,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) {
}
hits, err := testSearchDashboards(dashboardStore, query)
require.NoError(t, err)
assert.Equal(t, len(hits), 4)
assert.Equal(t, 4, len(hits))
assert.Equal(t, hits[0].ID, folder2.ID)
assert.Equal(t, hits[1].ID, childDash1.ID)
assert.Equal(t, hits[2].ID, childDash2.ID)
@@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/folderimpl"
libmodel "github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/search/model"
@@ -51,7 +52,8 @@ func TestIntegrationDashboardDataAccess(t *testing.T) {
// test dash 23
// test dash 45
// test dash 67
savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp")
folderStore := folderimpl.ProvideStore(sqlStore)
savedFolder = insertTestDashFolder(t, dashboardStore, folderStore, "1 test dash folder", 1, 0, "", "prod", "webapp")
savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp")
insertTestDashboard(t, dashboardStore, "test dash 45", 1, savedFolder.ID, savedFolder.UID, false, "prod")
savedDash2 = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, "", false, "prod")
@@ -814,7 +816,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) {
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch)
features := featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch)
dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
@@ -931,7 +933,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) {
}
sqlStore, cfg := db.InitTestDBWithCfg(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch)
features := featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch)
dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
@@ -970,110 +972,89 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) {
folderIDs []int64
folderUIDs []string
query string
expectedResult map[string][]res
expectedResult []res
typ string
}{
{
desc: "find dashboard under general using folder UID",
folderUIDs: []string{folder.GeneralFolderUID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under general"}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under general"}},
},
desc: "find dashboard under general using folder UID",
folderUIDs: []string{folder.GeneralFolderUID},
typ: searchstore.TypeDashboard,
expectedResult: []res{{title: "dashboard under general"}},
},
{
desc: "find dashboard under general using folder UID",
folderUIDs: []string{folder.GeneralFolderUID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under general"}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under general"}},
},
desc: "find dashboard under general using folder UID",
folderUIDs: []string{folder.GeneralFolderUID},
typ: searchstore.TypeDashboard,
expectedResult: []res{{title: "dashboard under general"}},
},
{
desc: "find dashboard under f0 using folder UID",
folderUIDs: []string{f0.UID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}},
},
desc: "find dashboard under f0 using folder UID",
folderUIDs: []string{f0.UID},
typ: searchstore.TypeDashboard,
expectedResult: []res{{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title}},
},
{
desc: "find dashboard under f0 or f1 using folder UID",
folderUIDs: []string{f0.UID, f1.UID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title}},
expectedResult: []res{
{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title},
},
},
{
desc: "find dashboard under general or f0 using folder UID",
folderUIDs: []string{folder.GeneralFolderUID, f0.UID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under general"}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under general"}},
expectedResult: []res{
{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under general"},
},
},
{
desc: "find dashboard under general or f0 or f1 using folder UID",
folderUIDs: []string{folder.GeneralFolderUID, f0.UID, f1.UID},
typ: searchstore.TypeDashboard,
expectedResult: map[string][]res{
"": {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title},
{title: "dashboard under general"}},
featuremgmt.FlagNestedFolders: {{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title},
{title: "dashboard under general"}},
expectedResult: []res{
{title: "dashboard under f0", folderUID: f0.UID, folderTitle: f0.Title},
{title: "dashboard under f1", folderUID: f1.UID, folderTitle: f1.Title},
{title: "dashboard under general"},
},
},
{
desc: "find subfolder",
folderUIDs: []string{f0.UID},
typ: searchstore.TypeFolder,
expectedResult: map[string][]res{
"": {},
featuremgmt.FlagNestedFolders: {{title: subfolder.Title, folderUID: f0.UID, folderTitle: f0.Title}},
},
desc: "find subfolder",
folderUIDs: []string{f0.UID},
typ: searchstore.TypeFolder,
expectedResult: []res{{title: subfolder.Title, folderUID: f0.UID, folderTitle: f0.Title}},
},
}
for _, tc := range testCases {
for featureFlags := range tc.expectedResult {
t.Run(fmt.Sprintf("%s with featureFlags: %v", tc.desc, featureFlags), func(t *testing.T) {
dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(featureFlags), tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
res, err := dashboardStore.FindDashboards(context.Background(), &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Type: tc.typ,
FolderUIDs: tc.folderUIDs,
})
require.NoError(t, err)
require.Equal(t, len(tc.expectedResult[featureFlags]), len(res))
for i, r := range tc.expectedResult[featureFlags] {
assert.Equal(t, r.title, res[i].Title)
if r.folderUID != "" {
assert.Equal(t, r.folderUID, res[i].FolderUID)
} else {
assert.Empty(t, res[i].FolderUID)
}
if r.folderTitle != "" {
assert.Equal(t, r.folderTitle, res[i].FolderTitle)
} else {
assert.Empty(t, res[i].FolderTitle)
}
}
t.Run(tc.desc, func(t *testing.T) {
dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore))
require.NoError(t, err)
res, err := dashboardStore.FindDashboards(context.Background(), &dashboards.FindPersistedDashboardsQuery{
SignedInUser: user,
Type: tc.typ,
FolderUIDs: tc.folderUIDs,
})
}
require.NoError(t, err)
require.Equal(t, len(tc.expectedResult), len(res))
for i, r := range tc.expectedResult {
assert.Equal(t, r.title, res[i].Title)
if r.folderUID != "" {
assert.Equal(t, r.folderUID, res[i].FolderUID)
} else {
assert.Empty(t, res[i].FolderUID)
}
if r.folderTitle != "" {
assert.Equal(t, r.folderTitle, res[i].FolderTitle)
} else {
assert.Empty(t, res[i].FolderTitle)
}
}
})
}
}
@@ -1260,6 +1241,34 @@ func insertTestDashboard(t *testing.T, dashboardStore dashboards.Store, title st
return dash
}
func insertTestDashFolder(t *testing.T, dashboardStore dashboards.Store, folderStore folder.Store, title string, orgId int64,
folderId int64, folderUID string, tags ...interface{}) *dashboards.Dashboard {
t.Helper()
cmd := dashboards.SaveDashboardCommand{
OrgID: orgId,
FolderID: folderId, // nolint:staticcheck
FolderUID: folderUID,
IsFolder: true,
Dashboard: simplejson.NewFromAny(map[string]interface{}{
"id": nil,
"title": title,
"tags": tags,
}),
}
dash, err := dashboardStore.SaveDashboard(context.Background(), cmd)
require.NoError(t, err)
require.NotNil(t, dash)
dash.Data.Set("id", dash.ID)
dash.Data.Set("uid", dash.UID)
_, err = folderStore.Create(context.Background(), folder.CreateFolderCommand{
Title: title,
UID: dash.UID,
OrgID: orgId,
})
require.NoError(t, err)
return dash
}
func insertTestDashboardForPlugin(t *testing.T, dashboardStore dashboards.Store, title string, orgId int64,
folderUID string, isFolder bool, pluginId string) *dashboards.Dashboard {
t.Helper()
@@ -1162,7 +1162,7 @@ func (dr *DashboardServiceImpl) SetDefaultPermissionsAfterCreate(ctx context.Con
isNested := obj.GetFolder() != ""
if !dr.features.IsEnabledGlobally(featuremgmt.FlagKubernetesDashboards) {
// legacy behavior
if !isNested || !dr.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
if !isNested {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
@@ -1392,7 +1392,7 @@ func (dr *DashboardServiceImpl) FindDashboards(ctx context.Context, query *dashb
ctx, span := tracer.Start(ctx, "dashboards.service.FindDashboards")
defer span.End()
if dr.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && len(query.FolderUIDs) > 0 && slices.Contains(query.FolderUIDs, folder.SharedWithMeFolderUID) {
if len(query.FolderUIDs) > 0 && slices.Contains(query.FolderUIDs, folder.SharedWithMeFolderUID) {
start := time.Now()
userDashboardUIDs, err := dr.getUserSharedDashboardUIDs(ctx, query.SignedInUser)
if err != nil {
@@ -1402,7 +1402,6 @@ func TestSearchDashboards(t *testing.T) {
t.Run("Should handle Shared with me folder correctly", func(t *testing.T) {
ctx, k8sCliMock := setupK8sDashboardTests(service)
service.features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default")
k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resourcepb.ResourceSearchRequest) bool {
if len(req.Options.Fields) == 0 {
@@ -2095,9 +2094,9 @@ func TestSetDefaultPermissionsAfterCreate(t *testing.T) {
// Setup mocks and service
dashboardStore := &dashboards.FakeDashboardStore{}
folderStore := foldertest.FakeFolderStore{}
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
features := featuremgmt.WithFeatures()
if tc.featureKubernetesDashboards {
features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards, featuremgmt.FlagNestedFolders)
features = featuremgmt.WithFeatures(featuremgmt.FlagKubernetesDashboards)
}
permService := acmock.NewMockedPermissionsService()
-7
View File
@@ -118,13 +118,6 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
},
{
Name: "nestedFolders",
Description: "Enable folder nesting",
Stage: FeatureStageGeneralAvailability,
Owner: grafanaSearchAndStorageSquad,
Expression: "true", // enabled by default
},
{
Name: "alertingBacktesting",
Description: "Rule backtesting API for alerting",
@@ -88,7 +88,6 @@ showDashboardValidationWarnings,2022-10-14T13:51:05Z,,2e16d5499e1cf29e67db5031fe
interFont,2022-10-15T14:22:33Z,2022-12-01T11:59:37Z,9f5e691994c9db15f5984b196ab55fd7213b7f72,Torkel Ödegaard
accessControlOnCall,2022-10-19T16:10:09Z,2025-02-25T12:44:40Z,717bd4a6c051d1447c9e35385a4ba176dd391c65,Gabriel MABILLE
newDBLibrary,2022-10-26T01:20:41Z,2023-11-14T14:51:35Z,a3acfb1a48126119fd4913efbb572de162264e92,Ryan McKinley
nestedFolders,2022-10-26T14:15:14Z,,b346ae03105af606521bbefec16722d590378646,Kristin Laemmert
datasourceLogger,2022-11-02T13:51:51Z,2023-02-07T11:49:16Z,06705a49e236dc3ab3b35db51b7bad8625e4866f,Carl Bergquist
promQueryBuilder,2022-11-03T17:34:01Z,2022-12-19T13:52:06Z,857e545c5ac71722c8b2d3d27621dfb453dbb8ff,Ryan McKinley
elasticsearchBackendMigration,2022-11-10T15:35:15Z,2023-04-12T12:20:43Z,261d620f1c46eb43282cc444ffb4633b77af4283,Ivana Huckova
1 #name created deleted hash author
88 interFont 2022-10-15T14:22:33Z 2022-12-01T11:59:37Z 9f5e691994c9db15f5984b196ab55fd7213b7f72 Torkel Ödegaard
89 accessControlOnCall 2022-10-19T16:10:09Z 2025-02-25T12:44:40Z 717bd4a6c051d1447c9e35385a4ba176dd391c65 Gabriel MABILLE
90 newDBLibrary 2022-10-26T01:20:41Z 2023-11-14T14:51:35Z a3acfb1a48126119fd4913efbb572de162264e92 Ryan McKinley
nestedFolders 2022-10-26T14:15:14Z b346ae03105af606521bbefec16722d590378646 Kristin Laemmert
91 datasourceLogger 2022-11-02T13:51:51Z 2023-02-07T11:49:16Z 06705a49e236dc3ab3b35db51b7bad8625e4866f Carl Bergquist
92 promQueryBuilder 2022-11-03T17:34:01Z 2022-12-19T13:52:06Z 857e545c5ac71722c8b2d3d27621dfb453dbb8ff Ryan McKinley
93 elasticsearchBackendMigration 2022-11-10T15:35:15Z 2023-04-12T12:20:43Z 261d620f1c46eb43282cc444ffb4633b77af4283 Ivana Huckova
-1
View File
@@ -13,7 +13,6 @@ grpcServer,preview,@grafana/search-and-storage,false,false,false
cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false
showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,false,false,false
mysqlAnsiQuotes,experimental,@grafana/search-and-storage,false,false,false
nestedFolders,GA,@grafana/search-and-storage,false,false,false
alertingBacktesting,experimental,@grafana/alerting-squad,false,false,false
editPanelCSVDragAndDrop,experimental,@grafana/dataviz-squad,false,false,true
logsContextDatasourceUi,GA,@grafana/observability-logs,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
13 cloudWatchCrossAccountQuerying GA @grafana/aws-datasources false false false
14 showDashboardValidationWarnings experimental @grafana/dashboards-squad false false false
15 mysqlAnsiQuotes experimental @grafana/search-and-storage false false false
nestedFolders GA @grafana/search-and-storage false false false
16 alertingBacktesting experimental @grafana/alerting-squad false false false
17 editPanelCSVDragAndDrop experimental @grafana/dataviz-squad false false true
18 logsContextDatasourceUi GA @grafana/observability-logs false false true
-4
View File
@@ -63,10 +63,6 @@ const (
// Use double quotes to escape keyword in a MySQL query
FlagMysqlAnsiQuotes = "mysqlAnsiQuotes"
// FlagNestedFolders
// Enable folder nesting
FlagNestedFolders = "nestedFolders"
// FlagAlertingBacktesting
// Rule backtesting API for alerting
FlagAlertingBacktesting = "alertingBacktesting"
-13
View File
@@ -2096,19 +2096,6 @@
"codeowner": "@grafana/search-and-storage"
}
},
{
"metadata": {
"name": "nestedFolders",
"resourceVersion": "1753448760331",
"creationTimestamp": "2022-10-26T14:15:14Z"
},
"spec": {
"description": "Enable folder nesting",
"stage": "GA",
"codeowner": "@grafana/search-and-storage",
"expression": "true"
}
},
{
"metadata": {
"name": "newClickhouseConfigPageDesign",
+13 -42
View File
@@ -243,29 +243,11 @@ func (s *Service) GetFoldersLegacy(ctx context.Context, q folder.GetFoldersQuery
}
}
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
qry.WithFullpath = false // do not request full path if nested folders are disabled
qry.WithFullpathUIDs = false
}
dashFolders, err := s.store.GetFolders(ctx, qry)
if err != nil {
return nil, folder.ErrInternal.Errorf("failed to fetch subfolders: %w", err)
}
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
if q.WithFullpathUIDs || q.WithFullpath {
for _, f := range dashFolders { // and fix the full path with folder title (unescaped)
if q.WithFullpath {
f.Fullpath = f.Title
}
if q.WithFullpathUIDs {
f.FullpathUIDs = f.UID
}
}
}
}
return dashFolders, nil
}
@@ -286,7 +268,7 @@ func (s *Service) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*fol
return folder.RootFolder, nil
}
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && q.UID != nil && *q.UID == folder.SharedWithMeFolderUID {
if q.UID != nil && *q.UID == folder.SharedWithMeFolderUID {
return folder.SharedWithMeFolder.WithURL(), nil
}
@@ -317,11 +299,6 @@ func (s *Service) GetLegacy(ctx context.Context, q *folder.GetFolderQuery) (*fol
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
// nolint:staticcheck
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
f.Fullpath = f.Title // set full path to the folder title (unescaped)
f.FullpathUIDs = f.UID // set full path to the folder UID
}
return f, err
}
@@ -378,7 +355,7 @@ func (s *Service) GetChildrenLegacy(ctx context.Context, q *folder.GetChildrenQu
return nil, folder.ErrBadRequest.Errorf("missing signed in user")
}
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && q.UID == folder.SharedWithMeFolderUID {
if q.UID == folder.SharedWithMeFolderUID {
return s.GetSharedWithMe(ctx, q, true)
}
@@ -646,7 +623,7 @@ func (s *Service) GetParents(ctx context.Context, q folder.GetParentsQuery) ([]*
func (s *Service) GetParentsLegacy(ctx context.Context, q folder.GetParentsQuery) ([]*folder.Folder, error) {
ctx, span := s.tracer.Start(ctx, "folder.GetParentsLegacy")
defer span.End()
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) || q.UID == accesscontrol.GeneralFolderUID {
if q.UID == accesscontrol.GeneralFolderUID {
return nil, nil
}
if q.UID == folder.SharedWithMeFolderUID {
@@ -667,7 +644,7 @@ func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderComm
dashFolder := dashboards.NewDashboardFolder(cmd.Title)
dashFolder.OrgID = cmd.OrgID
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && cmd.ParentUID != "" {
if cmd.ParentUID != "" {
// Check that the user is allowed to create a subfolder in this folder
parentUIDScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(cmd.ParentUID)
legacyEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, parentUIDScope)
@@ -694,7 +671,7 @@ func (s *Service) CreateLegacy(ctx context.Context, cmd *folder.CreateFolderComm
}
}
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) && cmd.UID == folder.SharedWithMeFolderUID {
if cmd.UID == folder.SharedWithMeFolderUID {
return nil, folder.ErrBadRequest.Errorf("cannot create folder with UID %s", folder.SharedWithMeFolderUID)
}
@@ -819,10 +796,6 @@ func (s *Service) UpdateLegacy(ctx context.Context, cmd *folder.UpdateFolderComm
return nil, err
}
if !s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
return dashFolder, nil
}
// always expose the dashboard store sequential ID
metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Folder).Inc()
// nolint:staticcheck
@@ -1279,17 +1252,15 @@ func (s *Service) GetDescendantCountsLegacy(ctx context.Context, q *folder.GetDe
folders := []string{*q.UID}
countsMap := make(folder.DescendantCounts, len(s.registry)+1)
if s.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
descendantFolders, err := s.store.GetDescendants(ctx, q.OrgID, *q.UID)
if err != nil {
s.log.ErrorContext(ctx, "failed to get descendant folders", "error", err)
return nil, err
}
for _, f := range descendantFolders {
folders = append(folders, f.UID)
}
countsMap[entity.StandardKindFolder] = int64(len(descendantFolders))
descendantFolders, err := s.store.GetDescendants(ctx, q.OrgID, *q.UID)
if err != nil {
s.log.ErrorContext(ctx, "failed to get descendant folders", "error", err)
return nil, err
}
for _, f := range descendantFolders {
folders = append(folders, f.UID)
}
countsMap[entity.StandardKindFolder] = int64(len(descendantFolders))
for _, v := range s.registry {
c, err := v.CountInFolders(ctx, q.OrgID, folders, q.SignedInUser)
@@ -129,7 +129,7 @@ func TestIntegrationDashboardFileReader(t *testing.T) {
}
sql, cfgT := db.InitTestDBWithCfg(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
features := featuremgmt.WithFeatures()
fStore := folderimpl.ProvideStore(sql)
tagService := tagimpl.ProvideService(sql)
dashStore, err := database.ProvideDashboardStore(sql, cfgT, features, tagService)
@@ -48,7 +48,7 @@ func TestIntegrationDuplicatesValidator(t *testing.T) {
logger := log.New("test.logger")
sql, cfgT := db.InitTestDBWithCfg(t)
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
features := featuremgmt.WithFeatures()
fStore := folderimpl.ProvideStore(sql)
tagService := tagimpl.ProvideService(sql)
dashStore, err := database.ProvideDashboardStore(sql, cfgT, features, tagService)
+28 -52
View File
@@ -220,36 +220,23 @@ func (f *accessControlDashboardPermissionFilter) buildClauses(dialect migrator.D
}
permSelector.WriteRune(')')
switch f.features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
case true:
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ")
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString(fmt.Sprintf("WHERE d.org_id = ? AND d.uid IN (SELECT uid FROM %s)", recQueryName))
args = append(args, orgID)
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "folder_id", "d.id", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
args = append(args, nestedFoldersArgs...)
}
} else {
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ")
builder.WriteString("WHERE 1 = 0")
}
default:
builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ")
if len(permSelectorArgs) > 0 {
builder.WriteString("WHERE d.org_id = ? AND d.uid IN ")
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString(fmt.Sprintf("WHERE d.org_id = ? AND d.uid IN (SELECT uid FROM %s)", recQueryName))
args = append(args, orgID)
builder.WriteString(permSelector.String())
args = append(args, permSelectorArgs...)
} else {
builder.WriteString("WHERE 1 = 0")
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "folder_id", "d.id", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
args = append(args, nestedFoldersArgs...)
}
} else {
builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ")
builder.WriteString("WHERE 1 = 0")
}
builder.WriteString(") AND NOT dashboard.is_folder)")
@@ -297,33 +284,22 @@ func (f *accessControlDashboardPermissionFilter) buildClauses(dialect migrator.D
permSelector.WriteRune(')')
switch f.features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
case true:
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(dashboard.uid IN ")
builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName))
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "d.uid", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
builder.WriteRune(')')
args = append(args, nestedFoldersArgs...)
}
} else {
builder.WriteString("(1 = 0")
}
default:
if len(permSelectorArgs) > 0 {
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(dashboard.uid IN ")
builder.WriteString(permSelector.String())
args = append(args, permSelectorArgs...)
} else {
builder.WriteString("(1 = 0")
builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName))
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "d.uid", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
builder.WriteRune(')')
args = append(args, nestedFoldersArgs...)
}
} else {
builder.WriteString("(1 = 0")
}
builder.WriteString(" AND dashboard.is_folder)")
} else {
@@ -7,7 +7,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
@@ -107,36 +106,22 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses(di
permSelector.WriteRune(')')
switch f.features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
case true:
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(folder.uid IN (SELECT uid FROM " + recQueryName)
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "folder", "uid", "", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
args = append(args, nestedFoldersArgs...)
}
f.folderIsRequired = true
builder.WriteString(") AND NOT dashboard.is_folder)")
} else {
builder.WriteString("( 1 = 0 AND NOT dashboard.is_folder)")
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(folder.uid IN (SELECT uid FROM " + recQueryName)
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "folder", "uid", "", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
args = append(args, nestedFoldersArgs...)
}
default:
builder.WriteString("(")
if len(permSelectorArgs) > 0 {
builder.WriteString("folder.uid IN ")
builder.WriteString(permSelector.String())
args = append(args, permSelectorArgs...)
f.folderIsRequired = true
} else {
builder.WriteString("1 = 0 ")
}
builder.WriteString(" AND NOT dashboard.is_folder)")
f.folderIsRequired = true
builder.WriteString(") AND NOT dashboard.is_folder)")
} else {
builder.WriteString("( 1 = 0 AND NOT dashboard.is_folder)")
}
// Include all the dashboards under the root if the user has the required permissions on the root (used to be the General folder)
@@ -181,33 +166,22 @@ func (f *accessControlDashboardPermissionFilterNoFolderSubquery) buildClauses(di
}
permSelector.WriteRune(')')
switch f.features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
case true:
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(dashboard.uid IN ")
builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName))
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
builder.WriteRune(')')
args = append(args, nestedFoldersArgs...)
}
} else {
builder.WriteString("(1 = 0")
}
default:
if len(permSelectorArgs) > 0 {
if len(permSelectorArgs) > 0 {
switch f.recursiveQueriesAreSupported {
case true:
recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries))
f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs, orgID)
builder.WriteString("(dashboard.uid IN ")
builder.WriteString(permSelector.String())
args = append(args, permSelectorArgs...)
} else {
builder.WriteString("(1 = 0")
builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName))
default:
nestedFoldersSelectors, nestedFoldersArgs := f.nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "dashboard", "uid", "", orgID)
builder.WriteRune('(')
builder.WriteString(nestedFoldersSelectors)
builder.WriteRune(')')
args = append(args, nestedFoldersArgs...)
}
} else {
builder.WriteString("(1 = 0")
}
builder.WriteString(" AND dashboard.is_folder)")
} else {
@@ -384,14 +384,12 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
permission dashboardaccess.PermissionType
permissions []accesscontrol.Permission
expectedResult []string
features []any
}{
{
desc: "Should not be able to view dashboards under inherited folders with no permissions if nested folders are enabled",
queryType: searchstore.TypeDashboard,
permission: dashboardaccess.PERMISSION_VIEW,
permissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
@@ -399,14 +397,12 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
queryType: searchstore.TypeFolder,
permission: dashboardaccess.PERMISSION_VIEW,
permissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
desc: "Should not be able to view inherited dashboards and folders with no permissions if nested folders are enabled",
permission: dashboardaccess.PERMISSION_VIEW,
permissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
@@ -416,7 +412,6 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
permissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersAll},
},
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: []string{"dashboard under the root", "dashboard under parent folder", "dashboard under subfolder"},
},
{
@@ -426,19 +421,8 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
permissions: []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent", Kind: "folders", Identifier: "parent"},
},
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: []string{"parent", "subfolder"},
},
{
desc: "Should not be able to view inherited folders if nested folders are not enabled",
queryType: searchstore.TypeFolder,
permission: dashboardaccess.PERMISSION_VIEW,
permissions: []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent", Kind: "folders", Identifier: "parent"},
},
features: []any{},
expectedResult: []string{"parent"},
},
}
var orgID int64 = 1
@@ -452,7 +436,7 @@ func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) {
})
usr := &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByActionContext(context.Background(), tc.permissions)}}
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} {
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} {
m := features.GetEnabled(context.Background())
keys := make([]string, 0, len(m))
for k := range m {
@@ -494,14 +478,12 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
permission dashboardaccess.PermissionType
signedInUserPermissions []accesscontrol.Permission
expectedResult []string
features []any
}{
{
desc: "Should not be able to view dashboards under inherited folders with no permissions if nested folders are enabled",
queryType: searchstore.TypeDashboard,
permission: dashboardaccess.PERMISSION_VIEW,
signedInUserPermissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
@@ -509,14 +491,12 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
queryType: searchstore.TypeFolder,
permission: dashboardaccess.PERMISSION_VIEW,
signedInUserPermissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
desc: "Should not be able to view inherited dashboards and folders with no permissions if nested folders are enabled",
permission: dashboardaccess.PERMISSION_VIEW,
signedInUserPermissions: nil,
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: nil,
},
{
@@ -526,7 +506,6 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
signedInUserPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsRead, Scope: dashboards.ScopeFoldersAll},
},
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: []string{"dashboard under the root", "dashboard under parent folder", "dashboard under subfolder"},
},
{
@@ -536,19 +515,8 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
signedInUserPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"},
},
features: []any{featuremgmt.FlagNestedFolders},
expectedResult: []string{"parent", "subfolder"},
},
{
desc: "Should not be able to view inherited folders if nested folders are not enabled",
queryType: searchstore.TypeFolder,
permission: dashboardaccess.PERMISSION_VIEW,
signedInUserPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"},
},
features: []any{},
expectedResult: []string{"parent"},
},
}
var orgID int64 = 1
@@ -566,7 +534,7 @@ func TestIntegration_DashboardNestedPermissionFilter_WithSelfContainedPermission
}),
},
}
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(tc.features...), featuremgmt.WithFeatures(append(tc.features, featuremgmt.FlagPermissionsFilterRemoveSubquery)...)} {
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} {
m := features.GetEnabled(context.Background())
keys := make([]string, 0, len(m))
for k := range m {
@@ -676,7 +644,7 @@ func TestIntegration_DashboardNestedPermissionFilter_WithActionSets(t *testing.T
Scope: "folders:uid:unrelated"})
usr := &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByActionContext(context.Background(), tc.signedInUserPermissions)}}
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery)} {
for _, features := range []featuremgmt.FeatureToggles{featuremgmt.WithFeatures(), featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery)} {
m := features.GetEnabled(context.Background())
keys := make([]string, 0, len(m))
for k := range m {
@@ -711,56 +679,65 @@ func TestIntegration_DashboardNestedPermissionFilter_WithActionSets(t *testing.T
func setupTest(t *testing.T, numFolders, numDashboards int, permissions []accesscontrol.Permission) db.DB {
t.Helper()
store := db.InitTestDB(t)
err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
dashes := make([]dashboards.Dashboard, 0, numFolders+numDashboards)
for i := 1; i <= numFolders; i++ {
str := strconv.Itoa(i)
dashes = append(dashes, dashboards.Dashboard{
OrgID: 1,
Slug: str,
UID: str,
Title: str,
IsFolder: true,
Data: simplejson.New(),
Created: time.Now(),
Updated: time.Now(),
})
}
// Seed dashboards
for i := numFolders + 1; i <= numFolders+numDashboards; i++ {
str := strconv.Itoa(i)
folderID := 0
if i%(numFolders+1) != 0 {
folderID = i % (numFolders + 1)
}
dashes = append(dashes, dashboards.Dashboard{
OrgID: 1,
IsFolder: false,
FolderUID: strconv.Itoa(folderID),
UID: str,
Slug: str,
Title: str,
Data: simplejson.New(),
Created: time.Now(),
Updated: time.Now(),
})
db, cfg := db.InitTestDBWithCfg(t)
dashStore, err := database.ProvideDashboardStore(db, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(db))
require.NoError(t, err)
fStore := folderimpl.ProvideStore(db)
// Create a signed-in user for folder creation
usr := &user.SignedInUser{
UserID: 1,
OrgID: 1,
Login: "test",
}
// folders need to be created in both the folder and dashboard table
for i := 1; i <= numFolders; i++ {
str := strconv.Itoa(i)
folder, err := fStore.Create(context.Background(), folder.CreateFolderCommand{
Title: str,
OrgID: 1,
UID: str,
SignedInUser: usr,
})
require.NoError(t, err)
_, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{
OrgID: 1,
Dashboard: simplejson.NewFromAny(map[string]any{
"title": str,
"uid": folder.UID,
}),
IsFolder: true,
})
require.NoError(t, err)
}
// now create dashboards
for i := numFolders + 1; i <= numFolders+numDashboards; i++ {
str := strconv.Itoa(i)
folderID := 0
if i%(numFolders+1) != 0 {
folderID = i % (numFolders + 1)
}
// Insert dashboards in batches
batchSize := 500
for i := 0; i < len(dashes); i += batchSize {
end := i + batchSize
if end > len(dashes) {
end = len(dashes)
}
_, err := sess.InsertMulti(dashes[i:end])
if err != nil {
return err
}
cmd := dashboards.SaveDashboardCommand{
OrgID: 1,
Dashboard: simplejson.NewFromAny(map[string]any{
"title": str,
"uid": str,
}),
IsFolder: false,
}
if folderID != 0 {
cmd.FolderUID = strconv.Itoa(folderID)
}
_, err := dashStore.SaveDashboard(context.Background(), cmd)
require.NoError(t, err)
}
// insert permissions
err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error {
role := &accesscontrol.Role{
OrgID: 0,
UID: "basic_viewer",
@@ -792,6 +769,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access
}
if len(permissions) > 0 {
batchSize := 500
for i := 0; i < len(permissions); i += batchSize {
end := i + batchSize
if end > len(permissions) {
@@ -808,7 +786,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access
return nil
})
require.NoError(t, err)
return store
return db
}
func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol.Permission, orgID int64, features featuremgmt.FeatureToggles) db.DB {
@@ -44,11 +44,6 @@ func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards, n
}}
features := featuremgmt.WithFeatures()
// if nestingLevel > 0 enable nested folders
if nestingLevel > 0 {
features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)
}
store := setupBenchMark(b, usr, features, numUsers, numDashboards, numFolders, nestingLevel)
recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported()
+5 -18
View File
@@ -37,14 +37,9 @@ func (b *Builder) ToSQL(limit, page int64) (string, []any) {
INNER JOIN dashboard ON ids.id = dashboard.id`)
b.sql.WriteString("\n")
if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
// covered by UQE_folder_org_id_uid
b.sql.WriteString(
`LEFT OUTER JOIN folder ON folder.uid = dashboard.folder_uid AND folder.org_id = dashboard.org_id`)
} else {
b.sql.WriteString(`
LEFT OUTER JOIN dashboard AS folder ON folder.id = dashboard.folder_id`)
}
// covered by UQE_folder_org_id_uid
b.sql.WriteString(
`LEFT OUTER JOIN folder ON folder.uid = dashboard.folder_uid AND folder.org_id = dashboard.org_id`)
b.sql.WriteString(`
LEFT OUTER JOIN dashboard_tag ON dashboard.id = dashboard_tag.dashboard_id`)
b.sql.WriteString("\n")
@@ -69,17 +64,9 @@ func (b *Builder) buildSelect() {
dashboard.folder_id,
dashboard.deleted,
folder.uid AS folder_uid,
folder.title AS folder_slug,
folder.title AS folder_title
`)
if b.Features.IsEnabledGlobally(featuremgmt.FlagNestedFolders) {
b.sql.WriteString(`
folder.title AS folder_slug,`)
} else {
b.sql.WriteString(`
folder.slug AS folder_slug,`)
}
b.sql.WriteString(`
folder.title AS folder_title `)
for _, f := range b.Filters {
if f, ok := f.(model.FilterSelect); ok {
b.sql.WriteString(fmt.Sprintf(", %s", f.Select()))
+7 -24
View File
@@ -66,10 +66,9 @@ func (f FolderFilter) Where() (string, []any) {
}
type FolderUIDFilter struct {
Dialect migrator.Dialect
OrgID int64
UIDs []string
NestedFoldersEnabled bool
Dialect migrator.Dialect
OrgID int64
UIDs []string
}
func (f FolderUIDFilter) Where() (string, []any) {
@@ -92,35 +91,19 @@ func (f FolderUIDFilter) Where() (string, []any) {
case len(params) < 1:
// do nothing
case len(params) == 1:
q = "dashboard.folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid = ?)"
if f.NestedFoldersEnabled {
q = "dashboard.org_id = ? AND dashboard.folder_uid = ?"
}
q = "dashboard.org_id = ? AND dashboard.folder_uid = ?"
params = append([]any{f.OrgID}, params...)
default:
sqlArray := "(?" + strings.Repeat(",?", len(params)-1) + ")"
q = "dashboard.folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid IN " + sqlArray + ")"
if f.NestedFoldersEnabled {
q = "dashboard.org_id = ? AND dashboard.folder_uid IN " + sqlArray
}
q = "dashboard.org_id = ? AND dashboard.folder_uid IN " + sqlArray
params = append([]any{f.OrgID}, params...)
}
if includeGeneral {
if q == "" {
if f.NestedFoldersEnabled {
q = "dashboard.folder_uid IS NULL "
} else {
q = "dashboard.folder_id = ? "
params = append(params, 0)
}
q = "dashboard.folder_uid IS NULL "
} else {
if f.NestedFoldersEnabled {
q = "(" + q + " OR dashboard.folder_uid IS NULL)"
} else {
q = "(" + q + " OR dashboard.folder_id = ?)"
params = append(params, 0)
}
q = "(" + q + " OR dashboard.folder_uid IS NULL)"
}
}
@@ -12,67 +12,34 @@ func TestIntegrationFolderUIDFilter(t *testing.T) {
t.Skip("skipping integration test in short mode")
}
testCases := []struct {
description string
uids []string
expectedSql string
expectedParams []any
nestedFoldersEnabled bool
description string
uids []string
expectedSql string
expectedParams []any
}{
{
description: "searching general folder",
uids: []string{"general"},
expectedSql: "dashboard.folder_id = ? ",
expectedParams: []any{0},
nestedFoldersEnabled: false,
description: "searching general folder",
uids: []string{"general"},
expectedSql: "dashboard.folder_uid IS NULL ",
expectedParams: []any{},
},
{
description: "searching a specific folder",
uids: []string{"abc-123"},
expectedSql: "dashboard.folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid = ?)",
expectedParams: []any{int64(1), "abc-123"},
nestedFoldersEnabled: false,
description: "searching a specific folder",
uids: []string{"abc-123"},
expectedSql: "dashboard.org_id = ? AND dashboard.folder_uid = ?",
expectedParams: []any{int64(1), "abc-123"},
},
{
description: "searching a specific folders",
uids: []string{"abc-123", "def-456"},
expectedSql: "dashboard.folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid IN (?,?))",
expectedParams: []any{int64(1), "abc-123", "def-456"},
nestedFoldersEnabled: false,
description: "searching a specific folders",
uids: []string{"abc-123", "def-456"},
expectedSql: "dashboard.org_id = ? AND dashboard.folder_uid IN (?,?)",
expectedParams: []any{int64(1), "abc-123", "def-456"},
},
{
description: "searching a specific folders or general",
uids: []string{"general", "abc-123", "def-456"},
expectedSql: "(dashboard.folder_id IN (SELECT id FROM dashboard WHERE org_id = ? AND uid IN (?,?)) OR dashboard.folder_id = ?)",
expectedParams: []any{int64(1), "abc-123", "def-456", 0},
nestedFoldersEnabled: false,
},
{
description: "searching general folder with nestedFoldersEnabled",
uids: []string{"general"},
expectedSql: "dashboard.folder_uid IS NULL ",
expectedParams: []any{},
nestedFoldersEnabled: true,
},
{
description: "searching a specific folder with nestedFoldersEnabled",
uids: []string{"abc-123"},
expectedSql: "dashboard.org_id = ? AND dashboard.folder_uid = ?",
expectedParams: []any{int64(1), "abc-123"},
nestedFoldersEnabled: true,
},
{
description: "searching a specific folders with nestedFoldersEnabled",
uids: []string{"abc-123", "def-456"},
expectedSql: "dashboard.org_id = ? AND dashboard.folder_uid IN (?,?)",
expectedParams: []any{int64(1), "abc-123", "def-456"},
nestedFoldersEnabled: true,
},
{
description: "searching a specific folders or general with nestedFoldersEnabled",
uids: []string{"general", "abc-123", "def-456"},
expectedSql: "(dashboard.org_id = ? AND dashboard.folder_uid IN (?,?) OR dashboard.folder_uid IS NULL)",
expectedParams: []any{int64(1), "abc-123", "def-456"},
nestedFoldersEnabled: true,
description: "searching a specific folders or general",
uids: []string{"general", "abc-123", "def-456"},
expectedSql: "(dashboard.org_id = ? AND dashboard.folder_uid IN (?,?) OR dashboard.folder_uid IS NULL)",
expectedParams: []any{int64(1), "abc-123", "def-456"},
},
}
@@ -81,10 +48,9 @@ func TestIntegrationFolderUIDFilter(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.description, func(t *testing.T) {
f := searchstore.FolderUIDFilter{
Dialect: store.GetDialect(),
OrgID: 1,
UIDs: tc.uids,
NestedFoldersEnabled: tc.nestedFoldersEnabled,
Dialect: store.GetDialect(),
OrgID: 1,
UIDs: tc.uids,
}
sql, params := f.Where()
@@ -146,48 +146,6 @@ func TestIntegrationBuilder_RBAC(t *testing.T) {
int64(1),
},
},
{
desc: "user with view permission",
userPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"},
},
level: dashboardaccess.PERMISSION_VIEW,
features: featuremgmt.WithFeatures(),
expectedParams: []any{
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:read",
"dashboards:view",
"dashboards:edit",
"dashboards:admin",
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:read",
"folders:view",
"folders:edit",
"folders:admin",
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"folders:read",
"folders:view",
"folders:edit",
"folders:admin",
},
},
{
desc: "user with write permission",
userPermissions: []accesscontrol.Permission{
@@ -196,16 +154,6 @@ func TestIntegrationBuilder_RBAC(t *testing.T) {
level: dashboardaccess.PERMISSION_EDIT,
features: featuremgmt.WithFeatures(),
expectedParams: []any{
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:write",
"dashboards:edit",
"dashboards:admin",
int64(1),
int64(1),
int64(1),
@@ -218,6 +166,7 @@ func TestIntegrationBuilder_RBAC(t *testing.T) {
"folders:admin",
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
@@ -225,15 +174,26 @@ func TestIntegrationBuilder_RBAC(t *testing.T) {
"dashboards:create",
"folders:edit",
"folders:admin",
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:write",
"dashboards:edit",
"dashboards:admin",
int64(1),
},
},
{
desc: "user with view permission with nesting",
desc: "user with view permission",
userPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"},
},
level: dashboardaccess.PERMISSION_VIEW,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders),
features: featuremgmt.WithFeatures(),
expectedParams: []any{
int64(1),
int64(1),
@@ -272,53 +232,12 @@ func TestIntegrationBuilder_RBAC(t *testing.T) {
},
},
{
desc: "user with view permission with remove subquery",
userPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"},
},
level: dashboardaccess.PERMISSION_VIEW,
features: featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery),
expectedParams: []any{
int64(1),
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:read",
"dashboards:view",
"dashboards:edit",
"dashboards:admin",
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"dashboards:read",
"folders:view",
"folders:edit",
"folders:admin",
int64(1),
int64(1),
0,
"Viewer",
int64(1),
0,
"folders:read",
"folders:view",
"folders:edit",
"folders:admin",
},
},
{
desc: "user with edit permission with nesting and remove subquery",
desc: "user with edit permission remove subquery",
userPermissions: []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:1"},
},
level: dashboardaccess.PERMISSION_EDIT,
features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPermissionsFilterRemoveSubquery),
features: featuremgmt.WithFeatures(featuremgmt.FlagPermissionsFilterRemoveSubquery),
expectedParams: []any{
int64(1),
int64(1),
-1
View File
@@ -361,7 +361,6 @@ func TestIntegrationAlertRuleNestedPermissions(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
DisableAnonymous: true,
+1 -5
View File
@@ -9,7 +9,6 @@ import (
"github.com/grafana/grafana-openapi-client-go/client/folders"
"github.com/grafana/grafana-openapi-client-go/models"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests"
@@ -46,7 +45,6 @@ func TestIntegrationFolderServiceGetFolder(t *testing.T) {
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
})
grafanaListedAddr, _ := testinfra.StartGrafanaEnv(t, dir, p)
@@ -199,7 +197,7 @@ func TestIntegrationCreateFolder(t *testing.T) {
})
}
func TestIntegrationNestedFoldersOn(t *testing.T) {
func TestIntegrationNestedFolders(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
@@ -359,7 +357,6 @@ func TestIntegrationSharedWithMe(t *testing.T) {
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
@@ -550,7 +547,6 @@ func TestIntegrationFineGrainedPermissions(t *testing.T) {
EnableUnifiedAlerting: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{featuremgmt.FlagNestedFolders},
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
-25
View File
@@ -150,9 +150,6 @@ func TestIntegrationFoldersApp(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
}))
})
@@ -166,9 +163,6 @@ func TestIntegrationFoldersApp(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
}))
})
@@ -182,9 +176,6 @@ func TestIntegrationFoldersApp(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
}))
})
@@ -198,9 +189,6 @@ func TestIntegrationFoldersApp(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
}))
})
}
@@ -228,9 +216,6 @@ func TestIntegrationFoldersApp(t *testing.T) {
},
// We set it to 1 here, so we always get forced pagination based on the response size.
UnifiedStorageMaxPageSizeBytes: 1,
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
}), mode)
})
}
@@ -669,9 +654,6 @@ func TestIntegrationFolderCreatePermissions(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
})
user := helper.CreateUser("user", apis.Org1, org.RoleViewer, tc.permissions)
@@ -776,9 +758,6 @@ func TestIntegrationFolderGetPermissions(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
})
// Create parent folder
@@ -959,9 +938,6 @@ func TestIntegrationFoldersCreateAPIEndpointK8S(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
},
})
userTest := helper.CreateUser("user", apis.Org1, org.RoleViewer, tc.permissions)
@@ -1134,7 +1110,6 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagNestedFolders,
featuremgmt.FlagUnifiedStorageSearch,
},
})
@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from 'test/test-utils';
import { config, setBackendSrv } from '@grafana/runtime';
import { setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
@@ -144,112 +144,61 @@ describe('NestedFolderPicker', () => {
expect(screen.getByLabelText(folderC.item.title)).toBeInTheDocument();
});
describe('when nestedFolders is enabled', () => {
let originalToggles = { ...config.featureToggles };
it('can expand and collapse a folder to show its children', async () => {
const { user } = render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
beforeAll(() => {
config.featureToggles.nestedFolders = true;
});
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
await user.click(button);
await screen.findByLabelText(folderA.item.title);
afterAll(() => {
config.featureToggles = originalToggles;
});
// Expand Folder A
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
it('can expand and collapse a folder to show its children', async () => {
const { user } = render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
// Folder A's children are visible
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
await user.click(button);
await screen.findByLabelText(folderA.item.title);
// Collapse Folder A
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// Expand Folder A
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Expand Folder A again
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Folder A's children are visible
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
// Collapse Folder A
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Collapse folder ${folderA.item.title}` }));
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// Expand Folder A again
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
fireEvent.mouseDown(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` }));
// Select the first child
await user.click(screen.getByLabelText(folderA_folderA.item.title));
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
});
it('can expand and collapse a folder to show its children with the keyboard', async () => {
const { user } = render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await user.click(button);
// Expand Folder A
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}');
// Folder A's children are visible
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderC.item.title)).toBeInTheDocument();
// Collapse Folder A
await user.keyboard('{ArrowLeft}');
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// Expand Folder A again
await user.keyboard('{ArrowRight}');
// Select the first child
await user.keyboard('{ArrowDown}{Enter}');
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderC.item.uid, folderA_folderC.item.title);
});
// Select the first child
await user.click(screen.getByLabelText(folderA_folderA.item.title));
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderA.item.uid, folderA_folderA.item.title);
});
describe('when nestedFolders is disabled', () => {
let originalToggles = { ...config.featureToggles };
it('can expand and collapse a folder to show its children with the keyboard', async () => {
const { user } = render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
beforeAll(() => {
config.featureToggles.nestedFolders = false;
});
await user.click(button);
afterAll(() => {
config.featureToggles = originalToggles;
});
// Expand Folder A
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}');
it('does not show an expand button', async () => {
const { user } = render(<NestedFolderPicker onChange={mockOnChange} />);
// Folder A's children are visible
expect(await screen.findByLabelText(folderA_folderA.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderB.item.title)).toBeInTheDocument();
expect(await screen.findByLabelText(folderA_folderC.item.title)).toBeInTheDocument();
// Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' });
await user.click(button);
await screen.findByLabelText(folderA.item.title);
// Collapse Folder A
await user.keyboard('{ArrowLeft}');
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
// There should be no expand button
// Note: we need to use mouseDown here because userEvent's click event doesn't get prevented correctly
expect(screen.queryByRole('button', { name: `Expand folder ${folderA.item.title}` })).not.toBeInTheDocument();
});
// Expand Folder A again
await user.keyboard('{ArrowRight}');
it('does not expand a folder with the keyboard', async () => {
const { user } = render(<NestedFolderPicker onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' });
await user.click(button);
// try to expand Folder A
await user.keyboard('{ArrowDown}{ArrowDown}{ArrowRight}');
// Folder A's children are not visible
expect(screen.queryByLabelText(folderA_folderA.item.title)).not.toBeInTheDocument();
expect(screen.queryByLabelText(folderA_folderB.item.title)).not.toBeInTheDocument();
});
// Select the first child
await user.keyboard('{ArrowDown}{Enter}');
expect(mockOnChange).toHaveBeenCalledWith(folderA_folderC.item.uid, folderA_folderC.item.title);
});
});
@@ -7,7 +7,6 @@ import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui';
import { getStatusFromError } from 'app/core/utils/errors';
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
@@ -78,8 +77,6 @@ export function NestedFolderPicker({
// but doesn't have access to the folder
const isForbidden = getStatusFromError(selectedFolder.error) === 403;
const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders);
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null);
const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false);
@@ -361,7 +358,7 @@ export function NestedFolderPicker({
onFolderExpand={handleFolderExpand}
onFolderSelect={handleFolderSelect}
idPrefix={overlayId}
foldersAreOpenable={nestedFoldersEnabled && !(search && searchResults)}
foldersAreOpenable={!(search && searchResults)}
isItemLoaded={isItemLoaded}
requestLoadMore={handleLoadMore}
/>
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import * as React from 'react';
import { config } from '@grafana/runtime';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
@@ -27,7 +26,6 @@ export function useTreeInteractions({
visible,
}: TreeInteractionProps) {
const [focusedItemIndex, setFocusedItemIndex] = useState(-1);
const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders);
useEffect(() => {
if (visible) {
@@ -47,7 +45,7 @@ export function useTreeInteractions({
const handleKeyDown = useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
const foldersAreOpenable = nestedFoldersEnabled && !search;
const foldersAreOpenable = !search;
switch (ev.key) {
// Expand/collapse folder on right/left arrow keys
case 'ArrowRight':
@@ -87,7 +85,7 @@ export function useTreeInteractions({
break;
}
},
[focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, nestedFoldersEnabled, search, tree]
[focusedItemIndex, handleCloseOverlay, handleFolderExpand, handleFolderSelect, search, tree]
);
return {
@@ -67,7 +67,7 @@ describe('RuleEditor grafana managed rules', () => {
await user.type(await ui.inputs.name.find(), 'my great new rule');
await user.click(await screen.findByRole('button', { name: /select folder/i }));
await user.click(await screen.findByLabelText(/folder a/i));
await user.click(await screen.findByLabelText('Folder A'));
const groupInput = await ui.inputs.group.find();
await user.click(await byRole('combobox').find(groupInput));
await clickSelectOption(groupInput, grafanaRulerGroup.name);
@@ -148,7 +148,7 @@ describe('RuleEditor grafana managed rules', () => {
await user.type(await ui.inputs.name.find(), 'my great new rule');
await user.click(await screen.findByRole('button', { name: /select folder/i }));
await user.click(await screen.findByLabelText(/folder a/i));
await user.click(await screen.findByLabelText('Folder A'));
// Select the existing group with 5m interval
const groupInput = await ui.inputs.group.find();
@@ -202,8 +202,7 @@ export const browseDashboardsAPI = createApi({
};
for (const folderCounts of results) {
// TODO remove nullish coalescing once nestedFolders is toggled on
totalCounts.folder += folderCounts.folder ?? 0;
totalCounts.folder += folderCounts.folder;
totalCounts.dashboard += folderCounts.dashboard;
totalCounts.alertRule += folderCounts.alertrule;
totalCounts.libraryPanel += folderCounts.librarypanel;
@@ -1,4 +1,4 @@
import { config, getBackendSrv } from '@grafana/runtime';
import { getBackendSrv } from '@grafana/runtime';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { NestedFolderDTO } from 'app/features/search/service/types';
@@ -17,7 +17,7 @@ export async function listFolders(
page = 1,
pageSize = PAGE_SIZE
): Promise<DashboardViewItem[]> {
if (parentUID && !config.featureToggles.nestedFolders) {
if (parentUID) {
return [];
}
@@ -43,10 +43,7 @@ export function BrowseActions({ folderDTO }: Props) {
);
// Folders can only be moved if nested folders is enabled
const moveIsInvalid = useMemo(
() => !config.featureToggles.nestedFolders && Object.values(selectedItems.folder).some((v) => v),
[selectedItems]
);
const moveIsInvalid = useMemo(() => Object.values(selectedItems.folder).some((v) => v), [selectedItems]);
const isSearching = stateManager.hasSearchFilters();
@@ -2,7 +2,6 @@ import { render as rtlRender, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime';
import { appEvents } from 'app/core/core';
import { ShowModalReactEvent } from 'app/types/events';
@@ -43,225 +42,114 @@ describe('browse-dashboards FolderActionsButton', () => {
jest.restoreAllMocks();
});
describe('with nestedFolders enabled', () => {
beforeAll(() => {
config.featureToggles.nestedFolders = true;
});
afterAll(() => {
config.featureToggles.nestedFolders = false;
});
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
canEditFolders: false,
canViewPermissions: false,
canSetPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canViewPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canEditFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
it('clicking the "Move" option opens the move modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Move' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: MoveModal,
})
)
);
});
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
canEditFolders: false,
canViewPermissions: false,
canSetPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
});
describe('with nestedFolders disabled', () => {
it('does not render anything when the user has no permissions to do anything', () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
canEditFolders: false,
canViewPermissions: false,
canSetPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.queryByRole('button', { name: 'Folder actions' })).not.toBeInTheDocument();
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
});
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canViewPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
it('renders a "Folder actions" button when the user has permissions to do something', () => {
render(<FolderActionsButton folder={mockFolder} />);
expect(screen.getByRole('button', { name: 'Folder actions' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canEditFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
it('does not render a "Move" button even if it has permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Move' })).not.toBeInTheDocument();
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
it('renders all the options if the user has full permissions', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Move' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Manage permissions" option if the user does not have permission to view permissions', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canViewPermissions: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.queryByRole('menuitem', { name: 'Manage permissions' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('clicking the "Move" option opens the move modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Move" option if the user does not have permission to edit', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canEditFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Move' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: MoveModal,
})
)
);
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument();
});
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
it('does not render the "Delete" option if the user does not have permission to delete', async () => {
jest.spyOn(permissions, 'getFolderPermissions').mockImplementation(() => {
return {
...mockPermissions,
canDeleteFolders: false,
};
});
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
expect(screen.getByRole('menuitem', { name: 'Manage permissions' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Delete' })).not.toBeInTheDocument();
});
it('clicking the "Manage permissions" option opens the permissions drawer', async () => {
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Manage permissions' }));
expect(screen.getByRole('dialog', { name: 'Drawer title Manage permissions' })).toBeInTheDocument();
});
it('clicking the "Delete" option opens the delete modal', async () => {
jest.spyOn(appEvents, 'publish');
render(<FolderActionsButton folder={mockFolder} />);
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
});
await userEvent.click(screen.getByRole('button', { name: 'Folder actions' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Delete' }));
expect(appEvents.publish).toHaveBeenCalledWith(
new ShowModalReactEvent(
expect.objectContaining({
component: DeleteModal,
})
)
);
});
});
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
import { Permissions } from 'app/core/components/AccessControl';
import { appEvents } from 'app/core/core';
@@ -29,8 +29,8 @@ export function FolderActionsButton({ folder }: Props) {
const { canEditFolders, canDeleteFolders, canViewPermissions, canSetPermissions } = getFolderPermissions(folder);
const isProvisionedFolder = folder.managedBy === ManagerKind.Repo;
// Can only move folders when nestedFolders is enabled and the folder is not provisioned
const canMoveFolder = config.featureToggles.nestedFolders && canEditFolders && !isProvisionedFolder;
// Can only move folders when the folder is not provisioned
const canMoveFolder = canEditFolders && !isProvisionedFolder;
const onMove = async (destinationUID: string) => {
await moveFolder({ folder, destinationUID });
@@ -1,4 +1,3 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types/accessControl';
import { FolderDTO } from 'app/types/folders';
@@ -8,8 +7,8 @@ function checkFolderPermission(action: AccessControlAction, folderDTO?: FolderDT
}
function checkCanCreateFolders(folderDTO?: FolderDTO) {
// Can only create a folder if we have permissions and either we're at root or nestedFolders is enabled
if (folderDTO && folderDTO.uid !== 'general' && !config.featureToggles.nestedFolders) {
// Can only create a folder if we have permissions and either we're at root
if (folderDTO && folderDTO.uid !== 'general') {
return false;
}
@@ -1,4 +1,3 @@
import config from 'app/core/config';
import { listFolders } from 'app/features/browse-dashboards/api/services';
import { DashboardViewItem } from '../types';
@@ -11,11 +10,6 @@ export async function getFolderChildren(
parentTitle?: string,
dashboardsAtRoot = false
): Promise<DashboardViewItem[]> {
if (!config.featureToggles.nestedFolders) {
console.error('getFolderChildren requires nestedFolders feature toggle');
return [];
}
if (!dashboardsAtRoot && !parentUid) {
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
// folder that FolderView adds in
+1 -2
View File
@@ -44,8 +44,7 @@ export interface FolderState {
}
export interface DescendantCountDTO {
// TODO: make this required once nestedFolders is enabled by default
folder?: number;
folder: number;
dashboard: number;
librarypanel: number;
alertrule: number;