Folders: Migrate getFolder API to app platform (#107617)
* Add /children endpoint * Update folder client * Add comment * Add feature toggle * Add new version of useFoldersQuery * Error handling * Format * Rename feature toggle * Remove options and move root folder constant * Fix feature toggle merge * Add feature toggle again * Rename useFoldersQuery files * Update API spec * Fix test * Add test * Migrate delete folder button * useGetFolderQueryFacade * Use getFolder facade hook * Recreate legacy getFolder from the APIs * Fix imports * Add comment * Rename function * Simulate virtual folders in the API client * Translations * Update test * Move the hook out of the index file * Fix undefined in test * Better status combining * Use real access api for virtual folders * Add basic test for the hook * Remove commented import * Remove the access control api and use legacy api for it * Update tests * Moved delete folder into facade hook * Remove namespace attribute from virtual folders * go lint --------- Co-authored-by: Clarity-89 <homes89@ukr.net>
This commit is contained in:
@@ -863,6 +863,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"]
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
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 {
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
@@ -44,17 +47,28 @@ 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)
|
||||
if name == folder.GeneralFolderUID || name == folder.SharedWithMeFolderUID {
|
||||
responder.Object(http.StatusOK, &folders.FolderInfoList{
|
||||
Items: []folders.FolderInfo{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := r.getter.Get(ctx, name, &metav1.GetOptions{})
|
||||
if storage.IsNotFound(err) {
|
||||
responder.Object(http.StatusNotFound, nil)
|
||||
}
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
}
|
||||
|
||||
folderObj, ok := obj.(*folders.Folder)
|
||||
if !ok {
|
||||
responder.Error(fmt.Errorf("expecting folder, found: %T", folderObj))
|
||||
}
|
||||
|
||||
info := r.parents(ctx, folderObj)
|
||||
// Start from the root
|
||||
slices.Reverse(info.Items)
|
||||
responder.Object(http.StatusOK, info)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { QueryStatus } from '@reduxjs/toolkit/query';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useGetFolderQuery as useGetFolderQueryLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
|
||||
import {
|
||||
AnnoKeyCreatedBy,
|
||||
AnnoKeyFolder,
|
||||
AnnoKeyManagerKind,
|
||||
AnnoKeyUpdatedBy,
|
||||
AnnoKeyUpdatedTimestamp,
|
||||
DeprecatedInternalId,
|
||||
} from '../../../../features/apiserver/types';
|
||||
import { useGetDisplayMappingQuery } from '../../iam/v0alpha1';
|
||||
|
||||
import { useGetFolderQueryFacade } from './hooks';
|
||||
|
||||
import { useGetFolderQuery, useGetFolderParentsQuery } from './index';
|
||||
|
||||
// Mocks for the hooks used inside useGetFolderQueryFacade
|
||||
jest.mock('./index', () => ({
|
||||
useGetFolderQuery: jest.fn(),
|
||||
useGetFolderParentsQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('app/features/browse-dashboards/api/browseDashboardsAPI', () => ({
|
||||
useGetFolderQuery: 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 mockLegacyResponse = {
|
||||
data: {
|
||||
id: 1,
|
||||
uid: 'uiduiduid',
|
||||
orgId: 1,
|
||||
title: 'bar',
|
||||
url: '/dashboards/f/uiduiduid/bar',
|
||||
hasAcl: false,
|
||||
canSave: true,
|
||||
canEdit: true,
|
||||
canAdmin: true,
|
||||
canDelete: true,
|
||||
createdBy: 'Anonymous',
|
||||
created: '2025-07-14T12:07:36+02:00',
|
||||
updatedBy: 'Anonymous',
|
||||
updated: '2025-07-15T18:01:36+02:00',
|
||||
version: 1,
|
||||
accessControl: {
|
||||
'dashboards.permissions:write': true,
|
||||
'dashboards:create': true,
|
||||
},
|
||||
},
|
||||
...getResponseAttributes(),
|
||||
};
|
||||
|
||||
const mockUserDisplay = {
|
||||
data: {
|
||||
keys: ['user-1', 'user-2'],
|
||||
display: [{ displayName: 'User One' }, { displayName: 'User Two' }],
|
||||
},
|
||||
...getResponseAttributes(),
|
||||
};
|
||||
|
||||
describe('useGetFolderQueryFacade', () => {
|
||||
const oldToggleValue = config.featureToggles.foldersAppPlatformAPI;
|
||||
|
||||
afterAll(() => {
|
||||
config.featureToggles.foldersAppPlatformAPI = oldToggleValue;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(useGetFolderQuery as jest.Mock).mockReturnValue(mockFolder);
|
||||
(useGetFolderParentsQuery as jest.Mock).mockReturnValue(mockParents);
|
||||
(useGetDisplayMappingQuery as jest.Mock).mockReturnValue(mockUserDisplay);
|
||||
(useGetFolderQueryLegacy as jest.Mock).mockReturnValue(mockLegacyResponse);
|
||||
});
|
||||
|
||||
it('merges multiple responses into a single FolderDTO-like object if flag is true', () => {
|
||||
config.featureToggles.foldersAppPlatformAPI = true;
|
||||
const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid'));
|
||||
expect(result.current.data).toMatchObject({
|
||||
canAdmin: true,
|
||||
canDelete: true,
|
||||
canEdit: true,
|
||||
canSave: true,
|
||||
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,
|
||||
accessControl: {
|
||||
'dashboards.permissions:write': true,
|
||||
'dashboards:create': true,
|
||||
},
|
||||
parents: [
|
||||
{
|
||||
title: 'Parent Folder',
|
||||
uid: 'parent-uid',
|
||||
url: '/grafana/dashboards/f/parent-uid/parent-folder',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns legacy folder response if flag is false', () => {
|
||||
config.featureToggles.foldersAppPlatformAPI = false;
|
||||
const { result } = renderHook(() => useGetFolderQueryFacade('folder-uid'));
|
||||
expect(result.current.data).toMatchObject(mockLegacyResponse.data);
|
||||
});
|
||||
});
|
||||
|
||||
function getResponseAttributes() {
|
||||
return {
|
||||
status: QueryStatus.fulfilled,
|
||||
isUninitialized: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isSuccess: true,
|
||||
isError: false,
|
||||
error: undefined,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { QueryStatus, skipToken } from '@reduxjs/toolkit/query';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, getAppEvents } from '@grafana/runtime';
|
||||
import {
|
||||
useDeleteFolderMutation as useDeleteFolderMutationLegacy,
|
||||
useGetFolderQuery as useGetFolderQueryLegacy,
|
||||
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
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 { 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 { useDispatch } from '../../../../types/store';
|
||||
import { useGetDisplayMappingQuery } from '../../iam/v0alpha1';
|
||||
|
||||
import { rootFolder, sharedWithMeFolder } from './virtualFolders';
|
||||
|
||||
import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } 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) {
|
||||
// This may look weird that we call the legacy folder anyway all the time, but the issue is we don't have good API
|
||||
// for the access control metadata yet, and so we still take it from the old api.
|
||||
// see https://github.com/grafana/identity-access-team/issues/1103
|
||||
const legacyFolderResult = useGetFolderQueryLegacy(uid || skipToken);
|
||||
|
||||
if (!config.featureToggles.foldersAppPlatformAPI) {
|
||||
return legacyFolderResult;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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 &&
|
||||
legacyFolderResult.data &&
|
||||
(needsUserData ? resultUserDisplay.data : true)
|
||||
) {
|
||||
const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy];
|
||||
const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy];
|
||||
|
||||
newData = {
|
||||
canAdmin: legacyFolderResult.data.canAdmin,
|
||||
canDelete: legacyFolderResult.data.canDelete,
|
||||
canEdit: legacyFolderResult.data.canEdit,
|
||||
canSave: legacyFolderResult.data.canSave,
|
||||
accessControl: legacyFolderResult.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, legacyFolderResult, resultUserDisplay, needsUserData),
|
||||
refetch: async () => {
|
||||
return Promise.all([
|
||||
resultFolder.refetch(),
|
||||
resultParents.refetch(),
|
||||
legacyFolderResult.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,
|
||||
};
|
||||
}
|
||||
|
||||
export function useDeleteFolderMutationFacade() {
|
||||
const [deleteFolder] = useDeleteFolderMutation();
|
||||
const [deleteFolderLegacy] = useDeleteFolderMutationLegacy();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return async (folder: FolderDTO) => {
|
||||
if (config.featureToggles.foldersAppPlatformAPI) {
|
||||
const result = await deleteFolder({ name: folder.uid });
|
||||
if (!result.error) {
|
||||
// We need to update a legacy version of the folder storage for now until all is in the new API.
|
||||
// we could do it in the enhanceEndpoint method but we would also need to change the args as we need parentUID
|
||||
// here and so it seemed easier to do it here.
|
||||
dispatch(
|
||||
refetchChildren({
|
||||
parentUID: folder.parentUid || GENERAL_FOLDER_UID,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
);
|
||||
// Before this was done in backend srv automatically because the old API sent a message wiht 200 request. see
|
||||
// public/app/core/services/backend_srv.ts#L341-L361. New API does not do that so we do it here.
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: [t('folders.api.folder-deleted-success', 'Folder deleted')],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return deleteFolderLegacy(folder);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function combinedState(
|
||||
result: ReturnType<typeof useGetFolderQuery>,
|
||||
resultParents: ReturnType<typeof useGetFolderParentsQuery>,
|
||||
resultLegacyFolder: ReturnType<typeof useGetFolderQueryLegacy>,
|
||||
resultUserDisplay: ReturnType<typeof useGetDisplayMappingQuery>,
|
||||
needsUserData: boolean
|
||||
) {
|
||||
const results = needsUserData
|
||||
? [result, resultParents, resultLegacyFolder, resultUserDisplay]
|
||||
: [result, resultParents, resultLegacyFolder];
|
||||
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<typeof useGetFolderQuery>): string[] {
|
||||
return resultFolder.data
|
||||
? [
|
||||
resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy],
|
||||
resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy],
|
||||
].filter((v) => v !== undefined)
|
||||
: [];
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { generatedAPI } from './endpoints.gen';
|
||||
|
||||
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({});
|
||||
|
||||
export const { useGetFolderQuery } = folderAPIv1beta1;
|
||||
export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1;
|
||||
|
||||
// eslint-disable-next-line no-barrel-files/no-barrel-files
|
||||
export { type Folder, type FolderList } from './endpoints.gen';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const rootFolder = {
|
||||
kind: 'Folder',
|
||||
apiVersion: 'folder.grafana.app/v1beta1',
|
||||
metadata: {
|
||||
name: 'general',
|
||||
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',
|
||||
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: {},
|
||||
};
|
||||
@@ -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';
|
||||
@@ -8,8 +7,8 @@ import * as React from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { getStatusFromError } from 'app/core/utils/errors';
|
||||
import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
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);
|
||||
// user might not have access to the folder, but they have access to the dashboard
|
||||
// in this case we disable the folder picker - this is an edge case when user has edit access to a dashboard
|
||||
// but doesn't have access to the folder
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string
|
||||
}
|
||||
}, [isSearching, searchState.result, stateManager]);
|
||||
|
||||
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
|
||||
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
|
||||
const [saveFolder] = useSaveFolderMutation();
|
||||
const navModel = useMemo(() => {
|
||||
if (!folderDTO) {
|
||||
@@ -90,7 +90,7 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
|
||||
const hasSelection = useHasSelection();
|
||||
|
||||
// Fetch the root (aka general) folder if we're not in a specific folder
|
||||
const { data: rootFolderDTO } = useGetFolderQuery(folderDTO ? skipToken : 'general');
|
||||
const { data: rootFolderDTO } = useGetFolderQueryFacade(folderDTO ? undefined : 'general');
|
||||
const folder = folderDTO ?? rootFolderDTO;
|
||||
|
||||
const {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Alert } from '@grafana/ui';
|
||||
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
|
||||
|
||||
@@ -12,14 +13,14 @@ import { GRAFANA_RULER_CONFIG } from '../alerting/unified/api/featureDiscoveryAp
|
||||
import { stringifyErrorLike } from '../alerting/unified/utils/misc';
|
||||
import { rulerRuleType } from '../alerting/unified/utils/rules';
|
||||
|
||||
import { useGetFolderQuery, useSaveFolderMutation } from './api/browseDashboardsAPI';
|
||||
import { useSaveFolderMutation } from './api/browseDashboardsAPI';
|
||||
import { FolderActionsButton } from './components/FolderActionsButton';
|
||||
|
||||
const { useRulerNamespaceQuery } = alertRuleApi;
|
||||
|
||||
export function BrowseFolderAlertingPage() {
|
||||
const { uid: folderUID = '' } = useParams();
|
||||
const { data: folderDTO, isLoading: isFolderLoading } = useGetFolderQuery(folderUID);
|
||||
const { data: folderDTO, isLoading: isFolderLoading } = useGetFolderQueryFacade(folderUID);
|
||||
|
||||
const {
|
||||
data: rulerNamespace = {},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui';
|
||||
@@ -8,8 +9,9 @@ import { appEvents } from 'app/core/core';
|
||||
import { ShowModalReactEvent } from 'app/types/events';
|
||||
import { FolderDTO } from 'app/types/folders';
|
||||
|
||||
import { useDeleteFolderMutationFacade } from '../../../api/clients/folder/v1beta1/hooks';
|
||||
import { ManagerKind } from '../../apiserver/types';
|
||||
import { useDeleteFolderMutation, useMoveFolderMutation } from '../api/browseDashboardsAPI';
|
||||
import { useMoveFolderMutation } from '../api/browseDashboardsAPI';
|
||||
import { getFolderPermissions } from '../permissions';
|
||||
|
||||
import { DeleteModal } from './BrowseActions/DeleteModal';
|
||||
@@ -25,7 +27,8 @@ export function FolderActionsButton({ folder }: Props) {
|
||||
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
|
||||
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
|
||||
const [moveFolder] = useMoveFolderMutation();
|
||||
const [deleteFolder] = useDeleteFolderMutation();
|
||||
|
||||
const deleteFolder = useDeleteFolderMutationFacade();
|
||||
|
||||
const { canEditFolders, canDeleteFolders, canViewPermissions, canSetPermissions } = getFolderPermissions(folder);
|
||||
const isProvisionedFolder = folder.managedBy === ManagerKind.Repo;
|
||||
@@ -44,7 +47,21 @@ export function FolderActionsButton({ folder }: Props) {
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
await deleteFolder(folder);
|
||||
const result = await deleteFolder(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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from '../../../api/clients/provisioning/v0alpha1';
|
||||
import { Repository, provisioningAPIv0alpha1 as provisioningAPI } from 'app/api/clients/provisioning/v0alpha1';
|
||||
|
||||
const emptyRepos: Repository[] = [];
|
||||
|
||||
|
||||
@@ -3588,6 +3588,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"
|
||||
@@ -7496,6 +7497,9 @@
|
||||
"badge-tooltip": "Provisioned"
|
||||
},
|
||||
"folders": {
|
||||
"api": {
|
||||
"folder-deleted-success": "Folder deleted"
|
||||
},
|
||||
"get-loading-nav": {
|
||||
"main": {
|
||||
"title": {
|
||||
|
||||
Reference in New Issue
Block a user