Folders: Refactor hooks to (eventually) consume app platform `/counts` endpoint (#110894)

This commit is contained in:
Tom Ratcliffe 2025-09-17 12:46:59 +01:00 committed by GitHub
parent 4f43761630
commit 3ea093b596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 329 additions and 104 deletions

View File

@ -1901,11 +1901,6 @@
"count": 1
}
},
"public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx": {
"no-restricted-syntax": {
"count": 1
}
},
"public/app/features/browse-dashboards/components/NewFolderForm.tsx": {
"no-restricted-syntax": {
"count": 1

View File

@ -3,7 +3,7 @@ import { HttpResponse, http } from 'msw';
import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders';
const [mockTree] = wellFormedTree();
const [mockTree, { folderB }] = wellFormedTree();
const [mockTreeThatViewersCanEdit] = treeViewersCanEdit();
const collator = new Intl.Collator();
@ -122,6 +122,38 @@ const saveFolderHandler = () =>
return HttpResponse.json({ ...folder.item, title: body.title });
});
const handlers = [listFoldersHandler(), getFolderHandler(), createFolderHandler(), saveFolderHandler()];
const getMockFolderCounts = (folder: number, dashboard: number, librarypanel: number, alertrule: number) => {
return {
folder,
dashboard,
librarypanel,
alertrule,
};
};
const folderCountsHandler = () =>
http.get<{ uid: string }, { title: string; version: number }>('/api/folders/:uid/counts', async ({ params }) => {
const { uid } = params;
const folder = mockTree.find((v) => v.item.uid === uid);
if (!folder) {
// The legacy API returns 0's for a folder that doesn't exist 🤷‍♂️
return HttpResponse.json(getMockFolderCounts(0, 0, 0, 0));
}
if (uid === folderB.item.uid) {
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json(getMockFolderCounts(1, 1, 1, 1));
});
const handlers = [
listFoldersHandler(),
getFolderHandler(),
createFolderHandler(),
saveFolderHandler(),
folderCountsHandler(),
];
export default handlers;

View File

@ -4,7 +4,7 @@ import { HttpResponse, http } from 'msw';
import { wellFormedTree } from '../../../../fixtures/folders';
import { getErrorResponse } from '../../../helpers';
const [mockTree] = wellFormedTree();
const [mockTree, { folderB }] = wellFormedTree();
const baseResponse = {
kind: 'Folder',
@ -164,4 +164,67 @@ const replaceFolderHandler = () =>
return HttpResponse.json(appPlatformFolder);
}
);
export default [getFolderHandler(), getFolderParentsHandler(), createFolderHandler(), replaceFolderHandler()];
const getMockFolderCounts = (folders: number, dashboards: number, library_elements: number, alertrules: number) => {
return {
kind: 'DescendantCounts',
apiVersion: 'folder.grafana.app/v1beta1',
counts: [
{
group: 'dashboard.grafana.app',
resource: 'dashboards',
count: dashboards,
},
{
group: 'sql-fallback',
resource: 'alertrules',
count: alertrules,
},
{
group: 'sql-fallback',
resource: 'dashboards',
count: dashboards,
},
{
group: 'sql-fallback',
resource: 'folders',
count: folders,
},
{
group: 'sql-fallback',
resource: 'library_elements',
count: library_elements,
},
],
};
};
const folderCountsHandler = () =>
http.get<{ folderUid: string; namespace: string }, PartialFolderPayload>(
'/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders/:folderUid/counts',
async ({ params }) => {
const { folderUid } = params;
const matchedFolder = mockTree.find(({ item }) => {
return item.uid === folderUid;
});
if (!matchedFolder) {
// The API returns 0's for a folder that doesn't exist 🤷‍♂️
return HttpResponse.json(getMockFolderCounts(0, 0, 0, 0));
}
if (folderUid === folderB.item.uid) {
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json(getMockFolderCounts(1, 1, 1, 1));
}
);
export default [
getFolderHandler(),
getFolderParentsHandler(),
createFolderHandler(),
replaceFolderHandler(),
folderCountsHandler(),
];

View File

@ -13,10 +13,12 @@ import {
useMoveFoldersMutation as useMoveFoldersMutationLegacy,
useSaveFolderMutation as useLegacySaveFolderMutation,
useMoveFolderMutation as useMoveFolderMutationLegacy,
useGetAffectedItemsQuery as useLegacyGetAffectedItemsQuery,
MoveFoldersArgs,
DeleteFoldersArgs,
MoveFolderArgs,
} from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { DashboardTreeSelection } from 'app/features/browse-dashboards/types';
import { FolderDTO, NewFolder } from 'app/types/folders';
import kbn from '../../../../core/utils/kbn';
@ -49,6 +51,7 @@ import {
CreateFolderApiArg,
useReplaceFolderMutation,
ReplaceFolderApiArg,
useGetAffectedItemsQuery,
} from './index';
function getFolderUrl(uid: string, title: string): string {
@ -393,6 +396,26 @@ function useRefreshFolders() {
};
}
export function useGetAffectedItems({ folder, dashboard }: Pick<DashboardTreeSelection, 'folder' | 'dashboard'>) {
const folderUIDs = Object.keys(folder).filter((uid) => folder[uid]);
const dashboardUIDs = Object.keys(dashboard).filter((uid) => dashboard[uid]);
// TODO: Remove constant condition here once we have a solution for the app platform counts
// As of now, the counts are not calculated recursively, so we need to use the legacy API
const shouldUseAppPlatformAPI = false && Boolean(config.featureToggles.foldersAppPlatformAPI);
const hookParams:
| Parameters<typeof useLegacyGetAffectedItemsQuery>[0]
| Parameters<typeof useGetAffectedItemsQuery>[0] = {
folderUIDs,
dashboardUIDs,
};
const legacyResult = useLegacyGetAffectedItemsQuery(!shouldUseAppPlatformAPI ? hookParams : skipToken);
const appPlatformResult = useGetAffectedItemsQuery(shouldUseAppPlatformAPI ? hookParams : skipToken);
return shouldUseAppPlatformAPI ? appPlatformResult : legacyResult;
}
function combinedState(
result: ReturnType<typeof useGetFolderQuery>,
resultParents: ReturnType<typeof useGetFolderParentsQuery>,

View File

@ -1,43 +1,91 @@
import { generatedAPI } from './endpoints.gen';
import { DescendantCount } from 'app/types/folders';
export const folderAPIv1beta1 = generatedAPI.enhanceEndpoints({
endpoints: {
getFolder: {
providesTags: (result, error, arg) => (result ? [{ type: 'Folder', id: arg.name }] : []),
import { generatedAPI } from './endpoints.gen';
import { getParsedCounts } from './utils';
export const folderAPIv1beta1 = generatedAPI
.enhanceEndpoints({
endpoints: {
getFolder: {
providesTags: (result, error, arg) => (result ? [{ type: 'Folder', id: arg.name }] : []),
},
listFolder: {
providesTags: (result) =>
result
? [
{ type: 'Folder', id: 'LIST' },
...result.items
.map((folder) => ({ type: 'Folder' as const, id: folder.metadata?.name }))
.filter(Boolean),
]
: [{ type: 'Folder', id: 'LIST' }],
},
deleteFolder: {
// We don't want delete to invalidate getFolder tags, as that would lead to unnecessary 404s
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Folder', id: 'LIST' }]),
},
updateFolder: {
query: (queryArg) => ({
url: `/folders/${queryArg.name}`,
method: 'PATCH',
// We need to stringify the body and set the correct header for the call to work with k8s api.
body: JSON.stringify(queryArg.patch),
headers: {
'Content-Type': 'application/strategic-merge-patch+json',
},
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
force: queryArg.force,
},
}),
},
},
listFolder: {
providesTags: (result) =>
result
? [
{ type: 'Folder', id: 'LIST' },
...result.items.map((folder) => ({ type: 'Folder' as const, id: folder.metadata?.name })).filter(Boolean),
]
: [{ type: 'Folder', id: 'LIST' }],
},
deleteFolder: {
// We don't want delete to invalidate getFolder tags, as that would lead to unnecessary 404s
invalidatesTags: (result, error) => (error ? [] : [{ type: 'Folder', id: 'LIST' }]),
},
updateFolder: {
query: (queryArg) => ({
url: `/folders/${queryArg.name}`,
method: 'PATCH',
// We need to stringify the body and set the correct header for the call to work with k8s api.
body: JSON.stringify(queryArg.patch),
headers: {
'Content-Type': 'application/strategic-merge-patch+json',
},
params: {
pretty: queryArg.pretty,
dryRun: queryArg.dryRun,
fieldManager: queryArg.fieldManager,
fieldValidation: queryArg.fieldValidation,
force: queryArg.force,
})
.injectEndpoints({
endpoints: (builder) => ({
getAffectedItems: builder.query<Record<string, number>, { folderUIDs: string[]; dashboardUIDs: string[] }>({
// Similar to legacy API, don't cache this data as we don't have full knowledge of the descendant entities
// and when they're created/deleted, so we can't easily know when this data is stale
keepUnusedDataFor: 0,
queryFn: async ({ folderUIDs, dashboardUIDs }, queryApi) => {
const initialCounts: DescendantCount = {
folders: folderUIDs.length,
dashboards: dashboardUIDs.length,
library_elements: 0,
alertrules: 0,
};
const promises = folderUIDs.map(async (folderUID) =>
queryApi.dispatch(generatedAPI.endpoints.getFolderCounts.initiate({ name: folderUID }))
);
try {
const results = await Promise.all(promises);
const mapped = results.reduce((acc, result) => {
const { data, error } = result;
if (error) {
throw error;
}
const counts = getParsedCounts(data?.counts ?? []);
acc.folders += counts.folders;
acc.dashboards += counts.dashboards;
acc.alertrules += counts.alertrules;
acc.library_elements += counts.library_elements;
return acc;
}, initialCounts);
return { data: mapped };
} catch (error) {
return { error };
}
},
}),
},
},
});
}),
});
export const {
useGetFolderQuery,
@ -46,6 +94,7 @@ export const {
useCreateFolderMutation,
useUpdateFolderMutation,
useReplaceFolderMutation,
useGetAffectedItemsQuery,
} = folderAPIv1beta1;
// eslint-disable-next-line no-barrel-files/no-barrel-files

View File

@ -6,6 +6,8 @@ import appEvents from '../../../../core/app_events';
import { isProvisionedFolder } from '../../../../features/browse-dashboards/api/isProvisioned';
import { useDispatch } from '../../../../types/store';
import { ResourceStats } from './endpoints.gen';
import { folderAPIv1beta1 as folderAPI } from './index';
export async function isProvisionedFolderCheck(
@ -34,3 +36,34 @@ export async function isProvisionedFolderCheck(
return false;
}
}
const initialCounts: Record<string, number> = {
folder: 0,
dashboard: 0,
libraryPanel: 0,
alertRule: 0,
};
/**
* Parses descendant counts into legacy-friendly format
*
* Takes the first count information as the source of truth, e.g. if
* the array has a
*
* `"group": "dashboard.grafana.app"`
*
* entry first, and a
*
* `"group": "sql-fallback"`
*
* entry later, the `dashboard.grafana.app` count will be used
*/
export const getParsedCounts = (counts: ResourceStats[]) => {
return counts.reduce((acc, { resource, count }) => {
// If there's no value already, then use that count, so a fallback count is not used
if (!acc[resource]) {
acc[resource] = count;
}
return acc;
}, initialCounts);
};

View File

@ -1,6 +1,6 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { AppEvents, isTruthy, locationUtil } from '@grafana/data';
import { AppEvents, locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
@ -22,7 +22,6 @@ import { FolderListItemDTO, FolderDTO, DescendantCount, DescendantCountDTO } fro
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { deletedDashboardsCache } from '../../search/service/deletedDashboardsCache';
import { refetchChildren, refreshParents } from '../state/actions';
import { DashboardTreeSelection } from '../types';
import { isProvisionedDashboard } from './isProvisioned';
import { PAGE_SIZE } from './services';
@ -191,33 +190,34 @@ export const browseDashboardsAPI = createApi({
}),
// gets the descendant counts for a folder. used in the move/delete modals.
getAffectedItems: builder.query<DescendantCount, DashboardTreeSelection>({
getAffectedItems: builder.query<DescendantCount, { folderUIDs: string[]; dashboardUIDs: string[] }>({
// don't cache this data for now, since library panel/alert rule creation isn't done through rtk query
keepUnusedDataFor: 0,
queryFn: async (selectedItems) => {
const folderUIDs = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
queryFn: async ({ folderUIDs, dashboardUIDs }) => {
try {
const promises = folderUIDs.map((folderUID) => {
return getBackendSrv().get<DescendantCountDTO>(`/api/folders/${folderUID}/counts`);
});
const results = await Promise.all(promises);
const promises = folderUIDs.map((folderUID) => {
return getBackendSrv().get<DescendantCountDTO>(`/api/folders/${folderUID}/counts`);
});
const totalCounts: DescendantCount = {
folders: folderUIDs.length,
dashboards: dashboardUIDs.length,
library_elements: 0,
alertrules: 0,
};
const results = await Promise.all(promises);
for (const folderCounts of results) {
totalCounts.folders += folderCounts.folder;
totalCounts.dashboards += folderCounts.dashboard;
totalCounts.alertrules += folderCounts.alertrule;
totalCounts.library_elements += folderCounts.librarypanel;
}
const totalCounts = {
folder: Object.values(selectedItems.folder).filter(isTruthy).length,
dashboard: Object.values(selectedItems.dashboard).filter(isTruthy).length,
libraryPanel: 0,
alertRule: 0,
};
for (const folderCounts of results) {
totalCounts.folder += folderCounts.folder;
totalCounts.dashboard += folderCounts.dashboard;
totalCounts.alertRule += folderCounts.alertrule;
totalCounts.libraryPanel += folderCounts.librarypanel;
return { data: totalCounts };
} catch (error) {
return { error };
}
return { data: totalCounts };
},
}),

View File

@ -3,8 +3,8 @@ import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime';
import { Alert, ConfirmModal, Text, Space } from '@grafana/ui';
import { useGetAffectedItems } from 'app/api/clients/folder/v1beta1/hooks';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types';
import { DescendantCount } from './DescendantCount';
@ -17,8 +17,8 @@ export interface Props {
}
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const { data } = useGetAffectedItemsQuery(selectedItems);
const deleteIsInvalid = Boolean(data && (data.alertRule || data.libraryPanel));
const { data } = useGetAffectedItems(selectedItems);
const deleteIsInvalid = Boolean(data && (data.alertrules || data.library_elements));
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {

View File

@ -2,8 +2,8 @@ import Skeleton from 'react-loading-skeleton';
import { t } from '@grafana/i18n';
import { Alert, Text } from '@grafana/ui';
import { useGetAffectedItems } from 'app/api/clients/folder/v1beta1/hooks';
import { useGetAffectedItemsQuery } from '../../api/browseDashboardsAPI';
import { DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
@ -13,7 +13,7 @@ export interface Props {
}
export const DescendantCount = ({ selectedItems }: Props) => {
const { data, isFetching, isLoading, error } = useGetAffectedItemsQuery(selectedItems);
const { data, isFetching, isLoading, error } = useGetAffectedItems(selectedItems);
return error ? (
<Alert
@ -25,7 +25,7 @@ export const DescendantCount = ({ selectedItems }: Props) => {
/>
) : (
<Text element="p" color="secondary">
{data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)}
{data && buildBreakdownString(data.folders, data.dashboards, data.library_elements, data.alertrules)}
{(isFetching || isLoading) && <Skeleton width={200} />}
</Text>
);

View File

@ -1,18 +1,19 @@
import { HttpResponse, http } from 'msw';
import { render, screen } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { MoveModal, Props } from './MoveModal';
const [_, { folderA }] = getFolderFixtures();
const [_, { folderA, folderB }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
const originalToggles = { ...config.featureToggles };
describe('browse-dashboards MoveModal', () => {
const mockOnDismiss = jest.fn();
const mockOnConfirm = jest.fn();
@ -20,17 +21,6 @@ describe('browse-dashboards MoveModal', () => {
window.HTMLElement.prototype.scrollIntoView = () => {};
beforeEach(() => {
server.use(
http.get('/api/folders/:uid/counts', () => {
return HttpResponse.json({
folder: 1,
dashboard: 2,
librarypanel: 3,
alertrule: 4,
});
})
);
props = {
isOpen: true,
onConfirm: mockOnConfirm,
@ -68,15 +58,53 @@ describe('browse-dashboards MoveModal', () => {
expect(await screen.findByRole('button', { name: 'Select folder' })).toBeInTheDocument();
});
it('displays a warning about permissions if a folder is selected', async () => {
props.selectedItems.folder = {
myFolderUid: true,
};
render(<MoveModal {...props} />);
describe('when a folder is selected', () => {
describe.each([
// app platform
true,
// legacy
false,
])('with foldersAppPlatformAPI set to %s', (toggle) => {
beforeEach(() => {
props.selectedItems.folder = {
[folderA.item.uid]: true,
};
config.featureToggles.foldersAppPlatformAPI = toggle;
});
afterEach(() => {
config.featureToggles = originalToggles;
});
expect(
await screen.findByRole('status', { name: 'Moving this item may change its permissions.' })
).toBeInTheDocument();
it('displays a warning about permissions if a folder is selected', async () => {
render(<MoveModal {...props} />);
expect(
await screen.findByRole('status', { name: 'Moving this item may change its permissions.' })
).toBeInTheDocument();
});
it('displays summary of affected items', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByText(/This action will move the following content/i)).toBeInTheDocument();
expect(await screen.findByText(/5 item/)).toBeInTheDocument();
expect(screen.getByText(/2 folder/)).toBeInTheDocument();
expect(screen.getByText(/1 dashboard/)).toBeInTheDocument();
expect(screen.getByText(/1 library panel/)).toBeInTheDocument();
expect(screen.getByText(/1 alert rule/)).toBeInTheDocument();
});
it('shows an error if one of the folder counts cannot be fetched', async () => {
props.selectedItems.folder = {
[folderA.item.uid]: true,
[folderB.item.uid]: true,
};
render(<MoveModal {...props} />);
expect(await screen.findByRole('alert', { name: /unable to retrieve/i })).toBeInTheDocument();
});
});
});
it('enables the `Move` button once a folder is selected', async () => {

View File

@ -55,7 +55,7 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<Space v={3} />
<Field label={t('browse-dashboards.action.move-modal-field-label', 'Folder name')}>
<Field noMargin label={t('browse-dashboards.action.move-modal-field-label', 'Folder name')}>
<ProvisioningAwareFolderPicker
value={moveTarget}
excludeUIDs={selectedFolders}

View File

@ -47,6 +47,11 @@ export interface FolderState {
version: number;
}
/**
* API response from `/api/folders/${folderUID}/counts`
* @deprecated The properties here are inconsistently named with App Platform API responses.
* Avoid using this type as it will be removed after app platform folder migration is complete
*/
export interface DescendantCountDTO {
folder: number;
dashboard: number;
@ -54,12 +59,9 @@ export interface DescendantCountDTO {
alertrule: number;
}
export interface DescendantCount {
folder: number;
dashboard: number;
libraryPanel: number;
alertRule: number;
}
type DescendantResource = 'folders' | 'dashboards' | 'library_elements' | 'alertrules';
/** Summary of descendant counts by resource type, with keys matching the App Platform API response */
export interface DescendantCount extends Record<DescendantResource, number> {}
export interface FolderInfo {
/**