ProvisioningWizard: Refactor step status management into context provider (#106110)
* ProvisioningWizard: move stepper info to context propvider
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user