Blueprints: Add handler for creating new blueprint

This commit is contained in:
Amir 2024-02-01 14:40:33 +02:00 committed by Lucas Garfield
parent d26cecdedb
commit 74f71f2dca
8 changed files with 348 additions and 28 deletions

View file

@ -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 (
<WizardFooterWrapper>
<Button variant="primary" onClick={goToNextStep} isDisabled={disableNext}>
<Button
ouiaId="wizard-next-btn"
variant="primary"
onClick={goToNextStep}
isDisabled={disableNext}
>
Next
</Button>
<Button variant="secondary" onClick={goToPrevStep}>
<Button
ouiaId="wizard-back-btn"
variant="secondary"
onClick={goToPrevStep}
>
Back
</Button>
<Button variant="link" onClick={close}>
<Button ouiaId="wizard-cancel-btn" variant="link" onClick={close}>
Cancel
</Button>
</WizardFooterWrapper>
@ -212,7 +222,7 @@ const CreateImageWizard = () => {
<WizardStep
name="Review"
id="step-review"
footer={<CustomWizardFooter disableNext={true} />}
footer={<ReviewWizardFooter />}
>
<ReviewStep />
</WizardStep>

View file

@ -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.
</Text>
<FormGroup label="Blueprint name" fieldId="blueprint-name">
<FormGroup isRequired label="Blueprint name" fieldId="blueprint-name">
<ValidatedTextInput
ariaLabel="blueprint name"
dataTestId="blueprint"
@ -62,11 +62,7 @@ const DetailsStep = () => {
/>
<FormHelperText>
<HelperText>
<HelperTextItem>
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.
</HelperTextItem>
<HelperTextItem>The name can be 1-100 characters</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>

View file

@ -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 (
<WizardFooterWrapper>
<Dropdown
isOpen={isOpen}
onOpenChange={(isOpen: boolean) => setIsOpen(isOpen)}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
variant="primary"
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
icon={(isBuildLoading || isCreationLoading) && <SpinnerIcon />}
>
Save
</MenuToggle>
)}
ouiaId="wizard-finish-dropdown"
shouldFocusToggleOnSelect
>
<DropdownList>
<DropdownItem onClick={onSave} ouiaId="wizard-save-btn">
Save changes
</DropdownItem>
<DropdownItem onClick={onSaveAndBuild} ouiaId="wizard-build-btn">
Save and build images
</DropdownItem>
</DropdownList>
</Dropdown>
<Button
ouiaId="wizard-back-btn"
variant="secondary"
onClick={goToPrevStep}
>
Back
</Button>
<Button ouiaId="wizard-cancel-btn" variant="link" onClick={close}>
Cancel
</Button>
</WizardFooterWrapper>
);
};
export default ReviewWizardFooter;

View file

@ -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 };
}
};

View file

@ -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;

View file

@ -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 && (
<>
<FlexItem align={{ default: 'alignRight' }}>
<Button>New blueprint</Button>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create
</Link>
</FlexItem>
<FlexItem>
<Button

View file

@ -3,11 +3,11 @@ import { addNotification } from '@redhat-cloud-services/frontend-components-noti
import { imageBuilderApi } from './imageBuilderApi';
const enhancedApi = imageBuilderApi.enhanceEndpoints({
addTagTypes: ['Clone', 'Compose', 'Blueprint', 'BlueprintComposes'],
addTagTypes: ['Clone', 'Compose', 'Blueprints', 'BlueprintComposes'],
endpoints: {
getBlueprints: {
providesTags: () => {
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(() => {

View file

@ -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();
};