WizardV2: Validate steps through redux state

Store validation status in redux state.
This is bit complex on the redux side, but pretty simple on the components.
It allows for reuse of the validation state instead of revalidating wherever needed.
This commit is contained in:
Ondrej Ezr 2024-04-16 18:43:11 +02:00 committed by Klara Simickova
parent be5311e66a
commit 709ae39d23
8 changed files with 204 additions and 19 deletions

View file

@ -5,6 +5,7 @@ import {
Wizard,
WizardFooterWrapper,
WizardStep,
WizardStepType,
useWizardContext,
} from '@patternfly/react-core';
import { useNavigate, useSearchParams } from 'react-router-dom';
@ -27,8 +28,6 @@ import {
isAzureTenantGUIDValid,
isAzureSubscriptionIdValid,
isAzureResourceGroupValid,
isBlueprintDescriptionValid,
isBlueprintNameValid,
isGcpEmailValid,
} from './validators';
@ -48,12 +47,11 @@ import {
selectAzureSource,
selectAzureSubscriptionId,
selectAzureTenantId,
selectBlueprintDescription,
selectBlueprintName,
selectGcpEmail,
selectGcpShareMethod,
selectImageTypes,
selectRegistrationType,
selectStepValidation,
addImageType,
} from '../../store/wizardSlice';
import { resolveRelPath } from '../../Utilities/path';
@ -136,10 +134,16 @@ const CreateImageWizard = ({ startStepIndex = 1 }: CreateImageWizardProps) => {
const azureResourceGroup = useAppSelector(selectAzureResourceGroup);
const azureSource = useAppSelector(selectAzureSource);
const registrationType = useAppSelector(selectRegistrationType);
const blueprintName = useAppSelector(selectBlueprintName);
const blueprintDescription = useAppSelector(selectBlueprintDescription);
const activationKey = useAppSelector(selectActivationKey);
const [currentStep, setCurrentStep] = React.useState<WizardStepType>();
const onStepChange = (
_event: React.MouseEvent<HTMLButtonElement>,
currentStep: WizardStepType
) => setCurrentStep(currentStep);
const detailsValidation = useAppSelector(selectStepValidation('details'));
return (
<>
<ImageBuilderHeader />
@ -147,6 +151,7 @@ const CreateImageWizard = ({ startStepIndex = 1 }: CreateImageWizardProps) => {
<Wizard
startIndex={startStepIndex}
onClose={() => navigate(resolveRelPath(''))}
onStepChange={onStepChange}
isVisitRequired
>
<WizardStep
@ -280,12 +285,15 @@ const CreateImageWizard = ({ startStepIndex = 1 }: CreateImageWizardProps) => {
<WizardStep
name="Details"
id="step-details"
status={
currentStep?.id !== 'step-details' &&
detailsValidation === 'error'
? 'error'
: 'default'
}
footer={
<CustomWizardFooter
disableNext={
!isBlueprintNameValid(blueprintName) ||
!isBlueprintDescriptionValid(blueprintDescription)
}
disableNext={detailsValidation !== 'success'}
/>
}
>

View file

@ -7,16 +7,105 @@ import {
TextInputProps,
} from '@patternfly/react-core';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
setStepInputValidation,
selectInputValidation,
} from '../../store/wizardSlice';
interface ValidatedTextInputPropTypes extends TextInputProps {
dataTestId?: string | undefined;
ouiaId?: string;
ariaLabel: string | undefined;
helperText: string | undefined;
validator: (value: string | undefined) => Boolean;
validator: (value: string | undefined) => boolean;
value: string;
placeholder?: string;
}
interface StateValidatedTextInputPropTypes extends TextInputProps {
dataTestId?: string | undefined;
ouiaId?: string;
stepId: string;
inputId: string;
ariaLabel: string | undefined;
helperText: string | undefined;
validator: (value: string | undefined) => boolean;
value: string;
placeholder?: string;
}
export const StateValidatedInput = ({
dataTestId,
ouiaId,
stepId,
inputId,
ariaLabel,
helperText,
validator,
value,
placeholder,
onChange,
}: StateValidatedTextInputPropTypes) => {
const dispatch = useAppDispatch();
const validatedState = useAppSelector(selectInputValidation(stepId, inputId));
const [isPristine, setIsPristine] = useState(!value ? true : false);
// Do not surface validation on pristine state components
const validated = isPristine ? 'default' : validatedState;
const handleBlur = () => {
setIsPristine(false);
const isValid = validator(value);
dispatch(
setStepInputValidation({
stepId,
inputId,
isValid,
errorText: isValid ? helperText : undefined,
})
);
};
const wrappedOnChange = (
evt: React.FormEvent<HTMLInputElement>,
newVal: string
) => {
if (onChange) onChange(evt, newVal);
const isValid = validator(newVal);
dispatch(
setStepInputValidation({
stepId,
inputId,
isValid,
errorText: isValid ? helperText : undefined,
})
);
};
return (
<>
<TextInput
value={value}
data-testid={dataTestId}
ouiaId={ouiaId}
type="text"
onChange={wrappedOnChange}
validated={validated}
aria-label={ariaLabel}
onBlur={handleBlur}
placeholder={placeholder}
/>
{validated === 'error' && (
<HelperText>
<HelperTextItem variant="error" hasIcon>
{helperText}
</HelperTextItem>
</HelperText>
)}
</>
);
};
export const ValidatedTextInput = ({
dataTestId,
ouiaId,

View file

@ -17,7 +17,7 @@ import {
selectBlueprintDescription,
selectBlueprintName,
} from '../../../../store/wizardSlice';
import { ValidatedTextInput } from '../../ValidatedTextInput';
import { StateValidatedInput } from '../../ValidatedTextInput';
import {
isBlueprintDescriptionValid,
isBlueprintNameValid,
@ -52,9 +52,11 @@ const DetailsStep = () => {
blueprint.
</Text>
<FormGroup isRequired label="Blueprint name" fieldId="blueprint-name">
<ValidatedTextInput
<StateValidatedInput
ariaLabel="blueprint name"
dataTestId="blueprint"
stepId="details"
inputId="blueprint-name"
value={blueprintName}
validator={isBlueprintNameValid}
onChange={handleNameChange}
@ -74,9 +76,11 @@ const DetailsStep = () => {
label="Blueprint description"
fieldId="blueprint-description-name"
>
<ValidatedTextInput
<StateValidatedInput
ariaLabel="blueprint description"
dataTestId="blueprint description"
stepId="details"
inputId="blueprint-description"
value={blueprintDescription || ''}
validator={isBlueprintDescriptionValid}
onChange={handleDescriptionChange}

View file

@ -18,11 +18,13 @@ import {
type CreateDropdownProps = {
getBlueprintPayload: () => Promise<'' | CreateBlueprintRequest | undefined>;
setIsOpen: (isOpen: boolean) => void;
isDisabled?: boolean;
};
export const CreateSaveAndBuildBtn = ({
getBlueprintPayload,
setIsOpen,
isDisabled,
}: CreateDropdownProps) => {
const [buildBlueprint] = useComposeBlueprintMutation();
const [createBlueprint] = useCreateBlueprintMutation({
@ -42,7 +44,11 @@ export const CreateSaveAndBuildBtn = ({
return (
<DropdownList>
<DropdownItem onClick={onSaveAndBuild} ouiaId="wizard-create-build-btn">
<DropdownItem
onClick={onSaveAndBuild}
ouiaId="wizard-create-build-btn"
isDisabled={isDisabled}
>
Create blueprint and build image(s)
</DropdownItem>
</DropdownList>
@ -52,6 +58,7 @@ export const CreateSaveAndBuildBtn = ({
export const CreateSaveButton = ({
setIsOpen,
getBlueprintPayload,
isDisabled,
}: CreateDropdownProps) => {
const [createBlueprint, { isLoading }] = useCreateBlueprintMutation({
fixedCacheKey: 'createBlueprintKey',
@ -62,7 +69,11 @@ export const CreateSaveButton = ({
requestBody && createBlueprint({ createBlueprintRequest: requestBody });
};
return (
<MenuToggleAction onClick={onSave} id="wizard-create-save-btn">
<MenuToggleAction
onClick={onSave}
id="wizard-create-save-btn"
isDisabled={isDisabled}
>
<Flex display={{ default: 'inlineFlex' }}>
{isLoading && (
<FlexItem>

View file

@ -19,12 +19,14 @@ type EditDropdownProps = {
getBlueprintPayload: () => Promise<'' | CreateBlueprintRequest | undefined>;
setIsOpen: (isOpen: boolean) => void;
blueprintId: string;
isDisabled?: boolean;
};
export const EditSaveAndBuildBtn = ({
getBlueprintPayload,
setIsOpen,
blueprintId,
isDisabled,
}: EditDropdownProps) => {
const [buildBlueprint] = useComposeBlueprintMutation();
const [updateBlueprint] = useUpdateBlueprintMutation({
@ -44,7 +46,11 @@ export const EditSaveAndBuildBtn = ({
return (
<DropdownList>
<DropdownItem onClick={onSaveAndBuild} ouiaId="wizard-edit-build-btn">
<DropdownItem
onClick={onSaveAndBuild}
ouiaId="wizard-edit-build-btn"
isDisabled={isDisabled}
>
Save changes and build image(s)
</DropdownItem>
</DropdownList>
@ -55,6 +61,7 @@ export const EditSaveButton = ({
setIsOpen,
getBlueprintPayload,
blueprintId,
isDisabled,
}: EditDropdownProps) => {
const [updateBlueprint, { isLoading }] = useUpdateBlueprintMutation({
fixedCacheKey: 'updateBlueprintKey',
@ -66,7 +73,11 @@ export const EditSaveButton = ({
updateBlueprint({ id: blueprintId, createBlueprintRequest: requestBody });
};
return (
<MenuToggleAction onClick={onSave} id="wizard-edit-save-btn">
<MenuToggleAction
onClick={onSave}
id="wizard-edit-save-btn"
isDisabled={isDisabled}
>
<Flex display={{ default: 'inlineFlex' }}>
{isLoading && (
<FlexItem>

View file

@ -15,11 +15,12 @@ import { useNavigate, useParams } from 'react-router-dom';
import { CreateSaveAndBuildBtn, CreateSaveButton } from './CreateDropdown';
import { EditSaveAndBuildBtn, EditSaveButton } from './EditDropdown';
import { useServerStore } from '../../../../../store/hooks';
import { useServerStore, useAppSelector } from '../../../../../store/hooks';
import {
useCreateBlueprintMutation,
useUpdateBlueprintMutation,
} from '../../../../../store/imageBuilderApi';
import { selectIsValid } from '../../../../../store/wizardSlice';
import { resolveRelPath } from '../../../../../Utilities/path';
import { mapRequestFromState } from '../../../utilities/requestMapper';
@ -40,6 +41,7 @@ const ReviewWizardFooter = () => {
setIsOpen(!isOpen);
};
const navigate = useNavigate();
const isValid = useAppSelector(selectIsValid);
useEffect(() => {
if (isUpdateSuccess || isCreateSuccess) {
@ -68,6 +70,7 @@ const ReviewWizardFooter = () => {
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
isDisabled={!isValid}
splitButtonOptions={{
variant: 'action',
items: composeId
@ -77,6 +80,7 @@ const ReviewWizardFooter = () => {
getBlueprintPayload={getBlueprintPayload}
setIsOpen={setIsOpen}
blueprintId={composeId}
isDisabled={!isValid}
/>,
]
: [
@ -84,6 +88,7 @@ const ReviewWizardFooter = () => {
key="wizard-create-save-btn"
getBlueprintPayload={getBlueprintPayload}
setIsOpen={setIsOpen}
isDisabled={!isValid}
/>,
],
}}
@ -99,11 +104,13 @@ const ReviewWizardFooter = () => {
getBlueprintPayload={getBlueprintPayload}
setIsOpen={setIsOpen}
blueprintId={composeId}
isDisabled={!isValid}
/>
) : (
<CreateSaveAndBuildBtn
getBlueprintPayload={getBlueprintPayload}
setIsOpen={setIsOpen}
isDisabled={!isValid}
/>
)}
</Dropdown>

View file

@ -172,6 +172,7 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
repository: '',
isRequiredByOpenScap: false,
})) || [],
stepValidations: {},
};
};

View file

@ -82,6 +82,15 @@ export type wizardState = {
blueprintName: string;
blueprintDescription: string;
};
stepValidations: {
[key: string]: {
validated: 'default' | 'success' | 'error';
errorText: string | null;
inputs: {
[key: string]: boolean;
};
};
};
};
const initialState: wizardState = {
@ -131,6 +140,7 @@ const initialState: wizardState = {
blueprintName: '',
blueprintDescription: '',
},
stepValidations: {},
};
export const selectServerUrl = (state: RootState) => {
@ -245,6 +255,23 @@ export const selectBlueprintDescription = (state: RootState) => {
return state.wizard.details.blueprintDescription;
};
export const selectIsValid = (state: RootState) => {
return Object.values(state.wizard.stepValidations).every(
(step) => step.validated === 'success'
);
};
export const selectStepValidation = (stepId: string) => (state: RootState) => {
return state.wizard.stepValidations[stepId]?.validated || 'default';
};
export const selectInputValidation =
(stepId: string, inputId: string) => (state: RootState) => {
const isValid = state.wizard.stepValidations[stepId]?.inputs?.[inputId];
if (isValid === undefined) return 'default';
return isValid ? 'success' : 'error';
};
export const wizardSlice = createSlice({
name: 'wizard',
initialState,
@ -466,6 +493,32 @@ export const wizardSlice = createSlice({
changeBlueprintDescription: (state, action: PayloadAction<string>) => {
state.details.blueprintDescription = action.payload;
},
setStepInputValidation: (
state,
action: PayloadAction<{
stepId: string;
inputId: string;
isValid: boolean;
errorText: string | undefined;
}>
) => {
const inputs = {
...state.stepValidations[action.payload.stepId]?.inputs,
[action.payload.inputId]: action.payload.isValid,
};
const validated = Object.values(inputs).every((input) => input === true)
? 'success'
: 'error';
state.stepValidations[action.payload.stepId] = {
...state.stepValidations[action.payload.stepId],
validated,
inputs,
};
if (!action.payload.isValid && action.payload.errorText) {
state.stepValidations[action.payload.stepId].errorText =
action.payload.errorText;
}
},
},
});
@ -511,5 +564,6 @@ export const {
changeBlueprintName,
changeBlueprintDescription,
loadWizardState,
setStepInputValidation,
} = wizardSlice.actions;
export default wizardSlice.reducer;