Blueprints: Add import Wizard (HMS-3690)

This commit is contained in:
Anna Vítová 2024-04-12 11:41:22 +02:00 committed by Klara Simickova
parent 0e74de53fa
commit 3018d64df6
7 changed files with 368 additions and 12 deletions

View file

@ -0,0 +1,259 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom';
import { renderWithReduxRouter } from '../../test/testUtils';
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
isBeta: () => true,
isProd: () => true,
getEnvironment: () => 'stage',
}),
}));
window.HTMLElement.prototype.scrollTo = function () {};
jest.mock('@unleash/proxy-client-react', () => ({
useUnleashContext: () => jest.fn(),
useFlag: jest.fn((flag) => {
switch (flag) {
case 'image-builder.import.enabled':
return true;
case 'image-builder.new-wizard.enabled':
return true;
default:
return false;
}
}),
}));
const BLUEPRINT_JSON = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aarch64",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
],
"name": "Blueprint test"
}`;
const INVALID_JSON = `{
"name": "Blueprint test"
}`;
const INVALID_ARCHITECTURE_JSON = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aaaaa",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
],
"name": "Blueprint test"
}`;
const INVALID_IMAGE_TYPE_JSON = `{
"customizations": {
"files": [
],
"kernel": {
},
"openscap": {
},
"packages": [
"aide",
"sudo",
"audit",
"rsyslog",
"firewalld",
"nftables",
"libselinux"
],
"services": {
"enabled": [
"crond",
"firewalld",
"systemd-journald",
"rsyslog",
"auditd"
]
},
"subscription": {
}
},
"description": "Tested blueprint",
"distribution": "rhel-93",
"id": "052bf998-7955-45ad-952d-49ce3573e0b7",
"image_requests": [
{
"architecture": "aaaaa",
"image_type": "aws",
"upload_request": {
"options": {
"share_with_sources": [
"473980"
]
},
"type": "aws"
}
}
],
"name": "Blueprint test"
}`;
const uploadFile = async (filename: string, content: string): Promise<void> => {
const fileInput: HTMLElement | null =
// eslint-disable-next-line testing-library/no-node-access
document.querySelector('input[type="file"]');
if (fileInput) {
const file = new File([content], filename, { type: 'application/json' });
await userEvent.upload(fileInput, file);
}
};
describe('Import model', () => {
const user = userEvent.setup();
test('renders import component', async () => {
renderWithReduxRouter('', {});
const importButton = await screen.findByTestId('import-blueprint-button');
await waitFor(() => expect(importButton).toBeInTheDocument());
});
const setUp = async () => {
renderWithReduxRouter('', {});
await user.click(await screen.findByTestId('import-blueprint-button'));
const reviewButton = await screen.findByRole('button', {
name: /review and finish/i,
});
expect(reviewButton).toHaveClass('pf-m-disabled');
};
test('should show alert on invalid blueprint', async () => {
await setUp();
await uploadFile(`blueprints.json`, INVALID_JSON);
const reviewButton = screen.getByTestId('import-blueprint-finish');
expect(reviewButton).toHaveClass('pf-m-disabled');
const helperText = await screen.findByText(
/not compatible with the blueprints format\./i
);
expect(helperText).toBeInTheDocument();
});
test('should show alert on invalid blueprint incorrect architecture', async () => {
await setUp();
await uploadFile(`blueprints.json`, INVALID_ARCHITECTURE_JSON);
const reviewButton = screen.getByTestId('import-blueprint-finish');
expect(reviewButton).toHaveClass('pf-m-disabled');
const helperText = await screen.findByText(
/not compatible with the blueprints format\./i
);
expect(helperText).toBeInTheDocument();
});
test('should show alert on invalid blueprint incorrect image type', async () => {
await setUp();
await uploadFile(`blueprints.json`, INVALID_IMAGE_TYPE_JSON);
const reviewButton = screen.getByTestId('import-blueprint-finish');
expect(reviewButton).toHaveClass('pf-m-disabled');
const helperText = await screen.findByText(
/not compatible with the blueprints format\./i
);
expect(helperText).toBeInTheDocument();
});
test('should enable button on correct blueprint', async () => {
await setUp();
await uploadFile(`blueprints.json`, BLUEPRINT_JSON);
const reviewButton = screen.getByTestId('import-blueprint-finish');
await waitFor(() => expect(reviewButton).not.toHaveClass('pf-m-disabled'));
await userEvent.click(reviewButton);
await waitFor(() => {
expect(
screen.getByRole('heading', { name: 'Image output' })
).toBeInTheDocument();
});
});
});

View file

@ -13,6 +13,12 @@ import {
Modal,
ModalVariant,
} from '@patternfly/react-core';
import { useNavigate } from 'react-router-dom';
import { BlueprintResponse } from '../../store/imageBuilderApi';
import { wizardState } from '../../store/wizardSlice';
import { resolveRelPath } from '../../Utilities/path';
import { mapRequestToState } from '../CreateImageWizardV2/utilities/requestMapper';
interface ImportBlueprintModalProps {
setShowImportModal: React.Dispatch<React.SetStateAction<boolean>>;
@ -26,6 +32,9 @@ export const ImportBlueprintModal: React.FunctionComponent<
setShowImportModal(false);
};
const [jsonContent, setJsonContent] = React.useState('');
const [importedBlueprint, setImportedBlueprint] =
React.useState<wizardState>();
const [isInvalidFormat, setIsInvalidFormat] = React.useState(false);
const [filename, setFilename] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const [isRejected, setIsRejected] = React.useState(false);
@ -36,11 +45,13 @@ export const ImportBlueprintModal: React.FunctionComponent<
) => {
setFilename(file.name);
setIsRejected(false);
setIsInvalidFormat(false);
};
const handleClear = () => {
setFilename('');
setJsonContent('');
setIsRejected(false);
setIsInvalidFormat(false);
};
const handleTextChange = (
_: React.ChangeEvent<HTMLTextAreaElement>,
@ -49,10 +60,19 @@ export const ImportBlueprintModal: React.FunctionComponent<
setJsonContent(value);
};
const handleDataChange = (_: DropEvent, value: string) => {
setJsonContent(value);
try {
const importedBlueprint: BlueprintResponse = JSON.parse(value);
const importBlueprintState = mapRequestToState(importedBlueprint);
setImportedBlueprint(importBlueprintState);
setJsonContent(value);
} catch (error) {
setIsInvalidFormat(true);
}
};
const handleFileRejected = () => {
setIsRejected(true);
setJsonContent('');
setFilename('');
};
const handleFileReadStarted = () => {
setIsLoading(true);
@ -60,6 +80,7 @@ export const ImportBlueprintModal: React.FunctionComponent<
const handleFileReadFinished = () => {
setIsLoading(false);
};
const navigate = useNavigate();
return (
<Modal
@ -87,23 +108,37 @@ export const ImportBlueprintModal: React.FunctionComponent<
browseButtonText="Upload"
dropzoneProps={{
accept: { 'text/json': ['.json'] },
maxSize: 1024,
maxSize: 25000,
onDropRejected: handleFileRejected,
}}
validated={isRejected ? 'error' : 'default'}
validated={isRejected || isInvalidFormat ? 'error' : 'default'}
/>
<FormHelperText>
<HelperText>
<HelperTextItem variant={isRejected ? 'error' : 'default'}>
{isRejected
? 'Must be a JSON file no larger than 1 KB'
? 'Must be a valid Blueprint JSON file no larger than 25 KB'
: isInvalidFormat
? 'Not compatible with the blueprints format.'
: 'Upload a JSON file'}
</HelperTextItem>
</HelperText>
</FormHelperText>
</FormGroup>
<ActionGroup>
<Button type="button">Review and finish</Button>
<Button
type="button"
isDisabled={isRejected || isInvalidFormat || !jsonContent}
onClick={() =>
navigate(resolveRelPath(`imagewizard/import`), {
state: { blueprint: importedBlueprint },
})
}
ouiaId="import-blueprint-finish"
data-testid="import-blueprint-finish"
>
Review and finish
</Button>
<Button variant="link" type="button" onClick={onImportClose}>
Cancel
</Button>

View file

@ -0,0 +1,34 @@
import React, { useEffect } from 'react';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { useLocation, useNavigate } from 'react-router-dom';
import CreateImageWizard from './CreateImageWizard';
import { useAppDispatch } from '../../store/hooks';
import { loadWizardState, wizardState } from '../../store/wizardSlice';
import { resolveRelPath } from '../../Utilities/path';
const ImportImageWizard = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const locationState = location.state as { blueprint?: wizardState };
const blueprint = locationState?.blueprint;
useEffect(() => {
if (blueprint) {
dispatch(loadWizardState(blueprint));
} else {
navigate(resolveRelPath(''));
dispatch(
addNotification({
variant: 'warning',
title: 'No blueprint was imported',
})
);
}
}, [blueprint, dispatch]);
return <CreateImageWizard />;
};
export default ImportImageWizard;

View file

@ -177,6 +177,10 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
isNextButtonTouched: true,
};
const arch = request.image_requests[0].architecture;
if (arch !== 'x86_64' && arch !== 'aarch64') {
throw new Error(`image type: ${arch} has no implementation yet`);
}
return {
wizardMode,
details: {
@ -195,7 +199,7 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
firstBoot: {
script: getFirstBootScript(request.customizations.files),
},
architecture: request.image_requests[0].architecture,
architecture: arch,
distribution: getLatestMinorRelease(request.distribution),
imageTypes: request.image_requests.map((image) => image.image_type),
azure: {

View file

@ -9,19 +9,28 @@ export const isAwsAccountIdValid = (awsAccountId: string | undefined) => {
};
export const isAzureTenantGUIDValid = (azureTenantGUID: string) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureTenantGUID
return (
azureTenantGUID !== undefined &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureTenantGUID
)
);
};
export const isAzureSubscriptionIdValid = (azureSubscriptionId: string) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureSubscriptionId
return (
azureSubscriptionId !== undefined &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureSubscriptionId
)
);
};
export const isAzureResourceGroupValid = (azureResourceGroup: string) => {
return /^[-\w._()]+[-\w_()]$/.test(azureResourceGroup);
return (
azureResourceGroup !== undefined &&
/^[-\w._()]+[-\w_()]$/.test(azureResourceGroup)
);
};
export const isGcpEmailValid = (gcpShareWithAccount: string | undefined) => {
@ -37,6 +46,7 @@ export const isMountpointMinSizeValid = (minSize: string) => {
};
export const isBlueprintNameValid = (blueprintName: string) =>
blueprintName !== undefined &&
blueprintName.length >= 2 &&
blueprintName.length <= 100 &&
/\w+/.test(blueprintName);

View file

@ -132,6 +132,7 @@ export const ImageBuilderHeader = ({
<FlexItem>
{importExportFlag && (
<Button
data-testid="import-blueprint-button"
variant="secondary"
icon={<ImportIcon />}
iconPosition="end"

View file

@ -12,13 +12,16 @@ const LandingPage = lazy(() => import('./Components/LandingPage/LandingPage'));
const CreateImageWizard = lazy(() =>
import('./Components/CreateImageWizard/CreateImageWizard')
);
const ImportImageWizard = lazy(() =>
import('./Components/CreateImageWizardV2/ImportImageWizard')
);
const CreateImageWizardV2 = lazy(() =>
import('./Components/CreateImageWizardV2')
);
export const Router = () => {
const edgeParityFlag = useFlag('edgeParity.image-list');
const importExportFlag = useFlag('image-builder.import.enabled');
const experimentalFlag = useExperimentalFlag();
return (
<Routes>
@ -33,6 +36,16 @@ export const Router = () => {
<Route path="share/:composeId" element={<ShareImageModal />} />
</Route>
{importExportFlag && experimentalFlag && (
<Route
path="imagewizard/import"
element={
<Suspense>
<ImportImageWizard />
</Suspense>
}
/>
)}
<Route
path="imagewizard/:composeId?"
element={