Provisioning: Enable navigating to previous step (#108834)

* Provisioning: Enable navigation to previous step in Wizard

* Show confirmation modal

* Show success message

* Extract
This commit is contained in:
Alex Khomenko
2025-07-29 16:49:51 +03:00
committed by GitHub
parent 2c7349f863
commit 2bd023edc0
7 changed files with 131 additions and 40 deletions
@@ -47,7 +47,12 @@ export function FinishedJobStatus({ jobUid, repositoryName }: FinishedJobProps)
},
});
} else if (state === 'success') {
setStepStatusInfo({ status: 'success' });
setStepStatusInfo({
status: 'success',
success: {
title: t('provisioning.job-status.status.title-success-running-job', 'Job completed successfully'),
},
});
} else if (state === 'warning') {
setStepStatusInfo({
status: 'warning',
@@ -1,28 +1,31 @@
import { t } from '@grafana/i18n';
import { Alert } from '@grafana/ui';
import { ProvisioningErrorInfo } from '../types';
import { StatusInfo } from '../types';
import { MessageList } from './MessageList';
interface ProvisioningAlertProps {
error?: string | ProvisioningErrorInfo;
warning?: string | ProvisioningErrorInfo;
error?: string | StatusInfo;
warning?: string | StatusInfo;
success?: string | StatusInfo;
}
const getTitle = (alert: string | ProvisioningErrorInfo, isWarning = false) => {
const getTitle = (alert: string | StatusInfo, type: 'error' | 'warning' | 'success' = 'error') => {
if (typeof alert === 'string') {
return alert;
}
if (isWarning) {
if (type === 'warning') {
return alert.title || t('provisioning.warning-title-default', 'Warning');
} else if (type === 'success') {
return alert.title || t('provisioning.success-title-default', 'Success');
} else {
return alert.title || t('provisioning.error-title-default', 'Error');
}
};
const getMessage = (alert: string | ProvisioningErrorInfo) => {
const getMessage = (alert: string | StatusInfo) => {
if (typeof alert === 'string' || !alert.message) {
return null;
}
@@ -34,17 +37,17 @@ const getMessage = (alert: string | ProvisioningErrorInfo) => {
return alert.message;
};
export function ProvisioningAlert({ error, warning }: ProvisioningAlertProps) {
const alertData = error || warning;
const isWarning = Boolean(warning);
const severity = isWarning ? 'warning' : 'error';
export function ProvisioningAlert({ error, warning, success }: ProvisioningAlertProps) {
const alertData = error || warning || success;
const type = error ? 'error' : warning ? 'warning' : 'success';
const severity = type === 'success' ? 'success' : type;
if (!alertData) {
return null;
}
return (
<Alert severity={severity} title={getTitle(alertData, isWarning)}>
<Alert severity={severity} title={getTitle(alertData, type)}>
{getMessage(alertData)}
</Alert>
);
@@ -406,7 +406,7 @@ describe('ProvisioningWizard', () => {
expect(mockNavigate).toHaveBeenCalledWith('/admin/provisioning');
});
it('should handle cancel on subsequent steps with repository deletion', async () => {
it('should handle going back to previous step', async () => {
const { user } = setup(<ProvisioningWizard type="github" />);
await fillConnectionForm(user, 'github', {
@@ -422,10 +422,10 @@ describe('ProvisioningWizard', () => {
expect(screen.getByRole('heading', { name: /2\. Choose what to synchronize/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await user.click(screen.getByRole('button', { name: /Previous/i }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/admin/provisioning');
expect(screen.getByRole('heading', { name: /1\. Connect to external storage/i })).toBeInTheDocument();
});
});
@@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom-v5-compat';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getAppEvents, isFetchError } from '@grafana/runtime';
import { Box, Button, Stack, Text, useStyles2 } from '@grafana/ui';
import { Box, Button, ConfirmModal, Stack, Text, useStyles2 } from '@grafana/ui';
import { useDeleteRepositoryMutation, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1';
import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt';
@@ -64,10 +64,16 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const [showCancelConfirmation, setShowCancelConfirmation] = useState(false);
const { stepStatusInfo, setStepStatusInfo, isStepSuccess, isStepRunning, hasStepError, hasStepWarning } =
useStepStatus();
const isSyncCompleted = activeStep === 'synchronize' && (isStepSuccess || hasStepWarning || hasStepError);
const isFinishWithSyncCompleted =
activeStep === 'finish' && (isStepSuccess || completedSteps.includes('synchronize'));
const shouldUseCancelBehavior = activeStep === 'connection' || isSyncCompleted || isFinishWithSyncCompleted;
const { data } = useGetFrontendSettingsQuery();
const isLegacyStorage = Boolean(data?.legacyStorage);
const navigate = useNavigate();
@@ -98,7 +104,11 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
const [repoName = '', repoType] = watch(['repositoryName', 'repository.type']);
const [submitData] = useCreateOrUpdateRepository(repoName);
const [deleteRepository] = useDeleteRepositoryMutation();
const { shouldSkipSync, requiresMigration } = useResourceStats(repoName, isLegacyStorage);
const {
shouldSkipSync,
requiresMigration,
isLoading: isResourceStatsLoading,
} = useResourceStats(repoName, isLegacyStorage);
const { createSyncJob, isLoading: isCreatingSkipJob } = useCreateSyncJob({
repoName: repoName,
requiresMigration,
@@ -110,6 +120,8 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
const currentStepConfig = steps[currentStepIndex];
const canSkipSync = repoName && !isResourceStatsLoading && shouldSkipSync;
// A different repository is marked with instance target -- nothing will succeed
useEffect(() => {
if (data?.items.some((item) => item.target === 'instance' && item.name !== repoName)) {
@@ -125,6 +137,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
}, [navigate, repoName, data?.items]);
const handleRepositoryDeletion = async (name: string) => {
setIsCancelling(true);
try {
await deleteRepository({ name });
// Wait before redirecting to ensure deletion is processed
@@ -136,13 +149,44 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
}
};
const handleCancel = async () => {
// For the first step, do not delete anything — just go back.
if (activeStep === 'connection' || !repoName) {
navigate(PROVISIONING_URL);
const handleBack = () => {
const currentStepIndex = steps.findIndex((s) => s.id === activeStep);
if (currentStepIndex > 0) {
let previousStepIndex = currentStepIndex - 1;
// Handle special case: if we're on finish step and sync was skipped
if (activeStep === 'finish' && canSkipSync) {
previousStepIndex = currentStepIndex - 2; // Go back to bootstrap
}
if (previousStepIndex >= 0) {
const previousStep = steps[previousStepIndex];
setActiveStep(previousStep.id);
// Remove current step from completed steps when going back
setCompletedSteps((prev) => prev.filter((step) => step !== activeStep));
setStepStatusInfo({ status: 'idle' });
}
}
};
const handlePrevious = async () => {
// For cancel actions, show confirmation modal
if (shouldUseCancelBehavior) {
if (!repoName) {
navigate(PROVISIONING_URL);
return;
}
setShowCancelConfirmation(true);
return;
}
setIsCancelling(true);
// For other steps, go back one step
handleBack();
};
const handleConfirmCancel = () => {
setShowCancelConfirmation(false);
handleRepositoryDeletion(repoName);
};
@@ -157,7 +201,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
}
// If on bootstrap step and should skip sync, show finish step name
if (currentStep === 'bootstrap' && shouldSkipSync) {
if (currentStep === 'bootstrap' && canSkipSync) {
const finishStepIndex = stepIndex + 2;
if (finishStepIndex < steps.length) {
return steps[finishStepIndex].name;
@@ -167,9 +211,22 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
return steps[stepIndex + 1].name;
},
[steps, shouldSkipSync]
[steps, canSkipSync]
);
// Calculate previous/cancel button text based on current state
const getPreviousButtonText = useCallback(() => {
if (isCancelling) {
return t('provisioning.wizard-content.button-cancelling', 'Cancelling...');
}
if (shouldUseCancelBehavior) {
return t('provisioning.wizard-content.button-cancel', 'Cancel');
}
return t('provisioning.wizard-content.button-previous', 'Previous');
}, [isCancelling, shouldUseCancelBehavior]);
const handleNext = async () => {
const isLastStep = currentStepIndex === steps.length - 1;
@@ -180,15 +237,13 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
let nextStepIndex = currentStepIndex + 1;
// Skip synchronize step if no sync is needed
if (activeStep === 'bootstrap' && shouldSkipSync) {
if (activeStep === 'bootstrap' && canSkipSync) {
nextStepIndex = currentStepIndex + 2; // Skip to finish step
// Create a pull job to initialize the repository
if (repoName) {
const job = await createSyncJob();
if (!job) {
return; // Don't proceed if job creation fails
}
const job = await createSyncJob();
if (!job) {
return; // Don't proceed if job creation fails
}
}
@@ -277,7 +332,10 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
<Stepper steps={steps} activeStep={activeStep} visitedSteps={completedSteps} />
<div className={styles.divider} />
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
<FormPrompt onDiscard={handleCancel} confirmRedirect={isDirty && activeStep !== 'finish' && !isCancelling} />
<FormPrompt
onDiscard={handlePrevious}
confirmRedirect={isDirty && !['connection', 'finish'].includes(activeStep) && !isCancelling}
/>
<Stack direction="column">
<Box marginBottom={2}>
<Text element="h2">
@@ -287,6 +345,7 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
{hasStepError && 'error' in stepStatusInfo && <ProvisioningAlert error={stepStatusInfo.error} />}
{hasStepWarning && 'warning' in stepStatusInfo && <ProvisioningAlert warning={stepStatusInfo.warning} />}
{isStepSuccess && 'success' in stepStatusInfo && <ProvisioningAlert success={stepStatusInfo.success} />}
<div className={styles.content}>
{activeStep === 'connection' && <ConnectStep />}
@@ -296,10 +355,12 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
</div>
<Stack gap={2} justifyContent="flex-end">
<Button variant={'secondary'} onClick={handleCancel} disabled={isSubmitting || isCancelling}>
{isCancelling
? t('provisioning.wizard-content.button-cancelling', 'Cancelling...')
: t('provisioning.wizard-content.button-cancel', 'Cancel')}
<Button
variant={'secondary'}
onClick={handlePrevious}
disabled={isSubmitting || isCancelling || isStepRunning || showCancelConfirmation}
>
{getPreviousButtonText()}
</Button>
<Button type={'submit'} disabled={isNextButtonDisabled()}>
{isSubmitting
@@ -310,6 +371,18 @@ export function ProvisioningWizard({ type }: { type: RepoType }) {
</Stack>
</form>
</Stack>
<ConfirmModal
isOpen={showCancelConfirmation}
title={t('provisioning.wizard.discard-modal.title', 'Discard repository setup?')}
body={t(
'provisioning.wizard.discard-modal.body',
'This will delete the repository configuration and you will lose all progress. Are you sure you want to discard your changes?'
)}
confirmText={t('provisioning.wizard.discard-modal.confirm', 'Yes, discard')}
dismissText={t('provisioning.wizard.discard-modal.dismiss', 'Keep working')}
onConfirm={handleConfirmCancel}
onDismiss={() => setShowCancelConfirmation(false)}
/>
</FormProvider>
);
}
@@ -1,6 +1,6 @@
import { RepositorySpec, SyncOptions } from 'app/api/clients/provisioning/v0alpha1';
import { ProvisioningErrorInfo, RepositoryFormData } from '../types';
import { StatusInfo, RepositoryFormData } from '../types';
export type WizardStep = 'connection' | 'bootstrap' | 'finish' | 'synchronize';
@@ -37,6 +37,7 @@ export const RepoTypeDisplay: { [key in RepoType]: string } = {
};
export type StepStatusInfo =
| { status: 'idle' | 'running' | 'success' }
| { status: 'error'; error: string | ProvisioningErrorInfo }
| { status: 'warning'; warning: string | ProvisioningErrorInfo };
| { status: 'idle' | 'running' }
| { status: 'success'; success?: string | StatusInfo }
| { status: 'error'; error: string | StatusInfo }
| { status: 'warning'; warning: string | StatusInfo };
+1 -1
View File
@@ -90,7 +90,7 @@ export type HistoryListResponse = {
items?: HistoryItem[];
};
export interface ProvisioningErrorInfo {
export interface StatusInfo {
title?: string;
message?: string | string[];
}
+9
View File
@@ -11243,6 +11243,7 @@
"starting": "Starting...",
"status": {
"title-error-running-job": "Error running job",
"title-success-running-job": "Job completed successfully",
"title-warning-running-job": "Job completed with warnings"
},
"summary": "Summary",
@@ -11391,6 +11392,7 @@
"label-current-step": "Current step",
"label-pending-step": "Pending step"
},
"success-title-default": "Success",
"sync-job": {
"error-no-job-id": "Failed to start job",
"error-no-repository-name": "No repository name provided",
@@ -11423,6 +11425,12 @@
"alert-title": "Important: No data or configuration will be lost, but dashboards will be temporarily unavailable for a few minutes.",
"button-next": "Finish",
"button-start": "Begin synchronization",
"discard-modal": {
"body": "This will delete the repository configuration and you will lose all progress. Are you sure you want to discard your changes?",
"confirm": "Yes, discard",
"dismiss": "Keep working",
"title": "Discard repository setup?"
},
"step-bootstrap": "Choose what to synchronize",
"step-connect": "Connect",
"step-finish": "Choose additional settings",
@@ -11437,6 +11445,7 @@
"wizard-content": {
"button-cancel": "Cancel",
"button-cancelling": "Cancelling...",
"button-previous": "Previous",
"button-submitting": "Submitting...",
"error-instance-repository-exists": "Instance repository already exists"
}