debian-image-builder-frontend/src/Components/CreateImageWizard/CreateImageWizard.js
lucasgarfield 7b9e726151 ImagesTable: Convert ImagesTable to Typescript & RTK Query
This commit converts the Images Table to Typescript and converts all API
calls to image-builder to use RTK Query hooks.

This should increase the performance of the app significantly.
Previously our calls to the image-builder API were made in series. They
are now made in parallel. We may want to investigate the possibility of
hitting rate limiting now that we will be issuing requests in much more
rapid succession.

In the tests, moving to RTK Query hooks has allowed us to remove
virtually all Jest mocking. However, this means that some of our
previous tests which tested against implementation details were broken.
Most notably, we no longer check the Redux store to verify that clones
have been added correctly and we no longer check that compose requests
were issued successfully. Test coverage will be restored in a follow-up
PR where the dev-dependency @msw/data is added. Adding a persistent data
layer to the tests using @msw/data will allow us to verify that our POST
requests (creating composes and cloning them) are working by testing
that the Images Table has been updated.
2023-09-18 10:35:04 +02:00

669 lines
21 KiB
JavaScript

import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useFlag } from '@unleash/proxy-client-react';
import { useDispatch, useStore } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import ImageCreator from './ImageCreator';
import {
awsTarget,
fileSystemConfiguration,
googleCloudTarget,
imageName,
imageOutput,
msAzureTarget,
packages,
packagesContentSources,
registration,
repositories,
review,
} from './steps';
import {
fileSystemConfigurationValidator,
targetEnvironmentValidator,
} from './validators';
import './CreateImageWizard.scss';
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../constants';
import {
useComposeImageMutation,
useGetArchitecturesQuery,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
import { useGetEnvironment } from '../../Utilities/useGetEnvironment';
import DocumentationButton from '../sharedComponents/DocumentationButton';
const handleKeyDown = (e, handleClose) => {
if (e.key === 'Escape') {
handleClose();
}
};
const onSave = (values) => {
const customizations = {
packages: values['selected-packages']?.map((p) => p.name),
};
if (values['payload-repositories']?.length > 0) {
customizations['payload_repositories'] = [
...values['payload-repositories'],
];
}
if (values['custom-repositories']?.length > 0) {
customizations['custom_repositories'] = [...values['custom-repositories']];
}
if (values['register-system'] === 'register-now-rhc') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
rhc: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now-insights') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: false,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
}
if (values['file-system-config-radio'] === 'manual') {
customizations.filesystem = [];
for (const fsc of values['file-system-configuration']) {
customizations.filesystem.push({
mountpoint: fsc.mountpoint,
min_size: fsc.size * fsc.unit,
});
}
}
const requests = [];
if (values['target-environment']?.aws) {
const options =
values['aws-target-type'] === 'aws-target-type-source'
? { share_with_sources: [values['aws-sources-select']] }
: { share_with_accounts: [values['aws-account-id']] };
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options,
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
switch (values['google-account-type']) {
case 'googleAccount':
share = `user:${values['google-email']}`;
break;
case 'serviceAccount':
share = `serviceAccount:${values['google-email']}`;
break;
case 'googleGroup':
share = `group:${values['google-email']}`;
break;
case 'domain':
share = `domain:${values['google-domain']}`;
break;
// no default
}
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: [share],
},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.azure) {
const upload_options =
values['azure-type'] === 'azure-type-source'
? { source_id: values['azure-sources-select'] }
: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
};
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
...upload_options,
resource_group: values['azure-resource-group'],
},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.vsphere) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['vsphere-ova']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere-ova',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['guest-image']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['image-installer']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.wsl) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'wsl',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
return requests;
};
const parseSizeUnit = (bytesize) => {
let size;
let unit;
if (bytesize % UNIT_GIB === 0) {
size = bytesize / UNIT_GIB;
unit = UNIT_GIB;
} else if (bytesize % UNIT_MIB === 0) {
size = bytesize / UNIT_MIB;
unit = UNIT_MIB;
} else if (bytesize % UNIT_KIB === 0) {
size = bytesize / UNIT_KIB;
unit = UNIT_KIB;
}
return [size, unit];
};
// map the compose request object to the expected form state
const requestToState = (composeRequest, distroInfo, isBeta, isProd) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
const formState = {};
formState['image-name'] = composeRequest.image_name;
formState['image-description'] = composeRequest.image_description;
formState.release = composeRequest?.distribution;
// set defaults for target environment first
formState['target-environment'] = {
aws: false,
azure: false,
gcp: false,
'guest-image': false,
};
// then select the one from the request
// if the image type is to a cloud provider we use the upload_request.type
// or if the image is intended for download we use the image_type
let targetEnvironment;
if (uploadRequest.type === 'aws.s3') {
targetEnvironment = imageRequest.image_type;
} else {
targetEnvironment = uploadRequest.type;
}
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
const shareWithSource = uploadRequest?.options?.share_with_sources?.[0];
const shareWithAccount = uploadRequest?.options?.share_with_accounts?.[0];
formState['aws-sources-select'] = shareWithSource;
formState['aws-account-id'] = shareWithAccount;
if (shareWithAccount && !shareWithSource) {
formState['aws-target-type'] = 'aws-target-type-account-id';
} else {
// if both shareWithAccount & shareWithSource are present, set radio
// to sources - this is essentially an arbitrary decision
// additionally, note that the source is not validated against the actual
// sources
formState['aws-target-type'] = 'aws-target-type-source';
}
} else if (targetEnvironment === 'azure') {
if (uploadRequest?.options?.source_id) {
formState['azure-type'] = 'azure-type-source';
formState['azure-sources-select'] = uploadRequest?.options?.source_id;
} else {
formState['azure-type'] = 'azure-type-manual';
formState['azure-tenant-id'] = uploadRequest?.options?.tenant_id;
formState['azure-subscription-id'] =
uploadRequest?.options?.subscription_id;
}
formState['azure-resource-group'] =
uploadRequest?.options?.resource_group;
} else if (targetEnvironment === 'gcp') {
// parse google account info
// roughly in the format `accountType:accountEmail`
const accountInfo = uploadRequest?.options?.share_with_accounts[0];
const [accountTypePrefix, account] = accountInfo.split(':');
switch (accountTypePrefix) {
case 'user':
formState['google-account-type'] = 'googleAccount';
formState['google-email'] = account;
break;
case 'serviceAccount':
formState['google-account-type'] = 'serviceAccount';
formState['google-email'] = account;
break;
case 'group':
formState['google-account-type'] = 'googleGroup';
formState['google-email'] = account;
break;
case 'domain':
formState['google-account-type'] = 'domain';
formState['google-domain'] = account;
break;
// no default
}
}
// customizations
// packages
const packages = composeRequest?.customizations?.packages?.map((name) => {
return {
name: name,
summary: undefined,
};
});
formState['selected-packages'] = packages;
// repositories
// 'original-payload-repositories' is treated as read-only and is used to populate
// the table in the repositories step
// This is necessary because there may be repositories present in the request's
// json blob that are not managed using the content sources API. In that case,
// they are still displayed in the table of repositories but without any information
// from the content sources API (in other words, only the URL of the repository is
// displayed). This information needs to persist throughout the lifetime of the
// Wizard as it is needed every time the repositories step is visited.
formState['original-payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// 'payload-repositories' is mutable and is used to generate the request
// sent to image-builder
formState['payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// these will be overwritten by the repositories step if revisited, and generated from the payload repositories added there
formState['custom-repositories'] =
composeRequest?.customizations?.custom_repositories;
// filesystem
const fs = composeRequest?.customizations?.filesystem;
if (fs) {
formState['file-system-config-radio'] = 'manual';
const fileSystemConfiguration = [];
for (const fsc of fs) {
const [size, unit] = parseSizeUnit(fsc.min_size);
fileSystemConfiguration.push({
mountpoint: fsc.mountpoint,
size,
unit,
});
}
formState['file-system-configuration'] = fileSystemConfiguration;
}
// subscription
const subscription = composeRequest?.customizations?.subscription;
if (subscription) {
if (subscription.rhc) {
formState['register-system'] = 'register-now-rhc';
} else if (subscription.insights) {
formState['register-system'] = 'register-now-insights';
} else {
formState['register-system'] = 'register-now';
}
formState['subscription-activation-key'] = subscription['activation-key'];
formState['subscription-organization-id'] = subscription.organization;
if (isProd) {
formState['subscription-server-url'] = 'subscription.rhsm.redhat.com';
formState['subscription-base-url'] = 'https://cdn.redhat.com/';
} else {
formState['subscription-server-url'] =
'subscription.rhsm.stage.redhat.com';
formState['subscription-base-url'] = 'https://cdn.stage.redhat.com/';
}
} else {
formState['register-system'] = 'register-later';
}
return formState;
} else {
return;
}
};
const formStepHistory = (composeRequest, contentSourcesEnabled) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
// the order of steps must match the order of the steps in the Wizard
const steps = ['image-output'];
if (uploadRequest.type === 'aws') {
steps.push('aws-target-env');
} else if (uploadRequest.type === 'azure') {
steps.push('ms-azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
if (contentSourcesEnabled) {
steps.push('File system configuration', 'packages', 'repositories');
const customRepositories =
composeRequest.customizations?.payload_repositories;
if (customRepositories) {
steps.push('packages-content-sources');
}
} else {
steps.push('File system configuration', 'packages');
}
steps.push('details');
return steps;
} else {
return [];
}
};
const CreateImageWizard = () => {
const [composeImage] = useComposeImageMutation();
const dispatch = useDispatch();
const navigate = useNavigate();
// composeId is an optional param that is used for Recreate image
const { composeId } = useParams();
const { data } = useGetComposeStatusQuery(
{ composeId: composeId },
{
skip: composeId ? false : true,
}
);
const composeRequest = composeId ? data?.request : undefined;
const contentSourcesEnabled = useFlag('image-builder.enable-content-sources');
// TODO: This causes an annoying re-render when using Recreate image
const { data: distroInfo } = useGetArchitecturesQuery(
{ distribution: composeRequest?.distribution },
{
// distroInfo is only needed when recreating an image, skip otherwise
skip: composeId ? false : true,
}
);
// Assume that if a request is available that we should start on review step
// This will occur if 'Recreate image' is clicked
const initialStep = composeRequest ? 'review' : undefined;
const { isBeta, isProd } = useGetEnvironment();
let initialState = requestToState(
composeRequest,
distroInfo,
isBeta(),
isProd()
);
const stepHistory = formStepHistory(composeRequest, contentSourcesEnabled);
if (initialState) {
initialState.isBeta = isBeta();
initialState.contentSourcesEnabled = contentSourcesEnabled;
} else {
initialState = {
isBeta: isBeta(),
contentSourcesEnabled,
};
}
const handleClose = () => navigate(resolveRelPath(''));
// In case the `created_at` date is undefined when creating an image
// a temporary value with current date is added
const currentDate = new Date();
return (
<ImageCreator
onClose={handleClose}
onSubmit={({ values, setIsSaving }) => {
setIsSaving(true);
const requests = onSave(values);
navigate(resolveRelPath(''));
// https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values
// If you want to immediately access the result of a mutation, you need to chain `.unwrap()`
// if you actually want the payload or to catch the error.
// We do this so we can dispatch the appropriate notification (success or failure).
Promise.all(
requests.map((composeRequest) =>
composeImage({ composeRequest }).unwrap()
)
)
.then(() => {
dispatch(
addNotification({
variant: 'success',
title: 'Your image is being created',
})
);
})
.catch((err) => {
let msg = err.response.statusText;
if (err.response.data?.errors[0]?.detail) {
msg = err.response.data?.errors[0]?.detail;
}
dispatch(
addNotification({
variant: 'danger',
title: 'Your image could not be created',
description: 'Status code ' + err.response.status + ': ' + msg,
})
);
});
}}
defaultArch="x86_64"
customValidatorMapper={{
fileSystemConfigurationValidator,
targetEnvironmentValidator,
}}
schema={{
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'imageBuilder',
isDynamic: true,
inModal: true,
onKeyDown: (e) => {
handleKeyDown(e, handleClose);
},
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
title: 'Create image',
crossroads: [
'target-environment',
'release',
'payload-repositories',
],
description: (
<>
Image builder allows you to create a custom image and push it to
target environments. <DocumentationButton />
</>
),
// order in this array does not reflect order in wizard nav, this order is managed inside
// of each step by `nextStep` property!
fields: [
imageOutput,
awsTarget,
googleCloudTarget,
msAzureTarget,
registration,
packages,
packagesContentSources,
repositories,
fileSystemConfiguration,
imageName,
review,
],
initialState: {
activeStep: initialStep || 'image-output', // name of the active step
activeStepIndex: stepHistory.length, // active index
maxStepIndex: stepHistory.length, // max achieved index
prevSteps: stepHistory, // array with names of previously visited steps
},
},
],
}}
initialValues={initialState}
/>
);
};
export default CreateImageWizard;