diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 00a787b4cb6..ce3182d28f4 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 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 747eb499cdf..be3f30adeda 100644 --- a/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/folders/handlers.ts @@ -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; 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 732a57c24ea..122497813dc 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 @@ -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(), +]; diff --git a/public/app/api/clients/folder/v1beta1/hooks.ts b/public/app/api/clients/folder/v1beta1/hooks.ts index e79e3bab4c5..90b47bc708c 100644 --- a/public/app/api/clients/folder/v1beta1/hooks.ts +++ b/public/app/api/clients/folder/v1beta1/hooks.ts @@ -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) { + 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[0] + | Parameters[0] = { + folderUIDs, + dashboardUIDs, + }; + + const legacyResult = useLegacyGetAffectedItemsQuery(!shouldUseAppPlatformAPI ? hookParams : skipToken); + const appPlatformResult = useGetAffectedItemsQuery(shouldUseAppPlatformAPI ? hookParams : skipToken); + + return shouldUseAppPlatformAPI ? appPlatformResult : legacyResult; +} + function combinedState( result: ReturnType, resultParents: ReturnType, diff --git a/public/app/api/clients/folder/v1beta1/index.ts b/public/app/api/clients/folder/v1beta1/index.ts index 38477c6c852..cefd69c4955 100644 --- a/public/app/api/clients/folder/v1beta1/index.ts +++ b/public/app/api/clients/folder/v1beta1/index.ts @@ -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, { 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 diff --git a/public/app/api/clients/folder/v1beta1/utils.ts b/public/app/api/clients/folder/v1beta1/utils.ts index 19e16b624c0..27655554361 100644 --- a/public/app/api/clients/folder/v1beta1/utils.ts +++ b/public/app/api/clients/folder/v1beta1/utils.ts @@ -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 = { + 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); +}; diff --git a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts index 4eb6aff8dca..c99119c8684 100644 --- a/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts +++ b/public/app/features/browse-dashboards/api/browseDashboardsAPI.ts @@ -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({ + getAffectedItems: builder.query({ // 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(`/api/folders/${folderUID}/counts`); + }); + const results = await Promise.all(promises); - const promises = folderUIDs.map((folderUID) => { - return getBackendSrv().get(`/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 }; }, }), diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx index 187f0eb1543..bcd489477ad 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/DeleteModal.tsx @@ -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 () => { diff --git a/public/app/features/browse-dashboards/components/BrowseActions/DescendantCount.tsx b/public/app/features/browse-dashboards/components/BrowseActions/DescendantCount.tsx index 44402318048..424ae3097b6 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/DescendantCount.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/DescendantCount.tsx @@ -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 ? ( { /> ) : ( - {data && buildBreakdownString(data.folder, data.dashboard, data.libraryPanel, data.alertRule)} + {data && buildBreakdownString(data.folders, data.dashboards, data.library_elements, data.alertrules)} {(isFetching || isLoading) && } ); diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx index c941cd76150..1f7b53b8511 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.test.tsx @@ -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(); + 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(); + + expect( + await screen.findByRole('status', { name: 'Moving this item may change its permissions.' }) + ).toBeInTheDocument(); + }); + + it('displays summary of affected items', async () => { + render(); + + 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(); + + expect(await screen.findByRole('alert', { name: /unable to retrieve/i })).toBeInTheDocument(); + }); + }); }); it('enables the `Move` button once a folder is selected', async () => { diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx index c710524f34e..dc7c576134d 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx @@ -55,7 +55,7 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro - + {} export interface FolderInfo { /**