diff --git a/.betterer.results b/.betterer.results index a98a09ed15f..2203206eda7 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2734,19 +2734,12 @@ exports[`better eslint`] = { [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"], [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"] ], - "public/app/features/provisioning/Wizard/BootstrapStep.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Card components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"] - ], "public/app/features/provisioning/Wizard/FinishStep.tsx:5381": [ [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "1"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "2"], [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "3"] ], - "public/app/features/provisioning/Wizard/SynchronizeStep.tsx:5381": [ - [0, 0, 0, "Add noMargin prop to Field components to remove built-in margins. Use layout components like Stack or Grid with the gap prop instead for consistent spacing.", "0"] - ], "public/app/features/provisioning/types.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx new file mode 100644 index 00000000000..b39f6b6ff63 --- /dev/null +++ b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx @@ -0,0 +1,273 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ReactNode } from 'react'; +import { useForm, FormProvider } from 'react-hook-form'; + +import { useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/clients/provisioning'; + +import { BootstrapStep, Props } from './BootstrapStep'; +import { getResourceStats, useModeOptions } from './actions'; +import { WizardFormData } from './types'; + +jest.mock('app/api/clients/provisioning', () => ({ + useGetRepositoryFilesQuery: jest.fn(), + useGetResourceStatsQuery: jest.fn(), +})); + +jest.mock('./actions', () => ({ + getResourceStats: jest.fn(), + useModeOptions: jest.fn(), +})); + +// Wrapper component to provide form context +function FormWrapper({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial }) { + const methods = useForm({ + defaultValues: { + repository: { + type: 'github', + url: 'https://github.com/test/repo', + title: '', + sync: { + target: 'instance', + enabled: true, + }, + branch: 'main', + path: '', + readOnly: false, + prWorkflow: false, + ...defaultValues?.repository, + }, + ...defaultValues, + }, + }); + + return {children}; +} + +function setup(props: Partial = {}, formDefaultValues?: Partial) { + const user = userEvent.setup(); + + const defaultProps: Props = { + onOptionSelect: jest.fn(), + onStepStatusUpdate: jest.fn(), + repoName: 'test-repo', + settingsData: undefined, + ...props, + }; + + const utils = render( + + + + ); + + return { + user, + props: defaultProps, + ...utils, + }; +} + +describe('BootstrapStep', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useGetRepositoryFilesQuery as jest.Mock).mockReturnValue({ + data: { items: [] }, + isLoading: false, + }); + + (useGetResourceStatsQuery as jest.Mock).mockReturnValue({ + data: { instance: [] }, + isLoading: false, + }); + + (getResourceStats as jest.Mock).mockReturnValue({ + fileCount: 0, + resourceCount: 0, + resourceCountString: '', + }); + + (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', + }, + ]); + }); + + describe('rendering', () => { + it('should render loading state when data is loading', () => { + (useGetRepositoryFilesQuery as jest.Mock).mockReturnValue({ + data: undefined, + isLoading: true, + }); + + const { props } = setup(); + + expect(screen.getByText('Loading resource information...')).toBeInTheDocument(); + expect(props.onStepStatusUpdate).toHaveBeenCalledWith({ status: 'running' }); + }); + + it('should render correct info for GitHub repository type', async () => { + const { props } = setup(); + + expect(await screen.findByText('Grafana instance')).toBeInTheDocument(); + expect(screen.getByText('External storage')).toBeInTheDocument(); + expect(screen.getAllByText('Empty')).toHaveLength(2); // Both should show empty + + expect(props.onStepStatusUpdate).toHaveBeenCalledWith({ status: 'idle' }); + }); + + it('should render correct info for local file repository type', async () => { + (useGetRepositoryFilesQuery as jest.Mock).mockReturnValue({ + data: { + items: [ + { path: 'dashboard1.json' }, + { path: 'dashboard2.yaml' }, + { path: 'README.md' }, // Should not be counted + ], + }, + isLoading: false, + }); + + (getResourceStats as jest.Mock).mockReturnValue({ + fileCount: 2, + resourceCount: 0, + resourceCountString: '', + }); + + setup(); + + expect(await screen.findByText('2 files')).toBeInTheDocument(); + }); + + it('should display resource counts when resources exist', async () => { + (useGetResourceStatsQuery as jest.Mock).mockReturnValue({ + data: { + instance: [ + { group: 'dashboard.grafana.app', count: 5 }, + { group: 'folders', count: 2 }, + ], + }, + isLoading: false, + }); + + (getResourceStats as jest.Mock).mockReturnValue({ + fileCount: 0, + resourceCount: 7, + resourceCountString: '7 resources', + }); + + setup(); + + expect(await screen.findByText('7 resources')).toBeInTheDocument(); + }); + }); + + describe('option selection', () => { + it('should call onOptionSelect with correct argument when no migration needed', async () => { + const { props } = setup(); + + await waitFor(() => { + expect(props.onOptionSelect).toHaveBeenCalledWith(false); + }); + }); + + it('should call onOptionSelect with true when legacy storage exists', async () => { + const { props } = setup({ + settingsData: { + legacyStorage: true, + items: [], + }, + }); + + await waitFor(() => { + expect(props.onOptionSelect).toHaveBeenCalledWith(true); + }); + }); + + it('should call onOptionSelect with true when resources exist', async () => { + (useGetResourceStatsQuery as jest.Mock).mockReturnValue({ + data: { + instance: [{ group: 'dashboard.grafana.app', count: 1 }], + }, + isLoading: false, + }); + + (getResourceStats as jest.Mock).mockReturnValue({ + fileCount: 0, + resourceCount: 1, + resourceCountString: '1 resource', + }); + + const { props } = setup(); + + await waitFor(() => { + expect(props.onOptionSelect).toHaveBeenCalledWith(true); + }); + }); + }); + + describe('sync target options', () => { + it('should display both instance and folder options by default', async () => { + setup(); + + expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument(); + expect(await screen.findByText('Sync external storage to a new Grafana folder')).toBeInTheDocument(); + }); + + 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', + }, + ]); + + setup({ + settingsData: { + legacyStorage: true, + items: [], + }, + }); + + expect(await screen.findByText('Sync all resources with external storage')).toBeInTheDocument(); + expect(screen.queryByText('Sync external storage to a new Grafana folder')).not.toBeInTheDocument(); + }); + + it('should allow selecting different sync targets', async () => { + const { user } = setup(); + + const folderOption = await screen.findByText('Sync external storage to a new Grafana folder'); + await user.click(folderOption); + + // Check that the folder option is now selected by looking for the title field + expect(await screen.findByRole('textbox', { name: /display name/i })).toBeInTheDocument(); + }); + }); + + describe('title field visibility', () => { + it('should show title field only for folder sync target', async () => { + const { user } = setup(); + + // Initially should not show title field (default is instance) + expect(screen.queryByRole('textbox', { name: /display name/i })).not.toBeInTheDocument(); + + const folderOption = await screen.findByText('Sync external storage to a new Grafana folder'); + await user.click(folderOption); + + expect(await screen.findByRole('textbox', { name: /display name/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index bf309fda9b3..5c0d3afef4d 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -8,7 +8,7 @@ import { RepositoryViewList, useGetRepositoryFilesQuery, useGetResourceStatsQuer import { getResourceStats, useModeOptions } from './actions'; import { StepStatusInfo, WizardFormData } from './types'; -interface Props { +export interface Props { onOptionSelect: (requiresMigration: boolean) => void; onStepStatusUpdate: (info: StepStatusInfo) => void; settingsData?: RepositoryViewList; @@ -29,10 +29,14 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepSt const filesQuery = useGetRepositoryFilesQuery({ name: repoName }); const selectedTarget = watch('repository.sync.target'); const options = useModeOptions(repoName, settingsData); + const { target } = options[0]; const { resourceCount, resourceCountString, fileCount } = useMemo( () => getResourceStats(filesQuery.data, resourceStats.data), [filesQuery.data, resourceStats.data] ); + const requiresMigration = settingsData?.legacyStorage || resourceCount > 0; + const isLoading = resourceStats.isLoading || filesQuery.isLoading; + const { t } = useTranslate(); useEffect(() => { // Pick a name nice name based on type+settings @@ -49,22 +53,15 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepSt }, [getValues, setValue]); useEffect(() => { - const isLoading = resourceStats.isLoading || filesQuery.isLoading; onStepStatusUpdate({ status: isLoading ? 'running' : 'idle' }); - }, [filesQuery.isLoading, onStepStatusUpdate, resourceStats.isLoading]); + }, [isLoading, onStepStatusUpdate]); - // Auto select the first option on mount useEffect(() => { - const { target } = options[0]; setValue('repository.sync.target', target); - onOptionSelect(settingsData?.legacyStorage || resourceCount > 0); - // Only run this effect on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + onOptionSelect(requiresMigration); + }, [target, setValue, onOptionSelect, requiresMigration]); - const { t } = useTranslate(); - - if (resourceStats.isLoading || filesQuery.isLoading) { + if (isLoading) { return ( ( <> - {options.map((action, index) => ( + {options.map((action) => ( { onChange(action.target); }} + noMargin {...field} > {action.label} @@ -140,8 +138,10 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepSt error={errors.repository?.title?.message} invalid={!!errors.repository?.title} required + noMargin > )} {activeStep === 'synchronize' && ( - + )} {activeStep === 'finish' && } diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index 087381ae6ec..f7822be4bde 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -12,13 +12,14 @@ import { StepStatusInfo, WizardFormData } from './types'; export interface SynchronizeStepProps { onStepStatusUpdate: (info: StepStatusInfo) => void; requiresMigration: boolean; + isLegacyStorage?: boolean; } -export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: SynchronizeStepProps) { +export function SynchronizeStep({ onStepStatusUpdate, requiresMigration, isLegacyStorage }: SynchronizeStepProps) { const [createJob] = useCreateRepositoryJobsMutation(); const { getValues, register, watch } = useFormContext(); const repoType = watch('repository.type'); - const supportsHistory = requiresMigration && repoType === 'github'; + const supportsHistory = repoType === 'github' && isLegacyStorage; const [job, setJob] = useState(); const { t } = useTranslate(); const startSynchronization = async () => { @@ -117,9 +118,10 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration }: Synch Synchronization options - +