CreateImageWizard: support multiple upload providers

The user can now select between multiple upload providers. AWS, Azure,
and Google. The selection uses tiles and the user can select one or more
destinations. Currently, only aws supports setting the upload
parameters.

The icons point to files hosted on cloud.redhat.com.
This commit is contained in:
Jacob Kozol 2021-02-04 19:03:34 +01:00 committed by jkozol
parent 61b9c2f3ce
commit 5e81d5daf9
7 changed files with 289 additions and 105 deletions

View file

@ -1,41 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, FormGroup, FormSelect, FormSelectOption, Title } from '@patternfly/react-core';
import { Form, FormGroup, FormSelect, FormSelectOption, Tile, Title } from '@patternfly/react-core';
import './WizardStepImageOutput.scss';
const WizardStepImageOutput = (props) => {
const releaseOptions = [
{ value: 'rhel-8', label: 'Red Hat Enterprise Linux (RHEL) 8.3' },
{ value: 'centos-8', label: 'CentOS Stream 8' },
];
const uploadOptions = [
{ value: 'aws', label: 'Amazon Web Services' },
];
return (
<Form>
<Title headingLevel="h2" size="xl">Image output</Title>
<FormGroup isRequired label="Release" fieldId="release-select">
<FormSelect value={ props.value } onChange={ value => props.setRelease(value) } isRequired
aria-label="Select release input" id="release-select" data-testid="release-select">
{ releaseOptions.map(option => <FormSelectOption key={ option.value } value={ option.value } label={ option.label } />) }
</FormSelect>
</FormGroup>
<FormGroup isRequired label="Target environment" fieldId="upload-destination">
<FormSelect value={ props.upload.type || '' } id="upload-destination"
data-testid="upload-destination" isRequired
onChange={ value => props.setUpload({ type: value, options: props.upload.options }) } aria-label="Select upload destination">
{ uploadOptions.map(option => <FormSelectOption key={ option.value } value={ option.value } label={ option.label } />) }
</FormSelect>
</FormGroup>
</Form>
<>
<Form>
<Title headingLevel="h2" size="xl">Image output</Title>
<FormGroup isRequired label="Release" fieldId="release-select">
<FormSelect value={ props.value } onChange={ value => props.setRelease(value) } isRequired
aria-label="Select release input" id="release-select" data-testid="release-select">
{ releaseOptions.map(option => <FormSelectOption key={ option.value } value={ option.value } label={ option.label } />) }
</FormSelect>
</FormGroup>
<FormGroup isRequired label="Select target environment" data-testid="target-select">
<div className="tiles">
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-aws"
title="Amazon Web Services"
icon={ <img
className='provider-icon'
src={ '/apps/frontend-assets/partners-icons/aws.svg' } /> }
onClick={ () => props.toggleUploadDestination('aws') }
isSelected={ props.uploadDestinations.aws }
isStacked
isDisplayLarge />
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-azure"
title="Microsoft Azure"
icon={ <img
className='provider-icon'
src={ '/apps/frontend-assets/partners-icons/microsoft-azure-short.svg' } /> }
onClick={ () => props.toggleUploadDestination('azure') }
isSelected={ props.uploadDestinations.azure }
isStacked
isDisplayLarge
isDisabled />
<Tile
className="tile"
data-testid="upload-google"
title="Google Cloud Platform"
icon={ <img
className='provider-icon'
src={ '/apps/frontend-assets/partners-icons/google-cloud-short.svg' } /> }
onClick={ () => props.toggleUploadDestination('google') }
isSelected={ props.uploadDestinations.google }
isStacked
isDisplayLarge
isDisabled />
</div>
</FormGroup>
</Form>
</>
);
};
WizardStepImageOutput.propTypes = {
toggleUploadAWS: PropTypes.func,
uploadDestinations: PropTypes.object,
setRelease: PropTypes.func,
value: PropTypes.string,
upload: PropTypes.object,
setUpload: PropTypes.func,
toggleUploadDestination: PropTypes.func,
};
export default WizardStepImageOutput;

View file

@ -0,0 +1,26 @@
.tiles {
display: flex;
}
.tile {
flex: 1 0 0px;
}
.pf-c-tile:focus {
--pf-c-tile__title--Color: var(--pf-c-tile__title--Color);
--pf-c-tile__icon--Color: var(---pf-global--Color--100);
--pf-c-tile--before--BorderWidth: var(--pf-global--BorderWidth--sm);
--pf-c-tile--before--BorderColor: var(--pf-global--BorderColor--100);
}
.pf-c-tile.pf-m-selected:focus {
--pf-c-tile__title--Color: var(--pf-c-tile--focus__title--Color);
--pf-c-tile__icon--Color: var(--pf-c-tile--focus__icon--Color);
--pf-c-tile--before--BorderWidth: var(--pf-c-tile--focus--before--BorderWidth);
--pf-c-tile--before--BorderColor: var(--pf-c-tile--focus--before--BorderColor);
}
.provider-icon {
width: 1em;
height: 1em;
}

View file

@ -16,7 +16,7 @@ const WizardStepReview = (props) => {
};
return (
<>
{ (Object.keys(props.uploadErrors).length > 0 ||
{ (Object.keys(props.uploadAWSErrors).length > 0 ||
Object.keys(props.subscriptionErrors).length > 0) &&
<Alert variant="danger" className="pf-u-mb-xl" isInline title="Required information is missing" /> }
<Title headingLevel="h2" size="xl">Create image</Title>
@ -26,7 +26,7 @@ const WizardStepReview = (props) => {
to create the image using the following criteria.
</small>
<h3>Image output</h3>
<dl>
<dl data-testid='review-image-output'>
<dt>
Release
</dt>
@ -37,14 +37,14 @@ const WizardStepReview = (props) => {
Target environment
</dt>
<dd>
{ props.upload && <>{ uploadOptions[props.upload.type] }</> }
{ props.uploadAWS && <>{ uploadOptions.aws }</> }
</dd>
</dl>
{ Object.entries(props.uploadErrors).length > 0 && (
{ Object.entries(props.uploadAWSErrors).length > 0 && (
<h3>Upload to AWS</h3>
)}
<dl>
{ Object.entries(props.uploadErrors).map(([ key, error ]) => {
{ Object.entries(props.uploadAWSErrors).map(([ key, error ]) => {
return (<React.Fragment key={ key }>
<dt>
{ error.label }
@ -86,10 +86,10 @@ const WizardStepReview = (props) => {
WizardStepReview.propTypes = {
release: PropTypes.string,
upload: PropTypes.object,
uploadAWS: PropTypes.object,
subscription: PropTypes.object,
subscribeNow: PropTypes.bool,
uploadErrors: PropTypes.object,
uploadAWSErrors: PropTypes.object,
subscriptionErrors: PropTypes.object,
};

View file

@ -16,10 +16,11 @@ const WizardStepUploadAWS = (props) => {
<FormGroup isRequired label="AWS account ID" fieldId="aws-account-id"
helperTextInvalid={ (props.errors['aws-account-id'] && props.errors['aws-account-id'].value) || '' }
validated={ (props.errors['aws-account-id'] && 'error') || 'default' }>
<TextInput value={ props.upload.options.share_with_accounts || '' }
<TextInput value={ props.uploadAWS.options.share_with_accounts || '' }
type="text" aria-label="AWS account ID" id="aws-account-id"
data-testid="aws-account-id" isRequired
onChange={ value => props.setUploadOptions(Object.assign(props.upload.options, { share_with_accounts: [ value ]})) } />
onChange={ value =>
props.setUploadOptions('aws', Object.assign(props.uploadAWS.options, { share_with_accounts: [ value ]})) } />
</FormGroup>
</Form>
);
@ -27,7 +28,7 @@ const WizardStepUploadAWS = (props) => {
WizardStepUploadAWS.propTypes = {
setUploadOptions: PropTypes.func,
upload: PropTypes.object,
uploadAWS: PropTypes.object,
errors: PropTypes.object,
};

View file

@ -12,16 +12,17 @@ import WizardStepRegistration from '../../PresentationalComponents/CreateImageWi
import WizardStepReview from '../../PresentationalComponents/CreateImageWizard/WizardStepReview';
import api from './../../api.js';
import './CreateImageWizard.scss';
class CreateImageWizard extends Component {
constructor(props) {
super(props);
this.setRelease = this.setRelease.bind(this);
this.setUpload = this.setUpload.bind(this);
this.setUploadOptions = this.setUploadOptions.bind(this);
this.setSubscription = this.setSubscription.bind(this);
this.setSubscribeNow = this.setSubscribeNow.bind(this);
this.toggleUploadDestination = this.toggleUploadDestination.bind(this);
this.onStep = this.onStep.bind(this);
this.onSave = this.onSave.bind(this);
this.onClose = this.onClose.bind(this);
@ -30,13 +31,32 @@ class CreateImageWizard extends Component {
this.validateSubscription = this.validateSubscription.bind(this);
this.state = {
arch: 'x86_64',
imageType: 'qcow2',
release: 'rhel-8',
upload: {
uploadAWS: {
type: 'aws',
options: {
share_with_accounts: [],
share_with_accounts: []
}
},
uploadAzure: {
type: 'azure',
options: {
temp: ''
}
},
uploadGoogle: {
type: 'google',
options: {
temp: ''
}
},
uploadDestinations: {
aws: false,
azure: false,
google: false
},
subscription: {
organization: null,
'activation-key': null,
@ -46,7 +66,9 @@ class CreateImageWizard extends Component {
},
subscribeNow: true,
/* errors take form of $fieldId: error */
uploadErrors: {},
uploadAWSErrors: {},
uploadAzureErrors: {},
uploadGoogleErrors: {},
subscriptionErrors: {},
};
}
@ -68,11 +90,23 @@ class CreateImageWizard extends Component {
validate() {
/* upload */
if (this.state.upload.type === 'aws') {
this.validateUploadAmazon();
} else {
this.setState({ uploadErrors: {}});
}
Object.keys(this.state.uploadDestinations).forEach(provider => {
switch (provider) {
case 'aws':
this.validateUploadAmazon();
break;
case 'azure':
break;
case 'google':
break;
default:
this.setState({
uploadAWSErrors: {},
uploadAzureErrors: {},
uploadGoogleErrors: {},
});
}
});
/* subscription */
if (this.state.subscribeNow) {
@ -83,14 +117,14 @@ class CreateImageWizard extends Component {
}
validateUploadAmazon() {
let uploadErrors = {};
let share = this.state.upload.options.share_with_accounts;
let uploadAWSErrors = {};
let share = this.state.uploadAWS.options.share_with_accounts;
if (share.length === 0 || share[0].length !== 12 || isNaN(share[0])) {
uploadErrors['aws-account-id'] =
uploadAWSErrors['aws-account-id'] =
{ label: 'AWS account ID', value: 'A 12-digit number is required' };
}
this.setState({ uploadErrors });
this.setState({ uploadAWSErrors });
}
validateSubscription() {
@ -107,19 +141,45 @@ class CreateImageWizard extends Component {
this.setState({ release });
}
setUpload(upload) {
this.setState({ upload });
toggleUploadDestination(provider) {
this.setState(prevState => ({
...prevState,
uploadDestinations: {
...prevState.uploadDestinations,
[provider]: !prevState.uploadDestinations[provider]
}
}));
}
setUploadOptions(uploadOptions) {
this.setState(oldState => {
return {
upload: {
type: oldState.upload.type,
options: uploadOptions
}
};
});
setUploadOptions(provider, uploadOptions) {
switch (provider) {
case 'aws':
this.setState({
uploadAWS: {
type: provider,
options: uploadOptions
}
});
break;
case 'azure':
this.setState({
uploadAzure: {
type: provider,
options: uploadOptions
}
});
break;
case 'google':
this.setState({
uploadGoogle: {
type: provider,
options: uploadOptions
}
});
break;
default:
break;
}
}
setSubscribeNow(subscribeNow) {
@ -131,38 +191,52 @@ class CreateImageWizard extends Component {
}
onSave () {
let request = {
distribution: this.state.release,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_requests: [{
type: 'aws',
options: {
share_with_accounts: this.state.upload.options.share_with_accounts,
let requests = [];
Object.keys(this.state.uploadDestinations).forEach(provider => {
switch (provider) {
case 'aws': {
let request = {
distribution: this.state.release,
image_requests: [
{
architecture: this.state.arch,
image_type: 'ami',
upload_requests: [ this.state.uploadAWS ],
}],
customizations: {
subscription: this.state.subscription,
},
}],
}],
customizations: {
subscription: this.state.subscription,
},
};
};
requests.push(request);
break;
}
let { updateCompose } = this.props;
api.composeImage(request).then(response => {
let compose = {};
compose[response.id] = {
image_status: {
status: 'pending',
},
distribution: request.distribution,
architecture: request.image_requests[0].architecture,
image_type: request.image_requests[0].image_type,
};
updateCompose(compose);
this.props.history.push('/landing');
case 'azure':
break;
case 'google':
break;
default:
break;
}
});
const composeRequests = [];
requests.forEach(request => {
const composeRequest = api.composeImage(request).then(response => {
let compose = {};
compose[response.id] = {
image_status: {
status: 'pending',
},
distribution: request.distribution,
architecture: request.image_requests[0].architecture,
image_type: request.image_requests[0].image_type,
};
this.props.updateCompose(compose);
});
composeRequests.push(composeRequest);
});
Promise.all(composeRequests).then(() => this.props.history.push('/landing'));
}
onClose () {
@ -174,21 +248,48 @@ class CreateImageWizard extends Component {
name: 'Image output',
component: <WizardStepImageOutput
value={ this.state.release }
upload={ this.state.upload }
setRelease={ this.setRelease }
setUpload={ this.setUpload } />
toggleUploadDestination={ this.toggleUploadDestination }
uploadDestinations={ this.state.uploadDestinations } />
};
const StepUploadAWS = {
name: 'Upload to AWS',
name: 'Amazon Web Services',
component: <WizardStepUploadAWS
upload={ this.state.upload }
uploadAWS={ this.state.uploadAWS }
setUploadOptions={ this.setUploadOptions }
errors={ this.state.uploadErrors } />
errors={ this.state.uploadAWSErrors } />
};
const StepUploadAzure = {
name: 'Microsoft Azure'
};
const StepUploadGoogle = {
name: 'Google Cloud Platform'
};
const uploadDestinationSteps = [];
if (this.state.uploadDestinations.aws) {
uploadDestinationSteps.push(StepUploadAWS);
}
if (this.state.uploadDestinations.azure) {
uploadDestinationSteps.push(StepUploadAzure);
}
if (this.state.uploadDestinations.google) {
uploadDestinationSteps.push(StepUploadGoogle);
}
const StepTargetEnv = {
name: 'Target environment',
steps: uploadDestinationSteps
};
const steps = [
StepImageOutput,
...(this.state.upload.type === 'aws' ? [ StepUploadAWS ] : []),
...(StepTargetEnv.steps.length > 0 ? [ StepTargetEnv ] : []),
{
name: 'Registration',
component: <WizardStepRegistration
@ -201,10 +302,10 @@ class CreateImageWizard extends Component {
name: 'Review',
component: <WizardStepReview
release={ this.state.release }
upload={ this.state.upload }
uploadAWS={ this.state.uploadAWS }
subscription={ this.state.subscription }
subscribeNow={ this.state.subscribeNow }
uploadErrors={ this.state.uploadErrors }
uploadAWSErrors={ this.state.uploadAWSErrors }
subscriptionErrors={ this.state.subscriptionErrors } />,
nextButtonText: 'Create',
}

View file

@ -0,0 +1,3 @@
.pf-c-wizard__nav-list {
padding-right: 0px;
}

View file

@ -1,7 +1,7 @@
import '@testing-library/jest-dom';
import React from 'react';
import { screen, getByText, waitForElementToBeRemoved } from '@testing-library/react';
import { screen, getByText, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithReduxRouter } from '../../testUtils';
import CreateImageWizard from '../../../SmartComponents/CreateImageWizard/CreateImageWizard';
@ -82,8 +82,10 @@ describe('Step Image output', () => {
// left sidebar navigation
const sidebar = screen.getByRole('navigation');
const anchor = getByText(sidebar, 'Image output');
screen.getByTestId('upload-destination');
// select aws as upload destination
const awsTile = screen.getByTestId('upload-aws');
awsTile.click();
// load from sidebar
anchor.click();
});
@ -116,9 +118,10 @@ describe('Step Image output', () => {
});
test('target environment is required', () => {
const destination = screen.getByTestId('upload-destination');
const destination = screen.getByTestId('target-select');
const required = within(destination).getByText('*');
expect(destination).toBeEnabled();
expect(destination).toBeRequired();
expect(destination).toContainElement(required);
});
});
@ -127,9 +130,13 @@ describe('Step Upload to AWS', () => {
const { _component, history } = renderWithReduxRouter(<CreateImageWizard />);
historySpy = jest.spyOn(history, 'push');
// select aws as upload destination
const awsTile = screen.getByTestId('upload-aws');
awsTile.click();
// left sidebar navigation
const sidebar = screen.getByRole('navigation');
const anchor = getByText(sidebar, 'Upload to AWS');
const anchor = getByText(sidebar, 'Amazon Web Services');
// load from sidebar
anchor.click();
@ -167,6 +174,10 @@ describe('Step Registration', () => {
const { _component, history } = renderWithReduxRouter(<CreateImageWizard />);
historySpy = jest.spyOn(history, 'push');
// select aws as upload destination
const awsTile = screen.getByTestId('upload-aws');
awsTile.click();
// left sidebar navigation
const sidebar = screen.getByRole('navigation');
const anchor = getByText(sidebar, 'Registration');
@ -236,6 +247,10 @@ describe('Step Review', () => {
const { _component, history } = renderWithReduxRouter(<CreateImageWizard />);
historySpy = jest.spyOn(history, 'push');
// select aws as upload destination
const awsTile = screen.getByTestId('upload-aws');
awsTile.click();
// left sidebar navigation
const sidebar = screen.getByRole('navigation');
const anchor = getByText(sidebar, 'Review');
@ -274,7 +289,7 @@ describe('Click through all steps', () => {
// select image output
userEvent.selectOptions(screen.getByTestId('release-select'), [ 'rhel-8' ]);
userEvent.selectOptions(screen.getByTestId('upload-destination'), [ 'aws' ]);
screen.getByTestId('upload-aws').click();
next.click();
// select upload target
@ -290,9 +305,10 @@ describe('Click through all steps', () => {
next.click();
// review
const imageOutput = screen.getByTestId('review-image-output');
await screen.
findByText('Review the information and click Create image to create the image using the following criteria.');
await screen.findByText('Amazon Web Services');
await within(imageOutput).findByText('Amazon Web Services');
await screen.findByText('Register the system on first boot');
// mock the backend API
@ -307,7 +323,7 @@ describe('Click through all steps', () => {
// returns back to the landing page
// but jsdom will not render the new page so we can't assert on that
await expect(historySpy).toHaveBeenCalledTimes(1);
await waitFor(() => expect(historySpy).toHaveBeenCalledTimes(1));
await expect(historySpy).toHaveBeenCalledWith('/landing');
});
@ -316,7 +332,7 @@ describe('Click through all steps', () => {
// select release
userEvent.selectOptions(screen.getByTestId('release-select'), [ 'rhel-8' ]);
userEvent.selectOptions(screen.getByTestId('upload-destination'), [ 'aws' ]);
screen.getByTestId('upload-aws').click();
next.click();
// leave AWS account id empty
@ -330,9 +346,10 @@ describe('Click through all steps', () => {
userEvent.clear(screen.getByTestId('subscription-activation'));
next.click();
const imageOutput = screen.getByTestId('review-image-output');
await screen.
findByText('Review the information and click Create image to create the image using the following criteria.');
await screen.findByText('Amazon Web Services');
await within(imageOutput).findByText('Amazon Web Services');
await screen.findByText('Register the system on first boot');
const errorMessages = await screen.findAllByText('A value is required');
@ -348,7 +365,7 @@ describe('Click through all steps', () => {
// select release
userEvent.selectOptions(screen.getByTestId('release-select'), [ 'rhel-8' ]);
// select upload target
userEvent.selectOptions(screen.getByTestId('upload-destination'), [ 'aws' ]);
screen.getByTestId('upload-aws').click();
next.click();
userEvent.type(screen.getByTestId('aws-account-id'), 'invalid, isNaN');
@ -362,9 +379,10 @@ describe('Click through all steps', () => {
userEvent.clear(screen.getByTestId('subscription-activation'));
next.click();
const imageOutput = screen.getByTestId('review-image-output');
await screen.
findByText('Review the information and click Create image to create the image using the following criteria.');
await screen.findByText('Amazon Web Services');
await within(imageOutput).findByText('Amazon Web Services');
await screen.findByText('Register the system on first boot');
const errorMessages = await screen.findAllByText('A value is required');