fix: restore export UI components for browse dashboards and dashboard scene

This commit is contained in:
Roberto Jimenez Sanchez
2025-12-03 12:19:08 +01:00
parent 0a29f2e49a
commit 0b8ebee57c
8 changed files with 268 additions and 23 deletions
@@ -6,8 +6,10 @@ import { Button, Drawer, Stack, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { ManagerKind } from 'app/features/apiserver/types';
import { BulkDeleteProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkDeleteProvisionedResource';
import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
import { useSelectionProvisioningStatus } from 'app/features/provisioning/hooks/useSelectionProvisioningStatus';
import { useSelectionUnmanagedStatus } from 'app/features/provisioning/hooks/useSelectionUnmanagedStatus';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
@@ -18,7 +20,7 @@ import {
useMoveMultipleFoldersMutationFacade,
} from '../../../../api/clients/folder/v1beta1/hooks';
import { useDeleteDashboardsMutation, useMoveDashboardsMutation } from '../../api/browseDashboardsAPI';
import { useActionSelectionState } from '../../state/hooks';
import { useActionSelectionState, useCheckboxSelectionState } from '../../state/hooks';
import { setAllSelection } from '../../state/slice';
import { DashboardTreeSelection } from '../../types';
@@ -33,9 +35,11 @@ export interface Props {
export function BrowseActions({ folderDTO }: Props) {
const [showBulkDeleteProvisionedResource, setShowBulkDeleteProvisionedResource] = useState(false);
const [showBulkMoveProvisionedResource, setShowBulkMoveProvisionedResource] = useState(false);
const [showBulkExportProvisionedResource, setShowBulkExportProvisionedResource] = useState(false);
const dispatch = useDispatch();
const selectedItems = useActionSelectionState();
const selectedItemsForActions = useActionSelectionState(); // For move/delete - filters out children
const selectedItems = useCheckboxSelectionState(); // For export - includes all selected items
const [deleteDashboards] = useDeleteDashboardsMutation();
const deleteFolders = useDeleteMultipleFoldersMutationFacade();
const [moveFolders] = useMoveMultipleFoldersMutationFacade();
@@ -44,9 +48,10 @@ export function BrowseActions({ folderDTO }: Props) {
const provisioningEnabled = config.featureToggles.provisioning;
const { hasProvisioned, hasNonProvisioned } = useSelectionProvisioningStatus(
selectedItems,
selectedItemsForActions,
folderDTO?.managedBy === ManagerKind.Repo
);
const { hasUnmanaged, isLoading: isLoadingUnmanaged } = useSelectionUnmanagedStatus(selectedItems);
const isSearching = stateManager.hasSearchFilters();
@@ -60,21 +65,29 @@ export function BrowseActions({ folderDTO }: Props) {
};
const onDelete = async () => {
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const selectedDashboards = Object.keys(selectedItemsForActions.dashboard).filter(
(uid) => selectedItemsForActions.dashboard[uid]
);
const selectedFolders = Object.keys(selectedItemsForActions.folder).filter(
(uid) => selectedItemsForActions.folder[uid]
);
await deleteDashboards({ dashboardUIDs: selectedDashboards });
await deleteFolders({ folderUIDs: selectedFolders });
trackAction('delete', selectedItems);
trackAction('delete', selectedItemsForActions);
onActionComplete();
};
const onMove = async (destinationUID: string) => {
const selectedDashboards = Object.keys(selectedItems.dashboard).filter((uid) => selectedItems.dashboard[uid]);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const selectedDashboards = Object.keys(selectedItemsForActions.dashboard).filter(
(uid) => selectedItemsForActions.dashboard[uid]
);
const selectedFolders = Object.keys(selectedItemsForActions.folder).filter(
(uid) => selectedItemsForActions.folder[uid]
);
await moveFolders({ folderUIDs: selectedFolders, destinationUID });
await moveDashboards({ dashboardUIDs: selectedDashboards, destinationUID });
trackAction('move', selectedItems);
trackAction('move', selectedItemsForActions);
onActionComplete();
};
@@ -140,10 +153,26 @@ export function BrowseActions({ folderDTO }: Props) {
</Button>
);
// Check if any dashboards are selected (export only supports dashboards, not folders)
// Use raw selectedItems (not selectedItemsForActions) to include all selected dashboards
const hasSelectedDashboards =
Object.keys(selectedItems.dashboard || {}).filter((uid) => selectedItems.dashboard[uid]).length > 0;
const pushButton = (
<Button
onClick={() => setShowBulkExportProvisionedResource(true)}
variant="secondary"
disabled={!hasUnmanaged || isLoadingUnmanaged || !hasSelectedDashboards}
>
<Trans i18nKey="browse-dashboards.action.export-to-repository-button">Export to Repository</Trans>
</Button>
);
return (
<>
<Stack gap={1} data-testid="manage-actions">
{moveButton}
{provisioningEnabled && pushButton}
<Button onClick={showDeleteModal} variant="destructive">
<Trans i18nKey="browse-dashboards.action.delete-button">Delete</Trans>
@@ -162,7 +191,7 @@ export function BrowseActions({ folderDTO }: Props) {
size="md"
>
<BulkDeleteProvisionedResource
selectedItems={selectedItems}
selectedItems={selectedItemsForActions}
folderUid={folderDTO?.uid || ''}
onDismiss={() => {
setShowBulkDeleteProvisionedResource(false);
@@ -184,7 +213,7 @@ export function BrowseActions({ folderDTO }: Props) {
size="md"
>
<BulkMoveProvisionedResource
selectedItems={selectedItems}
selectedItems={selectedItemsForActions}
folderUid={folderDTO?.uid}
onDismiss={() => {
setShowBulkMoveProvisionedResource(false);
@@ -192,6 +221,28 @@ export function BrowseActions({ folderDTO }: Props) {
/>
</Drawer>
)}
{/* bulk export */}
{showBulkExportProvisionedResource && (
<Drawer
title={
// Heading levels should only increase by one (a11y)
<Text variant="h3" element="h2">
{t('browse-dashboards.action.export-provisioned-resources', 'Export Resources')}
</Text>
}
onClose={() => setShowBulkExportProvisionedResource(false)}
size="md"
>
<BulkExportProvisionedResource
selectedItems={selectedItems}
folderUid={folderDTO?.uid}
onDismiss={() => {
setShowBulkExportProvisionedResource(false);
}}
/>
</Drawer>
)}
</>
);
}
@@ -13,7 +13,7 @@ import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch, useSelector } from 'app/types/store';
import { PAGE_SIZE } from '../api/services';
import { fetchNextChildrenPage } from '../state/actions';
import { fetchNextChildrenPage, selectFolderWithAllDashboards } from '../state/actions';
import {
useFlatTreeState,
useCheckboxSelectionState,
@@ -81,7 +81,13 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe
const handleItemSelectionChange = useCallback(
(item: DashboardViewItem, isSelected: boolean) => {
dispatch(setItemSelectionState({ item, isSelected }));
// If selecting a folder, use the async thunk to collect all dashboards recursively
// When deselecting, the normal reducer will handle deselecting all children
if (item.kind === 'folder') {
dispatch(selectFolderWithAllDashboards({ folderUID: item.uid, isSelected }));
} else {
dispatch(setItemSelectionState({ item, isSelected }));
}
},
[dispatch]
);
@@ -2,14 +2,16 @@ import { useState } from 'react';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { locationService, reportInteraction } from '@grafana/runtime';
import { config, locationService, reportInteraction } from '@grafana/runtime';
import { Button, Drawer, Dropdown, Icon, Menu, MenuItem, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { Permissions } from 'app/core/components/AccessControl/Permissions';
import { RepoType } from 'app/features/provisioning/Wizard/types';
import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { BulkMoveProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource';
import { DeleteProvisionedFolderForm } from 'app/features/provisioning/components/Folders/DeleteProvisionedFolderForm';
import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance';
import { collectAllDashboardsUnderFolder } from 'app/features/provisioning/utils/collectFolderDashboards';
import { getReadOnlyTooltipText } from 'app/features/provisioning/utils/repository';
import { ShowModalReactEvent } from 'app/types/events';
import { FolderDTO } from 'app/types/folders';
@@ -32,6 +34,8 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
const [showDeleteProvisionedFolderDrawer, setShowDeleteProvisionedFolderDrawer] = useState(false);
const [showMoveProvisionedFolderDrawer, setShowMoveProvisionedFolderDrawer] = useState(false);
const [showExportFolderDrawer, setShowExportFolderDrawer] = useState(false);
const [exportSelectedDashboards, setExportSelectedDashboards] = useState<Record<string, boolean>>({});
const [moveFolder] = useMoveFolderMutationFacade();
const isProvisionedInstance = useIsProvisionedInstance();
@@ -125,9 +129,38 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
setShowMoveProvisionedFolderDrawer(true);
};
const handleExportFolder = async () => {
try {
// Collect all dashboards under this folder and its children
const dashboardUIDs = await collectAllDashboardsUnderFolder(folder.uid);
// Create selected items object with all dashboards
const selectedDashboards: Record<string, boolean> = {};
dashboardUIDs.forEach((uid) => {
selectedDashboards[uid] = true;
});
setExportSelectedDashboards(selectedDashboards);
setShowExportFolderDrawer(true);
} catch (error) {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [
t(
'browse-dashboards.folder-actions-button.export-folder-error',
'Error collecting dashboards. Please try again later.'
),
],
});
}
};
const managePermissionsLabel = t('browse-dashboards.folder-actions-button.manage-permissions', 'Manage permissions');
const moveLabel = t('browse-dashboards.folder-actions-button.move', 'Move this folder');
const deleteLabel = t('browse-dashboards.folder-actions-button.delete', 'Delete this folder');
const exportLabel = t('browse-dashboards.folder-actions-button.export', 'Export to Repository');
const canExportToRepository = config.featureToggles.provisioning && !isProvisionedFolder;
const menu = (
<Menu>
@@ -147,10 +180,18 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
label={deleteLabel}
/>
)}
{canExportToRepository && <MenuItem onClick={handleExportFolder} label={exportLabel} />}
</Menu>
);
if (!canViewPermissions && !canMoveFolder && !canDeleteFolders) {
// Show menu if there are any available actions
const hasAnyActions =
(canViewPermissions && !isProvisionedFolder) ||
(canMoveFolder && !isReadOnlyRepo) ||
(canDeleteFolders && !isReadOnlyRepo) ||
canExportToRepository;
if (!hasAnyActions) {
return null;
}
@@ -213,6 +254,30 @@ export function FolderActionsButton({ folder, repoType, isReadOnlyRepo }: Props)
/>
</Drawer>
)}
{showExportFolderDrawer && (
<Drawer
title={
<Text variant="h3" element="h2">
{t('browse-dashboards.action.export-folder', 'Export Folder to Repository')}
</Text>
}
subtitle={folder.title}
onClose={() => setShowExportFolderDrawer(false)}
size="md"
>
<BulkExportProvisionedResource
folderUid={folder.uid}
selectedItems={{
dashboard: exportSelectedDashboards,
folder: {},
}}
onDismiss={() => {
setShowExportFolderDrawer(false);
setExportSelectedDashboards({});
}}
/>
</Drawer>
)}
</>
);
}
@@ -5,6 +5,7 @@ import { createAsyncThunk } from 'app/types/store';
import { listDashboards, listFolders, PAGE_SIZE } from '../api/services';
import { DashboardViewItemWithUIItems, UIDashboardViewItem } from '../types';
import { setItemSelectionState } from './slice';
import { findItem } from './utils';
interface FetchNextChildrenPageArgs {
@@ -88,6 +89,69 @@ export const refetchChildren = createAsyncThunk(
}
);
export const selectFolderWithAllDashboards = createAsyncThunk(
'browseDashboards/selectFolderWithAllDashboards',
async ({ folderUID, isSelected }: { folderUID: string; isSelected: boolean }, { dispatch, getState }) => {
const state = getState().browseDashboards;
// Find the folder item to get its parentUID and managedBy
const folderItem = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, folderUID);
if (!isSelected) {
// When deselecting, use the normal action - it will handle deselecting all children recursively
dispatch(
setItemSelectionState({
item: {
kind: 'folder',
uid: folderUID,
parentUID: folderItem?.parentUID,
managedBy: folderItem?.managedBy,
},
isSelected: false,
})
);
return;
}
// When selecting, collect all dashboards recursively
const { collectAllDashboardsUnderFolder } = await import('app/features/provisioning/utils/collectFolderDashboards');
const dashboardUIDs = await collectAllDashboardsUnderFolder(folderUID);
// First, select the folder itself
dispatch(
setItemSelectionState({
item: {
kind: 'folder',
uid: folderUID,
parentUID: folderItem?.parentUID,
managedBy: folderItem?.managedBy,
},
isSelected: true,
})
);
// Then select all dashboards found
// We need to get the parentUID for each dashboard from the state
// If a dashboard isn't in state yet, we still need to select it
for (const dashboardUID of dashboardUIDs) {
const dashboardItem = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, dashboardUID);
// Even if dashboard isn't in state, we can still select it by UID
// The reducer will handle setting selectedItems.dashboard[dashboardUID] = true
dispatch(
setItemSelectionState({
item: {
kind: 'dashboard',
uid: dashboardUID,
parentUID: dashboardItem?.parentUID ?? folderUID, // Fallback to folderUID if not found
managedBy: dashboardItem?.managedBy,
},
isSelected: true,
})
);
}
}
);
export const fetchNextChildrenPage = createAsyncThunk(
'browseDashboards/fetchNextChildrenPage',
async (
@@ -80,9 +80,8 @@ export function DeleteDashboardModal({ dashboardTitle, onConfirm, onClose }: Del
<>
<Text element="p">
<Trans i18nKey="dashboard-settings.delete-modal-restore-dashboards-text">
This action will delete the dashboard. Deleted dashboards will be kept in the history for up to 12
months and can be restored by your organization administrator during that time. The history is limited
to 1000 dashboards—older ones will be removed sooner if the limit is reached.
This action will mark the dashboard for deletion in 30 days. Your organization administrator can
restore it anytime before the 30 days expire.
</Trans>
</Text>
<Space v={1} />
@@ -28,6 +28,9 @@ export function addDashboardExportDrawerItem(item: ExportDrawerMenuItem) {
}
export default function ExportMenu({ dashboard }: { dashboard: DashboardScene }) {
const provisioningEnabled = config.featureToggles.provisioning;
const isUnmanaged = provisioningEnabled && !dashboard.isManagedRepository();
const onMenuItemClick = (shareView: string) => {
locationService.partial({ shareView });
};
@@ -59,8 +62,20 @@ export default function ExportMenu({ dashboard }: { dashboard: DashboardScene })
onClick: () => onMenuItemClick(shareDashboardType.image),
});
// Add "Export to Repository" option for unmanaged dashboards
if (isUnmanaged) {
menuItems.push({
shareId: 'export-to-repository',
testId: 'export-to-repository',
icon: 'cloud-upload',
label: t('share-dashboard.menu.export-to-repository-title', 'Export to Repository'),
renderCondition: true,
onClick: () => onMenuItemClick('export-to-repository'),
});
}
return menuItems.filter((item) => item.renderCondition);
}, []);
}, [isUnmanaged]);
const onClick = (item: ExportDrawerMenuItem) => {
DashboardInteractions.sharingCategoryClicked({
@@ -7,6 +7,7 @@ import { DashboardScene } from '../../scene/DashboardScene';
import { getDashboardSceneFor } from '../../utils/utils';
import { ExportAsCode } from '../ExportButton/ExportAsCode';
import { ExportAsImage } from '../ExportButton/ExportAsImage';
import { ExportToRepository } from '../ExportButton/ExportToRepository';
import { ShareExternally } from '../ShareButton/share-externally/ShareExternally';
import { ShareInternally } from '../ShareButton/share-internally/ShareInternally';
import { ShareSnapshot } from '../ShareButton/share-snapshot/ShareSnapshot';
@@ -96,6 +97,8 @@ function getShareView(
return new ExportAsCode({ onDismiss });
case shareDashboardType.image:
return new ExportAsImage({ onDismiss });
case 'export-to-repository':
return new ExportToRepository({ onDismiss });
default:
return new ShareInternally({ onDismiss });
}
@@ -2,11 +2,13 @@ import { saveAs } from 'file-saver';
import { memo, useState, useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, Modal, Switch } from '@grafana/ui';
import { config } from '@grafana/runtime';
import { Button, Drawer, Field, Modal, Switch, Text } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { DashboardExporter } from 'app/features/dashboard/components/DashExportModal/DashboardExporter';
import { makeExportableV1 } from 'app/features/dashboard-scene/scene/export/exporters';
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
import { BulkExportProvisionedResource } from 'app/features/provisioning/components/BulkActions/BulkExportProvisionedResource';
import { ShowModalReactEvent } from 'app/types/events';
import { ViewJsonModal } from './ViewJsonModal';
@@ -17,7 +19,10 @@ interface Props extends ShareModalTabProps {}
export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
const [shareExternally, setShareExternally] = useState(false);
const [showExportToRepositoryDrawer, setShowExportToRepositoryDrawer] = useState(false);
const exporter = useMemo(() => new DashboardExporter(), []);
const provisioningEnabled = config.featureToggles.provisioning;
const isUnmanaged = !dashboard.meta.provisioned;
const onShareExternallyChange = () => setShareExternally((prev) => !prev);
@@ -29,6 +34,10 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
if (shareExternally) {
makeExportableV1(dashboard).then((dashboardJson) => {
if ('error' in dashboardJson) {
console.error('Failed to export dashboard:', dashboardJson.error);
return;
}
openSaveAsDialog(dashboardJson);
});
} else {
@@ -51,13 +60,17 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
}
};
const openSaveAsDialog = (dash: any) => {
const openSaveAsDialog = (dash: unknown) => {
const dashboardJsonPretty = JSON.stringify(dash, null, 2);
const blob = new Blob([dashboardJsonPretty], {
type: 'application/json;charset=utf-8',
});
const time = new Date().getTime();
saveAs(blob, `${dash.title}-${time}.json`);
const title =
typeof dash === 'object' && dash !== null && 'title' in dash && typeof dash.title === 'string'
? dash.title
: 'dashboard';
saveAs(blob, `${title}-${time}.json`);
};
const openJsonModal = (clone: object) => {
@@ -80,13 +93,18 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
<p>
<Trans i18nKey="share-modal.export.info-text">Export this dashboard.</Trans>
</p>
<Field label={exportExternallyTranslation}>
<Field label={exportExternallyTranslation} noMargin>
<Switch id="share-externally-toggle" value={shareExternally} onChange={onShareExternallyChange} />
</Field>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="share-modal.export.cancel-button">Cancel</Trans>
</Button>
{provisioningEnabled && isUnmanaged && (
<Button variant="secondary" onClick={() => setShowExportToRepositoryDrawer(true)}>
<Trans i18nKey="share-modal.export.export-to-repository-button">Export to Repository</Trans>
</Button>
)}
<Button variant="secondary" onClick={onViewJson}>
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
</Button>
@@ -94,6 +112,30 @@ export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
<Trans i18nKey="share-modal.export.save-button">Save to file</Trans>
</Button>
</Modal.ButtonRow>
{showExportToRepositoryDrawer && (
<Drawer
title={
<Text variant="h3" element="h2">
{t('share-modal.export.export-to-repository-title', 'Export Dashboard to Repository')}
</Text>
}
subtitle={dashboard.title}
onClose={() => setShowExportToRepositoryDrawer(false)}
size="md"
>
<BulkExportProvisionedResource
folderUid={dashboard.meta.folderUid}
selectedItems={{
dashboard: dashboard.uid ? { [dashboard.uid]: true } : {},
folder: {},
}}
onDismiss={() => {
setShowExportToRepositoryDrawer(false);
onDismiss?.();
}}
/>
</Drawer>
)}
</>
);
});