Browse Dashboards: Change messaging of delete/move modal and add counts to tabs in folder detail (#124299)

This commit is contained in:
Andrej Ocenas
2026-06-12 14:52:36 +02:00
committed by GitHub
parent 03b4bb680b
commit 102a474753
34 changed files with 597 additions and 390 deletions
@@ -224,8 +224,7 @@ test.describe(
// Delete all selected
await page.getByRole('button', { name: 'Delete' }).click();
// Wait for the delete modal to finish loading folder contents.
// TODO: after #122747 is merged, match the exact count (e.g. /\d+ items?/) instead of /item/
await expect(page.getByText(/item/)).toBeVisible();
await expect(page.getByRole('alert', { name: /contains other resources that will be deleted/i })).toBeVisible();
await page.getByPlaceholder('Type "Delete" to confirm').fill('Delete');
await page.getByTestId(selectors.pages.ConfirmModal.delete).click();
@@ -147,8 +147,8 @@ test.describe(
await folderRow.getByRole('checkbox').click({ force: true });
await page.getByRole('button', { name: 'Delete' }).click();
// TODO: after #122747 is merged, match the exact count (e.g. /\d+ items?/) instead of /item/
await expect(page.getByText(/item/)).toBeVisible();
// Wait for the delete modal to finish loading folder contents.
await expect(page.getByRole('alert', { name: /contains other resources that will be deleted/i })).toBeVisible();
await page.getByPlaceholder('Type "Delete" to confirm').fill('Delete');
await page.getByTestId(selectors.pages.ConfirmModal.delete).click();
@@ -315,6 +315,24 @@ const getFolderListHandler = () =>
export const customCreateFolderHandler = (resolver: HttpResponseResolver) =>
http.post('/apis/folder.grafana.app/v1beta1/namespaces/:namespace/folders', resolver);
const customFolderCountsHandler = (resolver: HttpResponseResolver) =>
http.get('/apis/folder.grafana.app/:version/namespaces/:namespace/folders/:folderUid/counts', resolver);
export const mockFolderCountsHandler = (panels: number, rules: number) =>
customFolderCountsHandler(() =>
HttpResponse.json({
kind: 'DescendantCounts',
apiVersion: 'folder.grafana.app/v1beta1',
counts: [
{ group: 'sql-fallback', resource: 'library_elements', count: panels },
{ group: 'sql-fallback', resource: 'alertrules', count: rules },
],
})
);
export const mockFolderCountsErrorHandler = (status = 500) =>
customFolderCountsHandler(() => HttpResponse.json({ message: 'error' }, { status }));
export default [
getFolderListHandler(),
getFolderHandler(),
@@ -16,5 +16,7 @@ export { customSetTeamRolesHandler } from './handlers/api/access-control/handler
export { customCreateFolderHandler, customFolderCountsHandler } from './handlers/api/folders/handlers';
export { customCreateFolderHandler as customCreateFolderHandlerAppPlatform } from './handlers/apis/folder.grafana.app/v1beta1/handlers';
export * as folderHandlers from './handlers/apis/folder.grafana.app/v1beta1/handlers';
export { setTestFlags, getTestFeatureFlagClient } from './utilities/featureFlags';
export { mockLogger } from './utilities/mockLogger';
@@ -552,9 +552,9 @@ export function useGetAffectedItems({ folder, dashboard }: Pick<DashboardTreeSel
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);
// Note the app platform counts are not calculated recursively, so the two APIs don't report the same numbers for
// nested folders but both are good enough to report whether folder is empty or not.
const shouldUseAppPlatformAPI = Boolean(config.featureToggles.foldersAppPlatformAPI);
const hookParams:
| Parameters<typeof useLegacyGetAffectedItemsQuery>[0]
| Parameters<typeof useGetAffectedItemsQuery>[0] = {
@@ -110,10 +110,10 @@ export const folderAPIv1beta1 = generatedAPI
}
const counts = getParsedCounts(data?.counts ?? []);
acc.folders += counts.folders;
acc.dashboards += counts.dashboards;
acc.alertrules += counts.alertrules;
acc.library_elements += counts.library_elements;
acc.folders += counts.folders ?? 0;
acc.dashboards += counts.dashboards ?? 0;
acc.alertrules += counts.alertrules ?? 0;
acc.library_elements += counts.library_elements ?? 0;
return acc;
}, initialCounts);
@@ -134,6 +134,7 @@ export const {
useUpdateFolderMutation,
useReplaceFolderMutation,
useGetAffectedItemsQuery,
useGetFolderCountsQuery,
} = folderAPIv1beta1;
// eslint-disable-next-line no-barrel-files/no-barrel-files
+21 -25
View File
@@ -36,33 +36,29 @@ export async function isProvisionedFolderCheck(
}
}
const initialCounts: Record<string, number> = {
folder: 0,
dashboard: 0,
libraryPanel: 0,
alertRule: 0,
};
/**
* Parses descendant counts into legacy-friendly format
* Normalizes a descendant counts response into a `{ resource: count }` map.
*
* 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
* The API may return two entries for the same resource — one from the resource's own group
* (e.g. `dashboard.grafana.app`) and one from the `sql-fallback` group. The non-fallback
* entry wins; the fallback is only kept when no other entry exists for that resource.
*/
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;
export const getParsedCounts = (counts: ResourceStats[]): Record<string, number> => {
const result: Record<string, number> = {};
const isFromFallback: Record<string, boolean> = {};
for (const { resource, count, group } of counts) {
const fromFallback = group === 'sql-fallback';
if (
// first time we see this resource count
!(resource in result) ||
// or we have count already, but that count is sql-fallback and now we have non fallback value
(isFromFallback[resource] && !fromFallback)
) {
result[resource] = count;
isFromFallback[resource] = fromFallback;
}
return acc;
}, initialCounts);
}
return result;
};
@@ -9,7 +9,6 @@ import { Trans, t } from '@grafana/i18n';
import { Text, Box, Button, useStyles2, LoadingPlaceholder } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AddPermission } from './AddPermission';
import { PermissionList } from './PermissionList';
@@ -163,16 +162,8 @@ export const Permissions = ({
{canSetPermissions && resource === 'folders' && (
<Box paddingBottom={2}>
<Trans i18nKey="access-control.permissions.permissions-change-warning">
This will change permissions for this folder and all its descendants. In total, this will affect:
This will change permissions for this folder and all its descendants.
</Trans>
<DescendantCount
selectedItems={{
folder: { [resourceId]: true },
dashboard: {},
panel: {},
$all: false,
}}
/>
</Box>
)}
{permissions.value?.length === 0 && (
@@ -313,11 +313,11 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Panels' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Panels' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: /^Panels/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Panels/ })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toHaveAttribute('aria-selected', 'false');
});
it('displays the filters and hides the actions initially', async () => {
@@ -10,12 +10,12 @@ import { config, reportInteraction } from '@grafana/runtime';
import { Drawer, FilterInput, IconButton, useStyles2, Text, Stack } from '@grafana/ui';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/features/browse-dashboards/hooks/useNavModel';
import { useDispatch } from 'app/types/store';
import { FolderRepo } from '../../core/components/NestedFolderPicker/FolderRepo';
import { ManagerKind } from '../apiserver/types';
import { TemplateDashboardModal } from '../dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal';
import { buildNavModel, getDashboardsTabID } from '../folders/state/navModel';
import { ProvisionedFolderPreviewBanner } from '../provisioning/components/Folders/ProvisionedFolderPreviewBanner';
import { RenameProvisionedFolderForm } from '../provisioning/components/Folders/RenameProvisionedFolderForm';
import { OrphanedResourceBanner } from '../provisioning/components/Shared/OrphanedResourceBanner';
@@ -100,22 +100,9 @@ const BrowseDashboardsPage = memo(({ queryParams }: { queryParams: Record<string
}, [isRecentlyViewedEnabled, isExperimentRecentlyViewedDashboards]);
const { data: folderDTO } = useGetFolderQueryFacade(folderUID);
const navModel = useNavModel(folderDTO, 'dashboards');
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {
return undefined;
}
const model = buildNavModel(folderDTO);
// Set the "Dashboards" tab to active
const dashboardsTabID = getDashboardsTabID(folderDTO.uid);
const dashboardsTab = model.children?.find((child) => child.id === dashboardsTabID);
if (dashboardsTab) {
dashboardsTab.active = true;
}
return model;
}, [folderDTO]);
const hasSelection = useHasSelection();
// Fetch the root (aka general) folder if we're not in a specific folder
@@ -75,11 +75,11 @@ describe('browse-dashboards BrowseFolderAlertingPage', () => {
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Panels' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Panels' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: /^Panels/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Panels/ })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toHaveAttribute('aria-selected', 'true');
});
it('displays rules from the folder', async () => {
@@ -1,11 +1,10 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { buildNavModel, getAlertingTabID } from 'app/features/folders/state/navModel';
import { useNavModel } from 'app/features/browse-dashboards/hooks/useNavModel';
import { AlertsFolderView } from '../alerting/unified/AlertsFolderView';
import { alertRuleApi } from '../alerting/unified/api/alertRuleApi';
@@ -32,20 +31,7 @@ export function BrowseFolderAlertingPage() {
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {
return undefined;
}
const model = buildNavModel(folderDTO);
// Set the "Alerting" tab to active
const alertingTabID = getAlertingTabID(folderDTO.uid);
const alertingTab = model.children?.find((child) => child.id === alertingTabID);
if (alertingTab) {
alertingTab.active = true;
}
return model;
}, [folderDTO]);
const navModel = useNavModel(folderDTO, 'alerts');
const onEditTitle = folderUID
? async (newValue: string) => {
@@ -88,11 +88,11 @@ describe('browse-dashboards BrowseFolderLibraryPanelsPage', () => {
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Dashboards' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Panels' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Panels' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: /^Panels/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Panels/ })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Alert rules' })).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: /^Alert rules/ })).toHaveAttribute('aria-selected', 'false');
});
it('displays the library panels returned by the API', async () => {
@@ -1,11 +1,11 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { useParams } from 'react-router-dom-v5-compat';
import { useGetFolderQueryFacade, useUpdateFolder } from 'app/api/clients/folder/v1beta1/hooks';
import { Page } from 'app/core/components/Page/Page';
import { useNavModel } from 'app/features/browse-dashboards/hooks/useNavModel';
import { type GrafanaRouteComponentProps } from '../../core/navigation/types';
import { buildNavModel, getLibraryPanelsTabID } from '../folders/state/navModel';
import { LibraryPanelsSearch } from '../library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch';
import { OpenLibraryPanelModal } from '../library-panels/components/OpenLibraryPanelModal/OpenLibraryPanelModal';
import { type LibraryElementDTO } from '../library-panels/types';
@@ -20,20 +20,7 @@ export function BrowseFolderLibraryPanelsPage() {
const [selected, setSelected] = useState<LibraryElementDTO | undefined>(undefined);
const [saveFolder] = useUpdateFolder();
const navModel = useMemo(() => {
if (!folderDTO) {
return undefined;
}
const model = buildNavModel(folderDTO);
// Set the "Library panels" tab to active
const libraryPanelsTabID = getLibraryPanelsTabID(folderDTO.uid);
const libraryPanelsTab = model.children?.find((child) => child.id === libraryPanelsTabID);
if (libraryPanelsTab) {
libraryPanelsTab.active = true;
}
return model;
}, [folderDTO]);
const navModel = useNavModel(folderDTO, 'panels');
const onEditTitle = folderUID
? async (newValue: string) => {
@@ -93,10 +93,8 @@ export interface ListFolderQueryArgs {
const folderListTag = { type: 'getFolder' as const, id: 'LIST' };
const invalidateFolderListOnSuccess = (_result: unknown, error: unknown) => (error ? [] : [folderListTag]);
// TODO: Once backend returns alert rule counts, set this back to true
// when this is merged https://github.com/grafana/grafana/pull/67259
const deleteFolderParams = {
forceDeleteRules: false,
forceDeleteRules: true,
} as const;
export const browseDashboardsAPI = createApi({
@@ -0,0 +1,63 @@
import { HttpResponse } from 'msw';
import { render, screen } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { customFolderCountsHandler, getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { AffectedFolderContents } from './AffectedFolderContents';
setBackendSrv(backendSrv);
setupMockServer();
const [_, { folderA }] = getFolderFixtures();
const emptySelection = {
folder: {},
dashboard: {},
};
const folderASelection = {
folder: { [folderA.item.uid]: true },
dashboard: {},
};
describe('AffectedFolderContents', () => {
it('always renders the default message', () => {
render(<AffectedFolderContents selectedItems={emptySelection} defaultMessage={<p>Default body</p>} />);
expect(screen.getByText('Default body')).toBeInTheDocument();
});
it('does not render empty/non-empty alerts when no folder is selected', () => {
render(
<AffectedFolderContents
selectedItems={emptySelection}
emptyMessage="Folder is empty"
nonEmptyMessage="Folder has resources"
/>
);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('renders the non-empty warning alert when the selected folder has descendants', async () => {
render(<AffectedFolderContents selectedItems={folderASelection} nonEmptyMessage="Folder has other resources" />);
expect(await screen.findByRole('alert', { name: 'Folder has other resources' })).toBeInTheDocument();
});
it('renders the empty success alert when the selected folder has no descendants', async () => {
server.use(
customFolderCountsHandler(() =>
HttpResponse.json({ folders: 0, dashboards: 0, library_elements: 0, alertrules: 0 })
)
);
render(<AffectedFolderContents selectedItems={folderASelection} emptyMessage="Folder is empty" />);
expect(await screen.findByRole('status', { name: 'Folder is empty' })).toBeInTheDocument();
});
});
@@ -0,0 +1,80 @@
import { css } from '@emotion/css';
import { type ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton';
import { type GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Alert, useStyles2 } from '@grafana/ui';
import { useGetAffectedItems } from 'app/api/clients/folder/v1beta1/hooks';
import { type DashboardTreeSelection } from '../../types';
import { getFolderIsEmpty, getSelectedFolderUIDs } from './utils';
interface Props {
selectedItems: Pick<DashboardTreeSelection, 'folder' | 'dashboard'>;
/** Rendered always, regardless of loading state, counts, or whether any folders are selected. */
defaultMessage?: ReactNode;
/** Title of the success alert shown when the selected folders contain no descendants. Omit to render nothing. */
emptyMessage?: string;
/** Title of the warning alert shown when the selected folders contain other resources. Omit to render nothing. */
nonEmptyMessage?: string;
}
/**
* Renders an alert describing whether the selected folders contain other resources. Intended for confirmation
* surfaces (delete, move, manage permissions, etc.) where the user should know that an action on a folder
* cascades to its descendants.
*
* The conditional alert is only rendered when at least one folder is selected — there are no descendants to
* warn about otherwise. `defaultMessage` is always rendered.
*/
export function AffectedFolderContents({ selectedItems, defaultMessage, emptyMessage, nonEmptyMessage }: Props) {
const styles = useStyles2(getStyles);
const selectedFolders = getSelectedFolderUIDs(selectedItems);
const { data, isLoading, isFetching, error } = useGetAffectedItems(selectedItems);
let contents: ReactNode = undefined;
if (selectedFolders.length > 0) {
if (isLoading || isFetching) {
contents = <Skeleton width={200} />;
} else if (error) {
contents = (
<Alert
className={styles.alert}
severity="warning"
title={t(
'browse-dashboards.affected-folder-contents-error',
"We couldn't get information about folder contents."
)}
/>
);
} else if (data) {
const folderIsEmpty = getFolderIsEmpty(data, selectedItems);
contents = (
<>
{folderIsEmpty && emptyMessage && <Alert className={styles.alert} severity="success" title={emptyMessage} />}
{!folderIsEmpty && nonEmptyMessage && (
<Alert className={styles.alert} severity="warning" title={nonEmptyMessage} />
)}
</>
);
}
}
return (
<>
{defaultMessage}
{contents}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
// The alert title is a span that inherits font size, which in modals resolves to a size larger than the
// surrounding body text, so size it down to match.
alert: css({
fontSize: theme.typography.body.fontSize,
}),
});
@@ -85,7 +85,7 @@ describe('browse-dashboards DeleteModal', () => {
expect(mockOnDismiss).toHaveBeenCalled();
});
it('shows a numeric affected item count for a single folder selection', async () => {
it('warns that the selected folder contains other resources', async () => {
render(
<DeleteModal
{...defaultProps}
@@ -100,8 +100,8 @@ describe('browse-dashboards DeleteModal', () => {
/>
);
expect(await screen.findByText(/This action will delete the folder/i)).toBeInTheDocument();
expect(await screen.findByText(/5 item/)).toBeInTheDocument();
expect(screen.queryByText(/NaN item/)).not.toBeInTheDocument();
expect(
await screen.findByRole('alert', { name: /contains other resources that will be deleted/i })
).toBeInTheDocument();
});
});
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Alert, ConfirmModal, Space, Text } from '@grafana/ui';
import { useGetAffectedItems, useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { ConfirmModal, Space } from '@grafana/ui';
import { type DashboardTreeSelection } from '../../types';
import { DeletedDashboardsInfo } from '../DeletedDashboardsInfo';
import { DescendantCount } from './DescendantCount';
import { AffectedFolderContents } from './AffectedFolderContents';
import { getSelectedFolderUIDs } from './utils';
export interface Props {
isOpen: boolean;
@@ -18,19 +18,9 @@ export interface Props {
}
export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const { data } = useGetAffectedItems(selectedItems);
const deleteIsInvalid = Boolean(data && (data.alertrules || data.library_elements));
const [isDeleting, setIsDeleting] = useState(false);
const selectedFolders = Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]);
const selectedDashboards = Object.keys(selectedItems.dashboard || {}).filter((uid) => selectedItems.dashboard[uid]);
const selectedPanels = Object.keys(selectedItems.panel || {}).filter((uid) => selectedItems.panel[uid]);
const { data: folderData } = useGetFolderQueryFacade(selectedFolders.length === 1 ? selectedFolders[0] : undefined);
// If we are only moving one folder, we can show a different message
// (we might be in the "Folder actions" version of the modal)
const onlyOneFolderSelected =
selectedFolders.length === 1 && selectedDashboards.length === 0 && selectedPanels.length === 0;
const selectedFolders = getSelectedFolderUIDs(selectedItems);
const onDelete = async () => {
reportInteraction('grafana_manage_dashboards_delete_clicked', {
@@ -56,42 +46,23 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
<>
<DeletedDashboardsInfo target="folder" />
<Space v={2} />
<Text element="p">
{onlyOneFolderSelected ? (
<Trans
i18nKey="browse-dashboards.action.delete-modal-text-one-folder"
values={{ folderName: folderData?.title }}
>
This action will delete the folder &quot;
<Text variant="code" weight="bold">
{'{{ folderName }}'}
</Text>
&quot; and the following content:
</Trans>
) : (
<Trans i18nKey="browse-dashboards.action.delete-modal-text">
This action will delete the following content:
</Trans>
)}
</Text>
<DescendantCount selectedItems={selectedItems} />
<AffectedFolderContents
selectedItems={selectedItems}
emptyMessage={t('browse-dashboards.action.delete-modal-folder-empty', '', {
count: selectedFolders.length,
defaultValue_one: 'Selected folder is empty',
defaultValue_other: 'Selected folders are empty',
})}
nonEmptyMessage={t('browse-dashboards.action.delete-modal-folder-not-empty', '', {
count: selectedFolders.length,
defaultValue_one: 'Selected folder contains other resources that will be deleted',
defaultValue_other: 'Selected folders contain other resources that will be deleted',
})}
/>
<Space v={2} />
</>
}
description={
<>
{deleteIsInvalid ? (
<Alert
severity="warning"
title={t('browse-dashboards.action.delete-modal-invalid-title', 'Cannot delete folder')}
>
<Trans i18nKey="browse-dashboards.action.delete-modal-invalid-text">
One or more folders contain library panels or alert rules. Delete these first in order to proceed.
</Trans>
</Alert>
) : null}
</>
}
confirmationText={t('browse-dashboards.action.confirmation-text', 'Delete')}
confirmText={
isDeleting
@@ -102,7 +73,7 @@ export const DeleteModal = ({ onConfirm, onDismiss, selectedItems, ...props }: P
onConfirm={onDelete}
title={t('browse-dashboards.action.delete-modal-title', 'Delete')}
{...props}
disabled={deleteIsInvalid || isDeleting}
disabled={isDeleting}
/>
);
};
@@ -1,32 +0,0 @@
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 { type DashboardTreeSelection } from '../../types';
import { buildBreakdownString } from './utils';
export interface Props {
selectedItems: DashboardTreeSelection;
}
export const DescendantCount = ({ selectedItems }: Props) => {
const { data, isFetching, isLoading, error } = useGetAffectedItems(selectedItems);
return error ? (
<Alert
severity="error"
title={t(
'browse-dashboards.descendant-count.title-unable-to-retrieve-descendant-information',
'Unable to retrieve descendant information'
)}
/>
) : (
<Text element="p" color="secondary">
{data && buildBreakdownString(data.folders, data.dashboards, data.library_elements, data.alertrules)}
{(isFetching || isLoading) && <Skeleton width={200} />}
</Text>
);
};
@@ -7,7 +7,7 @@ import { backendSrv } from 'app/core/services/backend_srv';
import { MoveModal, type Props } from './MoveModal';
const [_, { folderA, folderB }] = getFolderFixtures();
const [_, { folderA }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
@@ -83,26 +83,12 @@ describe('browse-dashboards MoveModal', () => {
).toBeInTheDocument();
});
it('displays summary of affected items', async () => {
it('warns that the selected folder contains other resources', async () => {
render(<MoveModal {...props} />);
expect(await screen.findByText(/This action will move the folder/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();
expect(
await screen.findByRole('alert', { name: /contains other resources that will be moved/i })
).toBeInTheDocument();
});
});
});
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Modal, Text, Space, Box } from '@grafana/ui';
import { useGetFolderQueryFacade } from 'app/api/clients/folder/v1beta1/hooks';
import { Alert, Button, Field, Modal, Space } from '@grafana/ui';
import { MoveActionAvailableTargetWarning } from 'app/features/provisioning/components/Shared/MoveActionAvailableTargetWarning';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { type DashboardTreeSelection } from '../../types';
import { DescendantCount } from './DescendantCount';
import { AffectedFolderContents } from './AffectedFolderContents';
import { getSelectedFolderUIDs } from './utils';
export interface Props {
isOpen: boolean;
@@ -20,15 +20,8 @@ export interface Props {
export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Props) => {
const [moveTarget, setMoveTarget] = useState<string>();
const [isMoving, setIsMoving] = useState(false);
const selectedFolders = Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]);
const selectedDashboards = Object.keys(selectedItems.dashboard || {}).filter((uid) => selectedItems.dashboard[uid]);
const selectedPanels = Object.keys(selectedItems.panel || {}).filter((uid) => selectedItems.panel[uid]);
const { data: folderData } = useGetFolderQueryFacade(selectedFolders.length === 1 ? selectedFolders[0] : undefined);
// If we are only moving one folder, we can show a different message
// (we might be in the "Folder actions" version of the modal)
const onlyOneFolderSelected =
selectedFolders.length === 1 && selectedDashboards.length === 0 && selectedPanels.length === 0;
const selectedFolders = getSelectedFolderUIDs(selectedItems);
const onMove = async () => {
if (moveTarget !== undefined) {
@@ -54,27 +47,16 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<MoveActionAvailableTargetWarning />
<Box paddingTop={2}>
<Text element="p">
{onlyOneFolderSelected ? (
<Trans
i18nKey="browse-dashboards.action.move-modal-text-one-folder"
values={{ folderName: folderData?.title }}
>
This action will move the folder &quot;
<Text variant="code" weight="bold">
{'{{ folderName }}'}
</Text>
&quot; and the following content:
</Trans>
) : (
<Trans i18nKey="browse-dashboards.action.move-modal-text">
This action will move the following content:
</Trans>
)}
</Text>
<DescendantCount selectedItems={selectedItems} />
</Box>
<Space v={2} />
<AffectedFolderContents
selectedItems={selectedItems}
nonEmptyMessage={t('browse-dashboards.action.move-modal-folder-not-empty', '', {
count: selectedFolders.length,
defaultValue_one: 'Selected folder contains other resources that will be moved with it',
defaultValue_other: 'Selected folders contain other resources that will be moved with them',
})}
/>
<Space v={3} />
@@ -1,23 +1,61 @@
import { buildBreakdownString } from './utils';
import { getFolderIsEmpty, getSelectedFolderUIDs } from './utils';
describe('browse-dashboards utils', () => {
describe('buildBreakdownString', () => {
it.each`
folderCount | dashboardCount | libraryPanelCount | alertRuleCount | expected
${0} | ${0} | ${0} | ${0} | ${'0 items'}
${1} | ${0} | ${0} | ${0} | ${'1 item: 1 folder'}
${2} | ${0} | ${0} | ${0} | ${'2 items: 2 folders'}
${0} | ${1} | ${0} | ${0} | ${'1 item: 1 dashboard'}
${0} | ${2} | ${0} | ${0} | ${'2 items: 2 dashboards'}
${1} | ${0} | ${1} | ${1} | ${'3 items: 1 folder, 1 library panel, 1 alert rule'}
${2} | ${0} | ${3} | ${4} | ${'9 items: 2 folders, 3 library panels, 4 alert rules'}
${1} | ${1} | ${1} | ${1} | ${'4 items: 1 folder, 1 dashboard, 1 library panel, 1 alert rule'}
${1} | ${2} | ${3} | ${4} | ${'10 items: 1 folder, 2 dashboards, 3 library panels, 4 alert rules'}
`(
'returns the correct message for the various inputs',
({ folderCount, dashboardCount, libraryPanelCount, alertRuleCount, expected }) => {
expect(buildBreakdownString(folderCount, dashboardCount, libraryPanelCount, alertRuleCount)).toEqual(expected);
}
);
describe('getSelectedFolderUIDs', () => {
it('returns only the UIDs of folders that are selected', () => {
const selection = { folder: { 'folder-a': true, 'folder-b': false, 'folder-c': true } };
expect(getSelectedFolderUIDs(selection)).toEqual(['folder-a', 'folder-c']);
});
it('returns an empty array when nothing is selected', () => {
expect(getSelectedFolderUIDs({ folder: {} })).toEqual([]);
});
});
describe('getFolderIsEmpty', () => {
const selection = { folder: { 'folder-a': true }, dashboard: {} };
it('returns true when the only affected item is the selected folder itself', () => {
const affected = { folders: 1, dashboards: 0, library_elements: 0, alertrules: 0 };
expect(getFolderIsEmpty(affected, selection)).toBe(true);
});
it('returns false when the selected folder contains a child folder', () => {
const affected = { folders: 2, dashboards: 0, library_elements: 0, alertrules: 0 };
expect(getFolderIsEmpty(affected, selection)).toBe(false);
});
it('returns false when the selected folder contains a library panel', () => {
const affected = { folders: 1, dashboards: 0, library_elements: 1, alertrules: 0 };
expect(getFolderIsEmpty(affected, selection)).toBe(false);
});
it('returns false when the selected folder contains an alert rule', () => {
const affected = { folders: 1, dashboards: 0, library_elements: 0, alertrules: 1 };
expect(getFolderIsEmpty(affected, selection)).toBe(false);
});
it('subtracts selected dashboards so they are not counted as descendants', () => {
const affected = { folders: 1, dashboards: 1, library_elements: 0, alertrules: 0 };
const selectionWithDashboard = {
folder: { 'folder-a': true },
dashboard: { 'dashboard-a': true },
};
expect(getFolderIsEmpty(affected, selectionWithDashboard)).toBe(true);
});
it('ignores falsy entries when counting selected items', () => {
const affected = { folders: 1, dashboards: 0, library_elements: 0, alertrules: 0 };
const selectionWithDeselected = {
folder: { 'folder-a': true, 'folder-b': false },
dashboard: {},
};
expect(getFolderIsEmpty(affected, selectionWithDeselected)).toBe(true);
});
it('returns true when the affected counts are lower than the selection counts', () => {
const affected = { folders: 0, dashboards: 0, library_elements: 0, alertrules: 0 };
expect(getFolderIsEmpty(affected, selection)).toBe(true);
});
});
});
@@ -1,56 +1,32 @@
import { t } from '@grafana/i18n';
import { type DescendantCount } from 'app/types/folders';
export function buildBreakdownString(
folderCount: number,
dashboardCount: number,
libraryPanelCount: number,
alertRuleCount: number
) {
const total = folderCount + dashboardCount + libraryPanelCount + alertRuleCount;
const parts = [];
if (folderCount) {
parts.push(
t('browse-dashboards.counts.folder', '', {
count: folderCount,
defaultValue_one: '{{count}} folder',
defaultValue_other: '{{count}} folders',
})
);
}
if (dashboardCount) {
parts.push(
t('browse-dashboards.counts.dashboard', '', {
count: dashboardCount,
defaultValue_one: '{{count}} dashboard',
defaultValue_other: '{{count}} dashboards',
})
);
}
if (libraryPanelCount) {
parts.push(
t('browse-dashboards.counts.libraryPanel', '', {
count: libraryPanelCount,
defaultValue_one: '{{count}} library panel',
defaultValue_other: '{{count}} library panels',
})
);
}
if (alertRuleCount) {
parts.push(
t('browse-dashboards.counts.alertRule', '', {
count: alertRuleCount,
defaultValue_one: '{{count}} alert rule',
defaultValue_other: '{{count}} alert rules',
})
);
}
let breakdownString = t('browse-dashboards.counts.total', '', {
count: total,
defaultValue_one: '{{count}} item',
defaultValue_other: '{{count}} items',
});
if (parts.length > 0) {
breakdownString += `: ${parts.join(', ')}`;
}
return breakdownString;
import { type DashboardTreeSelection } from '../../types';
/** Returns the UIDs of folders that are currently selected in the tree selection. */
export function getSelectedFolderUIDs(selectedItems: Pick<DashboardTreeSelection, 'folder'>): string[] {
return Object.keys(selectedItems.folder || {}).filter((uid) => selectedItems.folder[uid]);
}
/**
* Returns true when the selected folders have no remaining descendants once items the user explicitly selected
* (folders/dashboards) are subtracted from the affected-items totals.
*
* We do this mainly because the way the API works, i.e. returning affected items, does not currently match the UI,
* which only needs whether folders have children items.
*/
export function getFolderIsEmpty(
affectedItems: DescendantCount,
selectedItems: Pick<DashboardTreeSelection, 'folder' | 'dashboard'>
): boolean {
const selectedFolderCount = Object.values(selectedItems.folder).filter(Boolean).length;
const selectedDashboardCount = Object.values(selectedItems.dashboard).filter(Boolean).length;
const remaining =
affectedItems.folders -
selectedFolderCount +
(affectedItems.dashboards - selectedDashboardCount) +
affectedItems.library_elements +
affectedItems.alertrules;
return remaining <= 0;
}
@@ -0,0 +1,103 @@
import { getWrapper, renderHook, waitFor } from 'test/test-utils';
import { config, setBackendSrv } from '@grafana/runtime';
import server, { setupMockServer } from '@grafana/test-utils/server';
import { folderHandlers } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { getAlertingTabID, getDashboardsTabID, getLibraryPanelsTabID } from 'app/features/folders/state/navModel';
import { type FolderDTO } from 'app/types/folders';
import { useNavModel } from './useNavModel';
setBackendSrv(backendSrv);
setupMockServer();
jest.mock('app/core/services/context_srv', () => ({
...jest.requireActual('app/core/services/context_srv'),
contextSrv: {
...jest.requireActual('app/core/services/context_srv').contextSrv,
hasPermission: () => true,
},
}));
const folder: FolderDTO = {
uid: 'test-folder-uid',
title: 'Test Folder',
url: '/dashboards/f/test-folder-uid',
id: 1,
created: '',
createdBy: '',
hasAcl: false,
updated: '',
updatedBy: '',
canSave: true,
canEdit: true,
canAdmin: true,
canDelete: true,
version: 1,
};
const renderUseNavModel = (folderDTO: FolderDTO | undefined, tab: 'dashboards' | 'panels' | 'alerts') =>
renderHook(() => useNavModel(folderDTO, tab), { wrapper: getWrapper({}) });
describe('useNavModel', () => {
const originalUnifiedAlerting = config.unifiedAlertingEnabled;
beforeEach(() => {
config.unifiedAlertingEnabled = true;
});
afterAll(() => {
config.unifiedAlertingEnabled = originalUnifiedAlerting;
});
it('returns undefined when folderDTO is not provided', () => {
const { result } = renderUseNavModel(undefined, 'dashboards');
expect(result.current).toBeUndefined();
});
it('marks the dashboards tab as active', () => {
const { result } = renderUseNavModel(folder, 'dashboards');
const dashboardsTab = result.current?.children?.find((c) => c.id === getDashboardsTabID(folder.uid));
expect(dashboardsTab?.active).toBe(true);
});
it('marks the panels tab as active', () => {
const { result } = renderUseNavModel(folder, 'panels');
const panelsTab = result.current?.children?.find((c) => c.id === getLibraryPanelsTabID(folder.uid));
expect(panelsTab?.active).toBe(true);
});
it('marks the alerts tab as active', () => {
const { result } = renderUseNavModel(folder, 'alerts');
const alertingTab = result.current?.children?.find((c) => c.id === getAlertingTabID(folder.uid));
expect(alertingTab?.active).toBe(true);
});
it('populates tab counters from the folder counts query', async () => {
server.use(folderHandlers.mockFolderCountsHandler(7, 3));
const { result } = renderUseNavModel(folder, 'dashboards');
await waitFor(() => {
const panelsTab = result.current?.children?.find((c) => c.id === getLibraryPanelsTabID(folder.uid));
expect(panelsTab?.tabCounter).toBe(7);
});
const alertingTab = result.current?.children?.find((c) => c.id === getAlertingTabID(folder.uid));
expect(alertingTab?.tabCounter).toBe(3);
});
it('leaves tab counters undefined when the counts query fails', async () => {
server.use(folderHandlers.mockFolderCountsErrorHandler());
const { result } = renderUseNavModel(folder, 'dashboards');
await waitFor(() => {
expect(result.current).toBeDefined();
});
const panelsTab = result.current?.children?.find((c) => c.id === getLibraryPanelsTabID(folder.uid));
const alertingTab = result.current?.children?.find((c) => c.id === getAlertingTabID(folder.uid));
expect(panelsTab?.tabCounter).toBeUndefined();
expect(alertingTab?.tabCounter).toBeUndefined();
});
});
@@ -0,0 +1,54 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useMemo } from 'react';
import { useGetFolderCountsQuery } from 'app/api/clients/folder/v1beta1';
import {
buildNavModel,
getAlertingTabID,
getLibraryPanelsTabID,
getDashboardsTabID,
} from 'app/features/folders/state/navModel';
import type { FolderDTO } from 'app/types/folders';
/**
* Returns a memoized nav model while also resolving counts for the tabs.
*/
export function useNavModel(folderDTO: FolderDTO | undefined, activeTab: 'dashboards' | 'panels' | 'alerts') {
const folderCountsResult = useGetFolderCountsQuery(folderDTO?.uid ? { name: folderDTO.uid } : skipToken, {
// Always refetch the counts as we don't have a way to invalidate the cache when descendant resources are
// created or deleted because they are in separate RTK slices.
refetchOnMountOrArgChange: true,
});
let panelsCount: number | undefined = undefined;
let rulesCount: number | undefined = undefined;
// The counts are not critical to have so we are not dealing with the possible api error state here, we just won't
// show the numbers in that case.
if (folderCountsResult.isSuccess) {
panelsCount = folderCountsResult.data.counts.find((c) => c.resource === 'library_elements')?.count ?? 0;
rulesCount = folderCountsResult.data.counts.find((c) => c.resource === 'alertrules')?.count ?? 0;
}
return useMemo(() => {
if (!folderDTO) {
return undefined;
}
const model = buildNavModel(
folderDTO,
undefined,
panelsCount !== undefined && rulesCount !== undefined ? { panels: panelsCount, rules: rulesCount } : undefined
);
const activeTabID =
activeTab === 'dashboards'
? getDashboardsTabID(folderDTO.uid)
: activeTab === 'panels'
? getLibraryPanelsTabID(folderDTO.uid)
: getAlertingTabID(folderDTO.uid);
const tab = model.children?.find((child) => child.id === activeTabID);
if (tab) {
tab.active = true;
}
return model;
}, [activeTab, folderDTO, panelsCount, rulesCount]);
}
@@ -13,7 +13,11 @@ export const getDashboardsTabID = (folderUID: string) => `folder-dashboards-${fo
export const getLibraryPanelsTabID = (folderUID: string) => `folder-library-panels-${folderUID}`;
export const getAlertingTabID = (folderUID: string) => `folder-alerting-${folderUID}`;
export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: FolderParent[]): NavModelItem {
export function buildNavModel(
folder: FolderDTO | FolderParent,
parentsArg?: FolderParent[],
counts?: { panels: number; rules: number }
): NavModelItem {
const parents = parentsArg ?? ('parents' in folder ? folder.parents : undefined);
const isProvisioned = 'managedBy' in folder ? folder.managedBy === ManagerKind.Repo : false;
@@ -47,6 +51,7 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
id: getLibraryPanelsTabID(folder.uid),
text: t('browse-dashboards.manage-folder-nav.panels', 'Panels'),
url: `${folder.url}/library-panels`,
tabCounter: counts ? counts.panels : undefined,
});
}
@@ -61,6 +66,7 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
id: getAlertingTabID(folder.uid),
text: t('browse-dashboards.manage-folder-nav.alert-rules', 'Alert rules'),
url: `${folder.url}/alerting`,
tabCounter: counts ? counts.rules : undefined,
});
}
@@ -8,10 +8,11 @@ import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidati
import { BulkDeleteProvisionedResource } from './BulkDeleteProvisionedResource';
import { type ResponseType } from './useBulkActionJob';
jest.mock('app/features/browse-dashboards/components/BrowseActions/DescendantCount', () => ({
DescendantCount: jest.fn(({ selectedItems }) => (
<div data-testid="descendant-count">
Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '}
jest.mock('app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents', () => ({
AffectedFolderContents: jest.fn(({ selectedItems, defaultMessage }) => (
<div data-testid="affected-folder-contents">
{defaultMessage}
Mocked affected folder contents for {Object.keys(selectedItems.folder).length} folders and{' '}
{Object.keys(selectedItems.dashboard).length} dashboards
</div>
)),
@@ -4,9 +4,10 @@ import { FormProvider, useForm } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents, reportInteraction } from '@grafana/runtime';
import { Box, Button, Stack } from '@grafana/ui';
import { Button, Stack } from '@grafana/ui';
import { type Job, type RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AffectedFolderContents } from 'app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents';
import { getSelectedFolderUIDs } from 'app/features/browse-dashboards/components/BrowseActions/utils';
import { collectSelectedItems } from 'app/features/browse-dashboards/utils/dashboards';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
@@ -107,12 +108,24 @@ function FormContent({ initialValues, selectedItems, repository, canPushToConfig
</>
) : (
<>
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.bulk-delete-resources-form.delete-warning">
This will delete selected folders and their descendants. In total, this will affect:
</Trans>
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
</Box>
<AffectedFolderContents
selectedItems={selectedItems}
defaultMessage={
<Trans i18nKey="browse-dashboards.bulk-delete-resources-form.delete-warning">
This will delete selected folders and their descendants.
</Trans>
}
emptyMessage={t('browse-dashboards.bulk-delete-resources-form.folder-empty', '', {
count: getSelectedFolderUIDs(selectedItems).length,
defaultValue_one: 'Selected folder is empty',
defaultValue_other: 'Selected folders are empty',
})}
nonEmptyMessage={t('browse-dashboards.bulk-delete-resources-form.folder-not-empty', '', {
count: getSelectedFolderUIDs(selectedItems).length,
defaultValue_one: 'Selected folder contains other resources that will be deleted',
defaultValue_other: 'Selected folders contain other resources that will be deleted',
})}
/>
<ResourceEditFormSharedFields
resourceType="folder"
isNew={false}
@@ -9,10 +9,11 @@ import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidati
import { BulkMoveProvisionedResource } from './BulkMoveProvisionedResource';
import { type ResponseType } from './useBulkActionJob';
jest.mock('app/features/browse-dashboards/components/BrowseActions/DescendantCount', () => ({
DescendantCount: jest.fn(({ selectedItems }) => (
<div data-testid="descendant-count">
Mocked descendant count for {Object.keys(selectedItems.folder).length} folders and{' '}
jest.mock('app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents', () => ({
AffectedFolderContents: jest.fn(({ selectedItems, defaultMessage }) => (
<div data-testid="affected-folder-contents">
{defaultMessage}
Mocked affected folder contents for {Object.keys(selectedItems.folder).length} folders and{' '}
{Object.keys(selectedItems.dashboard).length} dashboards
</div>
)),
@@ -161,8 +162,7 @@ describe('BulkMoveProvisionedResource', () => {
setup(null);
expect(await screen.findByText(/This will move selected folders and their descendants/)).toBeInTheDocument();
expect(screen.getByText(/In total, this will affect:/)).toBeInTheDocument();
expect(screen.getByTestId('descendant-count')).toBeInTheDocument();
expect(screen.getByTestId('affected-folder-contents')).toBeInTheDocument();
expect(screen.getByTestId('folder-picker')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Move/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument();
@@ -5,11 +5,12 @@ import { FormProvider, useForm } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents, reportInteraction } from '@grafana/runtime';
import { Box, Button, Field, Stack } from '@grafana/ui';
import { Button, Field, Stack } from '@grafana/ui';
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import { type RepositoryView, type Job } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeySourcePath } from 'app/features/apiserver/types';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AffectedFolderContents } from 'app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents';
import { getSelectedFolderUIDs } from 'app/features/browse-dashboards/components/BrowseActions/utils';
import { collectSelectedItems } from 'app/features/browse-dashboards/utils/dashboards';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import {
@@ -167,12 +168,14 @@ function FormContent({
) : (
<>
<MoveActionAvailableTargetWarning />
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.move-total">
In total, this will affect:
</Trans>
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
</Box>
<AffectedFolderContents
selectedItems={selectedItems}
nonEmptyMessage={t('browse-dashboards.bulk-move-resources-form.folder-not-empty', '', {
count: getSelectedFolderUIDs(selectedItems).length,
defaultValue_one: 'Selected folder contains other resources that will be moved with it',
defaultValue_other: 'Selected folders contain other resources that will be moved with them',
})}
/>
{/* Target folder selection */}
<Field
noMargin
@@ -189,7 +192,7 @@ function FormContent({
repositoryName={repository.name}
// selectedItems.folder contains false entries from deselect ancestor propagation
// in setItemSelectionState reducer - filter to only truly-selected UIDs
excludeUIDs={Object.keys(selectedItems?.folder ?? {}).filter((uid) => selectedItems.folder[uid])}
excludeUIDs={getSelectedFolderUIDs(selectedItems)}
/>
</Field>
<ResourceEditFormSharedFields
@@ -50,8 +50,10 @@ jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
jest.mock('../../hooks/useProvisionedFolderFormData');
jest.mock('app/features/browse-dashboards/components/BrowseActions/DescendantCount', () => ({
DescendantCount: () => <div data-testid="descendant-count">2 folders, 5 dashboards</div>,
jest.mock('app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents', () => ({
AffectedFolderContents: jest.fn(({ defaultMessage }) => (
<div data-testid="affected-folder-contents">{defaultMessage}</div>
)),
}));
jest.mock('../Shared/ResourceEditFormSharedFields', () => ({
@@ -209,7 +211,7 @@ describe('DeleteProvisionedFolderForm', () => {
setup();
// delete warning and descendant count
expect(screen.getByText(/This will delete this folder and all its descendants/)).toBeInTheDocument();
expect(screen.getByTestId('descendant-count')).toBeInTheDocument();
expect(screen.getByTestId('affected-folder-contents')).toBeInTheDocument();
// delete and cancel buttons
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
@@ -4,13 +4,13 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { Trans, t } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, Button, Stack } from '@grafana/ui';
import { Button, Stack } from '@grafana/ui';
import {
type Job,
type RepositoryView,
useDeleteRepositoryFilesWithPathMutation,
} from 'app/api/clients/provisioning/v0alpha1';
import { DescendantCount } from 'app/features/browse-dashboards/components/BrowseActions/DescendantCount';
import { AffectedFolderContents } from 'app/features/browse-dashboards/components/BrowseActions/AffectedFolderContents';
import { JobStatus } from 'app/features/provisioning/Job/JobStatus';
import { type StepStatusInfo } from 'app/features/provisioning/Wizard/types';
import { type FolderDTO } from 'app/types/folders';
@@ -179,19 +179,22 @@ function FormContent({ initialValues, parentFolder, repository, canPushToConfigu
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.delete-warning">
This will delete this folder and all its descendants. In total, this will affect:
</Trans>
<DescendantCount
selectedItems={{
folder: { [resourceId]: true },
dashboard: {},
panel: {},
$all: false,
}}
/>
</Box>
<AffectedFolderContents
selectedItems={{ folder: { [resourceId]: true }, dashboard: {} }}
defaultMessage={
<Trans i18nKey="browse-dashboards.delete-provisioned-folder-form.delete-warning">
This will delete this folder and all its descendants.
</Trans>
}
emptyMessage={t(
'browse-dashboards.delete-provisioned-folder-form.folder-empty',
'Selected folder is empty'
)}
nonEmptyMessage={t(
'browse-dashboards.delete-provisioned-folder-form.folder-not-empty',
'Selected folder contains other resources that will be deleted'
)}
/>
<ResourceEditFormSharedFields
resourceType="folder"
+19 -26
View File
@@ -29,7 +29,7 @@
"add-label": "Add a permission",
"loading": "Loading permissions...",
"no-permissions": "There are no permissions",
"permissions-change-warning": "This will change permissions for this folder and all its descendants. In total, this will affect:",
"permissions-change-warning": "This will change permissions for this folder and all its descendants.",
"role": "Role",
"serviceaccount": "Service Account",
"team": "Team",
@@ -4224,13 +4224,13 @@
"cancel-button": "Cancel",
"confirmation-text": "Delete",
"delete-button": "Delete",
"delete-modal-invalid-text": "One or more folders contain library panels or alert rules. Delete these first in order to proceed.",
"delete-modal-invalid-title": "Cannot delete folder",
"delete-modal-folder-empty_one": "Selected folder is empty",
"delete-modal-folder-empty_other": "Selected folders are empty",
"delete-modal-folder-not-empty_one": "Selected folder contains other resources that will be deleted",
"delete-modal-folder-not-empty_other": "Selected folders contain other resources that will be deleted",
"delete-modal-restore-dashboards-common": "Deleted dashboards will be kept in the history for up to 12 months. Users with delete permissions can restore the dashboards they deleted, and admins can restore dashboards deleted by any user. The history is limited to 1000 dashboards — older ones may be removed sooner if the limit is reached.",
"delete-modal-restore-dashboards-prefix-folder": "This action will delete the selected folders immediately.",
"delete-modal-restore-dashboards-suffix-folder": "Folders cannot be restored.",
"delete-modal-text": "This action will delete the following content:",
"delete-modal-text-one-folder": "This action will delete the folder \"<1>{{ folderName }}</1>\" and the following content:",
"delete-modal-title": "Delete",
"delete-provisioned-folder": "Delete provisioned folder",
"deleting": "Deleting...",
@@ -4238,8 +4238,8 @@
"move-button": "Move",
"move-modal-alert": "Moving this item may change its permissions.",
"move-modal-field-label": "Folder name",
"move-modal-text": "This action will move the following content:",
"move-modal-text-one-folder": "This action will move the folder \"<1>{{ folderName }}</1>\" and the following content:",
"move-modal-folder-not-empty_one": "Selected folder contains other resources that will be moved with it",
"move-modal-folder-not-empty_other": "Selected folders contain other resources that will be moved with them",
"move-modal-title": "Move",
"move-provisioned-folder": "Move provisioned folder",
"moving": "Moving...",
@@ -4254,6 +4254,7 @@
"actions": {
"button-to-recently-deleted": "Recently deleted"
},
"affected-folder-contents-error": "We couldn't get information about folder contents.",
"browse-folder-alerting-page": {
"title-folder-not-found": "Folder not found",
"title-ruler-namespace-error": "Cannot load rules"
@@ -4268,8 +4269,12 @@
"button-cancel": "Cancel",
"button-delete": "Delete",
"button-deleting": "Deleting...",
"delete-warning": "This will delete selected folders and their descendants. In total, this will affect:",
"error-deleting-resources": "Error deleting resources"
"delete-warning": "This will delete selected folders and their descendants.",
"error-deleting-resources": "Error deleting resources",
"folder-empty_one": "Selected folder is empty",
"folder-empty_other": "Selected folders are empty",
"folder-not-empty_one": "Selected folder contains other resources that will be deleted",
"folder-not-empty_other": "Selected folders contain other resources that will be deleted"
},
"bulk-move-resources-form": {
"button-cancel": "Cancel",
@@ -4285,23 +4290,12 @@
"error-already-in-target-folder": "Selected resources are already in the target folder.",
"error-moving-resources": "Error moving resources",
"error-no-target-folder-path": "Target folder path is invalid or empty, please select again.",
"move-total": "In total, this will affect:",
"folder-not-empty_one": "Selected folder contains other resources that will be moved with it",
"folder-not-empty_other": "Selected folders contain other resources that will be moved with them",
"move-warning": "This will move selected folders and their descendants. Available target folders depend on the selected resources.",
"move-warning-tooltip": "You can only move provisioned resources within their provisioned folder, and local resources to local folders.",
"target-folder": "Target Folder"
},
"counts": {
"alertRule_one": "{{count}} alert rule",
"alertRule_other": "{{count}} alert rules",
"dashboard_one": "{{count}} dashboard",
"dashboard_other": "{{count}} dashboards",
"folder_one": "{{count}} folder",
"folder_other": "{{count}} folders",
"libraryPanel_one": "{{count}} library panel",
"libraryPanel_other": "{{count}} library panels",
"total_one": "{{count}} item",
"total_other": "{{count}} items"
},
"create-new": {
"dashboard-group": "Dashboard"
},
@@ -4329,13 +4323,12 @@
"button-cancel": "Cancel",
"button-delete": "Delete",
"button-deleting": "Deleting...",
"delete-warning": "This will delete this folder and all its descendants. In total, this will affect:",
"delete-warning": "This will delete this folder and all its descendants.",
"folder-empty": "Selected folder is empty",
"folder-not-empty": "Selected folder contains other resources that will be deleted",
"missing-info": "Missing required fields",
"success-message": "Folder deleted successfully"
},
"descendant-count": {
"title-unable-to-retrieve-descendant-information": "Unable to retrieve descendant information"
},
"empty-state": {
"button-title": "Create dashboard",
"pro-tip": "Add/move dashboards to your folder at <2>Browse dashboards</2>",