Folders: Create folder using app platform APIs (#110166)

This commit is contained in:
Tom Ratcliffe
2025-09-01 13:09:09 +01:00
committed by GitHub
parent db924493f2
commit 66a99d0ae8
11 changed files with 252 additions and 62 deletions
@@ -78,6 +78,36 @@ const getFolderHandler = () =>
});
});
const handlers = [listFoldersHandler(), getFolderHandler()];
const createFolderHandler = () =>
http.post<never, { title: string; parentUid?: string }>('/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;
@@ -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<string, string> } };
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()];
@@ -0,0 +1,10 @@
export const getErrorResponse = (message: string, code: number) => {
return {
kind: 'Status',
apiVersion: 'v1',
metadata: {},
status: 'Failure',
message,
code,
};
};
@@ -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();
});
});
});
+91 -30
View File
@@ -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<typeof useGetFolderQuery>,
resultParents: ReturnType<typeof useGetFolderParentsQuery>,
@@ -254,3 +289,29 @@ function getUserKeys(resultFolder: ReturnType<typeof useGetFolderQuery>): string
].filter((v) => v !== undefined)
: [];
}
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,
};
};
@@ -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';
@@ -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 (
<>
<AppNotificationList />
<button onClick={() => createFolder({ title: 'test', parentUid: folderA.item.uid })}>Create Folder</button>
<div>{result.isSuccess ? 'Folder created' : 'Error creating folder'}</div>
</>
);
};
/** Renders test component with a button that will create a new folder */
export const setupCreateFolder = () => render(<TestCreationComponent />);
@@ -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);
@@ -100,7 +100,6 @@ export const browseDashboardsAPI = createApi({
}),
onQueryStarted: ({ parentUid }, { queryFulfilled, dispatch }) => {
queryFulfilled.then(async ({ data: folder }) => {
await contextSrv.fetchUserPermissions();
dispatch(
refetchChildren({
parentUID: parentUid,
@@ -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();
+3
View File
@@ -32,6 +32,9 @@ export interface FolderDTO extends WithAccessControlMetadata {
version?: number;
}
/** Minimal data required to create a new folder */
export type NewFolder = Pick<FolderDTO, 'title' | 'parentUid'>;
export interface FolderState {
id: number;
uid: string;