diff --git a/src/Components/CreateImageWizardV2/CreateImageWizard.tsx b/src/Components/CreateImageWizardV2/CreateImageWizard.tsx
index 0d8c565b..60cd23ec 100644
--- a/src/Components/CreateImageWizardV2/CreateImageWizard.tsx
+++ b/src/Components/CreateImageWizardV2/CreateImageWizard.tsx
@@ -15,6 +15,7 @@ import OscapStep from './steps/Oscap';
import RegistrationStep from './steps/Registration';
import RepositoriesStep from './steps/Repositories';
import ReviewStep from './steps/Review';
+import ReviewWizardFooter from './steps/Review/Footer';
import Aws from './steps/TargetEnvironment/Aws';
import Gcp from './steps/TargetEnvironment/Gcp';
import {
@@ -52,13 +53,22 @@ export const CustomWizardFooter = ({
const { goToNextStep, goToPrevStep, close } = useWizardContext();
return (
-
@@ -212,7 +222,7 @@ const CreateImageWizard = () => {
}
+ footer={}
>
diff --git a/src/Components/CreateImageWizardV2/steps/Details/index.tsx b/src/Components/CreateImageWizardV2/steps/Details/index.tsx
index 951d00dd..126eb5e3 100644
--- a/src/Components/CreateImageWizardV2/steps/Details/index.tsx
+++ b/src/Components/CreateImageWizardV2/steps/Details/index.tsx
@@ -50,7 +50,7 @@ const DetailsStep = () => {
Optionally enter a name to identify your image later quickly. If you do
not provide one, the UUID will be used as the name.
-
+
{
/>
-
- The image name can be 3-63 characters long. It can contain
- lowercase letters, digits and hyphens, has to start with a letter
- and cannot end with a hyphen.
-
+ The name can be 1-100 characters
diff --git a/src/Components/CreateImageWizardV2/steps/Review/Footer.tsx b/src/Components/CreateImageWizardV2/steps/Review/Footer.tsx
new file mode 100644
index 00000000..8beb3254
--- /dev/null
+++ b/src/Components/CreateImageWizardV2/steps/Review/Footer.tsx
@@ -0,0 +1,115 @@
+import React, { useState, useEffect } from 'react';
+
+import {
+ Button,
+ Dropdown,
+ DropdownItem,
+ DropdownList,
+ MenuToggle,
+ MenuToggleElement,
+ WizardFooterWrapper,
+ useWizardContext,
+} from '@patternfly/react-core';
+import { SpinnerIcon } from '@patternfly/react-icons';
+import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import {
+ useComposeBlueprintMutation,
+ useCreateBlueprintMutation,
+} from '../../../../store/imageBuilderApi';
+import { resolveRelPath } from '../../../../Utilities/path';
+import { mapRequestFromState } from '../../utilities/requestMapper';
+
+const ReviewWizardFooter = () => {
+ const { goToPrevStep, close } = useWizardContext();
+ const [
+ createBlueprint,
+ { isLoading: isCreationLoading, isSuccess: isCreationSuccess },
+ ] = useCreateBlueprintMutation();
+ const [buildBlueprint, { isLoading: isBuildLoading }] =
+ useComposeBlueprintMutation();
+ const { auth } = useChrome();
+ const navigate = useNavigate();
+ const { composeId } = useParams();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const onToggleClick = () => {
+ setIsOpen(!isOpen);
+ };
+
+ useEffect(() => {
+ if (isCreationSuccess) {
+ navigate(resolveRelPath(''));
+ }
+ }, [isCreationSuccess, navigate]);
+
+ const getBlueprintPayload = async () => {
+ const userData = await auth?.getUser();
+ const orgId = userData?.identity?.internal?.org_id;
+ const requestBody = orgId && mapRequestFromState(orgId);
+ return requestBody;
+ };
+
+ const onSave = async () => {
+ const requestBody = await getBlueprintPayload();
+ setIsOpen(false);
+ !composeId &&
+ requestBody &&
+ createBlueprint({ createBlueprintRequest: requestBody });
+ };
+
+ const onSaveAndBuild = async () => {
+ const requestBody = await getBlueprintPayload();
+ setIsOpen(false);
+ const blueprint =
+ !composeId &&
+ requestBody &&
+ (await createBlueprint({ createBlueprintRequest: requestBody }).unwrap());
+ blueprint && buildBlueprint({ id: blueprint.id });
+ setIsOpen(false);
+ };
+
+ return (
+
+ setIsOpen(isOpen)}
+ toggle={(toggleRef: React.Ref) => (
+ }
+ >
+ Save
+
+ )}
+ ouiaId="wizard-finish-dropdown"
+ shouldFocusToggleOnSelect
+ >
+
+
+ Save changes
+
+
+ Save and build images
+
+
+
+
+ Back
+
+
+ Cancel
+
+
+ );
+};
+
+export default ReviewWizardFooter;
diff --git a/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx b/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx
new file mode 100644
index 00000000..57900583
--- /dev/null
+++ b/src/Components/CreateImageWizardV2/utilities/requestMapper.tsx
@@ -0,0 +1,164 @@
+import { RootState, store } from '../../../store';
+import {
+ AwsUploadRequestOptions,
+ CreateBlueprintRequest,
+ Customizations,
+ GcpUploadRequestOptions,
+ ImageRequest,
+ ImageTypes,
+ Subscription,
+ UploadTypes,
+} from '../../../store/imageBuilderApi';
+import {
+ selectActivationKey,
+ selectArchitecture,
+ selectAwsAccountId,
+ selectAwsShareMethod,
+ selectAwsSource,
+ selectBaseUrl,
+ selectBlueprintDescription,
+ selectBlueprintName,
+ selectCustomRepositories,
+ selectDistribution,
+ selectGcpAccountType,
+ selectGcpEmail,
+ selectGcpShareMethod,
+ selectImageTypes,
+ selectRegistrationType,
+ selectServerUrl,
+} from '../../../store/wizardSlice';
+
+/**
+ * This function maps the wizard state to a valid CreateBlueprint request object
+ * @param {string} orgID organization ID
+ * @returns {CreateBlueprintRequest} blueprint creation request payload
+ */
+export const mapRequestFromState = (orgID: string): CreateBlueprintRequest => {
+ const state = store.getState();
+ const imageRequests = getImageRequests(state);
+ const customizations = getCustomizations(state, orgID);
+
+ return {
+ name: selectBlueprintName(state),
+ description: selectBlueprintDescription(state),
+ distribution: selectDistribution(state),
+ image_requests: imageRequests,
+ customizations,
+ };
+};
+
+const getImageRequests = (state: RootState): ImageRequest[] => {
+ const imageTypes = selectImageTypes(state);
+ return imageTypes.map((type) => ({
+ architecture: selectArchitecture(state),
+ image_type: type,
+ upload_request: {
+ type: uploadTypeByTargetEnv(type),
+ options: getImageOptions(type, state),
+ },
+ }));
+};
+
+const uploadTypeByTargetEnv = (imageType: ImageTypes): UploadTypes => {
+ switch (imageType) {
+ case 'aws':
+ return 'aws';
+ case 'gcp':
+ return 'gcp';
+ case 'azure':
+ return 'azure';
+ case 'oci':
+ return 'oci.objectstorage';
+ case 'wsl':
+ return 'aws.s3';
+ case 'image-installer':
+ return 'aws.s3';
+ case 'vsphere':
+ return 'aws.s3';
+ case 'ami':
+ return 'aws';
+ default: {
+ // TODO: add edge type
+ throw new Error(`image type: ${imageType} has no implementation yet`);
+ }
+ }
+};
+const getImageOptions = (
+ imageType: ImageTypes,
+ state: RootState
+): AwsUploadRequestOptions | GcpUploadRequestOptions => {
+ switch (imageType) {
+ case 'aws':
+ if (selectAwsShareMethod(state) === 'sources')
+ return { share_with_sources: [selectAwsSource(state)?.id || ''] };
+ else return { share_with_accounts: [selectAwsAccountId(state)] };
+ case 'gcp': {
+ let googleAccount: string = '';
+ if (selectGcpShareMethod(state) === 'withGoogle') {
+ const gcpEmail = selectGcpEmail(state);
+ switch (selectGcpAccountType(state)) {
+ case 'google':
+ googleAccount = `user:${gcpEmail}`;
+ break;
+ case 'service':
+ googleAccount = `serviceAccount:${gcpEmail}`;
+ break;
+ case 'group':
+ googleAccount = `group:${gcpEmail}`;
+ break;
+ case 'domain':
+ googleAccount = `domain:${gcpEmail}`;
+ }
+ return { share_with_accounts: [googleAccount] };
+ } else {
+ // TODO: GCP withInsights is not implemented yet
+ return {};
+ }
+ }
+ }
+ return {};
+};
+
+const getCustomizations = (state: RootState, orgID: string): Customizations => {
+ return {
+ containers: undefined,
+ directories: undefined,
+ files: undefined,
+ subscription: getSubscription(state, orgID),
+ packages: undefined,
+ payload_repositories: undefined,
+ custom_repositories: selectCustomRepositories(state),
+ openscap: undefined,
+ filesystem: undefined,
+ users: undefined,
+ services: undefined,
+ hostname: undefined,
+ kernel: undefined,
+ groups: undefined,
+ timezone: undefined,
+ locale: undefined,
+ firewall: undefined,
+ installation_device: undefined,
+ fdo: undefined,
+ ignition: undefined,
+ partitioning_mode: undefined,
+ fips: undefined,
+ };
+};
+
+const getSubscription = (state: RootState, orgID: string): Subscription => {
+ const initialSubscription = {
+ 'activation-key': selectActivationKey(state) || '',
+ organization: Number(orgID),
+ 'server-url': selectServerUrl(state),
+ 'base-url': selectBaseUrl(state),
+ };
+ switch (selectRegistrationType(state)) {
+ case 'register-now-insights':
+ return { ...initialSubscription, insights: true };
+ case 'register-now-rhc':
+ return { ...initialSubscription, insights: true, rhc: true };
+ default:
+ return { ...initialSubscription, insights: false };
+ }
+};
diff --git a/src/Components/CreateImageWizardV2/validators.ts b/src/Components/CreateImageWizardV2/validators.ts
index 34197b36..3e645494 100644
--- a/src/Components/CreateImageWizardV2/validators.ts
+++ b/src/Components/CreateImageWizardV2/validators.ts
@@ -14,15 +14,8 @@ export const isGcpEmailValid = (gcpShareWithAccount: string | undefined) => {
);
};
-export const isBlueprintNameValid = (blueprintName: string) => {
- if (blueprintName === '') {
- return true;
- }
- return (
- /^[a-z][a-z0-9-]+[a-z0-9]$/.test(blueprintName) &&
- blueprintName.length <= 63
- );
-};
+export const isBlueprintNameValid = (blueprintName: string) =>
+ blueprintName.length > 0 && blueprintName.length <= 100;
export const isBlueprintDescriptionValid = (blueprintDescription: string) => {
return blueprintDescription.length <= 250;
diff --git a/src/Components/sharedComponents/ImageBuilderHeader.tsx b/src/Components/sharedComponents/ImageBuilderHeader.tsx
index 6ac256b5..0c2b5582 100644
--- a/src/Components/sharedComponents/ImageBuilderHeader.tsx
+++ b/src/Components/sharedComponents/ImageBuilderHeader.tsx
@@ -15,8 +15,10 @@ import {
PageHeader,
PageHeaderTitle,
} from '@redhat-cloud-services/frontend-components';
+import { Link } from 'react-router-dom';
import { useComposeBlueprintMutation } from '../../store/imageBuilderApi';
+import { resolveRelPath } from '../../Utilities/path';
import './ImageBuilderHeader.scss';
type ImageBuilderHeaderPropTypes = {
@@ -101,7 +103,13 @@ export const ImageBuilderHeader = ({
{experimentalFlag && (
<>
- New blueprint
+
+ Create
+
{
- return [{ type: 'Blueprint' }];
+ return [{ type: 'Blueprints' }];
},
},
getBlueprintComposes: {
@@ -25,6 +25,31 @@ const enhancedApi = imageBuilderApi.enhanceEndpoints({
return [{ type: 'Clone', id: arg.composeId }];
},
},
+ createBlueprint: {
+ onQueryStarted: async (_, { dispatch, queryFulfilled }) => {
+ queryFulfilled
+ .then(() => {
+ // Typescript is unaware of tag types being defined concurrently in enhanceEndpoints()
+ // @ts-expect-error
+ dispatch(imageBuilderApi.util.invalidateTags(['Blueprints']));
+ dispatch(
+ addNotification({
+ variant: 'success',
+ title: 'Your blueprint is being created',
+ })
+ );
+ })
+ .catch((err) => {
+ dispatch(
+ addNotification({
+ variant: 'danger',
+ title: 'Your blueprint could not be created',
+ description: `Status code ${err.status}: ${err.data.errors[0].detail}`,
+ })
+ );
+ });
+ },
+ },
cloneCompose: {
onQueryStarted: async (
{ composeId, cloneRequest },
@@ -93,9 +118,11 @@ const enhancedApi = imageBuilderApi.enhanceEndpoints({
onQueryStarted: async (_, { dispatch, queryFulfilled }) => {
queryFulfilled
.then(() => {
- // Typescript is unaware of tag types being defined concurrently in enhanceEndpoints()
- // @ts-expect-error
- dispatch(imageBuilderApi.util.invalidateTags(['Compose']));
+ dispatch(
+ // Typescript is unaware of tag types being defined concurrently in enhanceEndpoints()
+ // @ts-expect-error
+ imageBuilderApi.util.invalidateTags(['Blueprints', 'Compose'])
+ );
dispatch(
addNotification({
variant: 'success',
@@ -120,7 +147,7 @@ const enhancedApi = imageBuilderApi.enhanceEndpoints({
},
},
deleteBlueprint: {
- invalidatesTags: [{ type: 'Blueprint' }],
+ invalidatesTags: [{ type: 'Blueprints' }],
onQueryStarted: async (_, { dispatch, queryFulfilled }) => {
queryFulfilled
.then(() => {
diff --git a/src/test/Components/CreateImageWizardV2/CreateImageWizard.test.tsx b/src/test/Components/CreateImageWizardV2/CreateImageWizard.test.tsx
index f93c518a..30625581 100644
--- a/src/test/Components/CreateImageWizardV2/CreateImageWizard.test.tsx
+++ b/src/test/Components/CreateImageWizardV2/CreateImageWizard.test.tsx
@@ -883,8 +883,7 @@ describe('Step Upload to AWS', () => {
const nameInput = await screen.findByRole('textbox', {
name: /blueprint name/i,
});
- // 64 character name
- const invalidName = 'a'.repeat(64);
+ const invalidName = 'a'.repeat(101);
await user.type(nameInput, invalidName);
expect(await getNextButton()).toHaveClass('pf-m-disabled');
expect(await getNextButton()).toBeDisabled();
@@ -952,6 +951,10 @@ describe('Step Upload to AWS', () => {
// skip repositories
await clickNext();
// skip Details
+ const blueprintName = await screen.findByRole('textbox', {
+ name: /blueprint name/i,
+ });
+ await user.type(blueprintName, 'valid-name');
await clickNext();
};
@@ -1009,6 +1012,10 @@ describe('Step Upload to AWS', () => {
// skip repositories
await clickNext();
// skip Details
+ const blueprintName = await screen.findByRole('textbox', {
+ name: /blueprint name/i,
+ });
+ await user.type(blueprintName, 'valid-name');
await clickNext();
};