2025-08-06 19:39:35 +08:00
import { QueryStatus , skipToken } from '@reduxjs/toolkit/query' ;
2025-08-29 23:17:33 +08:00
import { useEffect , useMemo } from 'react' ;
2025-08-06 19:39:35 +08:00
import { AppEvents } from '@grafana/data' ;
import { t } from '@grafana/i18n' ;
import { config , getAppEvents } from '@grafana/runtime' ;
2025-09-01 20:09:09 +08:00
import { useAppNotification } from 'app/core/copy/appNotification' ;
2025-08-06 19:39:35 +08:00
import {
useDeleteFolderMutation as useDeleteFolderMutationLegacy ,
useGetFolderQuery as useGetFolderQueryLegacy ,
2025-08-25 19:55:46 +08:00
useDeleteFoldersMutation as useDeleteFoldersMutationLegacy ,
2025-09-01 20:09:09 +08:00
useNewFolderMutation as useLegacyNewFolderMutation ,
2025-09-02 18:41:29 +08:00
useMoveFoldersMutation as useMoveFoldersMutationLegacy ,
2025-09-03 18:29:26 +08:00
useSaveFolderMutation as useLegacySaveFolderMutation ,
2025-09-02 18:41:29 +08:00
MoveFoldersArgs ,
DeleteFoldersArgs ,
2025-08-06 19:39:35 +08:00
} from 'app/features/browse-dashboards/api/browseDashboardsAPI' ;
2025-09-01 20:09:09 +08:00
import { dispatch } from 'app/store/store' ;
import { FolderDTO , NewFolder } from 'app/types/folders' ;
2025-08-06 19:39:35 +08:00
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' ;
2025-08-25 19:55:46 +08:00
import { refetchChildren , refreshParents } from '../../../../features/browse-dashboards/state/actions' ;
2025-08-06 19:39:35 +08:00
import { GENERAL_FOLDER_UID } from '../../../../features/search/constants' ;
import { useDispatch } from '../../../../types/store' ;
2025-08-29 23:17:33 +08:00
import { useLazyGetDisplayMappingQuery } from '../../iam/v0alpha1' ;
2025-08-06 19:39:35 +08:00
2025-08-25 19:55:46 +08:00
import { isProvisionedFolderCheck } from './utils' ;
2025-08-06 19:39:35 +08:00
import { rootFolder , sharedWithMeFolder } from './virtualFolders' ;
2025-09-01 20:09:09 +08:00
import {
useGetFolderQuery ,
useGetFolderParentsQuery ,
useDeleteFolderMutation ,
useCreateFolderMutation ,
2025-09-02 18:41:29 +08:00
useUpdateFolderMutation ,
2025-09-01 20:09:09 +08:00
Folder ,
CreateFolderApiArg ,
2025-09-03 18:29:26 +08:00
useReplaceFolderMutation ,
ReplaceFolderApiArg ,
2025-09-01 20:09:09 +08:00
} from './index' ;
/** Trigger necessary actions to ensure legacy folder stores are updated */
function dispatchRefetchChildren ( parentUID? : string ) {
dispatch (
refetchChildren ( {
parentUID : parentUID || GENERAL_FOLDER_UID ,
pageSize : PAGE_SIZE ,
} )
) ;
}
2025-08-06 19:39:35 +08:00
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 ) {
2025-08-29 23:17:33 +08:00
const shouldUseAppPlatformAPI = Boolean ( config . featureToggles . foldersAppPlatformAPI ) ;
const isVirtualFolder = uid && [ GENERAL_FOLDER_UID , config . sharedWithMeFolderUID ] . includes ( uid ) ;
const params = ! uid ? skipToken : { name : uid } ;
2025-08-06 19:39:35 +08:00
// 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 ) ;
2025-08-29 23:17:33 +08:00
let resultFolder = useGetFolderQuery ( shouldUseAppPlatformAPI && ! isVirtualFolder ? params : skipToken ) ;
// 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 ( shouldUseAppPlatformAPI ? params : skipToken ) ;
const [ triggerGetUserDisplayMapping , resultUserDisplay ] = useLazyGetDisplayMappingQuery ( ) ;
2025-08-06 19:39:35 +08:00
2025-08-29 23:17:33 +08:00
const needsUserData = useMemo ( ( ) = > {
const userKeys = getUserKeys ( resultFolder ) ;
return ! isVirtualFolder && Boolean ( userKeys . length ) ;
} , [ isVirtualFolder , resultFolder ] ) ;
2025-08-06 19:39:35 +08:00
2025-08-29 23:17:33 +08:00
useEffect ( ( ) = > {
const userKeys = getUserKeys ( resultFolder ) ;
if ( needsUserData && userKeys . length ) {
triggerGetUserDisplayMapping ( { key : userKeys } , true ) ;
}
} , [ needsUserData , resultFolder , triggerGetUserDisplayMapping ] ) ;
2025-08-06 19:39:35 +08:00
2025-08-29 23:17:33 +08:00
if ( ! shouldUseAppPlatformAPI ) {
return legacyFolderResult ;
}
2025-08-06 19:39:35 +08:00
// 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 ,
} ;
}
// 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 ] ;
2025-09-01 20:09:09 +08:00
const parsed = appPlatformFolderToLegacyFolder ( resultFolder . data ) ;
2025-08-06 19:39:35 +08:00
newData = {
canAdmin : legacyFolderResult.data.canAdmin ,
canDelete : legacyFolderResult.data.canDelete ,
canEdit : legacyFolderResult.data.canEdit ,
canSave : legacyFolderResult.data.canSave ,
accessControl : legacyFolderResult.data.accessControl ,
2025-09-01 20:09:09 +08:00
2025-08-06 19:39:35 +08:00
createdBy :
( createdBy && resultUserDisplay . data ? . display [ resultUserDisplay . data ? . keys . indexOf ( createdBy ) ] ? . displayName ) ||
'Anonymous' ,
2025-09-01 20:09:09 +08:00
2025-08-06 19:39:35 +08:00
updatedBy :
( updatedBy && resultUserDisplay . data ? . display [ resultUserDisplay . data ? . keys . indexOf ( updatedBy ) ] ? . displayName ) ||
'Anonymous' ,
2025-09-01 20:09:09 +08:00
. . . parsed ,
2025-08-06 19:39:35 +08:00
} ;
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 ( ) = > {
2025-08-29 23:17:33 +08:00
return Promise . all ( [ resultFolder . refetch ( ) , resultParents . refetch ( ) , legacyFolderResult . refetch ( ) ] ) ;
2025-08-06 19:39:35 +08:00
} ,
data : newData ,
} ;
}
export function useDeleteFolderMutationFacade() {
const [ deleteFolder ] = useDeleteFolderMutation ( ) ;
const [ deleteFolderLegacy ] = useDeleteFolderMutationLegacy ( ) ;
2025-09-01 20:09:09 +08:00
const notify = useAppNotification ( ) ;
2025-08-06 19:39:35 +08:00
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.
2025-09-01 20:09:09 +08:00
dispatchRefetchChildren ( folder . parentUid ) ;
2025-08-06 19:39:35 +08:00
// 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.
2025-09-01 20:09:09 +08:00
notify . success ( t ( 'folders.api.folder-deleted-success' , 'Folder deleted' ) ) ;
2025-08-06 19:39:35 +08:00
}
return result ;
} else {
return deleteFolderLegacy ( folder ) ;
}
} ;
}
2025-08-25 19:55:46 +08:00
export function useDeleteMultipleFoldersMutationFacade() {
const [ deleteFolders ] = useDeleteFoldersMutationLegacy ( ) ;
const [ deleteFolder ] = useDeleteFolderMutation ( ) ;
const dispatch = useDispatch ( ) ;
if ( ! config . featureToggles . foldersAppPlatformAPI ) {
return deleteFolders ;
}
2025-09-02 18:41:29 +08:00
return async function deleteFolders ( { folderUIDs } : DeleteFoldersArgs ) {
const successMessage = t ( 'folders.api.folder-deleted-success' , 'Folder deleted' ) ;
2025-08-25 19:55:46 +08:00
// Delete all the folders sequentially
// TODO error handling here
for ( const folderUID of folderUIDs ) {
// This also shows warning alert
2025-09-02 18:41:29 +08:00
const isProvisioned = await isProvisionedFolderCheck ( dispatch , folderUID ) ;
if ( ! isProvisioned ) {
const result = await deleteFolder ( { name : folderUID } ) ;
if ( ! result . error ) {
// 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 : [ successMessage ] ,
} ) ;
}
2025-08-25 19:55:46 +08:00
}
2025-09-02 18:41:29 +08:00
}
dispatch ( refreshParents ( folderUIDs ) ) ;
return { data : undefined } ;
} ;
}
export function useMoveMultipleFoldersMutationFacade() {
const moveFoldersLegacyResult = useMoveFoldersMutationLegacy ( ) ;
const [ updateFolder , updateFolderData ] = useUpdateFolderMutation ( ) ;
const dispatch = useDispatch ( ) ;
if ( ! config . featureToggles . foldersAppPlatformAPI ) {
return moveFoldersLegacyResult ;
}
async function moveFolders ( { folderUIDs , destinationUID } : MoveFoldersArgs ) {
const provisionedWarning = t (
'folders.api.folder-move-error-provisioned' ,
'Cannot move provisioned folder. To move it, move it in the repository and synchronise to apply the changes.'
) ;
const successMessage = t ( 'folders.api.folder-moved-success' , 'Folder moved' ) ;
// Move all the folders sequentially one by one
for ( const folderUID of folderUIDs ) {
// isProvisionedFolderCheck also shows a warning alert
const isFolderProvisioned = await isProvisionedFolderCheck ( dispatch , folderUID , { warning : provisionedWarning } ) ;
// If provisioned, we just skip this folder
if ( ! isFolderProvisioned ) {
const result = await updateFolder ( {
name : folderUID ,
patch : { metadata : { annotations : { [ AnnoKeyFolder ] : destinationUID } } } ,
2025-08-25 19:55:46 +08:00
} ) ;
2025-09-02 18:41:29 +08:00
if ( ! result . error ) {
getAppEvents ( ) . publish ( {
type : AppEvents . alertSuccess . name ,
payload : [ successMessage ] ,
} ) ;
}
2025-08-25 19:55:46 +08:00
}
}
2025-09-02 18:41:29 +08:00
// Refresh the state of the parent folders to update the UI after folders are moved
dispatch (
refetchChildren ( {
parentUID : destinationUID ,
pageSize : PAGE_SIZE ,
} )
) ;
dispatch ( refreshParents ( folderUIDs ) ) ;
2025-08-25 19:55:46 +08:00
return { data : undefined } ;
2025-09-02 18:41:29 +08:00
}
return [ moveFolders , updateFolderData ] as const ;
2025-08-25 19:55:46 +08:00
}
2025-09-01 20:09:09 +08:00
export function useCreateFolder() {
const [ createFolder , result ] = useCreateFolderMutation ( ) ;
const legacyHook = useLegacyNewFolderMutation ( ) ;
if ( ! config . featureToggles . foldersAppPlatformAPI ) {
return legacyHook ;
}
const createFolderAppPlatform = async ( folder : NewFolder ) = > {
const payload : CreateFolderApiArg = {
folder : {
spec : {
title : folder.title ,
} ,
metadata : {
generateName : 'f' ,
annotations : {
. . . ( folder . parentUid && { [ AnnoKeyFolder ] : folder . parentUid } ) ,
} ,
} ,
status : { } ,
} ,
} ;
const result = await createFolder ( payload ) ;
dispatchRefetchChildren ( folder . parentUid ) ;
return {
. . . result ,
data : result.data ? appPlatformFolderToLegacyFolder ( result . data ) : undefined ,
} ;
} ;
return [ createFolderAppPlatform , result ] as const ;
}
2025-09-03 18:29:26 +08:00
export function useUpdateFolder() {
const [ updateFolder , result ] = useReplaceFolderMutation ( ) ;
const legacyHook = useLegacySaveFolderMutation ( ) ;
if ( ! config . featureToggles . foldersAppPlatformAPI ) {
return legacyHook ;
}
const updateFolderAppPlatform = async ( folder : Pick < FolderDTO , ' uid ' | ' title ' | ' version ' | ' parentUid ' > ) = > {
const payload : ReplaceFolderApiArg = {
name : folder.uid ,
folder : {
spec : { title : folder.title } ,
metadata : {
name : folder.uid ,
} ,
status : { } ,
} ,
} ;
const result = await updateFolder ( payload ) ;
dispatchRefetchChildren ( folder . parentUid ) ;
return {
. . . result ,
data : result.data ? appPlatformFolderToLegacyFolder ( result . data ) : undefined ,
} ;
} ;
return [ updateFolderAppPlatform , result ] as const ;
}
2025-08-06 19:39:35 +08:00
function combinedState (
result : ReturnType < typeof useGetFolderQuery > ,
resultParents : ReturnType < typeof useGetFolderParentsQuery > ,
resultLegacyFolder : ReturnType < typeof useGetFolderQueryLegacy > ,
2025-08-29 23:17:33 +08:00
resultUserDisplay : ReturnType < typeof useLazyGetDisplayMappingQuery > [ 1 ] ,
2025-08-06 19:39:35 +08:00
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 )
: [ ] ;
}
2025-09-01 20:09:09 +08:00
const appPlatformFolderToLegacyFolder = (
folder : Folder
) : Omit < FolderDTO , ' parents ' | ' canSave ' | ' canEdit ' | ' canAdmin ' | ' canDelete ' | ' createdBy ' | ' updatedBy ' > = > {
// Omits properties that we can't easily get solely from the app platform response
// In some cases, these properties aren't used on the response of the hook,
// so it's best to discourage from using them anyway
const { annotations , name = '' , creationTimestamp , generation , labels } = folder . metadata ;
const { title = '' } = folder . spec ;
return {
id : parseInt ( labels ? . [ DeprecatedInternalId ] || '0' , 10 ) || 0 ,
uid : name ,
title ,
// general folder does not come with url
// see https://github.com/grafana/grafana/blob/8a05378ef3ae5545c6f7429eae5c174d3c0edbfe/pkg/services/folder/folderimpl/folder_unifiedstorage.go#L88
url : name === GENERAL_FOLDER_UID ? '' : getFolderUrl ( name , title ) ,
created : creationTimestamp || '0001-01-01T00:00:00Z' ,
updated : annotations?. [ AnnoKeyUpdatedTimestamp ] || '0001-01-01T00:00:00Z' ,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
managedBy : annotations?. [ AnnoKeyManagerKind ] as ManagerKind ,
parentUid : annotations?. [ AnnoKeyFolder ] ,
version : generation || 1 ,
hasAcl : false ,
} ;
} ;