ProvisioningWizard: Refactor step status management into context provider (#106110)

* ProvisioningWizard: move stepper info to context propvider
This commit is contained in:
Yunwen Zheng
2025-06-09 11:35:14 -04:00
committed by GitHub
parent 7ebb5bea87
commit 1da8c9bd18
9 changed files with 217 additions and 51 deletions
@@ -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 (
<Alert severity="error" title={t('provisioning.job-status.no-job-found', 'No job found')}>
<Trans i18nKey="provisioning.job-status.no-job-found-message">
@@ -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 (
<FinishedJobStatus jobUid={watch.metadata?.uid!} repositoryName={repoLabel} onStatusChange={onStatusChange} />
);
return <FinishedJobStatus jobUid={watch.metadata?.uid!} repositoryName={repoLabel} />;
}
return (
@@ -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 <FormProvider {...methods}>{children}</FormProvider>;
return (
<FormProvider {...methods}>
<StepStatusProvider>{children}</StepStatusProvider>
</FormProvider>
);
}
function setup(props: Partial<Props> = {}, formDefaultValues?: Partial<WizardFormData>) {
@@ -49,7 +54,6 @@ function setup(props: Partial<Props> = {}, formDefaultValues?: Partial<WizardFor
const defaultProps: Props = {
onOptionSelect: jest.fn(),
onStepStatusUpdate: jest.fn(),
repoName: 'test-repo',
settingsData: undefined,
...props,
@@ -111,20 +115,16 @@ describe('BootstrapStep', () => {
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 () => {
@@ -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);
@@ -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() {
}}
>
<Page.Contents>
<ProvisioningWizard type={type} />
<StepStatusProvider>
<ProvisioningWizard type={type} />
</StepStatusProvider>
</Page.Contents>
</Page>
);
@@ -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<WizardStep>('connection');
const [completedSteps, setCompletedSteps] = useState<WizardStep[]>([]);
const [requiresMigration, setRequiresMigration] = useState(false);
const [stepStatusInfo, setStepStatusInfo] = useState<StepStatusInfo>({ 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 }) {
</Text>
</Box>
{stepStatusInfo.status === 'error' && (
<Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />
)}
{hasStepError && <Alert severity="error" title={'error' in stepStatusInfo ? stepStatusInfo.error : ''} />}
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
{activeStep === 'bootstrap' && (
<BootstrapStep
onOptionSelect={setRequiresMigration}
onStepStatusUpdate={setStepStatusInfo}
settingsData={settingsQuery.data}
repoName={repoName ?? ''}
/>
)}
{activeStep === 'synchronize' && (
<SynchronizeStep
onStepStatusUpdate={setStepStatusInfo}
requiresMigration={requiresMigration}
isLegacyStorage={Boolean(settingsQuery.data?.legacyStorage)}
/>
@@ -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<{}>) => <StepStatusProvider>{children}</StepStatusProvider>;
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();
});
});
});
@@ -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<StepStatusContextData | undefined>(undefined);
export const StepStatusProvider = ({ children }: PropsWithChildren) => {
const [stepStatusInfo, setStepStatusInfoState] = useState<StepStatusInfo>({ 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 <StepStatusContext.Provider value={value}>{children}</StepStatusContext.Provider>;
};
export const useStepStatus = () => {
const context = useContext(StepStatusContext);
if (context === undefined) {
throw new Error('useStepStatus must be used within a StepStatusProvider');
}
return context;
};
@@ -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<WizardFormData>();
const repoType = watch('repository.type');
const supportsHistory = repoType === 'github' && isLegacyStorage;
const [job, setJob] = useState<Job>();
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 <JobStatus watch={job} onStatusChange={onStepStatusUpdate} />;
return <JobStatus watch={job} />;
}
return (