diff --git a/src/Components/CreateImageWizard/CreateImageWizard.tsx b/src/Components/CreateImageWizard/CreateImageWizard.tsx index 2c58c46ecc..b22c6b4161 100644 --- a/src/Components/CreateImageWizard/CreateImageWizard.tsx +++ b/src/Components/CreateImageWizard/CreateImageWizard.tsx @@ -110,6 +110,7 @@ import { useTimezoneValidation, useUserGroupsValidation, useUsersValidation, + WIZARD_STEP_IDS, } from '../CreateImageWizard/utilities/useValidation'; const CreateImageWizard = () => { @@ -358,7 +359,9 @@ const CreateImageWizard = () => { useEffect(() => { if (!isOnPremise && showWizardModal && !hasTrackedInitialStepRef.current) { const initialStepId = - mode === 'edit' ? 'review-step' : 'base-settings-step'; + mode === 'edit' + ? WIZARD_STEP_IDS.REVIEW + : WIZARD_STEP_IDS.BASE_SETTINGS; const accountId = userData?.identity.internal?.account_id; analytics.track(`${AMPLITUDE_MODULE_NAME} - Step Viewed`, { @@ -415,15 +418,15 @@ const CreateImageWizard = () => { ) => { const status = (step.id !== activeStep.id && step.status) || 'default'; - const isBaseSettingsStep = step.id === 'base-settings-step'; + const isBaseSettingsStep = step.id === WIZARD_STEP_IDS.BASE_SETTINGS; const hasVisitedBaseSettings = _steps.find( - (s) => s.id === 'base-settings-step', + (s) => s.id === WIZARD_STEP_IDS.BASE_SETTINGS, )?.isVisited; const canNavigate = mode === 'edit' || step.isVisited || isBaseSettingsStep || - (hasVisitedBaseSettings && !baseSettingsHasErrors); + hasVisitedBaseSettings; return ( { > { { { } diff --git a/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx b/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx index 3400f7cc42..6cb17be347 100644 --- a/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx +++ b/src/Components/CreateImageWizard/components/CustomWizardFooter.tsx @@ -17,6 +17,7 @@ import { } from '@/store/slices/wizard'; import { scrollToFirstError } from '../utilities/scrollToFirstError'; +import { WIZARD_STEP_IDS } from '../utilities/useValidation'; type CustomWizardFooterPropType = { disableBack?: boolean; @@ -78,7 +79,7 @@ export const CustomWizardFooter = ({ }); } dispatch(resetForceShowErrors()); - goToStepById('review-step'); + goToStepById(WIZARD_STEP_IDS.REVIEW); } }; diff --git a/src/Components/CreateImageWizard/components/ReviewWizardFooter.tsx b/src/Components/CreateImageWizard/components/ReviewWizardFooter.tsx index 83b9819530..92e0a79712 100644 --- a/src/Components/CreateImageWizard/components/ReviewWizardFooter.tsx +++ b/src/Components/CreateImageWizard/components/ReviewWizardFooter.tsx @@ -9,6 +9,7 @@ import { WizardFooterWrapper, } from '@patternfly/react-core'; import { MenuToggleElement } from '@patternfly/react-core/dist/esm/components/MenuToggle/MenuToggle'; +import { flushSync } from 'react-dom'; import { selectSelectedBlueprintId } from '@/store/slices/blueprint'; import { selectWizardModalMode } from '@/store/slices/wizardModal'; @@ -17,7 +18,11 @@ import { useCreateBPWithNotification as useCreateBlueprintMutation, useUpdateBPWithNotification as useUpdateBlueprintMutation, } from '../../../Hooks'; -import { useAppSelector } from '../../../store/hooks'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { + resetForceShowErrors, + setForceShowErrors, +} from '../../../store/slices/wizard'; import { CreateSaveAndBuildBtn, CreateSaveButton, @@ -26,10 +31,12 @@ import { EditSaveAndBuildBtn, EditSaveButton, } from '../steps/Review/Footer/EditDropdown'; -import { useIsBlueprintValid } from '../utilities/useValidation'; +import { scrollToFirstError } from '../utilities/scrollToFirstError'; +import { useBlueprintValidation } from '../utilities/useValidation'; const ReviewWizardFooter = () => { - const { goToPrevStep, close } = useWizardContext(); + const { goToPrevStep, goToStepById, close } = useWizardContext(); + const dispatch = useAppDispatch(); const { isSuccess: isCreateSuccess, reset: resetCreate } = useCreateBlueprintMutation({ fixedCacheKey: 'createBlueprintKey' }); @@ -38,10 +45,26 @@ const ReviewWizardFooter = () => { const mode = useAppSelector(selectWizardModalMode); const blueprintId = useAppSelector(selectSelectedBlueprintId); const [isOpen, setIsOpen] = useState(false); + const { isValid, firstErrorStepId } = useBlueprintValidation(); + + const handleValidationFail = () => { + if (!firstErrorStepId) return; + flushSync(() => { + dispatch(setForceShowErrors()); + }); + goToStepById(firstErrorStepId); + requestAnimationFrame(() => { + scrollToFirstError(); + }); + }; + const onToggleClick = () => { + if (!isValid) { + handleValidationFail(); + return; + } setIsOpen(!isOpen); }; - const isValid = useIsBlueprintValid(); useEffect(() => { if (isUpdateSuccess || isCreateSuccess) { @@ -51,6 +74,14 @@ const ReviewWizardFooter = () => { } }, [isUpdateSuccess, isCreateSuccess, resetCreate, resetUpdate, close]); + const validateBeforeAction = (): boolean => { + if (!isValid) { + handleValidationFail(); + return false; + } + return true; + }; + const isEditMode = mode === 'edit'; return ( @@ -59,7 +90,13 @@ const ReviewWizardFooter = () => { columnGap={{ default: 'columnGapSm' }} justifyContent={{ default: 'justifyContentFlexEnd' }} > - { ref={toggleRef} onClick={onToggleClick} isExpanded={isOpen} - isDisabled={!isValid} splitButtonItems={ isEditMode ? [ @@ -80,6 +116,7 @@ const ReviewWizardFooter = () => { setIsOpen={setIsOpen} blueprintId={blueprintId || ''} isDisabled={!isValid} + validateBeforeAction={validateBeforeAction} />, ] : [ @@ -87,6 +124,7 @@ const ReviewWizardFooter = () => { key='wizard-create-save-btn' setIsOpen={setIsOpen} isDisabled={!isValid} + validateBeforeAction={validateBeforeAction} />, ] } @@ -99,11 +137,13 @@ const ReviewWizardFooter = () => { blueprintId={blueprintId || ''} setIsOpen={setIsOpen} isDisabled={!isValid} + validateBeforeAction={validateBeforeAction} /> ) : ( )} diff --git a/src/Components/CreateImageWizard/steps/Review/Footer/CreateDropdown.tsx b/src/Components/CreateImageWizard/steps/Review/Footer/CreateDropdown.tsx index 89144f82bf..83376ba427 100644 --- a/src/Components/CreateImageWizard/steps/Review/Footer/CreateDropdown.tsx +++ b/src/Components/CreateImageWizard/steps/Review/Footer/CreateDropdown.tsx @@ -30,6 +30,8 @@ import { setBlueprintId } from '@/store/slices/blueprint'; import { selectIsOnPremise } from '@/store/slices/env'; import { mapStateToRequest, selectPackages } from '@/store/slices/wizard'; +import { shouldDisableAction } from './shouldDisableAction'; + import { AMPLITUDE_MODULE_NAME } from '../../../../../constants'; import { useComposeBPWithNotification as useComposeBlueprintMutation, @@ -41,11 +43,13 @@ import { createAnalytics } from '../../../../../Utilities/analytics'; type CreateDropdownProps = { setIsOpen: (isOpen: boolean) => void; isDisabled: boolean; + validateBeforeAction?: () => boolean; }; export const CreateSaveAndBuildBtn = ({ setIsOpen, isDisabled, + validateBeforeAction, }: CreateDropdownProps) => { const { analytics, auth, isBeta } = useChrome(); const { userData } = useGetUser(auth); @@ -59,7 +63,10 @@ export const CreateSaveAndBuildBtn = ({ fixedCacheKey: 'createBlueprintKey', }); const dispatch = useAppDispatch(); + const shouldDisable = shouldDisableAction(isDisabled, validateBeforeAction); + const onSaveAndBuild = async () => { + if (validateBeforeAction && !validateBeforeAction()) return; const requestBody = mapStateToRequest(store.getState()); setIsOpen(false); @@ -92,7 +99,7 @@ export const CreateSaveAndBuildBtn = ({ return ( - + Create blueprint and build image(s) @@ -133,6 +140,7 @@ const SaveAndBuildImagesModal = ({ export const CreateSaveButton = ({ setIsOpen, isDisabled, + validateBeforeAction, }: CreateDropdownProps) => { const { analytics, auth, isBeta } = useChrome(); const { userData } = useGetUser(auth); @@ -154,7 +162,10 @@ export const CreateSaveButton = ({ setShowModal(false); }; + const shouldDisable = shouldDisableAction(isDisabled, validateBeforeAction); + const onClick = () => { + if (validateBeforeAction && !validateBeforeAction()) return; if (!wasModalSeen) { setShowModal(true); window.localStorage.setItem('imageBuilder.saveAndBuildModalSeen', 'true'); @@ -194,7 +205,7 @@ export const CreateSaveButton = ({ {isLoading && ( diff --git a/src/Components/CreateImageWizard/steps/Review/Footer/EditDropdown.tsx b/src/Components/CreateImageWizard/steps/Review/Footer/EditDropdown.tsx index 7aa4ef99c4..2858fc33ba 100644 --- a/src/Components/CreateImageWizard/steps/Review/Footer/EditDropdown.tsx +++ b/src/Components/CreateImageWizard/steps/Review/Footer/EditDropdown.tsx @@ -20,6 +20,8 @@ import { import { selectIsOnPremise } from '@/store/slices/env'; import { mapStateToRequest, selectPackages } from '@/store/slices/wizard'; +import { shouldDisableAction } from './shouldDisableAction'; + import { AMPLITUDE_MODULE_NAME } from '../../../../../constants'; import { useComposeBPWithNotification as useComposeBlueprintMutation, @@ -32,12 +34,14 @@ type EditDropdownProps = { setIsOpen: (isOpen: boolean) => void; blueprintId: string; isDisabled: boolean; + validateBeforeAction?: () => boolean; }; export const EditSaveAndBuildBtn = ({ setIsOpen, blueprintId, isDisabled, + validateBeforeAction, }: EditDropdownProps) => { const { analytics, auth, isBeta } = useChrome(); const { userData } = useGetUser(auth); @@ -51,7 +55,10 @@ export const EditSaveAndBuildBtn = ({ fixedCacheKey: 'updateBlueprintKey', }); + const shouldDisable = shouldDisableAction(isDisabled, validateBeforeAction); + const onSaveAndBuild = async () => { + if (validateBeforeAction && !validateBeforeAction()) return; const requestBody = mapStateToRequest(store.getState()); if (!isOnPremise) { @@ -83,7 +90,7 @@ export const EditSaveAndBuildBtn = ({ return ( - + Save changes and build image(s) @@ -94,6 +101,7 @@ export const EditSaveButton = ({ setIsOpen, blueprintId, isDisabled, + validateBeforeAction, }: EditDropdownProps) => { const { analytics, auth, isBeta } = useChrome(); const { userData } = useGetUser(auth); @@ -105,7 +113,10 @@ export const EditSaveButton = ({ const { trigger: updateBlueprint, isLoading } = useUpdateBlueprintMutation({ fixedCacheKey: 'updateBlueprintKey', }); + const shouldDisable = shouldDisableAction(isDisabled, validateBeforeAction); + const onSave = async () => { + if (validateBeforeAction && !validateBeforeAction()) return; const requestBody = mapStateToRequest(store.getState()); if (!isOnPremise) { @@ -130,7 +141,7 @@ export const EditSaveButton = ({ {isLoading && ( diff --git a/src/Components/CreateImageWizard/steps/Review/Footer/shouldDisableAction.ts b/src/Components/CreateImageWizard/steps/Review/Footer/shouldDisableAction.ts new file mode 100644 index 0000000000..9440c9e8f4 --- /dev/null +++ b/src/Components/CreateImageWizard/steps/Review/Footer/shouldDisableAction.ts @@ -0,0 +1,6 @@ +// When validateBeforeAction is provided, buttons stay enabled and +// validation runs on click; otherwise fall back to the static flag. +export const shouldDisableAction = ( + isDisabled: boolean, + validateBeforeAction?: () => boolean, +): boolean => !validateBeforeAction && isDisabled; diff --git a/src/Components/CreateImageWizard/steps/Review/components/shared/ReviewCardHeader.tsx b/src/Components/CreateImageWizard/steps/Review/components/shared/ReviewCardHeader.tsx index ccc82dfbcb..16d8baf378 100644 --- a/src/Components/CreateImageWizard/steps/Review/components/shared/ReviewCardHeader.tsx +++ b/src/Components/CreateImageWizard/steps/Review/components/shared/ReviewCardHeader.tsx @@ -7,13 +7,15 @@ import { useWizardContext, } from '@patternfly/react-core'; +import { WizardStepId } from '@/Components/CreateImageWizard/utilities/useValidation'; + export const ReviewCardHeader = ({ title, stepId, sectionId, }: { title: string; - stepId: string; + stepId: WizardStepId; sectionId?: string; }) => { const { goToStepById } = useWizardContext(); diff --git a/src/Components/CreateImageWizard/tests/CreateMode.test.tsx b/src/Components/CreateImageWizard/tests/CreateMode.test.tsx index 0bdbf3c693..6934d976dd 100644 --- a/src/Components/CreateImageWizard/tests/CreateMode.test.tsx +++ b/src/Components/CreateImageWizard/tests/CreateMode.test.tsx @@ -34,7 +34,7 @@ describe('Create Image Wizard', () => { await screen.findByRole('button', { name: 'Review' }); }); - test('should only enable first navigation item in create mode', async () => { + test('should enable all navigation items in create mode', async () => { await renderCreateMode(); const navigation = await screen.findByRole('navigation', { @@ -54,9 +54,9 @@ describe('Create Image Wizard', () => { }); expect(baseNavItem).toBeEnabled(); - expect(contentNavItem).toBeDisabled(); - expect(advancedNavItem).toBeDisabled(); - expect(reviewNavItem).toBeDisabled(); + expect(contentNavItem).toBeEnabled(); + expect(advancedNavItem).toBeEnabled(); + expect(reviewNavItem).toBeEnabled(); }); }); diff --git a/src/Components/CreateImageWizard/utilities/scrollToFirstError.ts b/src/Components/CreateImageWizard/utilities/scrollToFirstError.ts index 59391dc3a6..6c74b66ef7 100644 --- a/src/Components/CreateImageWizard/utilities/scrollToFirstError.ts +++ b/src/Components/CreateImageWizard/utilities/scrollToFirstError.ts @@ -1,5 +1,7 @@ export const scrollToFirstError = (): boolean => { - const errorEl = document.querySelector('.pf-m-error'); + const scope = + document.querySelector('.pf-v6-c-wizard__main-body') ?? document; + const errorEl = scope.querySelector('.pf-m-error'); if (errorEl) { errorEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); const parent = errorEl.closest('.pf-v6-c-form-group, [role="group"]'); diff --git a/src/Components/CreateImageWizard/utilities/useValidation.tsx b/src/Components/CreateImageWizard/utilities/useValidation.tsx index 424fdf4b55..cd64aad73d 100644 --- a/src/Components/CreateImageWizard/utilities/useValidation.tsx +++ b/src/Components/CreateImageWizard/utilities/useValidation.tsx @@ -50,6 +50,7 @@ import { selectHostname, selectImageSource, selectImageTypes, + selectIsImageMode, selectIsOfficialImage, selectKernel, selectKeyboard, @@ -125,7 +126,22 @@ export type UsersStepValidation = { disabledNext: boolean; }; -export function useIsBlueprintValid(): boolean { +export const WIZARD_STEP_IDS = { + BASE_SETTINGS: 'base-settings-step', + CONTENT: 'content-step', + ADVANCED_SETTINGS: 'advanced-settings-step', + REVIEW: 'review-step', +} as const; + +export type WizardStepId = + (typeof WIZARD_STEP_IDS)[keyof typeof WIZARD_STEP_IDS]; + +type BlueprintValidation = { + isValid: boolean; + firstErrorStepId: WizardStepId | null; +}; + +export function useBlueprintValidation(): BlueprintValidation { const aap = useAAPValidation(); const registration = useRegistrationValidation(); const filesystem = useFilesystemValidation(); @@ -143,26 +159,47 @@ export function useIsBlueprintValid(): boolean { const azureTarget = useAzureValidation(); const gcpTarget = useGcpValidation(); const awsTarget = useAwsValidation(); - return ( - !aap.disabledNext && - !registration.disabledNext && - !filesystem.disabledNext && - !snapshot.disabledNext && - !timezone.disabledNext && - !locale.disabledNext && - !hostname.disabledNext && - !kernel.disabledNext && - !firewall.disabledNext && - !services.disabledNext && - !firstBoot.disabledNext && - !details.disabledNext && - !details.isPending && - !users.disabledNext && - !userGroups.disabledNext && - !azureTarget.disabledNext && - !gcpTarget.disabledNext && - !awsTarget.disabledNext - ); + const isOnPremise = useAppSelector(selectIsOnPremise); + const isImageMode = useAppSelector(selectIsImageMode); + + const usersAreStandalone = isImageMode && isOnPremise; + const usersHaveErrors = users.disabledNext || userGroups.disabledNext; + + const baseSettingsInvalid = + aap.disabledNext || + details.disabledNext || + registration.disabledNext || + snapshot.disabledNext || + awsTarget.disabledNext || + gcpTarget.disabledNext || + azureTarget.disabledNext || + (usersAreStandalone && usersHaveErrors); + + const advancedSettingsInvalid = + filesystem.disabledNext || + timezone.disabledNext || + locale.disabledNext || + hostname.disabledNext || + kernel.disabledNext || + firewall.disabledNext || + services.disabledNext || + firstBoot.disabledNext || + (!usersAreStandalone && usersHaveErrors); + + const isValid = !baseSettingsInvalid && !advancedSettingsInvalid; + + return { + isValid, + firstErrorStepId: !isValid + ? baseSettingsInvalid + ? WIZARD_STEP_IDS.BASE_SETTINGS + : WIZARD_STEP_IDS.ADVANCED_SETTINGS + : null, + }; +} + +export function useIsBlueprintValid(): boolean { + return useBlueprintValidation().isValid; } type PasswordValidationResult = { @@ -1187,6 +1224,8 @@ export function useDetailsValidation(): StepValidation { nameError = 'Invalid blueprint name'; } else if (isUniqueName === false) { nameError = 'Blueprint with this name already exists'; + } else if (isUniqueName === null) { + return { errors: { name: '' }, disabledNext: false }; } let descriptionError = '';