store: implement compose start action/reducer

When a compose is started the api call is no longer handled by the
CreateImageWizard onSave function. Instead, the CreateImageWizard
calls the composeStart thunk. This function calls the api and handles
the response. If successful, the compose is added to the store.
Otherwise, an error is added to the store.

The store's compose object now has a list of the compose ids and an
object containing key/value pairs mapping a compose id to the compose
for all composes. This "normalized" state will allow more efficiency
when selecting individual composes or iterating through all composes.

The compose objects in the store now match the composeRequest object
instead of having a shape unique to the UI. This can be changed in the
future if image-builder's api returns compose objects of a different
format.

Tests are updated for new compose format and action/reducer types.
This commit is contained in:
Jacob Kozol 2021-04-16 20:10:44 +02:00 committed by Sanne Raymaekers
parent 8a8a7229a1
commit f3eed9c28f
8 changed files with 379 additions and 166 deletions

View file

@ -16,7 +16,6 @@ import WizardStepRegistration from './WizardStepRegistration';
import WizardStepReview from './WizardStepReview';
import ImageWizardFooter from './ImageWizardFooter';
import api from './../../api.js';
import './CreateImageWizard.scss';
class CreateImageWizard extends Component {
@ -36,7 +35,6 @@ class CreateImageWizard extends Component {
uploadGoogleErrors: {},
isSaveInProgress: false,
isValidSubscription: true,
onSaveError: null,
};
}
@ -114,9 +112,7 @@ class CreateImageWizard extends Component {
}
onSave() {
this.setState({
isSaveInProgress: true,
});
this.setState({ isSaveInProgress: true });
let customizations = {
packages: this.props.selectedPackages,
@ -206,41 +202,21 @@ class CreateImageWizard extends Component {
customizations,
};
requests.push(request);
}
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,
upload_type: request.image_requests[0].upload_request.type,
};
this.props.composeUpdated(compose);
});
composeRequests.push(composeRequest);
});
const composeRequests = requests.map(request => this.props.composeStart(request));
Promise.all(composeRequests)
.then(() => {
this.props.addNotification({
variant: 'success',
title: 'Your image is being created',
});
this.props.history.push('/landing');
})
.catch(err => {
console.log('ERR', err);
this.setState({ isSaveInProgress: false });
if (err.response.status === 500) {
this.setState({ onSaveError: 'Error: Something went wrong serverside' });
if (!this.props.composesError) {
this.props.addNotification({
variant: 'success',
title: 'Your image is being created',
});
this.props.history.push('/landing');
}
this.setState({ isSaveInProgress: false });
});
}
@ -326,7 +302,7 @@ class CreateImageWizard extends Component {
isValidUploadDestination={ isValidUploadDestination }
isSaveInProgress={ this.state.isSaveInProgress }
isValidSubscription={ this.state.isValidSubscription }
error={ this.state.onSaveError } /> }
error={ this.props.composesError } /> }
isOpen />
</React.Fragment>
);
@ -335,6 +311,7 @@ class CreateImageWizard extends Component {
function mapStateToProps(state) {
return {
composesError: state.composes.error,
release: state.pendingCompose.release,
uploadDestinations: state.pendingCompose.uploadDestinations,
uploadAWS: state.pendingCompose.uploadAWS,
@ -349,12 +326,15 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
composeUpdated: (compose) => dispatch(actions.composeUpdated(compose)),
composeStart: (composeRequest) => dispatch(actions.composeStart(composeRequest)),
addNotification: (not) => dispatch(addNotification(not)),
};
}
CreateImageWizard.propTypes = {
composesError: PropTypes.string,
composeUpdated: PropTypes.func,
composeStart: PropTypes.func,
addNotification: PropTypes.func,
history: PropTypes.object,
release: PropTypes.object,

View file

@ -52,15 +52,14 @@ class ImagesTable extends Component {
pollComposeStatuses() {
let { composeUpdated, composes } = this.props;
Object.entries(composes).map(([ id, compose ]) => {
Object.entries(composes.byId).map(([ id, compose ]) => {
/* Skip composes that have been complete */
if (compose.image_status.status === 'success' || compose.image_status.status === 'failure') {
return;
}
api.getComposeStatus(id).then(response => {
let newCompose = {};
newCompose[id] = Object.assign({}, compose, { image_status: response.image_status });
const newCompose = Object.assign({}, compose, { image_status: response.image_status });
composeUpdated(newCompose);
});
});
@ -68,11 +67,11 @@ class ImagesTable extends Component {
render() {
let { composes } = this.props;
const rows = Object.entries(composes).map(([ id, compose ]) => {
const rows = Object.entries(composes.byId).map(([ id, compose ]) => {
return {
cells: [
id,
{ title: <Upload uploadType={ compose.upload_type } /> },
{ title: <Upload uploadType={ compose.image_requests[0].image_type } /> },
{ title: <Release release={ compose.distribution } /> },
{ title: <ImageBuildStatus status={ compose.image_status.status } /> },
''
@ -81,7 +80,7 @@ class ImagesTable extends Component {
});
return (
<React.Fragment>
{ Object.keys(composes).length === 0 && (
{ composes.allIds.length === 0 && (
<EmptyState variant={ EmptyStateVariant.large } data-testid="empty-state">
<EmptyStateIcon icon={ PlusCircleIcon } />
<Title headingLevel="h4" size="lg">

View file

@ -1,12 +1,40 @@
import api from '../../api';
import types from '../types';
function composeUpdated(compose) {
return {
type: types.COMPOSE_UPDATED,
compose
payload: { compose },
};
}
export const composeFailed = (error) => ({
type: types.COMPOSE_FAILED,
payload: { error }
});
export const composeAdded = (compose) => ({
type: types.COMPOSE_ADDED,
payload: { compose },
});
export const composeStart = (composeRequest) => async dispatch => {
// response will be of the format {id: ''}
const request = api.composeImage(composeRequest);
return request.then(response => {
// add the compose id to the composeRequest object to provide access to the
// id if iterating through composes and add an image status of 'pending'.
const compose = Object.assign({}, composeRequest, response, { image_status: { status: 'pending' }});
dispatch(composeAdded(compose));
}).catch(err => {
if (err.response.status === 500) {
dispatch(composeFailed('Error: Something went wrong serverside'));
} else {
dispatch(composeFailed('Error: Something went wrong with the compose'));
}
});
};
function setRelease({ arch, distro }) {
return {
type: types.SET_RELEASE,
@ -84,6 +112,7 @@ function setSubscribeNow(subscribeNow) {
}
export default {
composeStart,
composeUpdated,
setRelease,
setUploadDestinations,

View file

@ -3,19 +3,63 @@ import types from '../types';
// Example of action.compose
// {
// "77e4c693-0497-4b85-936d-b2a3ad69571b": {
// id: "77e4c693-0497-4b85-936d-b2a3ad69571b",
// distribution: "rhel-8",
// image_requests: [
// {
// architecture: "x86_64",
// image_type: "ami",
// upload_request: {
// type: "aws",
// options: {}
// }
// }
// ]
// image_status: {
// status: "uploading",
// },
// distribution: "rhel-8",
// architecture: "x86_64",
// image_type: "ami"
// }
// };
export function composes(state = { }, action) {
const initialComposesState = {
allIds: [],
byId: {},
error: null,
};
export function composes(state = initialComposesState, action) {
switch (action.type) {
case types.COMPOSE_ADDED:
return {
...state,
allIds: [
...state.allIds,
action.payload.compose.id
],
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
},
error: null,
};
case types.COMPOSE_FAILED:
return {
...state,
error: action.payload.error,
};
case types.COMPOSE_PENDING:
return {
...state,
error: null,
};
case types.COMPOSE_UPDATED:
return Object.assign({}, state, action.compose);
return {
...state,
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
}
};
default:
return state;
}

View file

@ -1,3 +1,5 @@
const COMPOSE_ADDED = 'COMPOSE_ADDED';
const COMPOSE_FAILED = 'COMPOSE_FAILED';
const COMPOSE_UPDATED = 'COMPOSE_UPDATED';
const SET_RELEASE = 'SET_RELEASE';
const SET_UPLOAD_DESTINATIONS = 'SET_UPLOAD_DESTINATIONS';
@ -9,6 +11,8 @@ const SET_SUBSCRIPTION = 'SET_SUBSCRIPTION';
const SET_SUBSCRIBE_NOW = 'SET_SUBSCRIBE_NOW';
export default {
COMPOSE_ADDED,
COMPOSE_FAILED,
COMPOSE_UPDATED,
SET_RELEASE,
SET_UPLOAD_DESTINATIONS,

View file

@ -8,119 +8,214 @@ import '@testing-library/jest-dom';
const store = {
composes: {
// kept "running" for backward compatibility
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
image_status: {
status: 'running',
errors: null,
allIds: [
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
'42ad0826-30b5-4f64-a24e-957df26fd564',
'955944a2-e149-4058-8ac1-35b514cb5a16',
'f7a60094-b376-4b58-a102-5c8c82dfd18b',
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
'61b0effa-c901-4ee5-86b9-2010b47f1b22',
'ca03f120-9840-4959-871e-94a5cb49d1f2',
'551de6f6-1533-4b46-a69f-7924051f9bc6',
'77fa8b03-7efb-4120-9a20-da66d68c4494',
],
byId: {
// kept "running" for backward compatibility
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
id: 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'running',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'edbae1c2-62bc-42c1-ae0c-3110ab718f58': {
image_status: {
status: 'pending',
'edbae1c2-62bc-42c1-ae0c-3110ab718f58': {
id: 'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'pending',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'42ad0826-30b5-4f64-a24e-957df26fd564': {
image_status: {
status: 'building',
'42ad0826-30b5-4f64-a24e-957df26fd564': {
id: '42ad0826-30b5-4f64-a24e-957df26fd564',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'building',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'955944a2-e149-4058-8ac1-35b514cb5a16': {
image_status: {
status: 'uploading',
'955944a2-e149-4058-8ac1-35b514cb5a16': {
id: '955944a2-e149-4058-8ac1-35b514cb5a16',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'uploading',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'f7a60094-b376-4b58-a102-5c8c82dfd18b': {
image_status: {
status: 'registering',
'f7a60094-b376-4b58-a102-5c8c82dfd18b': {
id: 'f7a60094-b376-4b58-a102-5c8c82dfd18b',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'registering',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c': {
image_status: {
status: 'success',
upload_status: {
options: {
ami: 'ami-0217b81d9be50e44b',
region: 'us-east-1'
},
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c': {
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'success',
type: 'aws'
}
upload_status: {
options: {
ami: 'ami-0217b81d9be50e44b',
region: 'us-east-1'
},
status: 'success',
type: 'aws'
}
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'ami',
upload_type: 'aws',
},
'61b0effa-c901-4ee5-86b9-2010b47f1b22': {
image_status: {
status: 'failure',
'61b0effa-c901-4ee5-86b9-2010b47f1b22': {
id: '61b0effa-c901-4ee5-86b9-2010b47f1b22',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'failure',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'vhd',
upload_type: 'gcp',
},
'ca03f120-9840-4959-871e-94a5cb49d1f2': {
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-d446d8cb-7c16-4756-bf7d-706293785b05',
project_id: 'red-hat-image-builder'
},
'ca03f120-9840-4959-871e-94a5cb49d1f2': {
id: 'ca03f120-9840-4959-871e-94a5cb49d1f2',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'gcp',
options: {}
}
}
],
image_status: {
status: 'success',
type: 'gcp'
}
upload_status: {
options: {
image_name: 'composer-api-d446d8cb-7c16-4756-bf7d-706293785b05',
project_id: 'red-hat-image-builder'
},
status: 'success',
type: 'gcp'
}
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'vhd',
upload_type: 'gcp',
},
'551de6f6-1533-4b46-a69f-7924051f9bc6': {
image_status: {
status: 'building',
'551de6f6-1533-4b46-a69f-7924051f9bc6': {
id: '551de6f6-1533-4b46-a69f-7924051f9bc6',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {}
}
}
],
image_status: {
status: 'building',
},
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'vhd',
upload_type: 'azure',
},
'77fa8b03-7efb-4120-9a20-da66d68c4494': {
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-cc5920c3-5451-4282-aab3-725d3df7f1cb'
},
'77fa8b03-7efb-4120-9a20-da66d68c4494': {
id: '77fa8b03-7efb-4120-9a20-da66d68c4494',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {}
}
}
],
image_status: {
status: 'success',
type: 'azure'
}
},
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'vhd',
upload_type: 'azure',
upload_status: {
options: {
image_name: 'composer-api-cc5920c3-5451-4282-aab3-725d3df7f1cb'
},
status: 'success',
type: 'azure'
}
},
}
}
}
};
@ -146,7 +241,7 @@ describe('Images Table', () => {
if (col1 === 'Image') // skip header
{continue;}
const compose = store.composes[col1];
const compose = store.composes.byId[col1];
expect(compose).toBeTruthy();
// render the expected <ImageBuildStatus /> and compare the text content
@ -155,7 +250,7 @@ describe('Images Table', () => {
expect(row.cells[3]).toHaveTextContent(testElement.textContent);
// do the same for the upload/target column
render(<Upload uploadType={ compose.upload_type } />, { container: testElement });
render(<Upload uploadType={ compose.image_requests[0].image_type } />, { container: testElement });
expect(row.cells[1]).toHaveTextContent(testElement.textContent);
}
});

View file

@ -3,10 +3,21 @@ import types from '../../../store/types';
const compose = {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {
status: 'uploading',
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'qcow2'
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'uploading',
},
}
};
@ -17,6 +28,6 @@ describe('composeUpdated', () => {
// this function updates the type attribute and
// returns everything else unchanged
expect(result.type).toBe(types.COMPOSE_UPDATED);
expect(result.compose).toBe(compose);
expect(result.payload.compose).toBe(compose);
});
});

View file

@ -2,12 +2,21 @@ import { composes } from '../../../store/reducers/composes';
import types from '../../../store/types';
const compose = {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: 'rhel-8',
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'uploading',
distribution: 'fedora-31',
architecture: 'x86_64',
image_type: 'qcow2'
}
},
};
describe('composes', () => {
@ -19,17 +28,59 @@ describe('composes', () => {
expect(result).toEqual({});
});
test('returns updates state for types.COMPOSE_UPDATED', () => {
test('returns updated state for types.COMPOSE_ADDED', () => {
const state = {
testAttr: 'test-me'
allIds: [],
byId: {},
errors: null,
};
const result = composes(state, {
type: types.COMPOSE_ADDED,
payload: { compose }
});
expect(result.allIds)
.toEqual([ '77e4c693-0497-4b85-936d-b2a3ad69571b' ]);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b'])
.toEqual(compose);
expect(result.error)
.toEqual(null);
});
test('returns updated state for types.COMPOSE_UPDATED', () => {
const state = {
allIds: [ '77e4c693-0497-4b85-936d-b2a3ad69571b' ],
byId: {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {},
},
error: null,
};
const result = composes(state, {
type: types.COMPOSE_UPDATED,
compose
payload: { compose }
});
expect(result.testAttr).toBe('test-me');
expect(result['77e4c693-0497-4b85-936d-b2a3ad69571b'])
.toEqual(compose['77e4c693-0497-4b85-936d-b2a3ad69571b']);
expect(result.allIds)
.toEqual([ '77e4c693-0497-4b85-936d-b2a3ad69571b' ]);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b'])
.toEqual(compose);
expect(result.error)
.toEqual(null);
});
test('returns updated state for types.COMPOSE_FAILED', () => {
const state = {
allIds: [],
byId: {},
error: null,
};
const result = composes(state, {
type: types.COMPOSE_FAILED,
payload: { error: 'test error' }
});
expect(result.error)
.toEqual('test error');
});
});