Blueprints: Add handler for creating new blueprint
This commit is contained in:
parent
d26cecdedb
commit
74f71f2dca
8 changed files with 348 additions and 28 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
src/Components/CreateImageWizardV2/steps/Review/Footer.tsx
Normal file
115
src/Components/CreateImageWizardV2/steps/Review/Footer.tsx
Normal 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;
|
||||
164
src/Components/CreateImageWizardV2/utilities/requestMapper.tsx
Normal file
164
src/Components/CreateImageWizardV2/utilities/requestMapper.tsx
Normal 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 };
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue