V2Wizard: Implement <Aws> subset of Image Output step

DDF has been dropped, RTK being used for state management now.
This commit is contained in:
lucasgarfield 2024-01-06 11:32:41 +01:00 committed by Lucas Garfield
parent e5c55828e3
commit 091c34431e
8 changed files with 430 additions and 106 deletions

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Button,
@ -10,10 +10,17 @@ import {
import { useNavigate } from 'react-router-dom';
import ImageOutputStep from './steps/ImageOutput';
import Aws, { AwsShareMethod } from './steps/TargetEnvironment/Aws';
import { isAwsAccountIdValid } from './validators';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import './CreateImageWizard.scss';
import { initializeWizard, selectImageTypes } from '../../store/wizardSlice';
import {
initializeWizard,
selectAwsAccount,
selectAwsSource,
selectImageTypes,
} from '../../store/wizardSlice';
import { resolveRelPath } from '../../Utilities/path';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
@ -49,6 +56,11 @@ const CreateImageWizard = () => {
const targetEnvironments = useAppSelector((state) => selectImageTypes(state));
const [awsShareMethod, setAwsShareMethod] =
useState<AwsShareMethod>('sources');
const awsAccountId = useAppSelector((state) => selectAwsAccount(state));
const awsSourceId = useAppSelector((state) => selectAwsSource(state));
return (
<>
<ImageBuilderHeader />
@ -68,15 +80,32 @@ const CreateImageWizard = () => {
<WizardStep
name="Target Environment"
id="step-target-environment"
isHidden={
!targetEnvironments.find(
(target) =>
target === 'aws' || target === 'gcp' || target === 'azure'
)
}
steps={[
<WizardStep
name="Amazon Web Services"
id="wizard-target-aws"
key="wizard-target-aws"
footer={<CustomWizardFooter disableNext={true} />}
footer={
<CustomWizardFooter
disableNext={
awsShareMethod === 'manual'
? !isAwsAccountIdValid(awsAccountId)
: awsSourceId === undefined
}
/>
}
isHidden={!targetEnvironments.includes('aws')}
>
{/* <Aws /> */}
<Aws
shareMethod={awsShareMethod}
setShareMethod={setAwsShareMethod}
/>
</WizardStep>,
]}
/>

View file

@ -0,0 +1,57 @@
import React, { useState } from 'react';
import {
HelperText,
HelperTextItem,
TextInput,
TextInputProps,
} from '@patternfly/react-core';
interface ValidatedTextInputPropTypes extends TextInputProps {
ariaLabel: string | undefined;
helperText: string | undefined;
validator: (value: string | undefined) => Boolean;
value: string;
}
export const ValidatedTextInput = ({
ariaLabel,
helperText,
validator,
value,
onChange,
}: ValidatedTextInputPropTypes) => {
const [isPristine, setIsPristine] = useState(!value ? true : false);
const handleBlur = () => {
setIsPristine(false);
};
const handleValidation = () => {
// Prevent premature validation during user's first entry
if (isPristine) {
return 'default';
}
return validator(value) ? 'success' : 'error';
};
return (
<>
<TextInput
value={value}
type="text"
onChange={onChange}
validated={handleValidation()}
aria-label={ariaLabel}
onBlur={handleBlur}
/>
{!isPristine && !validator(value) && (
<HelperText>
<HelperTextItem variant="error" hasIcon>
{helperText}
</HelperTextItem>
</HelperText>
)}
</>
);
};

View file

@ -0,0 +1,54 @@
import React from 'react';
import {
Alert,
HelperText,
HelperTextItem,
TextInput,
} from '@patternfly/react-core';
import { useGetSourceUploadInfoQuery } from '../../../../../store/provisioningApi';
import { V1ListSourceResponseItem } from '.';
type AwsAccountIdProps = {
source: V1ListSourceResponseItem | undefined;
};
export const AwsAccountId = ({ source }: AwsAccountIdProps) => {
const { data, isError } = useGetSourceUploadInfoQuery(
{
id: parseInt(source?.id as string),
},
{ skip: source === undefined }
);
return (
<>
<TextInput
readOnlyVariant="default"
isRequired
id="aws-account-id"
value={data ? data.aws?.account_id : ''}
aria-label="aws account id"
/>
<HelperText>
<HelperTextItem component="div" variant="indeterminate">
This is the account associated with the source.
</HelperTextItem>
</HelperText>
{isError && (
<Alert
variant={'danger'}
isPlain
isInline
title={'AWS details unavailable'}
>
The AWS account ID for the selected source could not be resolved.
There might be a problem with the source. Verify that the source is
valid in Sources or select a different source.
</Alert>
)}
</>
);
};

View file

@ -1,82 +1,56 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Alert } from '@patternfly/react-core';
import { FormGroup, Spinner } from '@patternfly/react-core';
import { Alert, Spinner } from '@patternfly/react-core';
import { FormGroup } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { extractProvisioningList } from '../../../store/helpers';
import { useAppDispatch } from '../../../../../store/hooks';
import { useGetSourceListQuery } from '../../../../../store/provisioningApi';
import {
useGetSourceListQuery,
useGetSourceUploadInfoQuery,
} from '../../../store/provisioningApi';
changeAwsSource,
resetAwsSource,
} from '../../../../../store/wizardSlice';
export const AWSSourcesSelect = ({
label,
isRequired,
className,
...props
}) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
import { V1ListSourceResponseItem } from '.';
type AwsSourcesSelectPropTypes = {
source: V1ListSourceResponseItem | undefined;
setSource: React.Dispatch<
React.SetStateAction<V1ListSourceResponseItem | undefined>
>;
};
export const AwsSourcesSelect = ({
source,
setSource,
}: AwsSourcesSelectPropTypes) => {
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const [selectedSourceId, setSelectedSourceId] = useState(
getState()?.values?.['aws-sources-select']
);
const {
data: rawSources,
isFetching,
isSuccess,
isError,
refetch,
} = useGetSourceListQuery({ provider: 'aws' });
const sources = extractProvisioningList(rawSources);
const { data, isFetching, isLoading, isSuccess, isError, refetch } =
useGetSourceListQuery({
provider: 'aws',
});
const {
data: sourceDetails,
isFetching: isFetchingDetails,
isSuccess: isSuccessDetails,
isError: isErrorDetails,
} = useGetSourceUploadInfoQuery(
{ id: selectedSourceId },
{
skip: !selectedSourceId,
}
);
const sources = data?.data;
useEffect(() => {
if (isFetchingDetails || !isSuccessDetails) return;
change('aws-associated-account-id', sourceDetails?.aws?.account_id);
}, [isFetchingDetails, isSuccessDetails]);
const onFormChange = ({ values }) => {
if (
values['aws-target-type'] !== 'aws-target-type-source' ||
values[input.name] === undefined
) {
change(input.name, undefined);
change('aws-associated-account-id', undefined);
}
};
const handleSelect = (_, sourceName) => {
const sourceId = sources.find((source) => source.name === sourceName).id;
setSelectedSourceId(sourceId);
const handleSelect = (
_event: React.MouseEvent<Element, MouseEvent>,
value: string
) => {
const source = sources?.find((source) => source.name === value);
setSource(source);
source?.id && dispatch(changeAwsSource(source.id));
setIsOpen(false);
change(input.name, sourceId);
};
const handleClear = () => {
setSelectedSourceId();
change(input.name, undefined);
dispatch(resetAwsSource());
setSource(undefined);
};
const handleToggle = () => {
@ -88,40 +62,36 @@ export const AWSSourcesSelect = ({
setIsOpen(!isOpen);
};
const selectOptions = sources?.map((source) => (
<SelectOption key={source.id} value={source.name} />
));
const loadingSpinner = (
<SelectOption key={'fetching'} isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
);
if (isFetching) {
selectOptions?.push(loadingSpinner);
}
return (
<>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<FormGroup
isRequired={isRequired}
label={label}
data-testid="sources"
className={className}
>
<FormGroup isRequired label={'Source Name'} data-testid="sources">
<Select
ouiaId="source_select"
variant={SelectVariant.typeahead}
onToggle={handleToggle}
onSelect={handleSelect}
onClear={handleClear}
selections={
selectedSourceId
? sources.find((source) => source.id === selectedSourceId)?.name
: undefined
}
selections={source?.name}
isOpen={isOpen}
placeholderText="Select source"
typeAheadAriaLabel="Select source"
isDisabled={!isSuccess}
isDisabled={!isSuccess || isLoading}
>
{isSuccess &&
sources.map((source) => (
<SelectOption key={source.id} value={source.name} />
))}
{isFetching && (
<SelectOption isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
)}
{selectOptions}
</Select>
</FormGroup>
<>
@ -136,25 +106,7 @@ export const AWSSourcesSelect = ({
ID manually.
</Alert>
)}
{!isError && isErrorDetails && (
<Alert
variant={'danger'}
isPlain
isInline
title={'AWS details unavailable'}
>
The AWS account ID for the selected source could not be resolved.
There might be a problem with the source. Verify that the source is
valid in Sources or select a different source.
</Alert>
)}
</>
</>
);
};
AWSSourcesSelect.propTypes = {
className: PropTypes.string,
label: PropTypes.node,
isRequired: PropTypes.bool,
};

View file

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import {
Radio,
Text,
Form,
Title,
FormGroup,
TextInput,
Gallery,
GalleryItem,
HelperText,
HelperTextItem,
Button,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { AwsAccountId } from './AwsAccountId';
import { AwsSourcesSelect } from './AwsSourcesSelect';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeAwsAccount,
resetAws,
selectAwsAccount,
} from '../../../../../store/wizardSlice';
import { ValidatedTextInput } from '../../../ValidatedTextInput';
import { isAwsAccountIdValid } from '../../../validators';
export type AwsShareMethod = 'manual' | 'sources';
// The Sources API only defines a V1ListSourceResponseItem[] type
export type V1ListSourceResponseItem = {
id?: string;
name?: string;
source_type_id?: string;
uid?: string;
};
const SourcesButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Create and manage sources here
</Button>
);
};
type AwsPropTypes = {
shareMethod: AwsShareMethod;
setShareMethod: React.Dispatch<React.SetStateAction<AwsShareMethod>>;
};
const Aws = ({ shareMethod, setShareMethod }: AwsPropTypes) => {
const dispatch = useAppDispatch();
const [source, setSource] = useState<V1ListSourceResponseItem | undefined>(
undefined
);
const shareWithAccount = useAppSelector((state) => selectAwsAccount(state));
return (
<Form>
<Title headingLevel="h2">Target environment - Amazon Web Services</Title>
<Text>
Your image will be uploaded to AWS and shared with the account you
provide below.
</Text>
<Text>
<b>The shared image will expire within 14 days.</b> To permanently
access the image, copy the image, which will be shared to your account
by Red Hat, to your own AWS account.
</Text>
<FormGroup label="Share method:">
<Radio
id="radio-with-description"
label="Use an account configured from Sources."
name="radio-7"
description="Use a configured sources to launch environments directly from the console."
isChecked={shareMethod === 'sources'}
onChange={() => {
dispatch(resetAws());
setSource(undefined);
setShareMethod('sources');
}}
/>
<Radio
id="radio"
label="Manually enter an account ID."
name="radio-8"
isChecked={shareMethod === 'manual'}
onChange={() => {
dispatch(resetAws());
setSource(undefined);
setShareMethod('manual');
}}
/>
</FormGroup>
{shareMethod === 'sources' && (
<>
<AwsSourcesSelect source={source} setSource={setSource} />
<SourcesButton />
<Gallery hasGutter>
<GalleryItem>
<TextInput
readOnlyVariant="default"
isRequired
id="someid"
value="us-east-1"
/>
<HelperText>
<HelperTextItem component="div" variant="indeterminate">
Images are built in the default region but can be copied to
other regions later.
</HelperTextItem>
</HelperText>
</GalleryItem>
<GalleryItem>
<AwsAccountId source={source} />
</GalleryItem>
</Gallery>
</>
)}
{shareMethod === 'manual' && (
<>
<FormGroup label="AWS account ID" isRequired>
<ValidatedTextInput
ariaLabel="aws account id"
value={shareWithAccount || ''}
validator={isAwsAccountIdValid}
onChange={(_event, value) => dispatch(changeAwsAccount(value))}
helperText="Should be 12 characters long."
/>
</FormGroup>
<FormGroup label="Default Region" isRequired>
<TextInput
value={'us-east-1'}
type="text"
aria-label="default region"
readOnlyVariant="default"
/>
</FormGroup>
</>
)}
</Form>
);
};
export default Aws;

View file

@ -0,0 +1,10 @@
export const isAwsAccountIdValid = (awsAccountId: string | undefined) => {
if (
awsAccountId !== undefined &&
/^\d+$/.test(awsAccountId) &&
awsAccountId.length === 12
) {
return true;
}
return false;
};