diff --git a/.betterer.results b/.betterer.results index ee7fabc2121dc..678ebb9b3f533 100644 --- a/.betterer.results +++ b/.betterer.results @@ -847,6 +847,9 @@ exports[`better eslint`] = { "packages/grafana-ui/src/utils/useAsyncDependency.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/api/clients/folder/v1beta1/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/core/TableModel.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/apps/folder/pkg/apis/folder/v1beta1/types.go b/apps/folder/pkg/apis/folder/v1beta1/types.go index 8c77a14430553..062c9f1117653 100644 --- a/apps/folder/pkg/apis/folder/v1beta1/types.go +++ b/apps/folder/pkg/apis/folder/v1beta1/types.go @@ -44,10 +44,11 @@ type FolderInfo struct { type FolderAccessInfo struct { metav1.TypeMeta `json:",inline"` - CanSave bool `json:"canSave"` - CanEdit bool `json:"canEdit"` - CanAdmin bool `json:"canAdmin"` - CanDelete bool `json:"canDelete"` + CanSave bool `json:"canSave"` + CanEdit bool `json:"canEdit"` + CanAdmin bool `json:"canAdmin"` + CanDelete bool `json:"canDelete"` + AccessControl map[string]bool `json:"accessControl,omitempty"` } // +k8s:deepcopy-gen=true diff --git a/apps/folder/pkg/apis/folder/v1beta1/zz_generated.deepcopy.go b/apps/folder/pkg/apis/folder/v1beta1/zz_generated.deepcopy.go index 4c160ad65cad5..9108693c4f048 100644 --- a/apps/folder/pkg/apis/folder/v1beta1/zz_generated.deepcopy.go +++ b/apps/folder/pkg/apis/folder/v1beta1/zz_generated.deepcopy.go @@ -45,6 +45,13 @@ func (in *DescendantCounts) DeepCopyObject() runtime.Object { func (in *FolderAccessInfo) DeepCopyInto(out *FolderAccessInfo) { *out = *in out.TypeMeta = in.TypeMeta + if in.AccessControl != nil { + in, out := &in.AccessControl, &out.AccessControl + *out = make(map[string]bool, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } diff --git a/apps/folder/pkg/apis/folder/v1beta1/zz_generated.openapi.go b/apps/folder/pkg/apis/folder/v1beta1/zz_generated.openapi.go index 02c4e7b301da6..0fa6bb545b46c 100644 --- a/apps/folder/pkg/apis/folder/v1beta1/zz_generated.openapi.go +++ b/apps/folder/pkg/apis/folder/v1beta1/zz_generated.openapi.go @@ -167,6 +167,21 @@ func schema_pkg_apis_folder_v1beta1_FolderAccessInfo(ref common.ReferenceCallbac Format: "", }, }, + "accessControl": { + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, + }, + }, + }, }, Required: []string{"canSave", "canEdit", "canAdmin", "canDelete"}, }, diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 397cbcb94a550..55ccfa41f0cf6 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -191,7 +191,10 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API getter: storage[resourceInfo.StoragePath()].(rest.Getter), // Get the parents } storage[resourceInfo.StoragePath("counts")] = &subCountREST{searcher: b.searcher} - storage[resourceInfo.StoragePath("access")] = &subAccessREST{b.folderSvc, b.ac} + storage[resourceInfo.StoragePath("access")] = &subAccessREST{ + getter: storage[resourceInfo.StoragePath()].(rest.Getter), + ac: b.ac, + } // Adds a path to return children of a given folder storage[resourceInfo.StoragePath("children")] = &subChildrenREST{ diff --git a/pkg/registry/apis/folders/sub_access.go b/pkg/registry/apis/folders/sub_access.go index ddfbac8bd50b7..0ffde6dd036bf 100644 --- a/pkg/registry/apis/folders/sub_access.go +++ b/pkg/registry/apis/folders/sub_access.go @@ -2,6 +2,9 @@ package folders import ( "context" + "fmt" + "github.com/grafana/grafana/pkg/services/folder" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "net/http" "k8s.io/apimachinery/pkg/runtime" @@ -10,14 +13,12 @@ import ( folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/folder" ) type subAccessREST struct { - service folder.Service - ac accesscontrol.AccessControl + getter rest.Getter + ac accesscontrol.AccessControl } var _ = rest.Connecter(&subAccessREST{}) @@ -47,37 +48,62 @@ func (r *subAccessREST) NewConnectOptions() (runtime.Object, bool, string) { } func (r *subAccessREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } user, err := identity.GetRequester(ctx) if err != nil { return nil, err } - // Can view is managed here (and in the Authorizer) - f, err := r.service.Get(ctx, &folder.GetFolderQuery{ - UID: &name, - OrgID: ns.OrgID, - SignedInUser: user, - }) - if err != nil { - return nil, err - } + folderUID := name return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { access := &folders.FolderAccessInfo{} - canEditEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + canEditEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)) access.CanEdit, _ = r.ac.Evaluate(ctx, user, canEditEvaluator) access.CanSave = access.CanEdit canAdminEvaluator := accesscontrol.EvalAll( - accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)), - accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)), + accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)), + accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)), ) access.CanAdmin, _ = r.ac.Evaluate(ctx, user, canAdminEvaluator) - canDeleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID)) + canDeleteEvaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID)) access.CanDelete, _ = r.ac.Evaluate(ctx, user, canDeleteEvaluator) + + // Cargo culted from pkg/api/folder.go#getFolderACMetadata + allMetadata, err := getFolderAccessControl(ctx, r.getter, user, name) + if err != nil { + responder.Error(err) + return + } + metadata := map[string]bool{} + // Flatten metadata - if any parent has a permission, the child folder inherits it + for _, md := range allMetadata { + for action := range md { + metadata[action] = true + } + } + + access.AccessControl = metadata responder.Object(http.StatusOK, access) }), nil } + +func getFolderAccessControl(ctx context.Context, folderGetter rest.Getter, user identity.Requester, name string) (map[string]accesscontrol.Metadata, error) { + folderIDs := map[string]bool{name: true} + + if name != folder.GeneralFolderUID && name != folder.SharedWithMeFolderUID { + obj, err := folderGetter.Get(ctx, name, &metav1.GetOptions{}) + if err != nil { + return nil, err + } + folderObj, ok := obj.(*folders.Folder) + if !ok { + return nil, fmt.Errorf("expecting folder, found: %T", folderObj) + } + parents := getFolderParents(ctx, folderGetter, folderObj) + for _, p := range parents.Items { + folderIDs[p.Name] = true + } + } + + return accesscontrol.GetResourcesMetadata(ctx, user.GetPermissions(), dashboards.ScopeFoldersPrefix, folderIDs), nil +} diff --git a/pkg/registry/apis/folders/sub_parents.go b/pkg/registry/apis/folders/sub_parents.go index 8cfe40662e21c..9d962abfe8623 100644 --- a/pkg/registry/apis/folders/sub_parents.go +++ b/pkg/registry/apis/folders/sub_parents.go @@ -3,6 +3,8 @@ package folders import ( "context" "fmt" + "github.com/grafana/grafana/pkg/services/folder" + "k8s.io/apiserver/pkg/storage" "net/http" "slices" @@ -44,63 +46,30 @@ func (r *subParentsREST) NewConnectOptions() (runtime.Object, bool, string) { } func (r *subParentsREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) { - obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{}) - if err != nil { - return nil, err - } - folder, ok := obj.(*folders.Folder) - if !ok { - return nil, fmt.Errorf("expecting folder, found: %T", folder) - } - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - info := r.parents(ctx, folder) - // Start from the root - slices.Reverse(info.Items) - responder.Object(http.StatusOK, info) - }), nil -} - -func (r *subParentsREST) parents(ctx context.Context, folder *folders.Folder) *folders.FolderInfoList { - info := &folders.FolderInfoList{ - Items: []folders.FolderInfo{}, - } - for folder != nil { - parent := getParent(folder) - descr := "" - if folder.Spec.Description != nil { - descr = *folder.Spec.Description - } - info.Items = append(info.Items, folders.FolderInfo{ - Name: folder.Name, - Title: folder.Spec.Title, - Description: descr, - Parent: parent, - }) - if parent == "" { - break + if name == folder.GeneralFolderUID || name == folder.SharedWithMeFolderUID { + responder.Object(http.StatusOK, &folders.FolderInfoList{ + Items: []folders.FolderInfo{}, + }) + return } - obj, err := r.getter.Get(ctx, parent, &metav1.GetOptions{}) + obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{}) + if storage.IsNotFound(err) { + responder.Object(http.StatusNotFound, nil) + } if err != nil { - info.Items = append(info.Items, folders.FolderInfo{ - Name: parent, - Detached: true, - Description: err.Error(), - }) - break + responder.Error(err) } - parentFolder, ok := obj.(*folders.Folder) + folderObj, ok := obj.(*folders.Folder) if !ok { - info.Items = append(info.Items, folders.FolderInfo{ - Name: parent, - Detached: true, - Description: fmt.Sprintf("expected folder, found: %T", obj), - }) - break + responder.Error(fmt.Errorf("expecting folder, found: %T", folderObj)) } - folder = parentFolder - } - return info + + info := getFolderParents(ctx, r.getter, folderObj) + // Start from the root + slices.Reverse(info.Items) + responder.Object(http.StatusOK, info) + }), nil } diff --git a/pkg/registry/apis/folders/utils.go b/pkg/registry/apis/folders/utils.go new file mode 100644 index 0000000000000..f55a33f23ccb0 --- /dev/null +++ b/pkg/registry/apis/folders/utils.go @@ -0,0 +1,57 @@ +package folders + +import ( + "context" + "fmt" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/registry/rest" +) + +// getFolderParents gets a list of info objects for each parent of a Folder +// TODO: There are some other implementations that seem to do similar thing like pkg/services/folder/service.go#GetParents. +// +// Not sure if they should be merged somehow or not. +func getFolderParents(ctx context.Context, folderGetter rest.Getter, folder *folders.Folder) *folders.FolderInfoList { + info := &folders.FolderInfoList{ + Items: []folders.FolderInfo{}, + } + for folder != nil { + parent := getParent(folder) + descr := "" + if folder.Spec.Description != nil { + descr = *folder.Spec.Description + } + info.Items = append(info.Items, folders.FolderInfo{ + Name: folder.Name, + Title: folder.Spec.Title, + Description: descr, + Parent: parent, + }) + if parent == "" { + break + } + + obj, err := folderGetter.Get(ctx, parent, &metav1.GetOptions{}) + if err != nil { + info.Items = append(info.Items, folders.FolderInfo{ + Name: parent, + Detached: true, + Description: err.Error(), + }) + break + } + + parentFolder, ok := obj.(*folders.Folder) + if !ok { + info.Items = append(info.Items, folders.FolderInfo{ + Name: parent, + Detached: true, + Description: fmt.Sprintf("expected folder, found: %T", obj), + }) + break + } + folder = parentFolder + } + return info +} diff --git a/pkg/registry/apis/folders/sub_parent_test.go b/pkg/registry/apis/folders/utils_test.go similarity index 93% rename from pkg/registry/apis/folders/sub_parent_test.go rename to pkg/registry/apis/folders/utils_test.go index 8d184cc8980e9..edfe35f3d158b 100644 --- a/pkg/registry/apis/folders/sub_parent_test.go +++ b/pkg/registry/apis/folders/utils_test.go @@ -12,7 +12,7 @@ import ( grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) -func TestSubParent(t *testing.T) { +func TestGetFolderParents(t *testing.T) { tests := []struct { name string input *folders.Folder @@ -65,13 +65,10 @@ func TestSubParent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := &subParentsREST{ - getter: gm, - } if tt.setuFn != nil { tt.setuFn(m) } - parents := r.parents(context.TODO(), tt.input) + parents := getFolderParents(context.TODO(), gm, tt.input) require.Equal(t, tt.expected, parents) }) } diff --git a/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1beta1.json b/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1beta1.json index f366842cb4db4..e785ed1394d9a 100644 --- a/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1beta1.json +++ b/pkg/tests/apis/openapi_snapshots/folder.grafana.app-v1beta1.json @@ -1159,6 +1159,13 @@ "canDelete" ], "properties": { + "accessControl": { + "type": "object", + "additionalProperties": { + "type": "boolean", + "default": false + } + }, "apiVersion": { "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", "type": "string" diff --git a/public/app/api/clients/folder/v1beta1/endpoints.gen.ts b/public/app/api/clients/folder/v1beta1/endpoints.gen.ts index ae8bba67c4b39..7349b5a0b1a8a 100644 --- a/public/app/api/clients/folder/v1beta1/endpoints.gen.ts +++ b/public/app/api/clients/folder/v1beta1/endpoints.gen.ts @@ -519,6 +519,9 @@ export type Status = { }; export type Patch = object; export type FolderAccessInfo = { + accessControl?: { + [key: string]: boolean; + }; /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; canAdmin: boolean; diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts new file mode 100644 index 0000000000000..40473dbeda064 --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -0,0 +1,138 @@ +import { QueryStatus } from '@reduxjs/toolkit/query'; +import { renderHook } from '@testing-library/react'; + +import { + AnnoKeyCreatedBy, + AnnoKeyFolder, + AnnoKeyManagerKind, + AnnoKeyUpdatedBy, + AnnoKeyUpdatedTimestamp, + DeprecatedInternalId, +} from '../../../../features/apiserver/types'; +import { useGetDisplayMappingQuery } from '../../iam/v0alpha1'; + +import { useGetFolderQueryFacade } from './hooks'; + +import { useGetFolderQuery, useGetFolderParentsQuery, useGetFolderAccessQuery } from './index'; + +// Mocks for the hooks used inside useGetFolderQueryFacade +jest.mock('./index', () => ({ + useGetFolderQuery: jest.fn(), + useGetFolderParentsQuery: jest.fn(), + useGetFolderAccessQuery: jest.fn(), +})); +jest.mock('../../iam/v0alpha1', () => ({ + useGetDisplayMappingQuery: jest.fn(), +})); + +// Mock config and constants +jest.mock('@grafana/runtime', () => { + const runtime = jest.requireActual('@grafana/runtime'); + return { + ...runtime, + config: { + ...runtime.config, + featureToggles: { + ...runtime.config.featureToggles, + foldersAppPlatformAPI: true, + }, + appSubUrl: '/grafana', + }, + }; +}); + +const mockFolder = { + data: { + metadata: { + name: 'folder-uid', + labels: { [DeprecatedInternalId]: '123' }, + annotations: { + [AnnoKeyUpdatedBy]: 'user-1', + [AnnoKeyCreatedBy]: 'user-2', + [AnnoKeyFolder]: 'parent-uid', + [AnnoKeyManagerKind]: 'user', + [AnnoKeyUpdatedTimestamp]: '2024-01-01T00:00:00Z', + }, + creationTimestamp: '2023-01-01T00:00:00Z', + generation: 2, + }, + spec: { title: 'Test Folder' }, + }, + ...getResponseAttributes(), +}; + +const mockParents = { + data: { items: [{ name: 'parent-uid', title: 'Parent Folder' }] }, + ...getResponseAttributes(), +}; + +const mockAccess = { + data: { + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + accessControl: [], + }, + ...getResponseAttributes(), +}; + +const mockUserDisplay = { + data: { + keys: ['user-1', 'user-2'], + display: [{ displayName: 'User One' }, { displayName: 'User Two' }], + }, + ...getResponseAttributes(), +}; + +describe('useGetFolderQueryFacade', () => { + beforeEach(() => { + (useGetFolderQuery as jest.Mock).mockReturnValue(mockFolder); + (useGetFolderParentsQuery as jest.Mock).mockReturnValue(mockParents); + (useGetFolderAccessQuery as jest.Mock).mockReturnValue(mockAccess); + (useGetDisplayMappingQuery as jest.Mock).mockReturnValue(mockUserDisplay); + }); + + it('merges multiple responses into a single FolderDTO-like object', () => { + const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid')); + expect(result.current.data).toMatchObject({ + canAdmin: true, + canDelete: true, + canEdit: true, + canSave: true, + accessControl: [], + created: '2023-01-01T00:00:00Z', + createdBy: 'User Two', + hasAcl: false, + id: 123, + parentUid: 'parent-uid', + managedBy: 'user', + title: 'Test Folder', + uid: 'folder-uid', + updated: '2024-01-01T00:00:00Z', + updatedBy: 'User One', + url: '/grafana/dashboards/f/folder-uid/test-folder', + version: 2, + parents: [ + { + title: 'Parent Folder', + uid: 'parent-uid', + url: '/grafana/dashboards/f/parent-uid/parent-folder', + }, + ], + }); + }); +}); + +function getResponseAttributes() { + return { + status: QueryStatus.fulfilled, + isUninitialized: false, + isLoading: false, + isFetching: false, + isSuccess: true, + isError: false, + error: undefined, + refetch: jest.fn(), + }; +} diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts new file mode 100644 index 0000000000000..12049ce199f1b --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -0,0 +1,180 @@ +import { QueryStatus, skipToken } from '@reduxjs/toolkit/query'; + +import { config } from '@grafana/runtime'; +import { FolderDTO } from 'app/types/folders'; + +import kbn from '../../../../core/utils/kbn'; +import { + AnnoKeyCreatedBy, + AnnoKeyFolder, + AnnoKeyManagerKind, + AnnoKeyUpdatedBy, + AnnoKeyUpdatedTimestamp, + DeprecatedInternalId, + ManagerKind, +} from '../../../../features/apiserver/types'; +import { useGetFolderQuery as useGetFolderQueryLegacy } from '../../../../features/browse-dashboards/api/browseDashboardsAPI'; +import { GENERAL_FOLDER_UID } from '../../../../features/search/constants'; +import { useGetDisplayMappingQuery } from '../../iam/v0alpha1'; + +import { rootFolder, sharedWithMeFolder } from './virtualFolders'; + +import { useGetFolderQuery, useGetFolderParentsQuery, useGetFolderAccessQuery } from './index'; + +function getFolderUrl(uid: string, title: string): string { + // mimics https://github.com/grafana/grafana/blob/79fe8a9902335c7a28af30e467b904a4ccfac503/pkg/services/dashboards/models.go#L188 + // Not the same slugify as on the backend https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L86 + // Probably does not matter as it seems to be only for better human readability. + const slug = kbn.slugifyForUrl(title); + return `${config.appSubUrl}/dashboards/f/${uid}/${slug}`; +} + +/** + * A proxy function that uses either legacy folder client or the new app platform APIs to get the data in the same + * format of a FolderDTO object. As the schema isn't the same, using the app platform needs multiple different calls + * which are then stitched together. + * @param uid + */ +export function useGetFolderQueryFacade(uid?: string) { + if (config.featureToggles.foldersAppPlatformAPI) { + const isVirtualFolder = uid && [GENERAL_FOLDER_UID, config.sharedWithMeFolderUID].includes(uid); + const params = !uid ? skipToken : { name: uid }; + + let resultFolder = useGetFolderQuery(isVirtualFolder ? skipToken : params); + + // For virtual folders we simulate the response with hardcoded data. + if (isVirtualFolder) { + resultFolder = { + ...resultFolder, + status: QueryStatus.fulfilled, + fulfilledTimeStamp: Date.now(), + isUninitialized: false, + error: undefined, + isError: false, + isSuccess: true, + isLoading: false, + isFetching: false, + data: GENERAL_FOLDER_UID === uid ? rootFolder : sharedWithMeFolder, + currentData: GENERAL_FOLDER_UID === uid ? rootFolder : sharedWithMeFolder, + }; + } + + // We get parents and folders for virtual folders too. Parents should just return empty array but it's easier to + // stitch the responses this way and access can actually return different response based on the grafana setup. + const resultParents = useGetFolderParentsQuery(params); + const resultAccess = useGetFolderAccessQuery(params); + + // Load users info if needed. + const userKeys = getUserKeys(resultFolder); + const needsUserData = !isVirtualFolder && Boolean(userKeys.length); + const resultUserDisplay = useGetDisplayMappingQuery(needsUserData ? { key: userKeys } : skipToken); + + // Stitch together the responses to create a single FolderDTO object so on the outside this behaves as the legacy + // api client. + let newData: FolderDTO | undefined = undefined; + if ( + resultFolder.data && + resultParents.data && + resultAccess.data && + (needsUserData ? resultUserDisplay.data : true) + ) { + const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy]; + const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy]; + + newData = { + canAdmin: resultAccess.data.canAdmin, + canDelete: resultAccess.data.canDelete, + canEdit: resultAccess.data.canEdit, + canSave: resultAccess.data.canSave, + accessControl: resultAccess.data.accessControl, + created: resultFolder.data.metadata.creationTimestamp || '0001-01-01T00:00:00Z', + createdBy: + (createdBy && + resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(createdBy)]?.displayName) || + 'Anonymous', + // Does not seem like this is set to true in the legacy API + hasAcl: false, + id: parseInt(resultFolder.data.metadata.labels?.[DeprecatedInternalId] || '0', 10) || 0, + parentUid: resultFolder.data.metadata.annotations?.[AnnoKeyFolder], + managedBy: resultFolder.data.metadata.annotations?.[AnnoKeyManagerKind] as ManagerKind, + + title: resultFolder.data.spec.title, + uid: resultFolder.data.metadata.name!, + updated: resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedTimestamp] || '0001-01-01T00:00:00Z', + updatedBy: + (updatedBy && + resultUserDisplay.data?.display[resultUserDisplay.data?.keys.indexOf(updatedBy)]?.displayName) || + 'Anonymous', + // Seems like this annotation is not populated + // url: result.data.metadata.annotations?.[AnnoKeyFolderUrl] || '', + // general folder does not come with url + // see https://github.com/grafana/grafana/blob/8a05378ef3ae5545c6f7429eae5c174d3c0edbfe/pkg/services/folder/folderimpl/folder_unifiedstorage.go#L88 + url: + uid === GENERAL_FOLDER_UID + ? '' + : getFolderUrl(resultFolder.data.metadata.name!, resultFolder.data.spec.title!), + version: resultFolder.data.metadata.generation || 1, + }; + + if (resultParents.data.items?.length) { + newData.parents = resultParents.data.items + .filter((i) => i.name !== resultFolder.data!.metadata.name) + .map((i) => ({ + title: i.title, + uid: i.name, + // No idea how to make slug, on the server it uses a go lib: https://github.com/grafana/grafana/blob/aac66e91198004bc044754105e18bfff8fbfd383/pkg/infra/slugify/slugify.go#L56 + // Don't think slug is needed for the URL to work though + url: getFolderUrl(i.name, i.title), + })); + } + } + + // Wrap the stitched data into single RTK query response type object so this looks like a single API call + return { + ...resultFolder, + ...combinedState(resultFolder, resultParents, resultAccess, resultUserDisplay, needsUserData), + refetch: async () => { + return Promise.all([ + resultFolder.refetch(), + resultParents.refetch(), + resultAccess.refetch(), + // TODO: Not sure about this, if we refetch this but the response from result change and this is dependant on + // that result what are we refetching here? Maybe this is redundant. + resultUserDisplay.refetch(), + ]); + }, + data: newData, + }; + } else { + return useGetFolderQueryLegacy(uid || skipToken); + } +} + +function combinedState( + result: ReturnType, + resultParents: ReturnType, + resultAccess: ReturnType, + resultUserDisplay: ReturnType, + needsUserData: boolean +) { + const results = needsUserData + ? [result, resultParents, resultAccess, resultUserDisplay] + : [result, resultParents, resultAccess]; + return { + isLoading: results.some((r) => r.isLoading), + isFetching: results.some((r) => r.isFetching), + isSuccess: results.every((r) => r.isSuccess), + isError: results.some((r) => r.isError), + // Only one error will be shown. TODO: somehow create a single error out of them? + error: results.find((r) => r.error), + }; +} + +function getUserKeys(resultFolder: ReturnType): string[] { + return resultFolder.data + ? [ + resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy], + resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy], + ].filter((v) => v !== undefined) + : []; +} diff --git a/public/app/api/clients/folder/v1beta1/index.ts b/public/app/api/clients/folder/v1beta1/index.ts index eed6f857ce762..70e2087121047 100644 --- a/public/app/api/clients/folder/v1beta1/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -1,8 +1,28 @@ +import { PAGE_SIZE } from '../../../../features/browse-dashboards/api/services'; +import { refetchChildren } from '../../../../features/browse-dashboards/state/actions'; +import { GENERAL_FOLDER_UID } from '../../../../features/search/constants'; + import { generatedAPI } from './endpoints.gen'; -export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({}); +export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({ + endpoints: { + deleteFolder: { + onQueryStarted: (args, { queryFulfilled, dispatch }) => { + // Refetch for anything using the old API. + queryFulfilled.then(() => { + dispatch( + refetchChildren({ + parentUID: GENERAL_FOLDER_UID, + pageSize: PAGE_SIZE, + }) + ); + }); + }, + }, + }, +}); -export const { useGetFolderQuery } = folderAPIv1beta1; +export const { useGetFolderQuery, useGetFolderParentsQuery, useGetFolderAccessQuery } = folderAPIv1beta1; // eslint-disable-next-line no-barrel-files/no-barrel-files export { type Folder, type FolderList } from './endpoints.gen'; diff --git a/public/app/api/clients/folder/v1beta1/virtualFolders.ts b/public/app/api/clients/folder/v1beta1/virtualFolders.ts new file mode 100644 index 0000000000000..90d6d011aa4b0 --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/virtualFolders.ts @@ -0,0 +1,39 @@ +export const rootFolder = { + kind: 'Folder', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: { + name: 'general', + namespace: 'org-0', + uid: 'DvhY6m059FraHn96xsOKsb8GRtHy2ftVDUPkqZTzP4kX', + resourceVersion: '-62135596800000', + creationTimestamp: undefined, + annotations: { + 'grafana.app/updatedTimestamp': '0001-01-01T00:00:00Z', + }, + }, + spec: { + title: 'Dashboards', + description: '', + }, + status: {}, +}; + +export const sharedWithMeFolder = { + kind: 'Folder', + apiVersion: 'folder.grafana.app/v1beta1', + metadata: { + name: 'sharedwithme', + namespace: 'org-0', + uid: 'DlDSzXw31VwXu6LHMw0JMoFvfVtYzyf3NEPzsOXHtxQX', + resourceVersion: '-62135596800000', + creationTimestamp: undefined, + annotations: { + 'grafana.app/updatedTimestamp': '0001-01-01T00:00:00Z', + }, + }, + spec: { + title: 'Shared with me', + description: 'Dashboards and folders shared with me', + }, + status: {}, +}; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index b1ceef4d2e714..34ae17eda6fa2 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -1,6 +1,5 @@ import { css } from '@emotion/css'; import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; -import { skipToken } from '@reduxjs/toolkit/query'; import debounce from 'debounce-promise'; import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import * as React from 'react'; @@ -9,7 +8,7 @@ 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 { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; import { QueryResponse } from 'app/features/search/service/types'; @@ -71,7 +70,7 @@ export function NestedFolderPicker({ onChange, }: NestedFolderPickerProps) { const styles = useStyles2(getStyles); - const selectedFolder = useGetFolderQuery(value || skipToken); + const selectedFolder = useGetFolderQueryFacade(value); const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); diff --git a/public/app/features/alerting/unified/hooks/useFolder.ts b/public/app/features/alerting/unified/hooks/useFolder.ts index 804c9c12b2569..a7a58dd182d19 100644 --- a/public/app/features/alerting/unified/hooks/useFolder.ts +++ b/public/app/features/alerting/unified/hooks/useFolder.ts @@ -1,6 +1,4 @@ -import { skipToken } from '@reduxjs/toolkit/query/react'; - -import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { FolderDTO } from 'app/types/folders'; interface ReturnBag { @@ -13,7 +11,7 @@ interface ReturnBag { * @TODO propagate error state */ export function useFolder(uid?: string): ReturnBag { - const fetchFolderState = useGetFolderQuery(uid || skipToken); + const fetchFolderState = useGetFolderQueryFacade(uid); return { loading: fetchFolderState.isLoading, diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index f37973f740109..23c2e1ca9fb0e 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -1,5 +1,4 @@ import { css } from '@emotion/css'; -import { skipToken } from '@reduxjs/toolkit/query'; import { memo, useEffect, useMemo } from 'react'; import { useLocation, useParams } from 'react-router-dom-v5-compat'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -8,6 +7,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { config, reportInteraction } from '@grafana/runtime'; import { LinkButton, FilterInput, useStyles2, Text, Stack } from '@grafana/ui'; +import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { useDispatch } from 'app/types/store'; @@ -19,7 +19,7 @@ import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel'; import { useSearchStateManager } from '../search/state/SearchStateManager'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; -import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI'; +import { useSaveFolderMutation } from './api/browseDashboardsAPI'; import { BrowseActions } from './components/BrowseActions/BrowseActions'; import { BrowseFilters } from './components/BrowseFilters'; import { BrowseView } from './components/BrowseView'; @@ -70,7 +70,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record { if (!folderDTO) { @@ -90,7 +90,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record ({ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({ useDeleteRepositoryFilesWithPathMutation: jest.fn(), - provisioningAPI: { + provisioningAPIv0alpha1: { endpoints: { listRepository: { select: jest.fn(() => () => ({ data: { items: [] } })), diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index dcb798a9749c1..9d7da7221c9e5 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; +import { AppEvents } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { folderAPIv1beta1 } from 'app/api/clients/folder/v1beta1'; import { Permissions } from 'app/core/components/AccessControl'; import { appEvents } from 'app/core/core'; import { ShowModalReactEvent } from 'app/types/events'; @@ -25,7 +27,9 @@ export function FolderActionsButton({ folder }: Props) { const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false); const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false); const [moveFolder] = useMoveFolderMutation(); - const [deleteFolder] = useDeleteFolderMutation(); + + const [deleteFolderAppPlatform] = folderAPIv1beta1.useDeleteFolderMutation(); + const [deleteFolderLegacy] = useDeleteFolderMutation(); const { canEditFolders, canDeleteFolders, canViewPermissions, canSetPermissions } = getFolderPermissions(folder); const isProvisionedFolder = folder.managedBy === ManagerKind.Repo; @@ -44,7 +48,23 @@ export function FolderActionsButton({ folder }: Props) { }; const onDelete = async () => { - await deleteFolder(folder); + const result = await (config.featureToggles.foldersAppPlatformAPI + ? deleteFolderAppPlatform({ name: folder.uid }) + : deleteFolderLegacy(folder)); + + if (result.error) { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'browse-dashboards.folder-actions-button.delete-folder-error', + 'Error deleting folder. Please try again later.' + ), + ], + }); + return; + } + reportInteraction('grafana_manage_dashboards_item_deleted', { item_counts: { folder: 1, diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 26030620753c3..df1505f50d7b8 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -2,6 +2,7 @@ import { PayloadAction } from '@reduxjs/toolkit'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +import { GENERAL_FOLDER_UID } from '../../search/constants'; import { isSharedWithMe } from '../components/utils'; import { BrowseDashboardsState } from '../types'; @@ -23,7 +24,7 @@ export function refetchChildrenFulfilled(state: BrowseDashboardsState, action: R isFullyLoaded: kind === 'dashboard' && lastPageOfKind, }; - if (parentUID) { + if (parentUID && parentUID !== GENERAL_FOLDER_UID) { state.childrenByParentUID[parentUID] = newCollection; } else { state.rootItems = newCollection; diff --git a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx index b3eee40069f58..0745b59ec99ee 100644 --- a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx +++ b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx @@ -7,7 +7,7 @@ import { Trans } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { CellProps, Stack, Text, Icon, useStyles2 } from '@grafana/ui'; import { getSvgSize } from '@grafana/ui/internal'; -import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; +import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks'; import { LocalPlugin } from '../../plugins/admin/types'; import { useGetDashboardByUidQuery, useGetLibraryElementByUidQuery } from '../api'; @@ -121,7 +121,7 @@ function FolderInfo({ data }: { data: ResourceTableItem }) { const folderUID = data.refId; const skipApiCall = !!data.name && !!data.parentName; - const { data: folderData, isLoading, isError } = useGetFolderQuery(folderUID, { skip: skipApiCall }); + const { data: folderData, isLoading, isError } = useGetFolderQueryFacade(skipApiCall ? undefined : folderUID); const folderName = data.name || folderData?.title; const folderParentName = data.parentName || folderData?.parents?.[folderData.parents.length - 1]?.title; diff --git a/public/app/features/provisioning/utils/selectors.ts b/public/app/features/provisioning/utils/selectors.ts index 1e88f0b9530b1..ea9737d724387 100644 --- a/public/app/features/provisioning/utils/selectors.ts +++ b/public/app/features/provisioning/utils/selectors.ts @@ -1,9 +1,8 @@ import { createSelector } from '@reduxjs/toolkit'; +import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from 'app/api/clients/provisioning/v0alpha1'; import { RootState } from 'app/store/configureStore'; -import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from '../../../api/clients/provisioning/v0alpha1'; - const emptyRepos: Repository[] = []; const baseSelector = provisioningAPI.endpoints.listRepository.select({}); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e0453c80fb6b0..71776e459a020 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3551,6 +3551,7 @@ }, "folder-actions-button": { "delete": "Delete", + "delete-folder-error": "Error deleting folder. Please try again later.", "folder-actions": "Folder actions", "manage-permissions": "Manage permissions", "move": "Move"