diff --git a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts index 6da13af3efe..4d5b1982434 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -78,6 +78,36 @@ const getFolderHandler = () => }); }); -const handlers = [listFoldersHandler(), getFolderHandler()]; +const createFolderHandler = () => + http.post('/api/folders', async ({ request }) => { + const body = await request.json(); + if (!body || !body.title) { + return HttpResponse.json({ message: 'folder title cannot be empty' }, { status: 400 }); + } + const random = Chance(body.title); + const uid = random.string({ length: 10 }); + const id = random.integer({ min: 1, max: 1000 }); + + return HttpResponse.json({ + id, + uid: uid, + orgId: 1, + title: body.title, + url: `/dashboards/f/${uid}/${body.title}`, + hasAcl: false, + canSave: true, + canEdit: true, + canAdmin: true, + canDelete: true, + parentUid: body.parentUid, + createdBy: 'admin', + created: '2025-08-26T12:19:27+01:00', + updatedBy: 'admin', + updated: '2025-08-26T12:19:27+01:00', + version: 1, + }); + }); + +const handlers = [listFoldersHandler(), getFolderHandler(), createFolderHandler()]; export default handlers; diff --git a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts index 3823e539ba3..f53261a9b97 100644 --- a/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/folder.grafana.app/v1beta1/handlers.ts @@ -1,9 +1,18 @@ +import { Chance } from 'chance'; import { HttpResponse, http } from 'msw'; import { wellFormedTree } from '../../../../fixtures/folders'; +import { getErrorResponse } from '../../../helpers'; const [mockTree] = wellFormedTree(); +const baseResponse = { + kind: 'Folder', + apiVersion: 'folder.grafana.app/v1beta1', +}; + +const folderNotFoundError = getErrorResponse('folder not found', 404); + const getFolderHandler = () => http.get<{ folderUid: string; namespace: string }>( '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid', @@ -14,22 +23,11 @@ const getFolderHandler = () => }); if (!response) { - return HttpResponse.json( - { - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'folder not found', - code: 404, - }, - { status: 404 } - ); + return HttpResponse.json(folderNotFoundError, { status: 404 }); } return HttpResponse.json({ - kind: 'Folder', - apiVersion: 'folder.grafana.app/v1beta1', + ...baseResponse, metadata: { name: response.item.uid, namespace, @@ -63,14 +61,7 @@ const getFolderParentsHandler = () => return item.kind === 'folder' && item.uid === folderUid; }); if (!folder || folder.item.kind !== 'folder') { - return HttpResponse.json({ - kind: 'Status', - apiVersion: 'v1', - metadata: {}, - status: 'Failure', - message: 'folder not found', - code: 404, - }); + return HttpResponse.json(folderNotFoundError, { status: 404 }); } const findParents = (parents: Array<(typeof mockTree)[number]>, folderUid?: string) => { @@ -106,12 +97,59 @@ const getFolderParentsHandler = () => } return HttpResponse.json({ + ...baseResponse, kind: 'FolderInfoList', - apiVersion: 'folder.grafana.app/v1beta1', metadata: {}, items: mapped, }); } ); -export default [getFolderHandler(), getFolderParentsHandler()]; +// TODO: Pull this from common API types rather than partially redefining here +type PartialFolderPayload = { spec: { title: string }; metadata: { annotations: Record } }; + +const createFolderHandler = () => + http.post<{ namespace: string }, PartialFolderPayload>( + '/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders', + async ({ params, request }) => { + const { namespace } = params; + const body = await request.json(); + const title = body?.spec?.title; + if (!body || !title) { + return HttpResponse.json(getErrorResponse('folder title cannot be empty', 400), { status: 400 }); + } + + const parentUid = body?.metadata?.annotations?.['grafana.app/folder']; + const random = Chance(title); + const name = random.string({ length: 10 }); + const uid = random.string({ length: 45 }); + const id = random.integer({ min: 1, max: 1000 }); + + return HttpResponse.json({ + ...baseResponse, + metadata: { + name, + namespace, + uid, + resourceVersion: '1756207979831', + generation: 1, + creationTimestamp: '2025-08-26T11:32:59Z', + labels: { + 'grafana.app/deprecatedInternalID': id, + }, + annotations: { + 'grafana.app/createdBy': 'user:1', + 'grafana.app/folder': parentUid, + 'grafana.app/updatedBy': 'user:1', + 'grafana.app/updatedTimestamp': '2025-08-26T11:32:59Z', + }, + }, + spec: { + title, + description: '', + }, + status: {}, + }); + } + ); +export default [getFolderHandler(), getFolderParentsHandler(), createFolderHandler()]; diff --git a/packages/grafana-test-utils/src/handlers/helpers.ts b/packages/grafana-test-utils/src/handlers/helpers.ts new file mode 100644 index 00000000000..5803deaf65b --- /dev/null +++ b/packages/grafana-test-utils/src/handlers/helpers.ts @@ -0,0 +1,10 @@ +export const getErrorResponse = (message: string, code: number) => { + return { + kind: 'Status', + apiVersion: 'v1', + metadata: {}, + status: 'Failure', + message, + code, + }; +}; diff --git a/public/app/api/clients/folder/v1beta1/hooks.test.ts b/public/app/api/clients/folder/v1beta1/hooks.test.ts index 5c7a4a2bf61..99f74fe7512 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.test.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.test.ts @@ -1,4 +1,4 @@ -import { renderHook, getWrapper, waitFor } from 'test/test-utils'; +import { renderHook, getWrapper, waitFor, screen } from 'test/test-utils'; import { AppEvents } from '@grafana/data'; import { config, setBackendSrv } from '@grafana/runtime'; @@ -8,6 +8,7 @@ import { backendSrv } from 'app/core/services/backend_srv'; import { useDeleteFoldersMutation as useDeleteFoldersMutationLegacy } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { useGetFolderQueryFacade, useDeleteMultipleFoldersMutationFacade } from './hooks'; +import { setupCreateFolder } from './test-utils'; import { useDeleteFolderMutation } from './index'; @@ -185,3 +186,27 @@ describe('useDeleteMultipleFoldersMutationFacade', () => { expect(mockDeleteFolderLegacy).toHaveBeenCalledWith({ folderUIDs }); }); }); + +describe('useCreateFolder', () => { + describe.each([ + // app platform + true, + // legacy + false, + ])('folderAppPlatformAPI toggle set to: %s', (toggle) => { + beforeEach(() => { + config.featureToggles.foldersAppPlatformAPI = toggle; + }); + afterEach(() => { + config.featureToggles = originalToggles; + }); + + it('creates a folder', async () => { + const { user } = setupCreateFolder(); + + await user.click(screen.getByText('Create Folder')); + + expect(await screen.findByText('Folder created')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index 323b9205533..c8f5e279dea 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -4,12 +4,15 @@ import { useEffect, useMemo } from 'react'; import { AppEvents } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getAppEvents } from '@grafana/runtime'; +import { useAppNotification } from 'app/core/copy/appNotification'; import { useDeleteFolderMutation as useDeleteFolderMutationLegacy, useGetFolderQuery as useGetFolderQueryLegacy, useDeleteFoldersMutation as useDeleteFoldersMutationLegacy, + useNewFolderMutation as useLegacyNewFolderMutation, } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { FolderDTO } from 'app/types/folders'; +import { dispatch } from 'app/store/store'; +import { FolderDTO, NewFolder } from 'app/types/folders'; import kbn from '../../../../core/utils/kbn'; import { @@ -30,7 +33,24 @@ import { useLazyGetDisplayMappingQuery } from '../../iam/v0alpha1'; import { isProvisionedFolderCheck } from './utils'; import { rootFolder, sharedWithMeFolder } from './virtualFolders'; -import { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } from './index'; +import { + useGetFolderQuery, + useGetFolderParentsQuery, + useDeleteFolderMutation, + useCreateFolderMutation, + Folder, + CreateFolderApiArg, +} 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, + }) + ); +} function getFolderUrl(uid: string, title: string): string { // mimics https://github.com/grafana/grafana/blob/79fe8a9902335c7a28af30e467b904a4ccfac503/pkg/services/dashboards/models.go#L188 @@ -106,36 +126,23 @@ export function useGetFolderQueryFacade(uid?: string) { const updatedBy = resultFolder.data.metadata.annotations?.[AnnoKeyUpdatedBy]; const createdBy = resultFolder.data.metadata.annotations?.[AnnoKeyCreatedBy]; + const parsed = appPlatformFolderToLegacyFolder(resultFolder.data); + 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], - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - 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, + ...parsed, }; if (resultParents.data.items?.length) { @@ -165,7 +172,7 @@ export function useGetFolderQueryFacade(uid?: string) { export function useDeleteFolderMutationFacade() { const [deleteFolder] = useDeleteFolderMutation(); const [deleteFolderLegacy] = useDeleteFolderMutationLegacy(); - const dispatch = useDispatch(); + const notify = useAppNotification(); return async (folder: FolderDTO) => { if (config.featureToggles.foldersAppPlatformAPI) { @@ -174,18 +181,10 @@ export function useDeleteFolderMutationFacade() { // 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, - }) - ); + dispatchRefetchChildren(folder.parentUid); // 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')], - }); + notify.success(t('folders.api.folder-deleted-success', 'Folder deleted')); } return result; } else { @@ -226,6 +225,42 @@ export function useDeleteMultipleFoldersMutationFacade() { }; } +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; +} + function combinedState( result: ReturnType, resultParents: ReturnType, @@ -254,3 +289,29 @@ function getUserKeys(resultFolder: ReturnType): string ].filter((v) => v !== undefined) : []; } + +const appPlatformFolderToLegacyFolder = ( + folder: Folder +): Omit => { + // 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, + }; +}; diff --git a/public/app/api/clients/folder/v1beta1/index.ts b/public/app/api/clients/folder/v1beta1/index.ts index 748159f1b46..984c6f173ea 100644 --- a/public/app/api/clients/folder/v1beta1/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -21,7 +21,8 @@ export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({ }, }); -export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation } = folderAPIv1beta1; +export const { useGetFolderQuery, useGetFolderParentsQuery, useDeleteFolderMutation, useCreateFolderMutation } = + folderAPIv1beta1; // eslint-disable-next-line no-barrel-files/no-barrel-files -export { type Folder, type FolderList } from './endpoints.gen'; +export { type Folder, type FolderList, type CreateFolderApiArg } from './endpoints.gen'; diff --git a/public/app/api/clients/folder/v1beta1/test-utils.tsx b/public/app/api/clients/folder/v1beta1/test-utils.tsx new file mode 100644 index 00000000000..34368bffa80 --- /dev/null +++ b/public/app/api/clients/folder/v1beta1/test-utils.tsx @@ -0,0 +1,23 @@ +import { render } from 'test/test-utils'; + +import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList'; + +import { useCreateFolder } from './hooks'; + +const [_, { folderA }] = getFolderFixtures(); + +const TestCreationComponent = () => { + const [createFolder, result] = useCreateFolder(); + + return ( + <> + + +
{result.isSuccess ? 'Folder created' : 'Error creating folder'}
+ + ); +}; + +/** Renders test component with a button that will create a new folder */ +export const setupCreateFolder = () => render(); diff --git a/public/app/features/alerting/unified/components/create-folder/CreateNewFolder.tsx b/public/app/features/alerting/unified/components/create-folder/CreateNewFolder.tsx index ab29218ebb0..bd8be9665b2 100644 --- a/public/app/features/alerting/unified/components/create-folder/CreateNewFolder.tsx +++ b/public/app/features/alerting/unified/components/create-folder/CreateNewFolder.tsx @@ -5,9 +5,9 @@ import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { Button, Field, Input, Label, Modal, Stack, useStyles2 } from '@grafana/ui'; +import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { useAppNotification } from 'app/core/copy/appNotification'; import { contextSrv } from 'app/core/core'; -import { useNewFolderMutation } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { AccessControlAction } from 'app/types/accessControl'; import { Folder } from '../../types/rule-form'; @@ -49,7 +49,7 @@ function FolderCreationModal({ const notifyApp = useAppNotification(); const [title, setTitle] = useState(''); const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [createFolder] = useNewFolderMutation(); + const [createFolder] = useCreateFolder(); const onSubmit = async () => { setIsCreatingFolder(true); diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 0c91b1414e8..6ada50ad691 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -100,7 +100,6 @@ export const browseDashboardsAPI = createApi({ }), onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => { queryFulfilled.then(async ({ data: folder }) => { - await contextSrv.fetchUserPermissions(); dispatch( refetchChildren({ parentUID: parentUid, diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.tsx index 0eae387ae58..017ec917e75 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.tsx @@ -4,6 +4,7 @@ import { useLocation } from 'react-router-dom-v5-compat'; import { locationUtil } from '@grafana/data'; import { config, locationService, reportInteraction } from '@grafana/runtime'; import { Button, Drawer, Dropdown, Icon, Menu, MenuItem } from '@grafana/ui'; +import { useCreateFolder } from 'app/api/clients/folder/v1beta1/hooks'; import { useAppNotification } from 'app/core/copy/appNotification'; import { RepoType } from 'app/features/provisioning/Wizard/types'; import { NewProvisionedFolderForm } from 'app/features/provisioning/components/Folders/NewProvisionedFolderForm'; @@ -18,7 +19,6 @@ import { import { FolderDTO } from 'app/types/folders'; import { ManagerKind } from '../../apiserver/types'; -import { useNewFolderMutation } from '../api/browseDashboardsAPI'; import { NewFolderForm } from './NewFolderForm'; @@ -39,7 +39,7 @@ export default function CreateNewButton({ }: Props) { const [isOpen, setIsOpen] = useState(false); const location = useLocation(); - const [newFolder] = useNewFolderMutation(); + const [newFolder] = useCreateFolder(); const [showNewFolderDrawer, setShowNewFolderDrawer] = useState(false); const notifyApp = useAppNotification(); const isProvisionedInstance = useIsProvisionedInstance(); diff --git a/public/app/types/folders.ts b/public/app/types/folders.ts index 5102e1f544d..4762752f5c0 100644 --- a/public/app/types/folders.ts +++ b/public/app/types/folders.ts @@ -32,6 +32,9 @@ export interface FolderDTO extends WithAccessControlMetadata { version?: number; } +/** Minimal data required to create a new folder */ +export type NewFolder = Pick; + export interface FolderState { id: number; uid: string;