Provisioning: Add ProvisioningAwareFolderPicker to prevent cross-repository folder moves (#110136)

* Added ProvisioningAwareFolderPicker component to prevent cross repo resource move
This commit is contained in:
Yunwen Zheng
2025-09-02 09:55:59 -04:00
committed by GitHub
parent 6d5fe47790
commit d33f0e0941
11 changed files with 309 additions and 32 deletions
@@ -17,7 +17,7 @@ export const getCustomRootFolderItem = ({
managedBy,
uid,
}: {
title: string;
title?: string;
managedBy?: ManagerKind;
uid?: string;
}): DashboardsTreeItem => ({
@@ -25,7 +25,7 @@ export const getCustomRootFolderItem = ({
level: 0,
item: {
kind: 'folder' as const,
title,
title: title || '',
uid: uid || '',
managedBy,
},
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Modal, Text, Space } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { Alert, Button, Field, Modal, Text, Space, Box } from '@grafana/ui';
import { MoveActionAvailableTargetWarning } from 'app/features/provisioning/components/Shared/MoveActionAvailableTargetWarning';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { DashboardTreeSelection } from '../../types';
@@ -18,7 +19,6 @@ 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 onMove = async () => {
@@ -43,16 +43,25 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
/>
)}
<Text element="p">
<Trans i18nKey="browse-dashboards.action.move-modal-text">This action will move the following content:</Trans>
</Text>
<MoveActionAvailableTargetWarning />
<DescendantCount selectedItems={selectedItems} />
<Box paddingTop={2}>
<Text element="p">
<Trans i18nKey="browse-dashboards.action.move-modal-text">This action will move the following content:</Trans>
</Text>
<DescendantCount selectedItems={selectedItems} />
</Box>
<Space v={3} />
<Field label={t('browse-dashboards.action.move-modal-field-label', 'Folder name')}>
<FolderPicker value={moveTarget} excludeUIDs={selectedFolders} onChange={setMoveTarget} />
<ProvisioningAwareFolderPicker
value={moveTarget}
excludeUIDs={selectedFolders}
onChange={setMoveTarget}
repositoryName={undefined} // is non-provisioned folder
/>
</Field>
<Modal.ButtonRow>
@@ -36,6 +36,7 @@ import { ShowConfirmModalEvent } from 'app/types/events';
import {
AnnoKeyManagerAllowsEdits,
AnnoKeyManagerIdentity,
AnnoKeyManagerKind,
AnnoKeySourcePath,
ManagerKind,
@@ -773,6 +774,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
return this.state.meta.k8s?.annotations?.[AnnoKeyManagerKind];
}
getManagerIdentity(): string | undefined {
// get repo name if any
return this.state.meta.k8s?.annotations?.[AnnoKeyManagerIdentity];
}
isManaged() {
return Boolean(this.getManagerKind());
}
@@ -19,11 +19,11 @@ import {
WeekStart,
} from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { TimePickerSettings } from 'app/features/dashboard/components/DashboardSettings/TimePickerSettings';
import { GenAIDashDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIDashDescriptionButton';
import { GenAIDashTitleButton } from 'app/features/dashboard/components/GenAI/GenAIDashTitleButton';
import { MoveProvisionedDashboardDrawer } from 'app/features/provisioning/components/Dashboards/MoveProvisionedDashboardDrawer';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { updateNavModel } from '../pages/utils';
import { DashboardScene } from '../scene/DashboardScene';
@@ -286,9 +286,10 @@ function GeneralSettingsEditViewComponent({ model }: SceneComponentProps<General
<TagsInput id="tags-input" tags={tags} onChange={model.onTagsChange} width={40} />
</Field>
<Field noMargin label={t('dashboard-settings.general.folder-label', 'Folder')}>
<FolderPicker
<ProvisioningAwareFolderPicker
value={meta.folderUid}
onChange={dashboard.isManagedRepository() ? model.onProvisionedFolderChange : model.onFolderChange}
repositoryName={dashboard.getManagerIdentity()}
/>
</Field>
@@ -4,7 +4,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { FolderPicker, getAppEvents } from '@grafana/runtime';
import { getAppEvents } from '@grafana/runtime';
import { Box, Button, Field, Stack } from '@grafana/ui';
import { useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, Job } from 'app/api/clients/provisioning/v0alpha1';
@@ -17,6 +17,8 @@ import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/us
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { useSelectionRepoValidation } from '../../hooks/useSelectionRepoValidation';
import { MoveActionAvailableTargetWarning } from '../Shared/MoveActionAvailableTargetWarning';
import { ProvisioningAwareFolderPicker } from '../Shared/ProvisioningAwareFolderPicker';
import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner';
import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields';
import { generateTimestamp } from '../utils/timestamp';
@@ -111,10 +113,9 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
<FormProvider {...methods}>
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
<MoveActionAvailableTargetWarning />
<Box paddingBottom={2}>
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.move-warning">
This will move selected folders and their descendants. In total, this will affect:
</Trans>
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.move-total">In total, this will affect:</Trans>
<DescendantCount selectedItems={{ ...selectedItems, panel: {}, $all: false }} />
</Box>
@@ -129,12 +130,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
error={errors.targetFolderUID?.message}
invalid={!!errors.targetFolderUID}
>
<FolderPicker
<ProvisioningAwareFolderPicker
value={targetFolderUID}
onChange={(uid) => {
setTargetFolderUID(uid || '');
clearErrors('targetFolderUID');
}}
repositoryName={repository.name}
/>
</Field>
<ResourceEditFormSharedFields
@@ -36,16 +36,6 @@ jest.mock('../../hooks/useProvisionedRequestHandler', () => {
};
});
jest.mock('app/core/components/Select/FolderPicker', () => {
const actual = jest.requireActual('app/core/components/Select/FolderPicker');
return {
...actual,
FolderPicker: function MockFolderPicker() {
return <div data-testid="folder-picker">Folder Picker</div>;
},
};
});
jest.mock('app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile', () => {
return {
useCreateOrUpdateRepositoryFile: jest.fn(),
@@ -58,6 +48,12 @@ jest.mock('app/features/provisioning/hooks/useGetResourceRepositoryView', () =>
};
});
jest.mock('app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker', () => {
return {
ProvisioningAwareFolderPicker: () => <div data-testid="folder-picker">Mocked Folder Picker</div>,
};
});
jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => {
const actual = jest.requireActual('app/features/manage-dashboards/services/ValidationSrv');
return {
@@ -8,7 +8,6 @@ import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Button, Field, Input, Stack, TextArea } from '@grafana/ui';
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import kbn from 'app/core/utils/kbn';
import { Resource } from 'app/features/apiserver/types';
import { SaveDashboardFormCommonOptions } from 'app/features/dashboard-scene/saving/SaveDashboardForm';
@@ -23,6 +22,7 @@ import {
import { ProvisionedDashboardFormData } from '../../types/form';
import { buildResourceBranchRedirectUrl } from '../../utils/redirect';
import { ProvisioningAwareFolderPicker } from '../Shared/ProvisioningAwareFolderPicker';
import { RepoInvalidStateBanner } from '../Shared/RepoInvalidStateBanner';
import { ResourceEditFormSharedFields } from '../Shared/ResourceEditFormSharedFields';
import { getProvisionedMeta } from '../utils/getProvisionedMeta';
@@ -208,10 +208,9 @@ export function SaveProvisionedDashboardForm({
name={'folder'}
render={({ field: { ref, value, onChange, ...field } }) => {
return (
<FolderPicker
<ProvisioningAwareFolderPicker
onChange={async (uid?: string, title?: string) => {
onChange({ uid, title });
// Update folderUid URL param
updateURLParams('folderUid', uid);
const meta = await getProvisionedMeta(uid);
dashboard.setState({
@@ -223,6 +222,7 @@ export function SaveProvisionedDashboardForm({
}}
value={value.uid}
{...field}
showAllFolders
/>
);
}}
@@ -284,7 +284,8 @@ async function validateTitle(title: string, formValues: ProvisionedDashboardForm
// Update the URL params without reloading the page
function updateURLParams(param: string, value?: string) {
if (!value) {
// only check undefine and null, empty string = root folder, we still want to update the URL
if (value === undefined || value === null) {
return;
}
const url = new URL(window.location.href);
@@ -0,0 +1,23 @@
import { t, Trans } from '@grafana/i18n';
import { Box, Icon, Tooltip } from '@grafana/ui';
export function MoveActionAvailableTargetWarning() {
return (
<Box>
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.move-warning">
This will move selected folders and their descendants. Available target folders depend on the selected
resources.
</Trans>
<Tooltip
content={t(
'browse-dashboards.bulk-move-resources-form.move-warning-tooltip',
'You can only move provisioned resources within their provisioned folder, and local resources to local folders.'
)}
>
<span style={{ marginLeft: '4px' }}>
<Icon name="info-circle" size="sm" />
</span>
</Tooltip>
</Box>
);
}
@@ -0,0 +1,121 @@
import { render, screen } from '@testing-library/react';
import { config } from '@grafana/runtime';
import { useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { useIsProvisionedInstance } from '../../hooks/useIsProvisionedInstance';
import { ProvisioningAwareFolderPicker } from './ProvisioningAwareFolderPicker';
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useGetFrontendSettingsQuery: jest.fn(),
}));
jest.mock('../../hooks/useIsProvisionedInstance', () => ({
useIsProvisionedInstance: jest.fn(),
}));
jest.mock('app/core/components/Select/FolderPicker', () => ({
FolderPicker: (props: { rootFolderUID?: string; excludeUIDs?: string[] }) => (
<div data-testid="folder-picker">
<div data-testid="root-folder-uid">{props.rootFolderUID || 'undefined'}</div>
<div data-testid="exclude-uids">{JSON.stringify(props.excludeUIDs || [])}</div>
</div>
),
}));
const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as Partial<
ReturnType<typeof useGetFrontendSettingsQuery>
>;
const mockUseIsProvisionedInstance = useIsProvisionedInstance as jest.MockedFunction<typeof useIsProvisionedInstance>;
const setup = ({
repoName = undefined,
excludeUIDs = undefined,
}: {
repoName?: string;
isNonProvisionedFolder?: boolean;
excludeUIDs?: string[];
}) => {
render(<ProvisioningAwareFolderPicker repositoryName={repoName} onChange={jest.fn()} excludeUIDs={excludeUIDs} />);
};
describe('ProvisioningAwareFolderPicker', () => {
const mockSettingsData = {
items: [{ name: 'repo1' }, { name: 'repo2' }, { name: 'repo3' }],
};
beforeEach(() => {
jest.clearAllMocks();
mockUseGetFrontendSettingsQuery.mockReturnValue({
data: mockSettingsData,
isLoading: false,
error: undefined,
refetch: jest.fn(),
});
config.featureToggles = { provisioning: true };
});
describe('Provisioned Instance', () => {
beforeEach(() => {
mockUseIsProvisionedInstance.mockReturnValue(true);
});
it('should not restrict folders', () => {
setup({});
expect(screen.getByTestId('root-folder-uid')).toHaveTextContent('undefined');
expect(screen.getByTestId('exclude-uids')).toHaveTextContent('[]');
});
});
describe('Non-Provisioned Instance', () => {
beforeEach(() => {
mockUseIsProvisionedInstance.mockReturnValue(false);
});
it('should set root folder for repository context', () => {
setup({ repoName: 'my-repo' });
expect(screen.getByTestId('root-folder-uid')).toHaveTextContent('my-repo');
});
it('should exclude provisioned folders for non-provisioned context', () => {
setup({ repoName: undefined });
expect(screen.getByTestId('exclude-uids')).toHaveTextContent('["repo1","repo2","repo3"]');
});
it('should merge excludeUIDs', () => {
setup({ repoName: undefined, excludeUIDs: ['custom1'] });
const excludeUIDs = JSON.parse(screen.getByTestId('exclude-uids').textContent || '[]');
expect(excludeUIDs).toEqual(['repo1', 'repo2', 'repo3', 'custom1']);
});
});
describe('Feature Toggle Disabled', () => {
beforeEach(() => {
mockUseIsProvisionedInstance.mockReturnValue(false);
config.featureToggles.provisioning = false;
});
it('should not apply restrictions', () => {
setup({ isNonProvisionedFolder: true });
expect(screen.getByTestId('root-folder-uid')).toHaveTextContent('undefined');
expect(screen.getByTestId('exclude-uids')).toHaveTextContent('[]');
});
});
describe('Edge Cases', () => {
beforeEach(() => {
mockUseIsProvisionedInstance.mockReturnValue(false);
});
it('should handle missing settings data', () => {
mockUseGetFrontendSettingsQuery.mockReturnValue({ data: undefined });
setup({ isNonProvisionedFolder: true });
expect(screen.getByTestId('exclude-uids')).toHaveTextContent('[]');
});
});
});
@@ -0,0 +1,116 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { config } from '@grafana/runtime';
import { RepositoryView, RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { NestedFolderPickerProps } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { getCustomRootFolderItem } from 'app/core/components/NestedFolderPicker/utils';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { ManagerKind } from 'app/features/apiserver/types';
import { useIsProvisionedInstance } from '../../hooks/useIsProvisionedInstance';
interface Props extends NestedFolderPickerProps {
/* Repository name (uid) or undefined (when it's non-provisioned folder). This decides when to show only one provisioned folder */
repositoryName?: string;
showAllFolders?: boolean;
}
export function ProvisioningAwareFolderPicker({ repositoryName, showAllFolders, ...props }: Props) {
const isProvisionedInstance = useIsProvisionedInstance();
const provisioningEnabled = config.featureToggles.provisioning;
const { data: settingsData } = useGetFrontendSettingsQuery(provisioningEnabled ? undefined : skipToken);
const isNonProvisionedResource = !repositoryName;
const rootFolderUID = getRootFolderUID({
isProvisionedInstance,
provisioningEnabled,
repositoryName,
});
const excludeUIDs = getExcludeUIDs({
isProvisionedInstance,
isNonProvisionedResource,
provisioningEnabled,
settingsData,
});
const rootFolderDisplayItem = getRootFolderDisplayItem({
isProvisionedInstance,
rootFolderUID,
settingsDataItem: settingsData?.items,
});
return (
<FolderPicker
{...props}
rootFolderUID={showAllFolders ? undefined : rootFolderUID}
excludeUIDs={showAllFolders ? undefined : [...excludeUIDs, ...(props.excludeUIDs || [])]}
rootFolderItem={showAllFolders ? undefined : rootFolderDisplayItem}
/>
);
}
function getRootFolderUID({
isProvisionedInstance,
provisioningEnabled,
repositoryName,
}: {
isProvisionedInstance?: boolean;
provisioningEnabled?: boolean;
repositoryName?: string;
}) {
if (isProvisionedInstance) {
return undefined;
}
if (provisioningEnabled && repositoryName) {
return repositoryName;
}
return undefined;
}
function getExcludeUIDs({
isProvisionedInstance,
isNonProvisionedResource,
provisioningEnabled,
settingsData,
}: {
isProvisionedInstance?: boolean;
isNonProvisionedResource?: boolean;
provisioningEnabled?: boolean;
settingsData?: RepositoryViewList;
}) {
if (isProvisionedInstance) {
return [];
}
if (isNonProvisionedResource) {
// If provisioning is disabled, we don't want to exclude any folders
if (!provisioningEnabled) {
return [];
}
// If provisioning is enabled, we want to exclude all provisioned folders
return settingsData?.items.map((repo) => repo.name) || [];
}
return [];
}
function getRootFolderDisplayItem({
isProvisionedInstance,
rootFolderUID,
settingsDataItem,
}: {
isProvisionedInstance?: boolean;
rootFolderUID?: string;
settingsDataItem?: RepositoryView[];
}) {
if (isProvisionedInstance) {
// If it's a provisioned instance, we use default root display ("Dashboards")
return undefined;
}
const repoFolder = settingsDataItem?.find((item: RepositoryView) => item.name === rootFolderUID);
return repoFolder
? getCustomRootFolderItem({ title: repoFolder.title, uid: repoFolder.name, managedBy: ManagerKind.Repo })
: undefined;
}
+3 -1
View File
@@ -3565,7 +3565,9 @@
},
"error-moving-resources": "Error moving resources",
"error-no-target-folder-path": "Target folder path is invalid or empty, please select again.",
"move-warning": "This will move selected folders and their descendants. In total, this will affect:",
"move-total": "In total, this will affect:",
"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": {