Provisioning Folder: Bugfix cancel action drawer should not reset selection state; Read only alert reoganize (#109087)

* Read only message on drawer, fix cancel drawer reset selection state.

* Bugfix when select provisioned sub items from root, we are missing repo info
This commit is contained in:
Yunwen Zheng
2025-08-07 09:19:04 -04:00
committed by GitHub
parent 8add059800
commit b5fca60bdb
17 changed files with 197 additions and 58 deletions
@@ -148,7 +148,6 @@ export function BrowseActions({ folderDTO }: Props) {
folderUid={folderDTO?.uid || ''}
onDismiss={() => {
setShowBulkDeleteProvisionedResource(false);
onActionComplete();
}}
/>
</Drawer>
@@ -166,7 +165,6 @@ export function BrowseActions({ folderDTO }: Props) {
folderUid={folderDTO?.uid}
onDismiss={() => {
setShowBulkMoveProvisionedResource(false);
onActionComplete();
}}
/>
</Drawer>
@@ -194,8 +194,8 @@ describe('BulkDeleteProvisionedResource', () => {
});
it('returns null when repository is not available', () => {
const { container } = setup(null);
setup(null);
expect(container.firstChild).toBeNull();
expect(screen.getByLabelText('Repository not found')).toBeInTheDocument();
});
});
@@ -25,7 +25,9 @@ import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
import { MoveResultFailed } from './BulkActionFailureBanner';
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
import { ProgressState } from './BulkActionProgress';
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
import { useBulkActionRequest } from './useBulkActionRequest';
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
import {
BulkActionFormData,
BulkActionProvisionResourceProps,
@@ -202,7 +204,8 @@ export function BulkDeleteProvisionedResource({
selectedItems,
onDismiss,
}: BulkActionProvisionResourceProps) {
const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid });
const folderName = useFolderNameFromSelection({ folderUid, selectedItems });
const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
const workflowOptions = getWorkflowOptions(repository);
const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
@@ -214,8 +217,8 @@ export function BulkDeleteProvisionedResource({
workflow: getDefaultWorkflow(repository),
};
if (!repository) {
return null;
if (!repository || isReadOnlyRepo) {
return <RepoInvalidStateBanner noRepository={!repository} isReadOnlyRepo={isReadOnlyRepo} />;
}
return (
@@ -30,7 +30,9 @@ import { collectSelectedItems, fetchProvisionedDashboardPath } from '../utils';
import { MoveResultFailed } from './BulkActionFailureBanner';
import { BulkActionPostSubmitStep } from './BulkActionPostSubmitStep';
import { ProgressState } from './BulkActionProgress';
import { RepoInvalidStateBanner } from './RepoInvalidStateBanner';
import { useBulkActionRequest } from './useBulkActionRequest';
import { useFolderNameFromSelection } from './useFolderNameFromSelection';
import {
BulkActionFormData,
BulkActionProvisionResourceProps,
@@ -39,6 +41,7 @@ import {
getResourceTargetPath,
MoveResultSuccessState,
} from './utils';
interface FormProps extends BulkActionProvisionResourceProps {
initialValues: BulkActionFormData;
repository: RepositoryView;
@@ -277,7 +280,8 @@ function FormContent({ initialValues, selectedItems, repository, workflowOptions
}
export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismiss }: BulkActionProvisionResourceProps) {
const { repository, folder } = useGetResourceRepositoryView({ folderName: folderUid });
const folderName = useFolderNameFromSelection({ folderUid, selectedItems });
const { repository, folder, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName });
const workflowOptions = getWorkflowOptions(repository);
const folderPath = folder?.metadata?.annotations?.[AnnoKeySourcePath] || '';
@@ -289,8 +293,8 @@ export function BulkMoveProvisionedResource({ folderUid, selectedItems, onDismis
workflow: getDefaultWorkflow(repository),
};
if (!repository) {
return null;
if (!repository || isReadOnlyRepo) {
return <RepoInvalidStateBanner noRepository={!repository} isReadOnlyRepo={isReadOnlyRepo} />;
}
return (
@@ -0,0 +1,44 @@
import { Trans, t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
interface Props {
noRepository: boolean;
isReadOnlyRepo: boolean;
readOnlyMessage?: string;
}
export function RepoInvalidStateBanner({ noRepository, isReadOnlyRepo, readOnlyMessage }: Props) {
if (noRepository) {
return (
<Alert
title={t('browse-dashboards.bulk-move-resources-form.error.repository-not-found-title', 'Repository not found')}
>
<Trans i18nKey="browse-dashboards.bulk-move-resources-form.error.repository-not-found-message">
The repository for the selected folder could not be found. Please ensure that the folder is provisioned
correctly.
</Trans>
</Alert>
);
}
if (isReadOnlyRepo) {
return (
<Alert
title={t('browse-dashboards.bulk-move-resources-form.error.read-only-title', 'This repository is read only')}
>
{readOnlyMessage
? t(
'browse-dashboards.bulk-move-resources-form.error.read-only-saving-message',
'Repository is read-only and provisioned in git. {{readOnlyMessage}}',
{ readOnlyMessage }
)
: t(
'browse-dashboards.bulk-move-resources-form.error.read-only-message',
'If you have direct access to the target, please make modifications directly in the target repository.'
)}
</Alert>
);
}
return null;
}
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { ManagerKind } from 'app/features/apiserver/types';
import { rootItemsSelector, useChildrenByParentUIDState } from '../../state/hooks';
import { findItem } from '../../state/utils';
import { DashboardTreeSelection } from '../../types';
// This hook retrieves the folder UID from the selection state. Because search endpoint currently does not return resource metadata
// NOTE: This is a temporary workaround until the search endpoint is updated
interface Props {
folderUid?: string;
selectedItems: Omit<DashboardTreeSelection, 'panel' | '$all'>;
}
export function useFolderNameFromSelection({ folderUid, selectedItems }: Props) {
const rootItems = useSelector(rootItemsSelector);
const childrenByParentUID = useChildrenByParentUIDState();
return useMemo(() => {
// if we already have a folderUid, return it;
if (folderUid) {
return folderUid;
}
// Helper to walk up tree and find provisioned folder
const findProvisionedParent = (itemUid: string): string | undefined => {
const item = findItem(rootItems?.items || [], childrenByParentUID, itemUid);
if (!item) {
return undefined;
}
if (item.managedBy === ManagerKind.Repo) {
return item.uid;
}
if (item.parentUID) {
return findProvisionedParent(item.parentUID);
}
return undefined;
};
// Try folders, then dashboards
const firstSelectedUid =
Object.keys(selectedItems.folder).find((uid) => selectedItems.folder[uid]) ||
Object.keys(selectedItems.dashboard).find((uid) => selectedItems.dashboard[uid]);
return firstSelectedUid ? findProvisionedParent(firstSelectedUid) : undefined;
}, [folderUid, selectedItems, rootItems, childrenByParentUID]);
}
@@ -134,6 +134,7 @@ const defaultHookData: ProvisionedFolderFormDataResult = {
repository: mockRepository,
folder: mockFolder,
initialValues: mockFormData,
isReadOnlyRepo: false,
};
function setup(
@@ -20,6 +20,7 @@ import { FolderDTO } from 'app/types/folders';
import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
import { DescendantCount } from './BrowseActions/DescendantCount';
import { RepoInvalidStateBanner } from './BulkActions/RepoInvalidStateBanner';
import { getFolderURL } from './utils';
interface FormProps extends DeleteProvisionedFolderFormProps {
@@ -150,14 +151,23 @@ function FormContent({ initialValues, parentFolder, repository, workflowOptions,
}
export function DeleteProvisionedFolderForm({ parentFolder, onDismiss }: DeleteProvisionedFolderFormProps) {
const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({
const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'delete',
title: parentFolder?.title,
});
if (!initialValues) {
return null;
if (isReadOnlyRepo || !initialValues) {
return (
<RepoInvalidStateBanner
noRepository={!initialValues}
isReadOnlyRepo={isReadOnlyRepo}
readOnlyMessage={t(
'browse-dashboards.delete-folder.read-only-message',
'To delete this folder, please remove the folder from your repository.'
)}
/>
);
}
return (
@@ -131,6 +131,7 @@ const mockHookData: ProvisionedFolderFormDataResult = {
workflows: ['write', 'branch'],
target: 'folder',
},
isReadOnlyRepo: false,
folder: {
metadata: {
annotations: {
@@ -188,18 +189,18 @@ describe('NewProvisionedFolderForm', () => {
});
it('should return null when initialValues is not available', () => {
const { container } = setup(
setup(
{},
{
...mockHookData,
initialValues: undefined,
}
);
expect(container.firstChild).toBeNull();
expect(screen.getByLabelText('Repository not found')).toBeInTheDocument();
});
it('should show error when repository is not found', () => {
const { container } = setup(
setup(
{},
{
...mockHookData,
@@ -207,7 +208,7 @@ describe('NewProvisionedFolderForm', () => {
initialValues: undefined,
}
);
expect(container.firstChild).toBeNull();
expect(screen.getByLabelText('Repository not found')).toBeInTheDocument();
});
it('should show branch field when branch workflow is selected', async () => {
@@ -22,6 +22,7 @@ import { FolderDTO } from 'app/types/folders';
import { useProvisionedFolderFormData } from '../hooks/useProvisionedFolderFormData';
import { RepoInvalidStateBanner } from './BulkActions/RepoInvalidStateBanner';
import { validateFolderName } from './NewFolderForm';
import { formatFolderName, hasFolderNameCharactersToReplace } from './utils';
@@ -213,14 +214,23 @@ function FormContent({ initialValues, repository, workflowOptions, folder, onDis
}
export function NewProvisionedFolderForm({ parentFolder, onDismiss }: Props) {
const { workflowOptions, repository, folder, initialValues } = useProvisionedFolderFormData({
const { workflowOptions, repository, folder, initialValues, isReadOnlyRepo } = useProvisionedFolderFormData({
folderUid: parentFolder?.uid,
action: 'create',
title: '', // Empty title for new folders
});
if (!initialValues) {
return null;
if (isReadOnlyRepo || !initialValues) {
return (
<RepoInvalidStateBanner
noRepository={!initialValues}
isReadOnlyRepo={isReadOnlyRepo}
readOnlyMessage={t(
'browse-dashboards.new-folder.read-only-message',
'To create this folder, please add the resource in your repository directly.'
)}
/>
);
}
return (
@@ -20,6 +20,7 @@ export interface ProvisionedFolderFormDataResult {
folder?: Folder;
workflowOptions: Array<{ label: string; value: string }>;
initialValues?: BaseProvisionedFormData;
isReadOnlyRepo: boolean;
}
/**
@@ -30,7 +31,7 @@ export function useProvisionedFolderFormData({
action,
title,
}: UseProvisionedFolderFormDataProps): ProvisionedFolderFormDataResult {
const { repository, folder, isLoading } = useGetResourceRepositoryView({ folderName: folderUid });
const { repository, folder, isLoading, isReadOnlyRepo } = useGetResourceRepositoryView({ folderName: folderUid });
const workflowOptions = getWorkflowOptions(repository);
const timestamp = generateTimestamp();
@@ -56,5 +57,6 @@ export function useProvisionedFolderFormData({
folder,
workflowOptions,
initialValues,
isReadOnlyRepo,
};
}
@@ -6,11 +6,12 @@ import { AppEvents, locationUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents, locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import { Alert, Button, Field, Input, Stack, TextArea } from '@grafana/ui';
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 { RepoInvalidStateBanner } from 'app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
@@ -60,7 +61,7 @@ export function SaveProvisionedDashboardForm({
reset(defaultValues);
}, [defaultValues, reset]);
const onRequestError = (error: unknown, info: ProvisionedOperationInfo) => {
const onRequestError = (error: unknown) => {
appEvents.publish({
type: AppEvents.alertError.name,
payload: [t('dashboard-scene.save-provisioned-dashboard-form.api-error', 'Error saving dashboard'), error],
@@ -80,6 +81,7 @@ export function SaveProvisionedDashboardForm({
};
const onWriteSuccess = (_: ProvisionedOperationInfo, upsert: Resource<Dashboard>) => {
handleDismiss();
if (isNew && upsert?.metadata.name) {
handleNewDashboard(upsert);
} else {
@@ -91,6 +93,7 @@ export function SaveProvisionedDashboardForm({
};
const onBranchSuccess = (ref: string, path: string, info: ProvisionedOperationInfo, upsert: Resource<Dashboard>) => {
handleDismiss();
if (isNew && upsert?.metadata?.name) {
handleNewDashboard(upsert);
} else {
@@ -104,7 +107,7 @@ export function SaveProvisionedDashboardForm({
}
};
const onDismiss = () => {
const handleDismiss = () => {
dashboard.setState({ isDirty: false });
panelEditor?.onDiscard();
drawer.onClose();
@@ -118,7 +121,6 @@ export function SaveProvisionedDashboardForm({
onBranchSuccess: ({ ref, path }, info, resource) => onBranchSuccess(ref, path, info, resource),
onWriteSuccess,
onError: onRequestError,
onDismiss,
},
});
@@ -157,16 +159,11 @@ export function SaveProvisionedDashboardForm({
<form onSubmit={handleSubmit(handleFormSubmit)} name="save-provisioned-form">
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.save-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.save-provisioned-dashboard-form.copy-json-message">
If you have direct access to the target, copy the JSON and paste it there.
</Trans>
</Alert>
<RepoInvalidStateBanner
noRepository={false}
isReadOnlyRepo={true}
readOnlyMessage="If you have direct access to the target, copy the JSON and paste it there."
/>
)}
{isNew && (
@@ -4,6 +4,7 @@ import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
import { useUrlParams } from 'app/core/navigation/hooks';
import { AnnoKeyManagerIdentity, AnnoKeyManagerKind, AnnoKeySourcePath } from 'app/features/apiserver/types';
import { useGetResourceRepositoryView } from 'app/features/provisioning/hooks/useGetResourceRepositoryView';
import { getIsReadOnlyRepo } from 'app/features/provisioning/utils/repository';
import { DashboardMeta } from 'app/types/dashboard';
import { DashboardScene } from '../../scene/DashboardScene';
@@ -110,8 +111,6 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio
const { values, isNew, repository } = defaultValuesResult;
const workflowOptions = getWorkflowOptions(repository, loadedFromRef);
const readOnly = !repository?.workflows?.length;
return {
isReady: true,
defaultValues: values,
@@ -119,7 +118,7 @@ export function useProvisionedDashboardData(dashboard: DashboardScene): Provisio
loadedFromRef,
workflowOptions,
isNew,
readOnly,
readOnly: getIsReadOnlyRepo(repository),
isLoading,
setIsLoading,
};
@@ -4,8 +4,9 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { getAppEvents } from '@grafana/runtime';
import { Alert, Button, Drawer, Stack } from '@grafana/ui';
import { Button, Drawer, Stack } from '@grafana/ui';
import { RepositoryView, useDeleteRepositoryFilesWithPathMutation } from 'app/api/clients/provisioning/v0alpha1';
import { RepoInvalidStateBanner } from 'app/features/browse-dashboards/components/BulkActions/RepoInvalidStateBanner';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { ResourceEditFormSharedFields } from '../components/Provisioned/ResourceEditFormSharedFields';
@@ -118,17 +119,11 @@ export function DeleteProvisionedDashboardForm({
<form onSubmit={handleSubmit(handleSubmitForm)}>
<Stack direction="column" gap={2}>
{readOnly && (
<Alert
title={t(
'dashboard-scene.delete-provisioned-dashboard-form.title-this-repository-is-read-only',
'This repository is read only'
)}
>
<Trans i18nKey="dashboard-scene.delete-provisioned-dashboard-form.delete-read-only-file-message">
This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete
this dashboard, please remove the file from your Git repository.
</Trans>
</Alert>
<RepoInvalidStateBanner
noRepository={false}
isReadOnlyRepo={true}
readOnlyMessage="To delete this dashboard, please remove the file from your repository."
/>
)}
<ResourceEditFormSharedFields
@@ -5,6 +5,8 @@ import { Folder, useGetFolderQuery } from 'app/api/clients/folder/v1beta1';
import { RepositoryView, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { AnnoKeyManagerIdentity } from 'app/features/apiserver/types';
import { getIsReadOnlyRepo } from '../utils/repository';
interface GetResourceRepositoryArgs {
name?: string; // the repository name
folderName?: string; // folder we are targeting
@@ -15,6 +17,7 @@ interface RepositoryViewData {
folder?: Folder;
isLoading?: boolean;
isInstanceManaged: boolean;
isReadOnlyRepo: boolean;
}
// This is safe to call as a viewer (you do not need full access to the Repository configs)
@@ -29,17 +32,17 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
);
if (!provisioningEnabled) {
return { isLoading: false, isInstanceManaged: false };
return { isLoading: false, isInstanceManaged: false, isReadOnlyRepo: false };
}
if (isSettingsLoading || isFolderLoading) {
return { isLoading: true, isInstanceManaged: false };
return { isLoading: true, isInstanceManaged: false, isReadOnlyRepo: false };
}
const items = settingsData?.items ?? [];
if (!items.length) {
return { folder, isInstanceManaged: false };
return { folder, isInstanceManaged: false, isReadOnlyRepo: false };
}
const instanceRepo = items.find((repo) => repo.target === 'instance');
@@ -52,6 +55,7 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
repository,
folder,
isInstanceManaged,
isReadOnlyRepo: getIsReadOnlyRepo(repository),
};
}
}
@@ -65,6 +69,7 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
repository,
folder,
isInstanceManaged,
isReadOnlyRepo: getIsReadOnlyRepo(repository),
};
}
@@ -77,6 +82,7 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
repository,
folder,
isInstanceManaged,
isReadOnlyRepo: getIsReadOnlyRepo(repository),
};
}
}
@@ -86,5 +92,6 @@ export const useGetResourceRepositoryView = ({ name, folderName }: GetResourceRe
repository: instanceRepo,
folder,
isInstanceManaged,
isReadOnlyRepo: getIsReadOnlyRepo(instanceRepo),
};
};
@@ -0,0 +1,10 @@
import { RepositoryView } from 'app/api/clients/provisioning/v0alpha1';
export function getIsReadOnlyRepo(repository: RepositoryView | undefined): boolean {
if (!repository) {
return false;
}
// Repository is consider read-only if it has no workflows defined (workflows are required for write operations)
return repository.workflows.length === 0;
}
+14 -5
View File
@@ -3539,6 +3539,13 @@
"button-move": "Move",
"button-moving": "Moving...",
"deleting": "Deleting",
"error": {
"read-only-message": "If you have direct access to the target, please make modifications directly in the target repository.",
"read-only-saving-message": "Repository is read-only and provisioned in git. {{readOnlyMessage}}",
"read-only-title": "This repository is read only",
"repository-not-found-message": "The repository for the selected folder could not be found. Please ensure that the folder is provisioned correctly.",
"repository-not-found-title": "Repository not found"
},
"error-file-content-not-found": "File content not found",
"error-path-not-found": "Path not found",
"error-target-folder-path-missing": "Target folder path is missing",
@@ -3568,6 +3575,9 @@
"select-checkbox": "Select",
"tags-column": "Tags"
},
"delete-folder": {
"read-only-message": "To delete this folder, please remove the folder from your repository."
},
"delete-provisioned-folder-form": {
"api-error": "Failed to delete folder",
"button-cancel": "Cancel",
@@ -3611,6 +3621,9 @@
"name-cell": {
"no-items": "No items"
},
"new-folder": {
"read-only-message": "To create this folder, please add the resource in your repository directly."
},
"new-folder-form": {
"cancel-label": "Cancel",
"create-label": "Create",
@@ -5691,11 +5704,9 @@
"api-error": "Failed to delete dashboard",
"cancel-action": "Cancel",
"delete-action": "Delete dashboard",
"delete-read-only-file-message": "This dashboard cannot be deleted directly from Grafana because the repository is read-only. To delete this dashboard, please remove the file from your Git repository.",
"deleting": "Deleting...",
"drawer-title": "Delete Provisioned Dashboard",
"success-message": "Dashboard deleted successfully",
"title-this-repository-is-read-only": "This repository is read only"
"success-message": "Dashboard deleted successfully"
},
"description-label": {
"description": "Description"
@@ -6067,7 +6078,6 @@
"api-error": "Error saving dashboard",
"cancel": "Cancel",
"cannot-be-saved": "This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy the JSON or save it to a file below, then you can update your dashboard in the provisioning source.",
"copy-json-message": "If you have direct access to the target, copy the JSON and paste it there.",
"copy-json-to-clipboard": "Copy JSON to clipboard",
"file-path": "<0>File path:</0> {{filePath}}",
"label-description": "Description",
@@ -6079,7 +6089,6 @@
"see-docs": "See <2>documentation</2> for more information about provisioning.",
"title-required": "Dashboard title is required",
"title-same-as-folder": "Dashboard name cannot be the same as the folder name",
"title-this-repository-is-read-only": "This repository is read only",
"title-validation-failed": "Dashboard title validation failed."
},
"scenes-new-rule-from-panel-button": {