From 3e9662d91004b8f033b2db74ad17ef0fd6ebdd64 Mon Sep 17 00:00:00 2001 From: Yunwen Zheng Date: Fri, 14 Nov 2025 10:22:17 -0500 Subject: [PATCH] BootstrapStep: Display disabled option and add reason (#113881) * BootstrapStep: Display disabled option and add reason --- .../Wizard/BootstrapStep.test.tsx | 58 ++++++++------ .../provisioning/Wizard/BootstrapStep.tsx | 43 +++++++++-- .../Wizard/hooks/useModeOptions.ts | 75 +++++++++++++++---- .../app/features/provisioning/Wizard/types.ts | 2 + public/locales/en-US/grafana.json | 9 +++ 5 files changed, 142 insertions(+), 45 deletions(-) diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx index 62b0cbd5509..ef16124895c 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx @@ -99,20 +99,22 @@ describe('BootstrapStep', () => { shouldSkipSync: true, }); - (useModeOptions as jest.Mock).mockReturnValue([ - { - target: 'instance', - label: 'Sync all resources with external storage', - description: 'Resources will be synced with external storage', - subtitle: 'Use this option if you want to sync your entire instance', - }, - { - target: 'folder', - label: 'Sync external storage to a new Grafana folder', - description: 'A new Grafana folder will be created', - subtitle: 'Use this option to sync into a new folder', - }, - ]); + (useModeOptions as jest.Mock).mockReturnValue({ + enabledOptions: [ + { + target: 'instance', + label: 'Sync all resources with external storage', + description: 'Resources will be synced with external storage', + subtitle: 'Use this option if you want to sync your entire instance', + }, + { + target: 'folder', + label: 'Sync external storage to a new Grafana folder', + description: 'A new Grafana folder will be created', + subtitle: 'Use this option to sync into a new folder', + }, + ], + }); }); describe('rendering', () => { @@ -232,14 +234,24 @@ describe('BootstrapStep', () => { }); it('should only display instance option when legacy storage exists', async () => { - (useModeOptions as jest.Mock).mockReturnValue([ - { - target: 'instance', - label: 'Sync all resources with external storage', - description: 'Resources will be synced with external storage', - subtitle: 'Use this option if you want to sync your entire instance', - }, - ]); + (useModeOptions as jest.Mock).mockReturnValue({ + enabledOptions: [ + { + target: 'instance', + label: 'Sync all resources with external storage', + description: 'Resources will be synced with external storage', + subtitle: 'Use this option if you want to sync your entire instance', + }, + ], + disabledOptions: [ + { + target: 'folder', + label: 'Sync external storage to a new Grafana folder', + description: 'A new Grafana folder will be created', + subtitle: 'Use this option to sync into a new folder', + }, + ], + }); setup({ settingsData: { @@ -251,7 +263,7 @@ describe('BootstrapStep', () => { }); expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument(); - expect(screen.queryByText('Sync external storage to a new Grafana folder')).not.toBeInTheDocument(); + expect(await screen.findByText('Sync external storage to a new Grafana folder')).not.toBeChecked(); }); it('should allow selecting different sync targets', async () => { diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index e373d2e7d55..1dafe14c709 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -4,7 +4,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Box, Card, Field, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Box, Card, Field, Icon, Input, LoadingPlaceholder, Stack, Text, useStyles2 } from '@grafana/ui'; import { RepositoryViewList } from 'app/api/clients/provisioning/v0alpha1'; import { generateRepositoryTitle } from 'app/features/provisioning/utils/data'; @@ -35,8 +35,8 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam const selectedTarget = watch('repository.sync.target'); const repositoryType = watch('repository.type'); - const options = useModeOptions(repoName, settingsData); - const { target } = options[0]; + const { enabledOptions, disabledOptions } = useModeOptions(repoName, settingsData); + const { target } = enabledOptions?.[0]; const { resourceCountString, fileCountString, isLoading } = useResourceStats(repoName, settingsData?.legacyStorage); const styles = useStyles2(getStyles); @@ -73,14 +73,17 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam control={control} render={({ field: { ref, onChange, ...field } }) => ( <> - {options.map((action) => ( + {enabledOptions?.map((action) => ( { - onChange(action.target); + if (!action.disabled) { + onChange(action.target); + } }} noMargin + disabled={action.disabled} {...field} > @@ -97,7 +100,6 @@ export const BootstrapStep = memo(function BootstrapStep({ settingsData, repoNam {action.subtitle} -
)} + + {disabledOptions?.length > 0 && ( + <> + {/* Unavailable options */} + + + {t('provisioning.bootstrap-step.unavailable-options.title', 'Unavailable options')} + + + {disabledOptions?.map((action) => ( + + + {action.label} + + +
+ {action.disabledReason} + + + ))} + + )} ); @@ -152,4 +176,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginTop: theme.spacing(2), marginBottom: theme.spacing(2), }), + infoIcon: css({ + color: theme.colors.primary.main, + marginRight: theme.spacing(0.25), + marginBottom: theme.spacing(0.25), + }), }); diff --git a/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts index bc9b704f872..b642c1ad41b 100644 --- a/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts +++ b/public/app/features/provisioning/Wizard/hooks/useModeOptions.ts @@ -11,28 +11,63 @@ import { ModeOption } from '../types'; function filterModeOptions(modeOptions: ModeOption[], repoName: string, settings?: RepositoryViewList): ModeOption[] { const folderConnected = settings?.items?.some((item) => item.target === 'folder' && item.name !== repoName); const allowedTargets = settings?.allowedTargets || ['instance', 'folder']; + const legacyStorageEnabled = settings?.legacyStorage; - return modeOptions.filter((option) => { - if (!allowedTargets.includes(option.target)) { - return false; + return modeOptions.map((option) => { + if (option.disabled) { + return option; } - if (settings?.legacyStorage) { - return option.target === 'instance'; + const disabledReason = resolveDisabledReason(option, { allowedTargets, folderConnected, legacyStorageEnabled }); + + if (!disabledReason) { + return option; } - if (option.target === 'folder') { - return true; - } - - if (option.target === 'instance') { - return !folderConnected; - } - - return false; + return { + ...option, + disabled: true, + disabledReason, + }; }); } +type DisableContext = { + allowedTargets: string[]; + folderConnected?: boolean; + legacyStorageEnabled?: boolean; +}; + +// Returns a translated reason why the given mode option should be disabled. +function resolveDisabledReason(option: ModeOption, context: DisableContext) { + if (!context.allowedTargets.includes(option.target)) { + return t( + 'provisioning.mode-options.disabled.not-allowed', + 'Provisioning settings for this repository restrict syncing to specific targets. Update the repository configuration to enable this option.' + ); + } + + if (context.legacyStorageEnabled && option.target !== 'instance') { + return t( + 'provisioning.mode-options.disabled.legacy-storage', + 'Legacy storage mode only supports syncing the entire Grafana instance.' + ); + } + + if (option.target === 'instance' && context.folderConnected) { + return t( + 'provisioning.mode-options.disabled.folder-connected', + 'Full instance synchronization is disabled because another folder is already synced with a repository.' + ); + } + + if (option.target !== 'instance' && option.target !== 'folder') { + return t('provisioning.mode-options.disabled.not-supported', 'This option is not supported yet.'); + } + + return undefined; +} + /** * Hook that provides filtered mode options * This needs to be a hook, so we can add translations @@ -51,6 +86,7 @@ export function useModeOptions(repoName: string, settings?: RepositoryViewList) 'provisioning.mode-options.instance.subtitle', 'Use this option if you want to sync and manage your entire Grafana instance through external storage.' ), + disabled: false, }, { target: 'folder', @@ -63,9 +99,18 @@ export function useModeOptions(repoName: string, settings?: RepositoryViewList) 'provisioning.mode-options.folder.subtitle', 'Use this option to sync external resources into a new folder without affecting the rest of your instance.' ), + disabled: false, }, ]; - return filterModeOptions(modeOptions, repoName, settings); + const options = filterModeOptions(modeOptions, repoName, settings); + // Filtering 2 mode options on each render; trivial cost, so no need for useMemo here. + const enabledOptions = options.filter((option) => !option.disabled); + const disabledOptions = options.filter((option) => option.disabled); + + return { + enabledOptions, + disabledOptions, + }; }, [repoName, settings]); } diff --git a/public/app/features/provisioning/Wizard/types.ts b/public/app/features/provisioning/Wizard/types.ts index 43b8545e379..0dc2f001a43 100644 --- a/public/app/features/provisioning/Wizard/types.ts +++ b/public/app/features/provisioning/Wizard/types.ts @@ -24,6 +24,8 @@ export interface ModeOption { label: string; description: string; subtitle: string; + disabled: boolean; + disabledReason?: string; } export const RepoTypeDisplay: { [key in RepoType]: string } = { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c027bdde11e..69a73888073 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11493,6 +11493,9 @@ "label-display-name": "Display name", "placeholder-my-repository-connection": "My repository connection", "text-loading-resource-information": "Loading resource information...", + "unavailable-options": { + "title": "Unavailable options" + }, "unmanaged-resources-label": "Unmanaged resources" }, "check-repository": { @@ -11774,6 +11777,12 @@ "truncated": "Message truncated" }, "mode-options": { + "disabled": { + "folder-connected": "Full instance synchronization is disabled because another folder is already synced with a repository.", + "legacy-storage": "Legacy storage mode only supports syncing the entire Grafana instance.", + "not-allowed": "Provisioning settings for this repository restrict syncing to specific targets. Update the repository configuration to enable this option.", + "not-supported": "This option is not supported yet." + }, "folder": { "description": "After setup, a new Grafana folder will be created and synced with external storage. If any resources are present in external storage, they will be provisioned to this new folder. All new resources created in this folder will be stored and versioned in external storage.", "label": "Sync external storage to a new Grafana folder",