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 + + + + + + + ); +}; + +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 && ( <> - + + Create +