diff --git a/public/app/features/provisioning/Job/FinishedJobStatus.tsx b/public/app/features/provisioning/Job/FinishedJobStatus.tsx index bbf0ab22c6b..0f7da964733 100644 --- a/public/app/features/provisioning/Job/FinishedJobStatus.tsx +++ b/public/app/features/provisioning/Job/FinishedJobStatus.tsx @@ -4,18 +4,18 @@ import { Trans, useTranslate } from '@grafana/i18n'; import { Alert, Spinner, Stack, Text } from '@grafana/ui'; import { useGetRepositoryJobsWithPathQuery } from 'app/api/clients/provisioning'; -import { StepStatusInfo } from '../Wizard/types'; +import { useStepStatus } from '../Wizard/StepStatusContext'; import { JobContent } from './JobContent'; export interface FinishedJobProps { jobUid: string; repositoryName: string; - onStatusChange: (status: StepStatusInfo, error?: string) => void; } -export function FinishedJobStatus({ jobUid, repositoryName, onStatusChange }: FinishedJobProps) { +export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps) { const hasRetried = useRef(false); + const { setStepStatusInfo } = useStepStatus(); const finishedQuery = useGetRepositoryJobsWithPathQuery({ name: repositoryName, uid: jobUid, @@ -36,7 +36,7 @@ export function FinishedJobStatus({ jobUid, repositoryName, onStatusChange }: Fi } if (finishedQuery.isSuccess) { - onStatusChange({ status: 'success' }); + setStepStatusInfo({ status: 'success' }); } return () => { @@ -44,12 +44,12 @@ export function FinishedJobStatus({ jobUid, repositoryName, onStatusChange }: Fi clearTimeout(timeoutId); } }; - }, [finishedQuery, job, onStatusChange]); + }, [finishedQuery, job, setStepStatusInfo]); const { t } = useTranslate(); if (retryFailed) { - onStatusChange({ status: 'error' }); + setStepStatusInfo({ status: 'error' }); return ( diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 9ff4b2991a5..7a4cbea501c 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -2,17 +2,17 @@ import { Trans } from '@grafana/i18n'; import { Spinner, Stack, Text } from '@grafana/ui'; import { Job, useListJobQuery } from 'app/api/clients/provisioning'; -import { StepStatusInfo } from '../Wizard/types'; +import { useStepStatus } from '../Wizard/StepStatusContext'; import { FinishedJobStatus } from './FinishedJobStatus'; import { JobContent } from './JobContent'; export interface JobStatusProps { watch: Job; - onStatusChange: (status: StepStatusInfo, error?: string) => void; } -export function JobStatus({ watch, onStatusChange }: JobStatusProps) { +export function JobStatus({ watch }: JobStatusProps) { + const { setStepStatusInfo } = useStepStatus(); const activeQuery = useListJobQuery({ fieldSelector: `metadata.name=${watch.metadata?.name}`, watch: true, @@ -36,7 +36,7 @@ export function JobStatus({ watch, onStatusChange }: JobStatusProps) { } if (activeQuery.isError) { - onStatusChange({ status: 'error', error: 'Error fetching active job' }); + setStepStatusInfo({ status: 'error', error: 'Error fetching active job' }); return null; } @@ -45,9 +45,7 @@ export function JobStatus({ watch, onStatusChange }: JobStatusProps) { } if (shouldCheckFinishedJobs) { - return ( - - ); + return ; } return ( diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx index b39f6b6ff63..2762049e960 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.test.tsx @@ -6,6 +6,7 @@ import { useForm, FormProvider } from 'react-hook-form'; import { useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/clients/provisioning'; import { BootstrapStep, Props } from './BootstrapStep'; +import { StepStatusProvider } from './StepStatusContext'; import { getResourceStats, useModeOptions } from './actions'; import { WizardFormData } from './types'; @@ -41,7 +42,11 @@ function FormWrapper({ children, defaultValues }: { children: ReactNode; default }, }); - return {children}; + return ( + + {children} + + ); } function setup(props: Partial = {}, formDefaultValues?: Partial) { @@ -49,7 +54,6 @@ function setup(props: Partial = {}, formDefaultValues?: Partial { isLoading: true, }); - const { props } = setup(); + 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(); - + 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 () => { diff --git a/public/app/features/provisioning/Wizard/BootstrapStep.tsx b/public/app/features/provisioning/Wizard/BootstrapStep.tsx index 5c0d3afef4d..758b74cc984 100644 --- a/public/app/features/provisioning/Wizard/BootstrapStep.tsx +++ b/public/app/features/provisioning/Wizard/BootstrapStep.tsx @@ -5,17 +5,18 @@ import { Trans, useTranslate } from '@grafana/i18n'; import { Box, Card, Field, Input, LoadingPlaceholder, Stack, Text } from '@grafana/ui'; import { RepositoryViewList, useGetRepositoryFilesQuery, useGetResourceStatsQuery } from 'app/api/clients/provisioning'; +import { useStepStatus } from './StepStatusContext'; import { getResourceStats, useModeOptions } from './actions'; -import { StepStatusInfo, WizardFormData } from './types'; +import { WizardFormData } from './types'; export interface Props { onOptionSelect: (requiresMigration: boolean) => void; - onStepStatusUpdate: (info: StepStatusInfo) => void; settingsData?: RepositoryViewList; repoName: string; } -export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepStatusUpdate }: Props) { +export function BootstrapStep({ onOptionSelect, settingsData, repoName }: Props) { + const { setStepStatusInfo } = useStepStatus(); const { register, control, @@ -53,8 +54,8 @@ export function BootstrapStep({ onOptionSelect, settingsData, repoName, onStepSt }, [getValues, setValue]); useEffect(() => { - onStepStatusUpdate({ status: isLoading ? 'running' : 'idle' }); - }, [isLoading, onStepStatusUpdate]); + setStepStatusInfo({ status: isLoading ? 'running' : 'idle' }); + }, [isLoading, setStepStatusInfo]); useEffect(() => { setValue('repository.sync.target', target); diff --git a/public/app/features/provisioning/Wizard/ConnectPage.tsx b/public/app/features/provisioning/Wizard/ConnectPage.tsx index 0504fd8bf7b..b3ddb223612 100644 --- a/public/app/features/provisioning/Wizard/ConnectPage.tsx +++ b/public/app/features/provisioning/Wizard/ConnectPage.tsx @@ -4,6 +4,7 @@ import { useTranslate } from '@grafana/i18n'; import { Page } from 'app/core/components/Page/Page'; import { ProvisioningWizard } from './ProvisioningWizard'; +import { StepStatusProvider } from './StepStatusContext'; import { RepoType } from './types'; export default function ConnectPage() { @@ -26,7 +27,9 @@ export default function ConnectPage() { }} > - + + + ); diff --git a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx index 90f562ca228..b23dd0b2579 100644 --- a/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx +++ b/public/app/features/provisioning/Wizard/ProvisioningWizard.tsx @@ -19,9 +19,10 @@ import { getFormErrors } from '../utils/getFormErrors'; import { BootstrapStep } from './BootstrapStep'; import { ConnectStep } from './ConnectStep'; import { FinishStep } from './FinishStep'; +import { useStepStatus } from './StepStatusContext'; import { Step, Stepper } from './Stepper'; import { SynchronizeStep } from './SynchronizeStep'; -import { RepoType, StepStatusInfo, WizardFormData, WizardStep } from './types'; +import { RepoType, WizardFormData, WizardStep } from './types'; const appEvents = getAppEvents(); @@ -58,11 +59,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { const [activeStep, setActiveStep] = useState('connection'); const [completedSteps, setCompletedSteps] = useState([]); const [requiresMigration, setRequiresMigration] = useState(false); - const [stepStatusInfo, setStepStatusInfo] = useState({ status: 'idle' }); const [isSubmitting, setIsSubmitting] = useState(false); const [isCancelling, setIsCancelling] = useState(false); + const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError } = useStepStatus(); + const settingsQuery = useGetFrontendSettingsQuery(); const navigate = useNavigate(); const { t } = useTranslate(); @@ -95,7 +97,6 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { const currentStepIndex = steps.findIndex((s) => s.id === activeStep); const currentStepConfig = steps[currentStepIndex]; - const isStepSuccess = stepStatusInfo.status === 'success'; // A different repository is marked with instance target -- nothing will succeed useEffect(() => { @@ -217,15 +218,15 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { }; const isNextButtonDisabled = () => { - if (activeStep === 'synchronize') { - return stepStatusInfo.status !== 'success'; + // If the step is not on Connect page, we only enable it if the job was successful + if (activeStep !== 'connection' && hasStepError) { + return true; } - return ( - isSubmitting || - isCancelling || - stepStatusInfo.status === 'running' || - (activeStep !== 'connection' && stepStatusInfo.status === 'error') - ); + // Synchronize step requires success to proceed + if (activeStep === 'synchronize') { + return !isStepSuccess; // Disable next button if the step is not successful + } + return isSubmitting || isCancelling || isStepRunning; }; return ( @@ -242,23 +243,19 @@ export function ProvisioningWizard({ type }: { type: RepoType }) { - {stepStatusInfo.status === 'error' && ( - - )} + {hasStepError && }
{activeStep === 'connection' && } {activeStep === 'bootstrap' && ( )} {activeStep === 'synchronize' && ( diff --git a/public/app/features/provisioning/Wizard/StepStatusContext.test.tsx b/public/app/features/provisioning/Wizard/StepStatusContext.test.tsx new file mode 100644 index 00000000000..d3382132565 --- /dev/null +++ b/public/app/features/provisioning/Wizard/StepStatusContext.test.tsx @@ -0,0 +1,117 @@ +import { renderHook, act } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; + +import { StepStatusProvider, useStepStatus } from './StepStatusContext'; + +describe('StepStatusContext', () => { + const wrapper = ({ children }: PropsWithChildren<{}>) => {children}; + + describe('useStepStatus hook', () => { + // Initial state (Status: Idle) + it('should provide initial idle status', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + expect(result.current.stepStatusInfo.status).toBe('idle'); + expect(result.current.isStepIdle).toBe(true); + expect(result.current.hasStepError).toBe(false); + expect(result.current.isStepRunning).toBe(false); + expect(result.current.isStepSuccess).toBe(false); + }); + + // Status: Running + it('should update status to running', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + act(() => { + result.current.setStepStatusInfo({ status: 'running' }); + }); + + expect(result.current.stepStatusInfo.status).toBe('running'); + expect(result.current.isStepRunning).toBe(true); + expect(result.current.isStepIdle).toBe(false); + }); + + // Status: Success + it('should update status to success', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + act(() => { + result.current.setStepStatusInfo({ status: 'success' }); + }); + + expect(result.current.stepStatusInfo.status).toBe('success'); + expect(result.current.isStepSuccess).toBe(true); + expect(result.current.isStepIdle).toBe(false); + expect(result.current.hasStepError).toBe(false); + expect(result.current.isStepRunning).toBe(false); + }); + + // Status: Error + it('should update status to error with message', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + act(() => { + result.current.setStepStatusInfo({ status: 'error', error: 'Test error' }); + }); + + const { stepStatusInfo } = result.current; + + expect(stepStatusInfo.status).toBe('error'); + expect(result.current.hasStepError).toBe(true); + + // Check if error property exists + if ('error' in stepStatusInfo) { + expect(stepStatusInfo.error).toBe('Test error'); + } + }); + + // Status transitions tests + it('should handle status transitions correctly', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + // idle -> running + act(() => { + result.current.setStepStatusInfo({ status: 'running' }); + }); + expect(result.current.isStepRunning).toBe(true); + + // running -> success + act(() => { + result.current.setStepStatusInfo({ status: 'success' }); + }); + expect(result.current.isStepSuccess).toBe(true); + expect(result.current.isStepRunning).toBe(false); + + // success -> idle (reset) + act(() => { + result.current.setStepStatusInfo({ status: 'idle' }); + }); + expect(result.current.isStepIdle).toBe(true); + expect(result.current.isStepSuccess).toBe(false); + }); + + it('should handle multiple status updates', () => { + const { result } = renderHook(() => useStepStatus(), { wrapper }); + + act(() => { + result.current.setStepStatusInfo({ status: 'running' }); + result.current.setStepStatusInfo({ status: 'error', error: 'Failed' }); + result.current.setStepStatusInfo({ status: 'success' }); + }); + + // Should end up in success state + expect(result.current.stepStatusInfo.status).toBe('success'); + expect(result.current.isStepSuccess).toBe(true); + }); + + it('should throw error when used outside provider', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + renderHook(() => useStepStatus()); + }).toThrow('useStepStatus must be used within a StepStatusProvider'); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/public/app/features/provisioning/Wizard/StepStatusContext.tsx b/public/app/features/provisioning/Wizard/StepStatusContext.tsx new file mode 100644 index 00000000000..e3081bfba02 --- /dev/null +++ b/public/app/features/provisioning/Wizard/StepStatusContext.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useState, useCallback, PropsWithChildren } from 'react'; + +import { StepStatusInfo } from './types'; + +interface StepStatusContextData { + // Current status + stepStatusInfo: StepStatusInfo; + + // Status setters + setStepStatusInfo: (info: StepStatusInfo) => void; + + // Computed status checks + hasStepError: boolean; + isStepRunning: boolean; + isStepSuccess: boolean; + isStepIdle: boolean; +} + +const StepStatusContext = createContext(undefined); + +export const StepStatusProvider = ({ children }: PropsWithChildren) => { + const [stepStatusInfo, setStepStatusInfoState] = useState({ status: 'idle' }); + + const setStepStatusInfo = useCallback((info: StepStatusInfo) => { + setStepStatusInfoState(info); + }, []); + + const value: StepStatusContextData = { + stepStatusInfo, + setStepStatusInfo, + hasStepError: stepStatusInfo.status === 'error', + isStepRunning: stepStatusInfo.status === 'running', + isStepSuccess: stepStatusInfo.status === 'success', + isStepIdle: stepStatusInfo.status === 'idle', + }; + + return {children}; +}; + +export const useStepStatus = () => { + const context = useContext(StepStatusContext); + + if (context === undefined) { + throw new Error('useStepStatus must be used within a StepStatusProvider'); + } + + return context; +}; diff --git a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx index f7822be4bde..19a79dc5f83 100644 --- a/public/app/features/provisioning/Wizard/SynchronizeStep.tsx +++ b/public/app/features/provisioning/Wizard/SynchronizeStep.tsx @@ -7,25 +7,27 @@ import { Job, useCreateRepositoryJobsMutation } from 'app/api/clients/provisioni import { JobStatus } from '../Job/JobStatus'; -import { StepStatusInfo, WizardFormData } from './types'; +import { useStepStatus } from './StepStatusContext'; +import { WizardFormData } from './types'; export interface SynchronizeStepProps { - onStepStatusUpdate: (info: StepStatusInfo) => void; requiresMigration: boolean; isLegacyStorage?: boolean; } -export function SynchronizeStep({ onStepStatusUpdate, requiresMigration, isLegacyStorage }: SynchronizeStepProps) { +export function SynchronizeStep({ requiresMigration, isLegacyStorage }: SynchronizeStepProps) { + const { setStepStatusInfo } = useStepStatus(); const [createJob] = useCreateRepositoryJobsMutation(); const { getValues, register, watch } = useFormContext(); const repoType = watch('repository.type'); const supportsHistory = repoType === 'github' && isLegacyStorage; const [job, setJob] = useState(); const { t } = useTranslate(); + const startSynchronization = async () => { const [history, repoName] = getValues(['migrate.history', 'repositoryName']); if (!repoName) { - onStepStatusUpdate({ + setStepStatusInfo({ status: 'error', error: t('provisioning.synchronize-step.error-no-repository-name', 'No repository name provided'), }); @@ -33,7 +35,7 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration, isLegac } try { - onStepStatusUpdate({ status: 'running' }); + setStepStatusInfo({ status: 'running' }); const jobSpec = requiresMigration ? { migrate: { @@ -52,14 +54,14 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration, isLegac }).unwrap(); if (!response?.metadata?.name) { - return onStepStatusUpdate({ + return setStepStatusInfo({ status: 'error', error: t('provisioning.synchronize-step.error-no-job-id', 'Failed to start job'), }); } setJob(response); } catch (error) { - onStepStatusUpdate({ + setStepStatusInfo({ status: 'error', error: t('provisioning.synchronize-step.error-starting-job', 'Error starting job'), }); @@ -67,7 +69,7 @@ export function SynchronizeStep({ onStepStatusUpdate, requiresMigration, isLegac }; if (job) { - return ; + return ; } return (