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:
Andrej Ocenas
2025-08-06 13:39:35 +02:00
committed by GitHub
parent 102d230321
commit 85e9bcaa2e
17 changed files with 502 additions and 33 deletions
+3
View File
@@ -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"]
+2 -2
View File
@@ -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 {
+24 -10
View File
@@ -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[] = [];
+4
View File
@@ -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": {