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:
@@ -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 };
|
||||
|
||||
@@ -90,7 +90,7 @@ export type HistoryListResponse = {
|
||||
items?: HistoryItem[];
|
||||
};
|
||||
|
||||
export interface ProvisioningErrorInfo {
|
||||
export interface StatusInfo {
|
||||
title?: string;
|
||||
message?: string | string[];
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user