Blueprints: Add import Wizard (HMS-3690)
This commit is contained in:
parent
0e74de53fa
commit
3018d64df6
7 changed files with 368 additions and 12 deletions
259
src/Components/Blueprints/ImportBlueprintModal.test.tsx
Normal file
259
src/Components/Blueprints/ImportBlueprintModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
34
src/Components/CreateImageWizardV2/ImportImageWizard.tsx
Normal file
34
src/Components/CreateImageWizardV2/ImportImageWizard.tsx
Normal 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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export const ImageBuilderHeader = ({
|
|||
<FlexItem>
|
||||
{importExportFlag && (
|
||||
<Button
|
||||
data-testid="import-blueprint-button"
|
||||
variant="secondary"
|
||||
icon={<ImportIcon />}
|
||||
iconPosition="end"
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue