diff --git a/public/app/core/components/NestedFolderPicker/utils.ts b/public/app/core/components/NestedFolderPicker/utils.ts index d6b9ccfd914..9d9be53b38f 100644 --- a/public/app/core/components/NestedFolderPicker/utils.ts +++ b/public/app/core/components/NestedFolderPicker/utils.ts @@ -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, }, diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx index 8191499ed7c..c710524f34e 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx @@ -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(); 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 /> )} - - This action will move the following content: - + - + + + This action will move the following content: + + + + - + diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index cf2a6ef2943..549b7850729 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 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()); } diff --git a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx index c30513be7e8..97618d9e15a 100644 --- a/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx +++ b/public/app/features/dashboard-scene/settings/GeneralSettingsEditView.tsx @@ -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 - diff --git a/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx index 0aeca36f3b9..fd2dc5f7a09 100644 --- a/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx +++ b/public/app/features/provisioning/components/BulkActions/BulkMoveProvisionedResource.tsx @@ -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
+ - - This will move selected folders and their descendants. In total, this will affect: - + In total, this will affect: @@ -129,12 +130,13 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions error={errors.targetFolderUID?.message} invalid={!!errors.targetFolderUID} > - { setTargetFolderUID(uid || ''); clearErrors('targetFolderUID'); }} + repositoryName={repository.name} /> { }; }); -jest.mock('app/core/components/Select/FolderPicker', () => { - const actual = jest.requireActual('app/core/components/Select/FolderPicker'); - return { - ...actual, - FolderPicker: function MockFolderPicker() { - return
Folder Picker
; - }, - }; -}); - 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: () =>
Mocked Folder Picker
, + }; +}); + jest.mock('app/features/manage-dashboards/services/ValidationSrv', () => { const actual = jest.requireActual('app/features/manage-dashboards/services/ValidationSrv'); return { diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index 1f2811089fc..747b0e407da 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -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 ( - { 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); diff --git a/public/app/features/provisioning/components/Shared/MoveActionAvailableTargetWarning.tsx b/public/app/features/provisioning/components/Shared/MoveActionAvailableTargetWarning.tsx new file mode 100644 index 00000000000..d5045204318 --- /dev/null +++ b/public/app/features/provisioning/components/Shared/MoveActionAvailableTargetWarning.tsx @@ -0,0 +1,23 @@ +import { t, Trans } from '@grafana/i18n'; +import { Box, Icon, Tooltip } from '@grafana/ui'; + +export function MoveActionAvailableTargetWarning() { + return ( + + + This will move selected folders and their descendants. Available target folders depend on the selected + resources. + + + + + + + + ); +} diff --git a/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.test.tsx b/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.test.tsx new file mode 100644 index 00000000000..05bf24b69a6 --- /dev/null +++ b/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.test.tsx @@ -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[] }) => ( +
+
{props.rootFolderUID || 'undefined'}
+
{JSON.stringify(props.excludeUIDs || [])}
+
+ ), +})); + +const mockUseGetFrontendSettingsQuery = useGetFrontendSettingsQuery as Partial< + ReturnType +>; +const mockUseIsProvisionedInstance = useIsProvisionedInstance as jest.MockedFunction; + +const setup = ({ + repoName = undefined, + excludeUIDs = undefined, +}: { + repoName?: string; + isNonProvisionedFolder?: boolean; + excludeUIDs?: string[]; +}) => { + render(); +}; + +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('[]'); + }); + }); +}); diff --git a/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.tsx b/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.tsx new file mode 100644 index 00000000000..c05ddede8e4 --- /dev/null +++ b/public/app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker.tsx @@ -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 ( + + ); +} + +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; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 15093cd347d..df0b8a4d53d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {