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:
parent
be5311e66a
commit
709ae39d23
8 changed files with 204 additions and 19 deletions
|
|
@ -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'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
|
|||
repository: '',
|
||||
isRequiredByOpenScap: false,
|
||||
})) || [],
|
||||
stepValidations: {},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue