From 3018d64df6d6ccc7260e4aa5db7dd7c1ab282ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anna=20V=C3=ADtov=C3=A1?= Date: Fri, 12 Apr 2024 11:41:22 +0200 Subject: [PATCH] Blueprints: Add import Wizard (HMS-3690) --- .../Blueprints/ImportBlueprintModal.test.tsx | 259 ++++++++++++++++++ .../Blueprints/ImportBlueprintModal.tsx | 45 ++- .../CreateImageWizardV2/ImportImageWizard.tsx | 34 +++ .../utilities/requestMapper.ts | 6 +- .../CreateImageWizardV2/validators.ts | 20 +- .../sharedComponents/ImageBuilderHeader.tsx | 1 + src/Router.js | 15 +- 7 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 src/Components/Blueprints/ImportBlueprintModal.test.tsx create mode 100644 src/Components/CreateImageWizardV2/ImportImageWizard.tsx diff --git a/src/Components/Blueprints/ImportBlueprintModal.test.tsx b/src/Components/Blueprints/ImportBlueprintModal.test.tsx new file mode 100644 index 00000000..29e287b9 --- /dev/null +++ b/src/Components/Blueprints/ImportBlueprintModal.test.tsx @@ -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 => { + 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(); + }); + }); +}); diff --git a/src/Components/Blueprints/ImportBlueprintModal.tsx b/src/Components/Blueprints/ImportBlueprintModal.tsx index 91a4b0c2..09d01515 100644 --- a/src/Components/Blueprints/ImportBlueprintModal.tsx +++ b/src/Components/Blueprints/ImportBlueprintModal.tsx @@ -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>; @@ -26,6 +32,9 @@ export const ImportBlueprintModal: React.FunctionComponent< setShowImportModal(false); }; const [jsonContent, setJsonContent] = React.useState(''); + const [importedBlueprint, setImportedBlueprint] = + React.useState(); + 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, @@ -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 ( {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'} - + diff --git a/src/Components/CreateImageWizardV2/ImportImageWizard.tsx b/src/Components/CreateImageWizardV2/ImportImageWizard.tsx new file mode 100644 index 00000000..67a38234 --- /dev/null +++ b/src/Components/CreateImageWizardV2/ImportImageWizard.tsx @@ -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 ; +}; + +export default ImportImageWizard; diff --git a/src/Components/CreateImageWizardV2/utilities/requestMapper.ts b/src/Components/CreateImageWizardV2/utilities/requestMapper.ts index 6adb6c5a..eebe4864 100644 --- a/src/Components/CreateImageWizardV2/utilities/requestMapper.ts +++ b/src/Components/CreateImageWizardV2/utilities/requestMapper.ts @@ -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: { diff --git a/src/Components/CreateImageWizardV2/validators.ts b/src/Components/CreateImageWizardV2/validators.ts index 50c4ad0f..8ff80b21 100644 --- a/src/Components/CreateImageWizardV2/validators.ts +++ b/src/Components/CreateImageWizardV2/validators.ts @@ -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); diff --git a/src/Components/sharedComponents/ImageBuilderHeader.tsx b/src/Components/sharedComponents/ImageBuilderHeader.tsx index aaf1d8a8..757880e7 100644 --- a/src/Components/sharedComponents/ImageBuilderHeader.tsx +++ b/src/Components/sharedComponents/ImageBuilderHeader.tsx @@ -132,6 +132,7 @@ export const ImageBuilderHeader = ({ {importExportFlag && (