Wizard: Drop the WizardV1

This commit is contained in:
Ondrej Ezr 2024-06-12 15:04:19 +02:00 committed by Klara Simickova
parent 54d09d636e
commit 5fcc80d2db
75 changed files with 103 additions and 11588 deletions

View file

@ -184,7 +184,7 @@ https://github.com/RedHatInsights/image-builder-frontend/blob/c84b493eba82ce83a7
##### Mocking flags for tests
Flags can be mocked for the unit tests to access some feature. Checkout:
https://github.com/RedHatInsights/image-builder-frontend/blob/c84b493eba82ce83a7844943943d91112ffe8322/src/test/Components/CreateImageWizard/CreateImageWizard.test.js#L74
https://github.com/osbuild/image-builder-frontend/blob/9a464e416bc3769cfc8e23b62f1dd410eb0e0455/src/test/Components/CreateImageWizardV2/CreateImageWizard.test.tsx#L49
If the two possible code path accessible via the toggles are defined in the code
base, then it's good practice to test the two of them. If not, only test what's

View file

@ -106,29 +106,6 @@ if (process.env.MSW) {
plugins[definePluginIndex] = newDefinePlugin;
}
if (process.env.EXPERIMENTAL) {
/*
We would like the client to be able to determine whether or not it is in
experimental mode at run time based on the value of process.env.EXPERIMENTAL.
We can add that variable to process.env via the DefinesPlugin plugin, but
DefinePlugin has already been added by config() to the default webpackConfig.
Therefore, we find it in the `plugins` array based on its type, then update
it to add our new process.env.EXPERIMENTAL variable.
*/
const definePluginIndex = plugins.findIndex(
(plugin) => plugin instanceof webpack.DefinePlugin
);
const definePlugin = plugins[definePluginIndex];
const newDefinePlugin = new webpack.DefinePlugin({
...definePlugin.definitions,
'process.env.EXPERIMENTAL': true,
});
plugins[definePluginIndex] = newDefinePlugin;
}
module.exports = {
...webpackConfig,
plugins,

View file

@ -127,9 +127,7 @@
"prod-stable": "PROXY=true webpack serve --config config/dev.webpack.config.js",
"stage-stable": "STAGE=true npm run prod-stable",
"stage-beta": "STAGE=true npm run prod-beta",
"stage-beta:experimental": "EXPERIMENTAL=TRUE npm run stage-beta",
"stage-beta:msw": "MSW=TRUE npm run stage-beta",
"stage-beta:msw+experimental": "EXPERIMENTAL=TRUE npm run stage-beta:msw",
"test": "TZ=UTC jest --verbose --no-cache",
"test:single": "jest --verbose -w 1",
"build": "webpack --config config/prod.webpack.config.js",

View file

@ -22,8 +22,6 @@ jest.mock('@unleash/proxy-client-react', () => ({
switch (flag) {
case 'image-builder.import.enabled':
return true;
case 'image-builder.new-wizard.enabled':
return true;
default:
return false;
}
@ -243,17 +241,15 @@ describe('Import model', () => {
expect(helperText).toBeInTheDocument();
});
test('should enable button on correct blueprint', async () => {
test('should enable button on correct blueprint and go to wizard', 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 user.click(reviewButton);
await userEvent.click(reviewButton);
await waitFor(() => {
expect(
screen.getByRole('heading', { name: 'Image output' })
).toBeInTheDocument();
});
expect(
await screen.findByText('Image output', { selector: 'h1' })
).toBeInTheDocument();
});
});

View file

@ -1,716 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { useFlag } from '@unleash/proxy-client-react';
import { useNavigate, useParams } from 'react-router-dom';
import ImageCreator from './ImageCreator';
import {
awsTarget,
fileSystemConfiguration,
googleCloudTarget,
imageName,
imageOutput,
msAzureTarget,
packages,
packagesContentSources,
registration,
repositories,
review,
oscap,
} from './steps';
import {
fileSystemConfigurationValidator,
targetEnvironmentValidator,
} from './validators';
import './CreateImageWizard.scss';
import {
CDN_PROD_URL,
CDN_STAGE_URL,
UNIT_GIB,
UNIT_KIB,
UNIT_MIB,
} from '../../constants';
import {
useComposeImageMutation,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
import { useGetEnvironment } from '../../Utilities/useGetEnvironment';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
const handleKeyDown = (e, handleClose) => {
if (e.key === 'Escape') {
handleClose();
}
};
const onSave = (values) => {
const customizations = {
packages: values['selected-packages']?.map((p) => p.name),
};
if (values['payload-repositories']?.length > 0) {
customizations['payload_repositories'] = [
...values['payload-repositories'],
];
}
if (values['custom-repositories']?.length > 0) {
customizations['custom_repositories'] = [...values['custom-repositories']];
}
if (values['register-system'] === 'register-now-rhc') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
rhc: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now-insights') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: true,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
} else if (values['register-system'] === 'register-now') {
customizations.subscription = {
'activation-key': values['subscription-activation-key'],
insights: false,
organization: Number(values['subscription-organization-id']),
'server-url': values['subscription-server-url'],
'base-url': values['subscription-base-url'],
};
}
if (values['file-system-config-radio'] === 'manual') {
customizations.filesystem = [];
for (const fsc of values['file-system-configuration']) {
customizations.filesystem.push({
mountpoint: fsc.mountpoint,
min_size: fsc.size * fsc.unit,
});
}
}
if (values['oscap-profile']) {
customizations.openscap = {
profile_id: values['oscap-profile'],
};
if (values['kernel']) {
customizations.kernel = values['kernel'];
}
if (
(Array.isArray(values['enabledServices']) &&
values['enabledServices'].length > 0) ||
(Array.isArray(values['maskedServices']) &&
values['maskedServices'].length > 0)
) {
customizations.services = {};
if (values['enabledServices'].length > 0) {
customizations.services.enabled = values['enabledServices'];
}
if (values['maskedServices'].length > 0) {
customizations.services.masked = values['maskedServices'];
}
}
}
const requests = [];
if (values['target-environment']?.aws) {
const options =
values['aws-target-type'] === 'aws-target-type-source'
? { share_with_sources: [values['aws-sources-select']] }
: { share_with_accounts: [values['aws-account-id']] };
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'aws',
upload_request: {
type: 'aws',
options,
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
if (values['image-sharing'] === 'gcp-account') {
switch (values['google-account-type']) {
case 'googleAccount':
share = `user:${values['google-email']}`;
break;
case 'serviceAccount':
share = `serviceAccount:${values['google-email']}`;
break;
case 'googleGroup':
share = `group:${values['google-email']}`;
break;
case 'domain':
share = `domain:${values['google-domain']}`;
break;
// no default
}
}
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
if (share !== '') {
request.image_requests[0].upload_request.options.share_with_accounts = [
share,
];
}
requests.push(request);
}
if (values['target-environment']?.azure) {
const upload_options =
values['azure-type'] === 'azure-type-source'
? { source_id: values['azure-sources-select'] }
: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
};
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
...upload_options,
resource_group: values['azure-resource-group'],
},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.oci) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'oci',
upload_request: {
type: 'oci.objectstorage',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.vsphere) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'vsphere',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.['vsphere-ova']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'vsphere-ova',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.['guest-image']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.['image-installer']) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
if (values['target-environment']?.wsl) {
const request = {
distribution: values.release,
image_name: values?.['image-name'],
image_description: values?.['image-description'],
image_requests: [
{
architecture: values['arch'],
image_type: 'wsl',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
client_id: 'ui',
};
requests.push(request);
}
return requests;
};
const parseSizeUnit = (bytesize) => {
let size;
let unit;
if (bytesize % UNIT_GIB === 0) {
size = bytesize / UNIT_GIB;
unit = UNIT_GIB;
} else if (bytesize % UNIT_MIB === 0) {
size = bytesize / UNIT_MIB;
unit = UNIT_MIB;
} else if (bytesize % UNIT_KIB === 0) {
size = bytesize / UNIT_KIB;
unit = UNIT_KIB;
}
return [size, unit];
};
// map the compose request object to the expected form state
const requestToState = (composeRequest, isProd, enableOscap) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
const formState = {};
formState['image-name'] = composeRequest.image_name;
formState['image-description'] = composeRequest.image_description;
formState.release = composeRequest?.distribution;
formState.arch = imageRequest.architecture;
// set defaults for target environment first
formState['target-environment'] = {
aws: false,
azure: false,
gcp: false,
oci: false,
'guest-image': false,
};
// then select the one from the request
// if the image type is to a cloud provider we use the upload_request.type
// or if the image is intended for download we use the image_type
let targetEnvironment;
if (
uploadRequest.type === 'aws.s3' ||
uploadRequest.type === 'oci.objectstorage'
) {
targetEnvironment = imageRequest.image_type;
} else {
targetEnvironment = uploadRequest.type;
}
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
const shareWithSource = uploadRequest?.options?.share_with_sources?.[0];
const shareWithAccount = uploadRequest?.options?.share_with_accounts?.[0];
formState['aws-sources-select'] = shareWithSource;
formState['aws-account-id'] = shareWithAccount;
if (shareWithAccount && !shareWithSource) {
formState['aws-target-type'] = 'aws-target-type-account-id';
} else {
// if both shareWithAccount & shareWithSource are present, set radio
// to sources - this is essentially an arbitrary decision
// additionally, note that the source is not validated against the actual
// sources
formState['aws-target-type'] = 'aws-target-type-source';
}
} else if (targetEnvironment === 'azure') {
if (uploadRequest?.options?.source_id) {
formState['azure-type'] = 'azure-type-source';
formState['azure-sources-select'] = uploadRequest?.options?.source_id;
} else {
formState['azure-type'] = 'azure-type-manual';
formState['azure-tenant-id'] = uploadRequest?.options?.tenant_id;
formState['azure-subscription-id'] =
uploadRequest?.options?.subscription_id;
}
formState['azure-resource-group'] =
uploadRequest?.options?.resource_group;
} else if (targetEnvironment === 'gcp') {
// parse google account info
// roughly in the format `accountType:accountEmail`
const accountInfo = uploadRequest?.options?.share_with_accounts[0];
const [accountTypePrefix, account] = accountInfo.split(':');
switch (accountTypePrefix) {
case 'user':
formState['google-account-type'] = 'googleAccount';
formState['google-email'] = account;
break;
case 'serviceAccount':
formState['google-account-type'] = 'serviceAccount';
formState['google-email'] = account;
break;
case 'group':
formState['google-account-type'] = 'googleGroup';
formState['google-email'] = account;
break;
case 'domain':
formState['google-account-type'] = 'domain';
formState['google-domain'] = account;
break;
// no default
}
}
// customizations
// packages
const packages = composeRequest?.customizations?.packages?.map((name) => {
return {
name: name,
summary: undefined,
};
});
formState['selected-packages'] = packages;
// repositories
// 'original-payload-repositories' is treated as read-only and is used to populate
// the table in the repositories step
// This is necessary because there may be repositories present in the request's
// json blob that are not managed using the content sources API. In that case,
// they are still displayed in the table of repositories but without any information
// from the content sources API (in other words, only the URL of the repository is
// displayed). This information needs to persist throughout the lifetime of the
// Wizard as it is needed every time the repositories step is visited.
formState['original-payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// 'payload-repositories' is mutable and is used to generate the request
// sent to image-builder
formState['payload-repositories'] =
composeRequest?.customizations?.payload_repositories;
// these will be overwritten by the repositories step if revisited, and generated from the payload repositories added there
formState['custom-repositories'] =
composeRequest?.customizations?.custom_repositories;
// filesystem
const fs = composeRequest?.customizations?.filesystem;
if (fs) {
formState['file-system-config-radio'] = 'manual';
const fileSystemConfiguration = [];
for (const fsc of fs) {
const [size, unit] = parseSizeUnit(fsc.min_size);
fileSystemConfiguration.push({
mountpoint: fsc.mountpoint.includes('/usr/')
? '/usr'
: fsc.mountpoint,
size,
unit,
});
}
formState['file-system-configuration'] = fileSystemConfiguration;
}
// subscription
const subscription = composeRequest?.customizations?.subscription;
if (subscription) {
if (subscription.rhc) {
formState['register-system'] = 'register-now-rhc';
} else if (subscription.insights) {
formState['register-system'] = 'register-now-insights';
} else {
formState['register-system'] = 'register-now';
}
formState['subscription-activation-key'] = subscription['activation-key'];
formState['subscription-organization-id'] = subscription.organization;
if (isProd) {
formState['subscription-server-url'] = 'subscription.rhsm.redhat.com';
formState['subscription-base-url'] = CDN_PROD_URL;
} else {
formState['subscription-server-url'] =
'subscription.rhsm.stage.redhat.com';
formState['subscription-base-url'] = CDN_STAGE_URL;
}
} else {
formState['register-system'] = 'register-later';
}
if (enableOscap) {
formState['oscap-profile'] =
composeRequest?.customizations?.openscap?.profile_id;
formState['kernel'] = composeRequest?.customizations?.kernel;
formState['enabledServices'] =
composeRequest?.customizations?.services?.enabled;
formState['maskedServices'] =
composeRequest?.customizations?.services?.masked;
}
return formState;
} else {
return;
}
};
const formStepHistory = (
composeRequest,
contentSourcesEnabled,
enableOscap
) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
// the order of steps must match the order of the steps in the Wizard
const steps = ['image-output'];
if (uploadRequest.type === 'aws') {
steps.push('aws-target-env');
} else if (uploadRequest.type === 'azure') {
steps.push('ms-azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
if (enableOscap) {
steps.push('Compliance');
}
if (contentSourcesEnabled) {
steps.push('File system configuration', 'packages', 'repositories');
const customRepositories =
composeRequest.customizations?.payload_repositories;
if (customRepositories) {
steps.push('packages-content-sources');
}
} else {
steps.push('File system configuration', 'packages');
}
steps.push('details');
return steps;
} else {
return [];
}
};
const CreateImageWizard = () => {
const [composeImage] = useComposeImageMutation();
const navigate = useNavigate();
// composeId is an optional param that is used for Recreate image
const { composeId } = useParams();
const { data } = useGetComposeStatusQuery(
{ composeId: composeId },
{
skip: composeId ? false : true,
}
);
const composeRequest = composeId ? data?.request : undefined;
const contentSourcesEnabled = useFlag('image-builder.enable-content-sources');
// Assume that if a request is available that we should start on review step
// This will occur if 'Recreate image' is clicked
const initialStep = composeRequest ? 'review' : undefined;
const { isBeta, isProd } = useGetEnvironment();
// Only allow oscap to be used in Beta even if the flag says the feature is
// activated.
const oscapFeatureFlag = useFlag('image-builder.wizard.oscap.enabled');
let initialState = requestToState(composeRequest, isProd(), oscapFeatureFlag);
const stepHistory = formStepHistory(
composeRequest,
contentSourcesEnabled,
oscapFeatureFlag
);
if (initialState) {
initialState.isBeta = isBeta();
initialState.contentSourcesEnabled = contentSourcesEnabled;
initialState.enableOscap = oscapFeatureFlag;
} else {
initialState = {
isBeta: isBeta(),
enableOscap: oscapFeatureFlag,
contentSourcesEnabled,
};
}
const handleClose = () => navigate(resolveRelPath(''));
return (
<>
<ImageBuilderHeader />
<section className="pf-l-page__main-section pf-c-page__main-section">
<ImageCreator
onClose={handleClose}
onSubmit={async ({ values, setIsSaving }) => {
setIsSaving(true);
const requests = onSave(values);
await Promise.allSettled(
requests.map((composeRequest) => composeImage({ composeRequest }))
);
navigate(resolveRelPath(''));
}}
defaultArch="x86_64"
customValidatorMapper={{
fileSystemConfigurationValidator,
targetEnvironmentValidator,
}}
schema={{
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'imageBuilder',
isDynamic: true,
inModal: false,
onKeyDown: (e) => {
handleKeyDown(e, handleClose);
},
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
crossroads: [
'target-environment',
'release',
'payload-repositories',
],
// order in this array does not reflect order in wizard nav, this order is managed inside
// of each step by `nextStep` property!
fields: [
imageOutput,
awsTarget,
googleCloudTarget,
msAzureTarget,
registration,
packages,
packagesContentSources,
repositories,
fileSystemConfiguration,
imageName,
review,
oscap,
],
initialState: {
activeStep: initialStep || 'image-output', // name of the active step
activeStepIndex: stepHistory.length, // active index
maxStepIndex: stepHistory.length, // max achieved index
prevSteps: stepHistory, // array with names of previously visited steps
},
},
],
}}
initialValues={initialState}
/>
</section>
</>
);
};
export default CreateImageWizard;

View file

@ -1,66 +0,0 @@
.pf-v5-c-wizard__nav-list {
padding-right: 0px;
}
.pf-v5-c-wizard__nav {
overflow-y: unset;
}
.pf-c-popover[data-popper-reference-hidden="true"] {
font-weight: initial;
visibility: initial;
pointer-events: initial;
}
.pf-v5-c-dual-list-selector {
--pf-v5-c-dual-list-selector__menu--MinHeight: 18rem;
--pf-v5-c-dual-list-selector--GridTemplateColumns--pane--MinMax--max: 100vw;
}
.pf-c-form {
--pf-c-form--GridGap: var(--pf-global--spacer--md);
}
.pf-c-form__group-label {
--pf-c-form__group-label--PaddingBottom: var(--pf-global--spacer--xs);
}
.tiles {
display: flex;
}
.tile {
flex: 1 0 0px;
max-width: 250px;
}
.pf-c-tile:focus {
--pf-c-tile__title--Color: var(--pf-c-tile__title--Color);
--pf-c-tile__icon--Color: var(---pf-global--Color--100);
--pf-c-tile--before--BorderWidth: var(--pf-global--BorderWidth--sm);
--pf-c-tile--before--BorderColor: var(--pf-global--BorderColor--100);
}
.pf-c-tile.pf-m-selected:focus {
--pf-c-tile__title--Color: var(--pf-c-tile--focus__title--Color);
--pf-c-tile__icon--Color: var(--pf-c-tile--focus__icon--Color);
}
.provider-icon {
width: 1em;
height: 1em;
}
.pf-u-min-width {
--pf-u-min-width--MinWidth: 18ch;
}
.pf-u-max-width {
--pf-u-max-width--MaxWidth: 26rem;
}
ul.pf-m-plain {
list-style: none;
padding-left: 0;
margin-left: 0;
}

View file

@ -1,112 +0,0 @@
import React from 'react';
import { componentMapper } from '@data-driven-forms/pf4-component-mapper';
import Pf4FormTemplate from '@data-driven-forms/pf4-component-mapper/form-template';
import Select from '@data-driven-forms/pf4-component-mapper/select';
import FormRenderer from '@data-driven-forms/react-form-renderer/form-renderer';
import { Spinner } from '@patternfly/react-core';
import PropTypes from 'prop-types';
import ActivationKeys from './formComponents/ActivationKeys';
import ArchSelect from './formComponents/ArchSelect';
import { AWSSourcesSelect } from './formComponents/AWSSourcesSelect';
import AzureAuthButton from './formComponents/AzureAuthButton';
import AzureResourceGroups from './formComponents/AzureResourceGroups';
import AzureSourcesSelect from './formComponents/AzureSourcesSelect';
import CentOSAcknowledgement from './formComponents/CentOSAcknowledgement';
import FileSystemConfiguration from './formComponents/FileSystemConfiguration';
import GalleryLayout from './formComponents/GalleryLayout';
import ImageOutputReleaseSelect from './formComponents/ImageOutputReleaseSelect';
import { Oscap } from './formComponents/Oscap';
import {
ContentSourcesPackages,
RedHatPackages,
} from './formComponents/Packages';
import RadioWithPopover from './formComponents/RadioWithPopover';
import Registration from './formComponents/Registration';
import RegistrationKeyInformation from './formComponents/RegistrationKeyInformation';
import ReleaseLifecycle from './formComponents/ReleaseLifecycle';
import Repositories from './formComponents/Repositories';
import Review from './formComponents/ReviewStep';
import TargetEnvironment from './formComponents/TargetEnvironment';
const ImageCreator = ({
schema,
onSubmit,
onClose,
customComponentMapper,
customValidatorMapper,
defaultArch,
className,
...props
}) => {
return schema ? (
<FormRenderer
initialValues={props.initialValues}
schema={schema}
className={`image-builder${className ? ` ${className}` : ''}`}
subscription={{ values: true }}
FormTemplate={(props) => (
<Pf4FormTemplate {...props} showFormControls={false} />
)}
onSubmit={(formValues) => onSubmit(formValues)}
validatorMapper={{ ...customValidatorMapper }}
componentMapper={{
...componentMapper,
review: Review,
output: TargetEnvironment,
select: Select,
'package-selector': {
component: RedHatPackages,
defaultArch,
},
'package-selector-content-sources': {
component: ContentSourcesPackages,
},
'radio-popover': RadioWithPopover,
'azure-auth-button': AzureAuthButton,
'activation-keys': ActivationKeys,
'activation-key-information': RegistrationKeyInformation,
'file-system-configuration': FileSystemConfiguration,
'image-output-release-select': ImageOutputReleaseSelect,
'centos-acknowledgement': CentOSAcknowledgement,
'repositories-table': Repositories,
'aws-sources-select': AWSSourcesSelect,
'azure-sources-select': AzureSourcesSelect,
'azure-resource-groups': AzureResourceGroups,
'gallery-layout': GalleryLayout,
'oscap-profile-selector': Oscap,
'image-output-arch-select': ArchSelect,
'image-output-release-lifecycle': ReleaseLifecycle,
registration: Registration,
...customComponentMapper,
}}
onCancel={onClose}
{...props}
/>
) : (
<Spinner />
);
};
ImageCreator.propTypes = {
schema: PropTypes.object,
onSubmit: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
customComponentMapper: PropTypes.shape({
[PropTypes.string]: PropTypes.oneOfType([
PropTypes.node,
PropTypes.shape({
component: PropTypes.node,
}),
]),
}),
customValidatorMapper: PropTypes.shape({
[PropTypes.string]: PropTypes.func,
}),
defaultArch: PropTypes.string,
className: PropTypes.string,
initialValues: PropTypes.object,
};
export default ImageCreator;

View file

@ -1,165 +0,0 @@
import React, { useEffect, useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Alert } from '@patternfly/react-core';
import { FormGroup, Spinner } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { extractProvisioningList } from '../../../store/helpers';
import {
useGetSourceListQuery,
useGetSourceUploadInfoQuery,
} from '../../../store/provisioningApi';
export const AWSSourcesSelect = ({
label,
isRequired,
className,
...props
}) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [selectedSourceId, setSelectedSourceId] = useState(
getState()?.values?.['aws-sources-select']
);
const {
data: rawSources,
isFetching,
isSuccess,
isError,
refetch,
} = useGetSourceListQuery({ provider: 'aws' });
const sources = extractProvisioningList(rawSources);
const {
data: sourceDetails,
isFetching: isFetchingDetails,
isSuccess: isSuccessDetails,
isError: isErrorDetails,
} = useGetSourceUploadInfoQuery(
{ id: selectedSourceId },
{
skip: !selectedSourceId,
}
);
useEffect(() => {
if (isFetchingDetails || !isSuccessDetails) return;
change('aws-associated-account-id', sourceDetails?.aws?.account_id);
}, [
isFetchingDetails,
isSuccessDetails,
change,
sourceDetails?.aws?.account_id,
]);
const onFormChange = ({ values }) => {
if (
values['aws-target-type'] !== 'aws-target-type-source' ||
values[input.name] === undefined
) {
change(input.name, undefined);
change('aws-associated-account-id', undefined);
}
};
const handleSelect = (_, sourceName) => {
const sourceId = sources.find((source) => source.name === sourceName).id;
setSelectedSourceId(sourceId);
setIsOpen(false);
change(input.name, sourceId);
};
const handleClear = () => {
setSelectedSourceId();
change(input.name, undefined);
};
const handleToggle = () => {
// Refetch upon opening (but not upon closing)
if (!isOpen) {
refetch();
}
setIsOpen(!isOpen);
};
return (
<>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<FormGroup
isRequired={isRequired}
label={label}
data-testid="sources"
className={className}
>
<Select
ouiaId="source_select"
variant={SelectVariant.typeahead}
onToggle={handleToggle}
onSelect={handleSelect}
onClear={handleClear}
selections={
selectedSourceId
? sources.find((source) => source.id === selectedSourceId)?.name
: undefined
}
isOpen={isOpen}
placeholderText="Select source"
typeAheadAriaLabel="Select source"
isDisabled={!isSuccess}
>
{isSuccess &&
sources.map((source) => (
<SelectOption key={source.id} value={source.name} />
))}
{isFetching && (
<SelectOption isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
)}
</Select>
</FormGroup>
<>
{isError && (
<Alert
variant={'danger'}
isPlain={true}
isInline={true}
title={'Sources unavailable'}
>
Sources cannot be reached, try again later or enter an AWS account
ID manually.
</Alert>
)}
{!isError && isErrorDetails && (
<Alert
variant={'danger'}
isPlain
isInline
title={'AWS details unavailable'}
>
The AWS account ID for the selected source could not be resolved.
There might be a problem with the source. Verify that the source is
valid in Sources or select a different source.
</Alert>
)}
</>
</>
);
};
AWSSourcesSelect.propTypes = {
className: PropTypes.string,
label: PropTypes.node,
isRequired: PropTypes.bool,
};

View file

@ -1,174 +0,0 @@
import React, { useContext } from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import {
Alert,
Spinner,
Text,
TextContent,
TextList,
TextListItem,
TextListItemVariants,
TextListVariants,
TextVariants,
} from '@patternfly/react-core';
import { Button, Popover } from '@patternfly/react-core';
import { HelpIcon } from '@patternfly/react-icons';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { useShowActivationKeyQuery } from '../../../store/rhsmApi';
const ActivationKeyInformation = (): JSX.Element => {
const { getState } = useFormApi();
const { currentStep } = useContext(WizardContext);
const activationKey = getState()?.values?.['subscription-activation-key'];
const {
data: activationKeyInfo,
isFetching: isFetchingActivationKeyInfo,
isSuccess: isSuccessActivationKeyInfo,
isError: isErrorActivationKeyInfo,
} = useShowActivationKeyQuery(
{ name: activationKey },
{
skip: !activationKey,
}
);
return (
<>
{isFetchingActivationKeyInfo && <Spinner size="lg" />}
{isSuccessActivationKeyInfo && (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Name:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{activationKey}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Role:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{activationKeyInfo.body?.role || 'Not defined'}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
SLA:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{activationKeyInfo.body?.serviceLevel || 'Not defined'}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Usage:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{activationKeyInfo.body?.usage || 'Not defined'}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Additional repositories:
<Popover
bodyContent={
<TextContent>
<Text>
The core repositories for your operating system version
are always enabled and do not need to be explicitly added
to the activation key.
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About additional repositories"
className="pf-u-pl-sm pf-u-pt-0 pf-u-pb-0"
size="sm"
>
<HelpIcon />
</Button>
</Popover>
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
className="pf-u-display-flex pf-u-align-items-flex-end"
>
{activationKeyInfo.body?.additionalRepositories &&
activationKeyInfo.body?.additionalRepositories?.length > 0 ? (
<Popover
bodyContent={
<TextContent>
<Text component={TextVariants.h3}>
Additional repositories
</Text>
<Table
aria-label="Additional repositories table"
variant="compact"
>
<Thead>
<Tr>
<Th>Name</Th>
</Tr>
</Thead>
<Tbody data-testid="additional-repositories-table">
{activationKeyInfo.body?.additionalRepositories?.map(
(repo, index) => (
<Tr key={index}>
<Td>{repo.repositoryLabel}</Td>
</Tr>
)
)}
</Tbody>
</Table>
</TextContent>
}
>
<Button
data-testid="repositories-popover-button"
variant="link"
aria-label="Show additional repositories"
className="pf-u-pl-0 pf-u-pt-0 pf-u-pb-0"
>
{activationKeyInfo.body?.additionalRepositories?.length}{' '}
repositories
</Button>
</Popover>
) : (
'None'
)}
</TextListItem>
</TextList>
</TextContent>
)}
{isErrorActivationKeyInfo && (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Name:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{activationKey}
</TextListItem>
</TextList>
</TextContent>
)}
{isErrorActivationKeyInfo && currentStep.name === 'registration' && (
<>
<br />
<Alert
title="Information about the activation key unavailable"
variant="danger"
isPlain
isInline
>
Information about the activation key cannot be loaded. Please check
the key was not removed and try again later.
</Alert>
</>
)}
</>
);
};
export default ActivationKeyInformation;

View file

@ -1,200 +0,0 @@
import React, { useEffect, useState } from 'react';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
Alert,
FormGroup,
Spinner,
EmptyState,
Button,
EmptyStateIcon,
EmptyStateBody,
EmptyStateHeader,
EmptyStateFooter,
EmptyStateActions,
} from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import { WrenchIcon, AddCircleOIcon } from '@patternfly/react-icons';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { CDN_PROD_URL, CDN_STAGE_URL } from '../../../constants';
import {
useListActivationKeysQuery,
useCreateActivationKeysMutation,
} from '../../../store/rhsmApi';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
const EmptyActivationsKeyState = ({ handleActivationKeyFn, isLoading }) => (
<EmptyState variant="xs">
<EmptyStateHeader
titleText="No activation keys found"
headingLevel="h4"
icon={<EmptyStateIcon icon={WrenchIcon} />}
/>
<EmptyStateBody>
Get started by building a default key, which will be generated and present
for you.
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button
onClick={handleActivationKeyFn}
icon={<AddCircleOIcon />}
isLoading={isLoading}
iconPosition="left"
variant="link"
>
Create activation key
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
);
EmptyActivationsKeyState.propTypes = {
handleActivationKeyFn: PropTypes.func.isRequired,
isLoading: PropTypes.bool,
};
const ActivationKeys = ({ label, isRequired, ...props }) => {
const { isProd } = useGetEnvironment();
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [activationKeySelected, selectActivationKey] = useState(
getState()?.values?.['subscription-activation-key']
);
const dispatch = useDispatch();
const {
data: activationKeys,
isFetching: isFetchingActivationKeys,
isSuccess: isSuccessActivationKeys,
isError: isErrorActivationKeys,
refetch,
} = useListActivationKeysQuery();
const [createActivationKey, { isLoading: isLoadingActivationKey }] =
useCreateActivationKeysMutation();
useEffect(() => {
if (isProd()) {
change('subscription-server-url', 'subscription.rhsm.redhat.com');
change('subscription-base-url', CDN_PROD_URL);
} else {
change('subscription-server-url', 'subscription.rhsm.stage.redhat.com');
change('subscription-base-url', CDN_STAGE_URL);
}
}, [isProd, change]);
const setActivationKey = (_, selection) => {
selectActivationKey(selection);
setIsOpen(false);
change(input.name, selection);
};
const handleClear = () => {
selectActivationKey();
change(input.name, undefined);
};
const handleToggle = () => {
if (!isOpen) {
refetch();
}
setIsOpen(!isOpen);
};
const handleCreateActivationKey = async () => {
const res = await createActivationKey({
body: {
name: 'activation-key-default',
serviceLevel: 'Self-Support',
},
});
refetch();
if (res.error) {
dispatch(
addNotification({
variant: 'danger',
title: 'Error creating activation key',
description: res.error?.data?.error?.message,
})
);
}
};
const isActivationKeysEmpty =
isSuccessActivationKeys && activationKeys.body.length === 0;
return (
<>
<FormGroup
isRequired={isRequired}
label={label}
data-testid="subscription-activation-key"
>
<Select
ouiaId="activation_key_select"
variant={SelectVariant.typeahead}
onToggle={handleToggle}
onSelect={setActivationKey}
onClear={handleClear}
selections={activationKeySelected}
isOpen={isOpen}
placeholderText="Select activation key"
typeAheadAriaLabel="Select activation key"
isDisabled={!isSuccessActivationKeys}
>
{isActivationKeysEmpty && (
<EmptyActivationsKeyState
handleActivationKeyFn={handleCreateActivationKey}
isLoading={isLoadingActivationKey}
/>
)}
{isSuccessActivationKeys &&
activationKeys.body.map((key, index) => (
<SelectOption key={index} value={key.name} />
))}
{!isSuccessActivationKeys && isFetchingActivationKeys && (
<SelectOption
isNoResultsOption={true}
data-testid="activation-keys-loading"
>
<Spinner size="md" />
</SelectOption>
)}
</Select>
</FormGroup>
{isErrorActivationKeys && (
<Alert
title="Activation keys unavailable"
variant="danger"
isPlain
isInline
>
Activation keys cannot be reached, try again later.
</Alert>
)}
</>
);
};
ActivationKeys.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
};
ActivationKeys.defaultProps = {
label: '',
isRequired: false,
};
export default ActivationKeys;

View file

@ -1,74 +0,0 @@
import React, { useEffect, useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { FormGroup } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { useSearchParams } from 'react-router-dom';
import { ARCHS, AARCH64 } from '../../../constants';
const ArchSelect = ({ label, isRequired, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [searchParams] = useSearchParams();
// Set the architecture via search parameter
// Used by Insights assistant or external hyperlinks (access.redhat.com, developers.redhat.com)
const preloadArch = searchParams.get('arch');
useEffect(() => {
preloadArch === AARCH64 && change(input.name, AARCH64);
}, [change, input.name, preloadArch]);
const setArch = (_, selection) => {
change(input.name, selection);
setIsOpen(false);
};
const setSelectOptions = () => {
var options = [];
ARCHS.forEach((arch) => {
options.push(
<SelectOption key={arch} value={arch}>
{arch}
</SelectOption>
);
});
return options;
};
return (
<FormSpy>
{() => (
<FormGroup isRequired={isRequired} label={label}>
<Select
ouiaId="arch_select"
variant={SelectVariant.single}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setArch}
selections={getState()?.values?.[input.name]}
isOpen={isOpen}
>
{setSelectOptions()}
</Select>
</FormGroup>
)}
</FormSpy>
);
};
ArchSelect.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
};
export default ArchSelect;

View file

@ -1,12 +0,0 @@
import React from 'react';
import { useGetSourceUploadInfoQuery } from '../../../store/provisioningApi';
type AwsAccountIdProps = {
sourceId: number;
};
export const AwsAccountId = ({ sourceId }: AwsAccountIdProps) => {
const { data } = useGetSourceUploadInfoQuery({ id: sourceId });
return <>{data?.aws?.account_id}</>;
};

View file

@ -1,35 +0,0 @@
import React from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Button, FormGroup } from '@patternfly/react-core';
const AzureAuthButton = () => {
const { getState } = useFormApi();
const tenantId = getState()?.values?.['azure-tenant-id'];
const guidRegex = new RegExp(
'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
'i'
);
return (
<FormGroup>
<Button
component="a"
target="_blank"
variant="secondary"
isDisabled={!guidRegex.test(tenantId)}
href={
'https://login.microsoftonline.com/' +
tenantId +
'/oauth2/v2.0/authorize?client_id=b94bb246-b02c-4985-9c22-d44e66f657f4&scope=openid&' +
'response_type=code&response_mode=query&redirect_uri=https://portal.azure.com'
}
>
Authorize Image Builder
</Button>
</FormGroup>
);
};
export default AzureAuthButton;

View file

@ -1,96 +0,0 @@
import React, { useState } from 'react';
import FormSpy from '@data-driven-forms/react-form-renderer/form-spy';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { FormGroup, Spinner } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { useGetSourceUploadInfoQuery } from '../../../store/provisioningApi';
const AzureResourceGroups = ({ label, isRequired, className, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [sourceId, setSourceId] = useState(
getState()?.values?.['azure-sources-select']
);
const onFormChange = ({ values }) => {
setSourceId(values['azure-sources-select']);
};
const { data: sourceDetails, isFetching } = useGetSourceUploadInfoQuery(
{ id: sourceId },
{
skip: !sourceId,
}
);
const resourceGroups =
(sourceId && sourceDetails?.azure?.resource_groups) || [];
const setResourceGroup = (_, selection) => {
setIsOpen(false);
change(input.name, selection);
};
const handleClear = () => {
change(input.name, undefined);
};
return (
<FormGroup
isRequired={isRequired}
label={label}
data-testid="azure-resource-groups"
>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<Select
ouiaId="resource_group_select"
variant={SelectVariant.typeahead}
className={className}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setResourceGroup}
onClear={handleClear}
selections={input.value}
isOpen={isOpen}
placeholderText="Select resource group"
typeAheadAriaLabel="Select resource group"
>
{isFetching && (
<SelectOption
isNoResultsOption={true}
data-testid="azure-resource-groups-loading"
>
<Spinner size="lg" />
</SelectOption>
)}
{resourceGroups.map((name, index) => (
<SelectOption
key={index}
value={name}
aria-label={`Resource group ${name}`}
/>
))}
</Select>
</FormGroup>
);
};
AzureResourceGroups.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
className: PropTypes.string,
};
AzureResourceGroups.defaultProps = {
label: '',
isRequired: false,
className: '',
};
export default AzureResourceGroups;

View file

@ -1,165 +0,0 @@
import React, { useState, useEffect } from 'react';
import FormSpy from '@data-driven-forms/react-form-renderer/form-spy';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Alert } from '@patternfly/react-core';
import { FormGroup, Spinner } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { extractProvisioningList } from '../../../store/helpers';
import {
useGetSourceListQuery,
useGetSourceUploadInfoQuery,
} from '../../../store/provisioningApi';
const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
const { change } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const selectedSourceId = input.value;
const {
data: rawSources,
isFetching,
isSuccess,
isError,
refetch,
} = useGetSourceListQuery({ provider: 'azure' });
const sources = extractProvisioningList(rawSources);
const {
data: sourceDetails,
isFetching: isFetchingDetails,
isSuccess: isSuccessDetails,
isError: isErrorDetails,
} = useGetSourceUploadInfoQuery(
{ id: selectedSourceId },
{
skip: !selectedSourceId,
}
);
useEffect(() => {
if (isFetchingDetails || !isSuccessDetails) return;
change('azure-tenant-id', sourceDetails?.azure?.tenant_id);
change('azure-subscription-id', sourceDetails?.azure?.subscription_id);
}, [
isFetchingDetails,
isSuccessDetails,
sourceDetails?.azure?.subscription_id,
sourceDetails?.azure?.tenant_id,
change,
]);
const onFormChange = ({ values }) => {
if (
values['azure-type'] !== 'azure-type-source' ||
values[input.name] === undefined
) {
change(input.name, undefined);
change('azure-tenant-id', undefined);
change('azure-subscription-id', undefined);
change('azure-resource-group', undefined);
}
};
const handleSelect = (_, sourceName) => {
const sourceId = sources.find((source) => source.name === sourceName).id;
change(input.name, sourceId);
setIsOpen(false);
};
const handleClear = () => {
change(input.name, undefined);
change('azure-resource-group', undefined);
};
const handleToggle = () => {
// Refetch upon opening (but not upon closing)
if (!isOpen) {
refetch();
}
setIsOpen(!isOpen);
};
return (
<>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<FormGroup
isRequired={isRequired}
label={label}
data-testid="azure-sources"
>
<Select
ouiaId="source_select"
variant={SelectVariant.typeahead}
className={className}
onToggle={handleToggle}
onSelect={handleSelect}
onClear={handleClear}
selections={
selectedSourceId
? sources.find((source) => source.id === selectedSourceId)?.name
: undefined
}
isOpen={isOpen}
placeholderText="Select source"
typeAheadAriaLabel="Select source"
menuAppendTo="parent"
maxHeight="25rem"
isDisabled={!isSuccess}
>
{isSuccess &&
sources.map((source) => (
<SelectOption key={source.id} value={source.name} />
))}
{isFetching && (
<SelectOption isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
)}
</Select>
</FormGroup>
<>
{isError && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Sources unavailable'}
>
Sources cannot be reached, try again later or enter an account info
for upload manually.
</Alert>
)}
{!isError && isErrorDetails && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Azure details unavailable'}
>
Could not fetch Tenant ID and Subscription ID from Azure for given
Source. Check Sources page for the source availability or select a
different Source.
</Alert>
)}
</>
</>
);
};
AzureSourcesSelect.propTypes = {
className: PropTypes.string,
label: PropTypes.node,
isRequired: PropTypes.bool,
};
export default AzureSourcesSelect;

View file

@ -1,46 +0,0 @@
import React from 'react';
import { Alert, Button } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { DEVELOPERS_URL } from '../../../constants';
const DeveloperProgramButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={DEVELOPERS_URL}
>
Red Hat Developer Program
</Button>
);
};
const CentOSAcknowledgement = () => {
return (
<Alert
variant="info"
isPlain
isInline
title={
<>
CentOS Stream builds are intended for the development of future
versions of RHEL and are not supported for production workloads or
other use cases.
</>
}
>
<p>
Join the <DeveloperProgramButton /> to learn about paid and no-cost RHEL
subscription options.
</p>
</Alert>
);
};
export default CentOSAcknowledgement;

View file

@ -1,121 +0,0 @@
import React, { useContext, useState } from 'react';
import { FormSpy, useFormApi } from '@data-driven-forms/react-form-renderer';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import { Button } from '@patternfly/react-core';
import PropTypes from 'prop-types';
import { contentSourcesApi } from '../../../store/contentSourcesApi';
import { rhsmApi } from '../../../store/rhsmApi';
import { useCheckRepositoriesAvailability } from '../../../Utilities/checkRepositoriesAvailability';
import { releaseToVersion } from '../../../Utilities/releaseToVersion';
const CustomButtons = ({
buttonLabels: { cancel, next, submit, back },
handleNext,
handlePrev,
nextStep,
}) => {
const { getState } = useFormApi();
const [isSaving, setIsSaving] = useState(false);
const { currentStep, formOptions } = useContext(WizardContext);
const prefetchActivationKeys = rhsmApi.usePrefetch('listActivationKeys');
const prefetchRepositories =
contentSourcesApi.usePrefetch('listRepositories');
const hasUnavailableRepo = useCheckRepositoriesAvailability();
const onNextOrSubmit = () => {
if (currentStep.id === 'wizard-review') {
formOptions.onSubmit({
values: formOptions.getState().values,
setIsSaving,
});
} else {
if (typeof nextStep === 'function') {
handleNext(nextStep({ values: formOptions.getState().values }));
} else {
handleNext(nextStep);
}
}
};
const onMouseEnter = () => {
if (currentStep.id === 'wizard-imageoutput') {
prefetchActivationKeys();
}
if (currentStep.id === 'wizard-systemconfiguration-packages') {
const arch = getState().values?.arch;
const release = getState().values?.release;
const version = releaseToVersion(release);
prefetchRepositories({
availableForArch: arch,
availableForVersion: version,
contentType: 'rpm',
origin: 'external',
});
}
};
return (
<FormSpy>
{() => (
<React.Fragment>
<Button
id={`${currentStep.id}-next-button`}
variant="primary"
type="button"
isDisabled={
!formOptions.valid ||
formOptions.getState().validating ||
isSaving ||
hasUnavailableRepo
}
isLoading={currentStep.id === 'wizard-review' ? isSaving : null}
onClick={onNextOrSubmit}
onMouseEnter={onMouseEnter}
>
{currentStep.id === 'wizard-review'
? isSaving
? 'Creating image'
: submit
: next}
</Button>
<Button
id={`${currentStep.id}-previous-button`}
type="button"
variant="secondary"
onClick={handlePrev}
isDisabled={isSaving}
>
{back}
</Button>
<div className="pf-c-wizard__footer-cancel">
<Button
id={`${currentStep.id}-cancel-button`}
type="button"
variant="link"
onClick={formOptions.onCancel}
isDisabled={isSaving}
>
{cancel}
</Button>
</div>
</React.Fragment>
)}
</FormSpy>
);
};
CustomButtons.propTypes = {
buttonLabels: PropTypes.shape({
cancel: PropTypes.node,
submit: PropTypes.node,
back: PropTypes.node,
next: PropTypes.node,
}),
handleNext: PropTypes.func,
handlePrev: PropTypes.func,
nextStep: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
};
export default CustomButtons;

View file

@ -1,84 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import { Button } from '@patternfly/react-core';
import PropTypes from 'prop-types';
import { imageBuilderApi } from '../../../store/imageBuilderApi';
// FileSystemconfigButtons are defined separately to display errors inside of the button footer
const FileSystemConfigButtons = ({ handleNext, handlePrev, nextStep }) => {
const { currentStep, formOptions } = useContext(WizardContext);
const { change, getState } = useFormApi();
const [hasErrors, setHasErrors] = useState(
getState()?.errors?.['file-system-configuration'] ? true : false
);
const [nextHasBeenClicked, setNextHasBeenClicked] = useState(false);
const prefetchArchitectures = imageBuilderApi.usePrefetch('getArchitectures');
const errors = getState()?.errors?.['file-system-configuration'];
useEffect(() => {
errors ? setHasErrors(true) : setHasErrors(false);
if (!errors) {
setNextHasBeenClicked(false);
change('file-system-config-show-errors', false);
}
}, [errors, change]);
const handleClick = () => {
if (!hasErrors) {
handleNext(nextStep);
}
setNextHasBeenClicked(true);
change('file-system-config-show-errors', true);
};
const handleMouseEnter = () => {
const distribution = getState()?.values?.release;
prefetchArchitectures({ distribution });
};
return (
<>
<Button
id={`${currentStep.id}-next-button`}
variant="primary"
type="button"
isDisabled={hasErrors && nextHasBeenClicked}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
>
Next
</Button>
<Button
id={`${currentStep.id}-previous-button`}
variant="secondary"
type="button"
onClick={handlePrev}
>
Back
</Button>
<div className="pf-c-wizard__footer-cancel">
<Button
id={`${currentStep.id}-cancel-button`}
type="button"
variant="link"
onClick={formOptions.onCancel}
>
Cancel
</Button>
</div>
</>
);
};
FileSystemConfigButtons.propTypes = {
handleNext: PropTypes.func,
handlePrev: PropTypes.func,
nextStep: PropTypes.string,
};
export default FileSystemConfigButtons;

View file

@ -1,495 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
Alert,
Button,
Popover,
Spinner,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import {
HelpIcon,
MinusCircleIcon,
PlusCircleIcon,
} from '@patternfly/react-icons';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import styles from '@patternfly/react-styles/css/components/Table/table';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { v4 as uuidv4 } from 'uuid';
import MountPoint, { MountPointValidPrefixes } from './MountPoint';
import SizeUnit from './SizeUnit';
import UsrSubDirectoriesDisabled from './UsrSubDirectoriesDisabled';
import {
FILE_SYSTEM_CUSTOMIZATION_URL,
UNIT_GIB,
UNIT_MIB,
} from '../../../constants';
import { useGetOscapCustomizationsQuery } from '../../../store/imageBuilderApi';
const initialRow = {
id: uuidv4(),
mountpoint: '/',
fstype: 'xfs',
size: 10,
unit: UNIT_GIB,
};
const FileSystemConfiguration = ({ ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [draggedItemId, setDraggedItemId] = useState(null);
const [draggingToItemIndex, setDraggingToItemIndex] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [itemOrder, setItemOrder] = useState([initialRow.id]);
const [tempItemOrder, setTempItemOrder] = useState([]);
const bodyref = useRef();
const [rows, setRows] = useState([initialRow]);
const oscapProfile = getState()?.values?.['oscap-profile'];
const hasNoOscapProfile = !oscapProfile;
const hasCustomizations = !(
getState()?.values?.['file-system-configuration'] === undefined ||
getState().values['file-system-configuration'].length === 1
);
const {
data: customizations,
isFetching: isFetchingCustomizations,
isSuccess,
} = useGetOscapCustomizationsQuery(
{
distribution: getState()?.values?.['release'],
profile: oscapProfile,
},
{
// Don't override the user's data if they made customizations
skip: hasNoOscapProfile || hasCustomizations,
}
);
useEffect(() => {
if (hasCustomizations || rows.length > 1) {
return;
}
if (customizations && customizations.filesystem && isSuccess) {
const newRows = rows;
// The filesystem customizations required by the OpenSCAP profile may
// contain some unsupported mountpoints. They need to be filtered out to
// prevent undefined behaviour in the frontend and the backend
const fss = customizations.filesystem.filter((fs) =>
MountPointValidPrefixes.includes(
'/'.concat(fs.mountpoint.split('/')[1])
)
);
// And add them all to the list.
for (const fs of fss) {
newRows.push({
id: uuidv4(),
mountpoint: fs.mountpoint,
fstype: 'xfs',
size: fs.min_size / UNIT_MIB, // the unit from the customizations are in bytes
unit: UNIT_MIB, // and using MIB seems sensible here instead
});
}
setRows(newRows);
setItemOrder(newRows.map((row) => row.id));
change('file-system-config-radio', 'manual');
}
}, [customizations, isSuccess, change, hasCustomizations, rows]);
useEffect(() => {
const fsc = getState()?.values?.['file-system-configuration'];
if (!fsc) {
return;
}
const newRows = [];
const newOrder = [];
fsc.forEach((r) => {
const id = uuidv4();
newRows.push({
id,
mountpoint: r.mountpoint,
fstype: 'xfs',
size: r.size,
unit: r.unit,
});
newOrder.push(id);
});
setRows(newRows);
setItemOrder(newOrder);
}, [getState]);
const showErrors = () =>
getState()?.values?.['file-system-config-show-errors'];
useEffect(() => {
change(
input.name,
itemOrder.map((r) => {
for (const r2 of rows) {
if (r2.id === r) {
return {
mountpoint: r2.mountpoint,
size: r2.size,
unit: r2.unit,
};
}
}
return null;
})
);
}, [rows, itemOrder, change, input.name]);
const addRow = () => {
const id = uuidv4();
setRows(
rows.concat([
{
id,
mountpoint: '/home',
fstype: 'xfs',
size: 1,
unit: UNIT_GIB,
},
])
);
setItemOrder(itemOrder.concat([id]));
};
const removeRow = (id) => {
const removeIndex = rows.map((e) => e.id).indexOf(id);
const newRows = [...rows];
newRows.splice(removeIndex, 1);
const removeOrderIndex = itemOrder.indexOf(id);
const newOrder = [...itemOrder];
newOrder.splice(removeOrderIndex, 1);
setRows(newRows);
setItemOrder(newOrder);
};
const moveItem = (arr, i1, toIndex) => {
const fromIndex = arr.indexOf(i1);
if (fromIndex === toIndex) {
return arr;
}
const temp = arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, temp[0]);
return arr;
};
const move = (itemOrder) => {
const ulNode = bodyref.current;
const nodes = Array.from(ulNode.children);
if (nodes.map((node) => node.id).every((id, i) => id === itemOrder[i])) {
return;
}
while (ulNode.firstChild) {
ulNode.removeChild(ulNode.lastChild);
}
itemOrder.forEach((id) => {
ulNode.appendChild(nodes.find((n) => n.id === id));
});
};
const onDragOver = (evt) => {
evt.preventDefault();
const curListItem = evt.target.closest('tr');
if (!curListItem || !bodyref.current.contains(curListItem)) {
return null;
}
const dragId = curListItem.id;
const newDraggingToItemIndex = Array.from(
bodyref.current.children
).findIndex((item) => item.id === dragId);
if (newDraggingToItemIndex !== draggingToItemIndex) {
const tempItemOrder = moveItem(
[...itemOrder],
draggedItemId,
newDraggingToItemIndex
);
move(tempItemOrder);
setDraggingToItemIndex(newDraggingToItemIndex);
setTempItemOrder(tempItemOrder);
}
};
const isValidDrop = (evt) => {
const ulRect = bodyref.current.getBoundingClientRect();
return (
evt.clientX > ulRect.x &&
evt.clientX < ulRect.x + ulRect.width &&
evt.clientY > ulRect.y &&
evt.clientY < ulRect.y + ulRect.height
);
};
const onDragLeave = (evt) => {
if (!isValidDrop(evt)) {
move(itemOrder);
setDraggingToItemIndex(null);
}
};
const onDrop = (evt) => {
if (isValidDrop(evt)) {
setItemOrder(tempItemOrder);
}
};
const onDragStart = (evt) => {
evt.dataTransfer.effectAllowed = 'move';
evt.dataTransfer.setData('text/plain', evt.currentTarget.id);
evt.currentTarget.classList.add(styles.modifiers.ghostRow);
evt.currentTarget.setAttribute('aria-pressed', 'true');
setDraggedItemId(evt.currentTarget.id);
setIsDragging(true);
};
const onDragEnd = (evt) => {
evt.target.classList.remove(styles.modifiers.ghostRow);
evt.target.setAttribute('aria-pressed', 'false');
setDraggedItemId(null);
setDraggingToItemIndex(null);
setIsDragging(false);
};
const setMountpoint = (id, mp) => {
const newRows = [...rows];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
const newRow = { ...newRows[i] };
newRow.mountpoint = mp;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
const setSize = (id, s, u) => {
const newRows = [...rows];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
const newRow = { ...newRows[i] };
newRow.size = s;
newRow.unit = u;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
// Don't let the user interact with the partitions while we are getting the
// customizations. Having a customizations added by the user first would mess
// up the logic.
if (isFetchingCustomizations) {
return <Spinner size="lg" />;
}
const hasIsoTarget = () => {
const isoTarget =
getState().values['target-environment']?.['image-installer'];
return isoTarget;
};
return (
<FormSpy>
{() => (
<>
<TextContent>
<Text component={TextVariants.h3}>Configure partitions</Text>
</TextContent>
{getState()?.values?.['file-system-configuration']?.find((mp) =>
mp.mountpoint.includes('/usr')
) && <UsrSubDirectoriesDisabled />}
{rows.length > 1 &&
getState()?.errors?.['file-system-configuration']?.duplicates
?.length !== 0 &&
showErrors() && (
<Alert
variant="danger"
isInline
title="Duplicate mount points: All mount points must be unique. Remove the duplicate or choose a new mount point."
data-testid="fsc-warning"
/>
)}
{rows.length >= 1 &&
getState()?.errors?.['file-system-configuration']?.root === false &&
showErrors() && (
<Alert
variant="danger"
isInline
title="No root partition configured."
/>
)}
<TextContent>
<Text>
Create partitions for your image by defining mount points and
minimum sizes. Image builder creates partitions with a logical
volume (LVM) device type.
</Text>
<Text>
The order of partitions may change when the image is installed in
order to conform to best practices and ensure functionality.
<br></br>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
href={FILE_SYSTEM_CUSTOMIZATION_URL}
className="pf-u-pl-0"
>
Read more about manual configuration here
</Button>
</Text>
</TextContent>
{hasIsoTarget() && (
<Alert
variant="warning"
isInline
title="Filesystem customizations are not applied to 'Bare metal - Installer' images"
/>
)}
<Table
aria-label="File system table"
className={isDragging && styles.modifiers.dragOver}
variant="compact"
>
<Thead>
<Tr>
<Th aria-label="Drag mount point" />
<Th>Mount point</Th>
<Th>Type</Th>
<Th>
Minimum size
<Popover
hasAutoWidth
bodyContent={
<TextContent>
<Text>
Image Builder may extend this size based on
requirements, selected packages, and configurations.
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="File system configuration info"
aria-describedby="file-system-configuration-info"
className="pf-c-form__group-label-help"
>
<HelpIcon />
</Button>
</Popover>
</Th>
<Th aria-label="Remove mount point" />
</Tr>
</Thead>
<Tbody
ref={bodyref}
onDragOver={onDragOver}
onDrop={onDragOver}
onDragLeave={onDragLeave}
data-testid="file-system-configuration-tbody"
>
{rows.map((row, rowIndex) => (
<Tr
key={rowIndex}
id={row.id}
draggable
onDrop={onDrop}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
>
<Td
draggableRow={{
id: `draggable-row-${row.id}`,
}}
/>
<Td className="pf-m-width-30">
<MountPoint
key={row.id + '-mountpoint'}
mountpoint={row.mountpoint}
onChange={(mp) => setMountpoint(row.id, mp)}
/>
{getState().errors['file-system-configuration']?.duplicates
.length !== 0 &&
getState().errors[
'file-system-configuration'
]?.duplicates.indexOf(row.mountpoint) !== -1 &&
showErrors() && (
<Alert
variant="danger"
isInline
isPlain
title="Duplicate mount point."
/>
)}
</Td>
<Td className="pf-m-width-20">
{/* always xfs */}
{row.fstype}
</Td>
<Td className="pf-m-width-30">
<SizeUnit
key={row.id + '-sizeunit'}
size={row.size}
unit={row.unit}
onChange={(s, u) => setSize(row.id, s, u)}
/>
</Td>
<Td className="pf-m-width-10">
<Button
variant="link"
icon={<MinusCircleIcon />}
onClick={() => removeRow(row.id)}
data-testid="remove-mount-point"
isDisabled={row.mountpoint === '/' ? true : false}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
<TextContent>
<Button
ouiaId="add-partition"
data-testid="file-system-add-partition"
className="pf-u-text-align-left"
variant="link"
icon={<PlusCircleIcon />}
onClick={addRow}
>
Add partition
</Button>
</TextContent>
</>
)}
</FormSpy>
);
};
export default FileSystemConfiguration;

View file

@ -1,25 +0,0 @@
import React from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import { Gallery, GalleryItem } from '@patternfly/react-core';
import PropTypes from 'prop-types';
const GalleryLayout = ({ fields, minWidths, maxWidths }) => {
const { renderForm } = useFormApi();
return (
<Gallery minWidths={minWidths} maxWidths={maxWidths} hasGutter>
{fields.map((field) => (
<GalleryItem key={field.name}>{renderForm([field])}</GalleryItem>
))}
</Gallery>
);
};
GalleryLayout.propTypes = {
fields: PropTypes.array,
maxWidths: PropTypes.object,
minWidths: PropTypes.object,
};
export default GalleryLayout;

View file

@ -1,123 +0,0 @@
import React, { useEffect, useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { FormGroup } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { useSearchParams } from 'react-router-dom';
import {
RELEASES,
RHEL_8,
RHEL_8_FULL_SUPPORT,
RHEL_8_MAINTENANCE_SUPPORT,
RHEL_9,
RHEL_9_FULL_SUPPORT,
RHEL_9_MAINTENANCE_SUPPORT,
} from '../../../constants';
import isRhel from '../../../Utilities/isRhel';
import { toMonthAndYear } from '../../../Utilities/time';
const ImageOutputReleaseSelect = ({ label, isRequired, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [showDevelopmentOptions, setShowDevelopmentOptions] = useState(false);
const [searchParams] = useSearchParams();
// Used to set release to RHEL 8 via search parameter, used by Insights assistant
const preloadRelease = searchParams.get('release');
useEffect(() => {
preloadRelease === 'rhel8' && change(input.name, RHEL_8);
}, [change, input.name, preloadRelease]);
const setRelease = (_, selection) => {
change(input.name, selection);
setIsOpen(false);
};
const handleExpand = () => {
setShowDevelopmentOptions(true);
};
const setDescription = (key) => {
let fullSupportEnd = '';
let maintenanceSupportEnd = '';
if (key === RHEL_8) {
fullSupportEnd = toMonthAndYear(RHEL_8_FULL_SUPPORT[1]);
maintenanceSupportEnd = toMonthAndYear(RHEL_8_MAINTENANCE_SUPPORT[1]);
}
if (key === RHEL_9) {
fullSupportEnd = toMonthAndYear(RHEL_9_FULL_SUPPORT[1]);
maintenanceSupportEnd = toMonthAndYear(RHEL_9_MAINTENANCE_SUPPORT[1]);
}
if (isRhel(key)) {
return `Full support ends: ${fullSupportEnd} | Maintenance support ends: ${maintenanceSupportEnd}`;
}
};
const setSelectOptions = () => {
var options = [];
const filteredRhel = new Map(
[...RELEASES].filter(([key]) => {
// Only show non-RHEL distros if expanded
if (showDevelopmentOptions) {
return true;
}
return isRhel(key);
})
);
filteredRhel.forEach((value, key) => {
options.push(
<SelectOption key={value} value={key} description={setDescription(key)}>
{RELEASES.get(key)}
</SelectOption>
);
});
return options;
};
return (
<FormSpy>
{() => (
<FormGroup isRequired={isRequired} label={label}>
<Select
ouiaId="release_select"
variant={SelectVariant.single}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setRelease}
selections={RELEASES.get(getState()?.values?.[input.name])}
isOpen={isOpen}
{...(!showDevelopmentOptions && {
loadingVariant: {
text: 'Show options for further development of RHEL',
onClick: handleExpand,
},
})}
>
{setSelectOptions()}
</Select>
</FormGroup>
)}
</FormSpy>
);
};
ImageOutputReleaseSelect.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
};
export default ImageOutputReleaseSelect;

View file

@ -1,104 +0,0 @@
import React, { useEffect, useState } from 'react';
import path from 'path';
import { Grid, GridItem, TextInput } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
export const MountPointValidPrefixes = [
'/app',
'/boot',
'/data',
'/home',
'/opt',
'/srv',
'/tmp',
'/usr',
'/var',
];
const MountPoint = ({ ...props }) => {
const [isOpen, setIsOpen] = useState(false);
const [prefix, setPrefix] = useState('/');
const [suffix, setSuffix] = useState('');
// split
useEffect(() => {
for (const p of MountPointValidPrefixes) {
if (props.mountpoint.startsWith(p)) {
setPrefix(p);
setSuffix(props.mountpoint.substring(p.length));
return;
}
}
}, [props.mountpoint]);
useEffect(() => {
let suf = suffix;
let mp = prefix;
if (suf) {
if (mp !== '/' && suf[0] !== '/') {
suf = '/' + suf;
}
mp += suf;
}
props.onChange(path.normalize(mp));
}, [prefix, suffix]);
const onToggle = (isOpen) => {
setIsOpen(isOpen);
};
const onSelect = (event, selection) => {
setPrefix(selection);
setIsOpen(false);
};
return (
// TODO make these stack vertically for xs viewport
<Grid>
<GridItem span={6}>
<Select
ouiaId="mount-point"
isOpen={isOpen}
onToggle={(_event, isOpen) => onToggle(isOpen)}
onSelect={onSelect}
selections={prefix}
variant={SelectVariant.single}
isDisabled={prefix === '/' ? true : false}
>
{MountPointValidPrefixes.map((pfx, index) => {
return <SelectOption key={index} value={pfx} />;
})}
</Select>
</GridItem>
<GridItem span={6}>
{prefix !== '/' &&
!prefix.startsWith('/boot') &&
!prefix.startsWith('/usr') && (
<TextInput
ouiaId="mount-suffix"
type="text"
value={suffix}
aria-label="Mount point suffix text input"
onChange={(_event, v) => setSuffix(v)}
/>
)}
</GridItem>
</Grid>
);
};
MountPoint.propTypes = {
mountpoint: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default MountPoint;

View file

@ -1,322 +0,0 @@
import React, { useEffect, useState } from 'react';
import useFieldApi, {
UseFieldApiConfig,
} from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
Alert,
FormGroup,
Spinner,
Popover,
TextContent,
Text,
Button,
} from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import { HelpIcon } from '@patternfly/react-icons';
import OscapProfileInformation from './OscapProfileInformation';
import {
DistributionProfileItem,
useGetOscapCustomizationsQuery,
useGetOscapProfilesQuery,
} from '../../../store/imageBuilderApi';
import { reinitFileSystemConfiguratioStep } from '../steps/fileSystemConfiguration';
import { reinitPackagesStep } from '../steps/packages';
type ChangeType = <F extends keyof Record<string, string | undefined>>(
name: F,
value?: Record<string, string | undefined>[F]
) => void;
/**
* Every time there is change on this form step's state, reinitialise the steps
* that are depending on it. This will ensure that if the user goes back and
* change their mind, going forward again leaves them in a coherent and workable
* form state.
*/
const reinitDependingSteps = (change: ChangeType) => {
reinitFileSystemConfiguratioStep(change);
reinitPackagesStep(change);
};
type ProfileSelectorProps = {
input: { name: string };
};
/**
* Component for the user to select the profile to apply to their image.
* The selected profile will be stored in the `oscap-profile` form state variable.
* The Component is shown or not depending on the ShowSelector variable.
*/
const ProfileSelector = ({ input }: ProfileSelectorProps) => {
const { change, getState } = useFormApi();
const [profileName, setProfileName] = useState<string>('None');
const [profile, setProfile] = useState(getState()?.values?.['oscap-profile']);
const [isOpen, setIsOpen] = useState(false);
const {
data: profiles,
isFetching,
isSuccess,
isError,
refetch,
} = useGetOscapProfilesQuery({
distribution: getState()?.values?.['release'],
});
const { data } = useGetOscapCustomizationsQuery(
{
distribution: getState()?.values?.['release'],
profile: profile,
},
{
skip: !profile,
}
);
useEffect(() => {
if (
data &&
data.openscap &&
typeof data.openscap.profile_name === 'string'
) {
setProfileName(data.openscap.profile_name);
}
if (data?.kernel) {
change('kernel', data.kernel);
}
if (data?.services?.enabled) {
change('enabledServices', data.services.enabled);
}
if (data?.services?.masked) {
change('maskedServices', data.services.masked);
}
}, [data, change]);
const handleToggle = () => {
if (!isOpen) {
refetch();
}
setIsOpen(!isOpen);
};
const handleClear = () => {
setProfile(undefined);
change(input.name, undefined);
change('kernel', undefined);
change('enabledServices', undefined);
change('maskedServices', undefined);
setProfileName('');
reinitDependingSteps(change);
};
const handleSelect = (_: React.MouseEvent, selection: string) => {
setProfile(selection);
setIsOpen(false);
change(input.name, selection);
reinitDependingSteps(change);
change('file-system-config-radio', 'manual');
};
const options = [
<OScapNoneOption setProfileName={setProfileName} key="oscap-none-option" />,
];
if (isSuccess) {
options.concat(
profiles.map((profile_id) => {
return (
<OScapSelectOption
key={profile_id}
profile_id={profile_id}
setProfileName={setProfileName}
/>
);
})
);
}
if (isFetching) {
options.push(
<SelectOption
isNoResultsOption={true}
data-testid="policies-loading"
key={'None'}
>
<Spinner size="md" />
</SelectOption>
);
}
return (
<FormGroup
isRequired={true}
data-testid="profiles-form-group"
label={
<>
OpenSCAP profile
<Popover
maxWidth="30rem"
position="left"
bodyContent={
<TextContent>
<Text>
To run a manual compliance scan in OpenSCAP, download this
image.
</Text>
</TextContent>
}
>
<Button variant="plain" aria-label="About OpenSCAP" isInline>
<HelpIcon />
</Button>
</Popover>
</>
}
>
<Select
ouiaId="profileSelect"
variant={SelectVariant.typeahead}
onToggle={handleToggle}
onSelect={handleSelect}
onClear={handleClear}
selections={profileName}
isOpen={isOpen}
placeholderText="Select a profile"
typeAheadAriaLabel="Select a profile"
isDisabled={!isSuccess}
onFilter={(_event, value) => {
if (profiles) {
return [
<OScapNoneOption
setProfileName={setProfileName}
key="oscap-none-option"
/>,
].concat(
profiles
// stig and stig_gui don't boot at the moment,
// so we should filter them out
.filter((profile_id) => {
const brokenProfiles = [
'xccdf_org.ssgproject.content_profile_stig',
'xccdf_org.ssgproject.content_profile_stig_gui',
];
return !brokenProfiles.includes(profile_id);
})
.map((profile_id, index) => {
return (
<OScapSelectOption
key={index}
profile_id={profile_id}
setProfileName={setProfileName}
input={value}
/>
);
})
);
}
}}
>
{options}
</Select>
{isError && (
<Alert
title="Error fetching the profiles"
variant="danger"
isPlain
isInline
>
Cannot get the list of profiles
</Alert>
)}
</FormGroup>
);
};
type OScapNoneOptionPropType = {
setProfileName: (name: string) => void;
};
const OScapNoneOption = ({ setProfileName }: OScapNoneOptionPropType) => {
return (
<SelectOption
value={undefined}
onClick={() => {
setProfileName('None');
}}
>
<p>{'None'}</p>
</SelectOption>
);
};
type OScapSelectOptionPropType = {
profile_id: DistributionProfileItem;
setProfileName: (name: string) => void;
input?: string;
};
const OScapSelectOption = ({
profile_id,
setProfileName,
input,
}: OScapSelectOptionPropType) => {
const { getState } = useFormApi();
const { data } = useGetOscapCustomizationsQuery({
distribution: getState()?.values?.['release'],
profile: profile_id,
});
if (
input &&
!data?.openscap?.profile_name?.toLowerCase().includes(input.toLowerCase())
) {
return null;
}
return (
<SelectOption
key={profile_id}
value={profile_id}
onClick={() => {
if (data?.openscap?.profile_name) {
setProfileName(data.openscap.profile_name);
}
}}
>
<p>{data?.openscap?.profile_name}</p>
</SelectOption>
);
};
type ProfileTypeProp = {
input: { name: string };
};
/**
* Component to prompt the use with two choices:
* - to add a profile, in which case the ProfileSelector will allow the user to
* pick a profile to be stored in the `oscap-profile` variable.
* - to not add a profile, in which case the `oscap-profile` form state goes
* undefined.
*/
const AddProfile = ({ input }: ProfileTypeProp) => {
return (
<>
<ProfileSelector input={input} />
<OscapProfileInformation />
</>
);
};
interface OscapProps extends UseFieldApiConfig {}
export const Oscap = (props: OscapProps) => {
const { input } = useFieldApi(props);
return <AddProfile input={input} />;
};

View file

@ -1,129 +0,0 @@
import React from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import {
Spinner,
TextContent,
TextList,
TextListItem,
TextListItemVariants,
TextListVariants,
CodeBlock,
CodeBlockCode,
Alert,
} from '@patternfly/react-core';
import { RELEASES } from '../../../constants';
import { useGetOscapCustomizationsQuery } from '../../../store/imageBuilderApi';
const OscapProfileInformation = (): JSX.Element => {
const { getState } = useFormApi();
const oscapProfile = getState()?.values?.['oscap-profile'];
const {
data: oscapProfileInfo,
isFetching: isFetchingOscapProfileInfo,
isSuccess: isSuccessOscapProfileInfo,
} = useGetOscapCustomizationsQuery(
{ distribution: getState()?.values?.['release'], profile: oscapProfile },
{
skip: !oscapProfile,
}
);
const enabledServicesDisplayString =
oscapProfileInfo?.services?.enabled?.join(' ');
const maskedServicesDisplayString =
oscapProfileInfo?.services?.masked?.join(' ');
return (
<>
{isFetchingOscapProfileInfo && <Spinner size="lg" />}
{isSuccessOscapProfileInfo && (
<>
<TextContent>
<br />
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Profile description:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{oscapProfileInfo.openscap?.profile_description}
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Operating system:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{RELEASES.get(getState()?.values?.['release'])}
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Reference ID:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{oscapProfileInfo.openscap?.profile_id}
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Kernel arguments:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
<CodeBlock>
<CodeBlockCode>
{oscapProfileInfo?.kernel?.append}
</CodeBlockCode>
</CodeBlock>
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Disabled services:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
<CodeBlock>
<CodeBlockCode>{maskedServicesDisplayString}</CodeBlockCode>
</CodeBlock>
</TextListItem>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Enabled services:
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
<CodeBlock>
<CodeBlockCode>{enabledServicesDisplayString}</CodeBlockCode>
</CodeBlock>
</TextListItem>
</TextList>
</TextContent>
<Alert
variant="info"
isInline
isPlain
title="Additional customizations"
>
Selecting an OpenSCAP profile will cause the appropriate packages,
file system configuration, kernel arguments, and services to be
added to your image.
</Alert>
</>
)}
</>
);
};
export default OscapProfileInformation;

View file

@ -1,533 +0,0 @@
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import {
Alert,
Divider,
DualListSelector,
DualListSelectorControl,
DualListSelectorControlsWrapper,
DualListSelectorList,
DualListSelectorListItem,
DualListSelectorPane,
SearchInput,
TextContent,
} from '@patternfly/react-core';
import {
AngleDoubleLeftIcon,
AngleDoubleRightIcon,
AngleLeftIcon,
AngleRightIcon,
} from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import api from '../../../api';
import {
useGetArchitecturesQuery,
useGetOscapCustomizationsQuery,
} from '../../../store/imageBuilderApi';
const ExactMatch = ({
pkgList,
search,
chosenPackages,
selectedAvailablePackages,
handleSelectAvailableFunc,
}) => {
const match = pkgList.find((pkg) => pkg.name === search);
return (
<DualListSelectorListItem
data-testid={`exact-match-${match.name}`}
isDisabled={chosenPackages[match.name] ? true : false}
isSelected={selectedAvailablePackages.has(match.name)}
onOptionSelect={(e) => handleSelectAvailableFunc(e, match.name)}
>
<TextContent key={`${match.name}`}>
<small className="pf-u-mb-sm">Exact match</small>
<span className="pf-c-dual-list-selector__item-text">{match.name}</span>
<small>{match.summary}</small>
<Divider />
</TextContent>
</DualListSelectorListItem>
);
};
export const RedHatPackages = ({ defaultArch }) => {
const { getState } = useFormApi();
const distribution = getState()?.values?.release;
const arch = getState()?.values?.arch;
const { data: distributionInformation, isSuccess: isSuccessDistroInfo } =
useGetArchitecturesQuery({ distribution });
const getAllPackages = async (packagesSearchName) => {
// if the env is stage beta then use content-sources api
// else use image-builder api
if (getState()?.values?.contentSourcesEnabled) {
const filteredByArch = distributionInformation.find(
(info) => info.arch === arch
);
const repoUrls = filteredByArch.repositories.map((repo) => repo.baseurl);
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
} else {
const args = [
getState()?.values?.release,
getState()?.values?.architecture || defaultArch,
packagesSearchName,
];
const response = await api.getPackages(...args);
let { data } = response;
const { meta } = response;
if (data?.length === meta.count) {
return data;
} else if (data) {
({ data } = await api.getPackages(...args, meta.count));
return data;
}
}
};
return (
<Packages getAllPackages={getAllPackages} isSuccess={isSuccessDistroInfo} />
);
};
export const ContentSourcesPackages = () => {
const { getState } = useFormApi();
const getAllPackages = async (packagesSearchName) => {
const repos = getState()?.values?.['payload-repositories'];
const repoUrls = repos?.map((repo) => repo.baseurl);
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
};
return <Packages getAllPackages={getAllPackages} />;
};
const Packages = ({ getAllPackages, isSuccess }) => {
const { currentStep } = useContext(WizardContext);
const { change, getState } = useFormApi();
const [packagesSearchName, setPackagesSearchName] = useState(undefined);
const [filterChosen, setFilterChosen] = useState('');
const [chosenPackages, setChosenPackages] = useState({});
const [focus, setFocus] = useState('');
const selectedPackages = getState()?.values?.['selected-packages'];
const [availablePackages, setAvailablePackages] = useState(undefined);
const [selectedAvailablePackages, setSelectedAvailablePackages] = useState(
new Set()
);
const [selectedChosenPackages, setSelectedChosenPackages] = useState(
new Set()
);
const firstInputElement = useRef(null);
const oscapProfile = getState()?.values?.['oscap-profile'];
const { data: customizations, isSuccess: isSuccessCustomizations } =
useGetOscapCustomizationsQuery(
{
distribution: getState()?.values?.['release'],
profile: oscapProfile,
},
{
skip: !oscapProfile,
}
);
useEffect(() => {
if (customizations && customizations.packages && isSuccessCustomizations) {
const oscapPackages = {};
for (const pkg of customizations.packages) {
oscapPackages[pkg] = { name: pkg };
}
updateState(oscapPackages);
}
}, [customizations, isSuccessCustomizations, updateState]);
// this effect only triggers on mount
useEffect(() => {
if (selectedPackages) {
const newChosenPackages = {};
for (const pkg of selectedPackages) {
newChosenPackages[pkg.name] = pkg;
}
setChosenPackages(newChosenPackages);
}
}, []);
useEffect(() => {
if (isSuccess) {
firstInputElement.current?.focus();
}
}, [isSuccess]);
const searchResultsComparator = useCallback((searchTerm) => {
return (a, b) => {
a = a.name.toLowerCase();
b = b.name.toLowerCase();
// check exact match first
if (a === searchTerm) {
return -1;
}
if (b === searchTerm) {
return 1;
}
// check for packages that start with the search term
if (a.startsWith(searchTerm) && !b.startsWith(searchTerm)) {
return -1;
}
if (b.startsWith(searchTerm) && !a.startsWith(searchTerm)) {
return 1;
}
// if both (or neither) start with the search term
// sort alphabetically
if (a < b) {
return -1;
}
if (b < a) {
return 1;
}
return 0;
};
}, []);
const availablePackagesDisplayList = useMemo(() => {
if (availablePackages === undefined) {
return [];
}
const availablePackagesList = Object.values(availablePackages).sort(
searchResultsComparator(packagesSearchName)
);
return availablePackagesList;
}, [availablePackages, packagesSearchName, searchResultsComparator]);
const chosenPackagesDisplayList = useMemo(() => {
const chosenPackagesList = Object.values(chosenPackages)
.filter((pkg) => (pkg.name.includes(filterChosen) ? true : false))
.sort(searchResultsComparator(filterChosen));
return chosenPackagesList;
}, [chosenPackages, filterChosen, searchResultsComparator]);
// call api to list available packages
const handleAvailablePackagesSearch = async () => {
const packageList = await getAllPackages(packagesSearchName);
// If no packages are found, Image Builder returns null, while
// Content Sources returns an empty array [].
if (packageList) {
const newAvailablePackages = {};
for (const pkg of packageList) {
newAvailablePackages[pkg.name] = pkg;
}
setAvailablePackages(newAvailablePackages);
} else {
setAvailablePackages([]);
}
};
const keydownHandler = (event) => {
if (event.key === 'Enter') {
if (focus === 'available') {
event.stopPropagation();
handleAvailablePackagesSearch();
}
}
};
useEffect(() => {
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
};
}, []);
const updateState = useCallback(
(newChosenPackages) => {
setSelectedAvailablePackages(new Set());
setSelectedChosenPackages(new Set());
setChosenPackages(newChosenPackages);
change('selected-packages', Object.values(newChosenPackages));
},
[change]
);
const moveSelectedToChosen = () => {
const newChosenPackages = { ...chosenPackages };
for (const pkgName of selectedAvailablePackages) {
newChosenPackages[pkgName] = { ...availablePackages[pkgName] };
}
updateState(newChosenPackages);
};
const moveAllToChosen = () => {
const newChosenPackages = { ...chosenPackages, ...availablePackages };
updateState(newChosenPackages);
};
const removeSelectedFromChosen = () => {
const newChosenPackages = {};
for (const pkgName in chosenPackages) {
if (!selectedChosenPackages.has(pkgName)) {
newChosenPackages[pkgName] = { ...chosenPackages[pkgName] };
}
}
updateState(newChosenPackages);
};
const removeAllFromChosen = () => {
const newChosenPackages = {};
updateState(newChosenPackages);
};
const handleSelectAvailable = (event, pkgName) => {
const newSelected = new Set(selectedAvailablePackages);
newSelected.has(pkgName)
? newSelected.delete(pkgName)
: newSelected.add(pkgName);
setSelectedAvailablePackages(newSelected);
};
const handleSelectChosen = (event, pkgName) => {
const newSelected = new Set(selectedChosenPackages);
newSelected.has(pkgName)
? newSelected.delete(pkgName)
: newSelected.add(pkgName);
setSelectedChosenPackages(newSelected);
};
const handleClearAvailableSearch = () => {
setPackagesSearchName('');
setAvailablePackages(undefined);
};
const handleClearChosenSearch = () => {
setFilterChosen('');
};
return (
<DualListSelector>
<DualListSelectorPane
title="Available packages"
searchInput={
<>
<SearchInput
placeholder="Search for a package"
data-testid="search-available-pkgs-input"
value={packagesSearchName}
ref={firstInputElement}
onFocus={() => setFocus('available')}
onBlur={() => setFocus('')}
onChange={(_, val) => setPackagesSearchName(val)}
submitSearchButtonLabel="Search button for available packages"
onSearch={handleAvailablePackagesSearch}
resetButtonLabel="Clear available packages search"
onClear={handleClearAvailableSearch}
isDisabled={currentStep.name === 'packages' ? !isSuccess : false}
/>
{availablePackagesDisplayList.length >= 100 && (
<Alert
title="Over 100 results found. Refine your search."
variant="warning"
isPlain
isInline
/>
)}
</>
}
status={
selectedAvailablePackages.size > 0
? `${selectedAvailablePackages.size}
of ${availablePackagesDisplayList.length} items`
: `${availablePackagesDisplayList.length} items`
}
>
<DualListSelectorList data-testid="available-pkgs-list">
{availablePackages === undefined ? (
<p className="pf-u-text-align-center pf-u-mt-md">
Search above to add additional
<br />
packages to your image
</p>
) : availablePackagesDisplayList.length === 0 ? (
<>
<p className="pf-u-text-align-center pf-u-mt-md pf-u-font-size-lg pf-u-font-weight-bold">
No results found
</p>
<br />
<p className="pf-u-text-align-center pf-u-mt-md">
Adjust your search and try again
</p>
</>
) : availablePackagesDisplayList.length >= 100 ? (
<>
{availablePackagesDisplayList.some(
(pkg) => pkg.name === packagesSearchName
) && (
<ExactMatch
pkgList={availablePackagesDisplayList}
search={packagesSearchName}
chosenPackages={chosenPackages}
selectedAvailablePackages={selectedAvailablePackages}
handleSelectAvailableFunc={handleSelectAvailable}
/>
)}
<p className="pf-u-text-align-center pf-u-mt-md pf-u-font-size-lg pf-u-font-weight-bold">
Too many results to display
</p>
<br />
<p className="pf-u-text-align-center pf-u-mt-md">
Please make the search more specific
<br />
and try again
</p>
</>
) : (
availablePackagesDisplayList.map((pkg) => {
return (
<DualListSelectorListItem
data-testid={`available-pkgs-${pkg.name}`}
key={pkg.name}
isDisabled={chosenPackages[pkg.name] ? true : false}
isSelected={selectedAvailablePackages.has(pkg.name)}
onOptionSelect={(e) => handleSelectAvailable(e, pkg.name)}
>
<TextContent key={`${pkg.name}`}>
<span
className={
chosenPackages[pkg.name] && 'pf-v5-u-color-400'
}
>
{pkg.name}
</span>
<small>{pkg.summary}</small>
</TextContent>
</DualListSelectorListItem>
);
})
)}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper aria-label="Selector controls">
<DualListSelectorControl
isDisabled={selectedAvailablePackages.size === 0}
onClick={() => moveSelectedToChosen()}
aria-label="Add selected"
tooltipContent="Add selected"
>
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={
availablePackagesDisplayList.length === 0 ||
// also disable the "Add all" button if there are too many matches
// (even if there's an exact match)
availablePackagesDisplayList.length >= 100
}
onClick={() => moveAllToChosen()}
aria-label="Add all"
tooltipContent="Add all"
>
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={Object.values(chosenPackages).length === 0}
onClick={() => removeAllFromChosen()}
aria-label="Remove all"
tooltipContent="Remove all"
>
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
onClick={() => removeSelectedFromChosen()}
isDisabled={selectedChosenPackages.size === 0}
aria-label="Remove selected"
tooltipContent="Remove selected"
>
<AngleLeftIcon />
</DualListSelectorControl>
</DualListSelectorControlsWrapper>
<DualListSelectorPane
title="Chosen packages"
searchInput={
<SearchInput
placeholder="Search for a package"
data-testid="search-chosen-pkgs-input"
value={filterChosen}
onFocus={() => setFocus('chosen')}
onBlur={() => setFocus('')}
onChange={(_, val) => setFilterChosen(val)}
resetButtonLabel="Clear chosen packages search"
onClear={handleClearChosenSearch}
/>
}
status={
selectedChosenPackages.size > 0
? `${selectedChosenPackages.size}
of ${chosenPackagesDisplayList.length} items`
: `${chosenPackagesDisplayList.length} items`
}
isChosen
>
<DualListSelectorList data-testid="chosen-pkgs-list">
{Object.values(chosenPackages).length === 0 ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages added
</p>
) : chosenPackagesDisplayList.length === 0 ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages found
</p>
) : (
chosenPackagesDisplayList.map((pkg) => {
return (
<DualListSelectorListItem
data-testid={`selected-pkgs-${pkg.name}`}
key={pkg.name}
isSelected={selectedChosenPackages.has(pkg.name)}
onOptionSelect={(e) => handleSelectChosen(e, pkg.name)}
>
<TextContent key={`${pkg.name}`}>
<span className="pf-c-dual-list-selector__item-text">
{pkg.name}
</span>
<small>{pkg.summary}</small>
</TextContent>
</DualListSelectorListItem>
);
})
)}
</DualListSelectorList>
</DualListSelectorPane>
</DualListSelector>
);
};
ExactMatch.propTypes = {
pkgList: PropTypes.arrayOf(PropTypes.object),
search: PropTypes.string,
chosenPackages: PropTypes.object,
selectedAvailablePackages: PropTypes.object,
handleSelectAvailableFunc: PropTypes.func,
};
RedHatPackages.propTypes = {
defaultArch: PropTypes.string,
};
Packages.propTypes = {
getAllPackages: PropTypes.func,
isSuccess: PropTypes.bool,
};

View file

@ -1,26 +0,0 @@
import React, { useRef } from 'react';
import Radio from '@data-driven-forms/pf4-component-mapper/radio';
import PropTypes from 'prop-types';
const RadioWithPopover = ({ Popover, ...props }) => {
const ref = useRef();
return (
<Radio
{...props}
label={
<span ref={ref} className="ins-c-image--builder__popover">
{props.label}
<Popover />
</span>
}
/>
);
};
RadioWithPopover.propTypes = {
Popover: PropTypes.elementType.isRequired,
label: PropTypes.node,
};
export default RadioWithPopover;

View file

@ -1,258 +0,0 @@
import React, { useState } from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
Button,
Checkbox,
FormGroup,
Popover,
Radio,
Text,
TextContent,
} from '@patternfly/react-core';
import { HelpIcon, ExternalLinkAltIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import {
INSIGHTS_URL,
RHC_URL,
SUBSCRIPTION_MANAGEMENT_URL,
} from '../../../constants';
const RHSMPopover = () => {
return (
<Popover
headerContent="About Red Hat Subscription Management"
position="right"
minWidth="30rem"
bodyContent={
<TextContent>
<Text>
Registered systems are entitled to support services, errata,
patches, and upgrades.
</Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={SUBSCRIPTION_MANAGEMENT_URL}
>
Learn more about Red Hat Subscription Management
</Button>
</TextContent>
}
>
<Button
variant="plain"
className="pf-c-form__group-label-help"
aria-label="About remote host configuration (rhc)"
isInline
>
<HelpIcon />
</Button>
</Popover>
);
};
const InsightsPopover = () => {
return (
<Popover
headerContent="About Red Hat Insights"
position="right"
minWidth="30rem"
bodyContent={
<TextContent>
<Text>
Red Hat Insights client provides actionable intelligence about your
Red Hat Enterprise Linux environments, helping to identify and
address operational and vulnerability risks before an issue results
in downtime.
</Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={INSIGHTS_URL}
>
Learn more about Red Hat Insights
</Button>
</TextContent>
}
>
<Button
variant="plain"
className="pf-c-form__group-label-help"
aria-label="About remote host configuration (rhc)"
isInline
>
<HelpIcon />
</Button>
</Popover>
);
};
const RhcPopover = () => {
return (
<Popover
headerContent="About remote host configuration (rhc)"
position="right"
minWidth="30rem"
bodyContent={
<TextContent>
<Text>
Remote host configuration allows Red Hat Enterprise Linux hosts to
connect to Red Hat Insights. Remote host configuration is required
to use the Red Hat Insights Remediations service.
</Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={RHC_URL}
>
Learn more about remote host configuration
</Button>
</TextContent>
}
>
<Button
variant="plain"
className="pf-c-form__group-label-help"
aria-label="About remote host configuration (rhc)"
isInline
>
<HelpIcon />
</Button>
</Popover>
);
};
const Registration = ({ label, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const registerSystem = getState()?.values?.['register-system'];
const [showOptions, setShowOptions] = useState(
registerSystem === 'register-now-insights' ||
registerSystem === 'register-now'
);
return (
<FormSpy>
{() => (
<FormGroup label={label}>
<Radio
autoFocus
label={
(!showOptions &&
'Automatically register and enable advanced capabilities') || (
<>
Monitor & manage subscriptions and access to Red Hat content
<RHSMPopover />
</>
)
}
data-testid="registration-radio-now"
name="register-system"
id="register-system-now"
isChecked={registerSystem.startsWith('register-now')}
onChange={() => {
change(input.name, 'register-now-rhc');
}}
description={
!showOptions && (
<Button
component="a"
data-testid="registration-additional-options"
variant="link"
isDisabled={!registerSystem.startsWith('register-now')}
isInline
onClick={() => setShowOptions(!showOptions)}
>
Show additional connection options
</Button>
)
}
body={
showOptions && (
<Checkbox
className="pf-u-ml-lg"
label={
<>
Enable predictive analytics and management capabilities
<InsightsPopover />
</>
}
data-testid="registration-checkbox-insights"
isChecked={
registerSystem === 'register-now-insights' ||
registerSystem === 'register-now-rhc'
}
onChange={(_event, checked) => {
if (checked) {
change(input.name, 'register-now-insights');
} else {
change(input.name, 'register-now');
}
}}
id="register-system-now-insights"
name="register-system-insights"
body={
<Checkbox
label={
<>
Enable remote remediations and system management with
automation
<RhcPopover />
</>
}
data-testid="registration-checkbox-rhc"
isChecked={registerSystem === 'register-now-rhc'}
onChange={(_event, checked) => {
if (checked) {
change(input.name, 'register-now-rhc');
} else {
change(input.name, 'register-now-insights');
}
}}
id="register-system-now-rhc"
name="register-system-rhc"
/>
}
/>
)
}
/>
<Radio
name="register-system"
className="pf-u-mt-md"
data-testid="registration-radio-later"
id="register-system-later"
label="Register later"
isChecked={registerSystem === 'register-later'}
onChange={() => {
setShowOptions(false);
change(input.name, 'register-later');
}}
/>
</FormGroup>
)}
</FormSpy>
);
};
Registration.propTypes = {
label: PropTypes.node,
};
export default Registration;

View file

@ -1,29 +0,0 @@
import React from 'react';
import { FormSpy } from '@data-driven-forms/react-form-renderer';
import { FormGroup } from '@patternfly/react-core';
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import ActivationKeyInformation from './ActivationKeyInformation';
const RegistrationKeyInformation = ({ label, valueReference }) => {
return (
<FormSpy>
{({ values }) =>
isEmpty(values[valueReference]) ? null : (
<FormGroup label={label}>
<ActivationKeyInformation />
</FormGroup>
)
}
</FormSpy>
);
};
RegistrationKeyInformation.propTypes = {
label: PropTypes.node,
valueReference: PropTypes.node,
};
export default RegistrationKeyInformation;

View file

@ -1,184 +0,0 @@
import React, { useContext } from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import {
Button,
ExpandableSection,
FormGroup,
Panel,
PanelMain,
Text,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { Chart, registerables } from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { Bar } from 'react-chartjs-2';
import {
RELEASES,
RELEASE_LIFECYCLE_URL,
RHEL_8,
RHEL_8_FULL_SUPPORT,
RHEL_8_MAINTENANCE_SUPPORT,
RHEL_9,
RHEL_9_FULL_SUPPORT,
RHEL_9_MAINTENANCE_SUPPORT,
} from '../../../constants';
import 'chartjs-adapter-moment';
import { toMonthAndYear } from '../../../Utilities/time';
Chart.register(annotationPlugin);
Chart.register(...registerables);
const currentDate = new Date().toISOString();
export const chartMajorVersionCfg = {
data: {
labels: ['RHEL 9', 'RHEL 8'],
datasets: [
{
label: 'Full support',
backgroundColor: '#0066CC',
data: [
{
x: RHEL_9_FULL_SUPPORT,
y: 'RHEL 9',
},
{
x: RHEL_8_FULL_SUPPORT,
y: 'RHEL 8',
},
],
},
{
label: 'Maintenance support',
backgroundColor: '#8BC1F7',
data: [
{
x: RHEL_9_MAINTENANCE_SUPPORT,
y: 'RHEL 9',
},
{
x: RHEL_8_MAINTENANCE_SUPPORT,
y: 'RHEL 8',
},
],
},
],
},
options: {
indexAxis: 'y' as const,
scales: {
x: {
type: 'time' as const,
time: {
unit: 'year' as const,
},
min: '2019-01-01' as const,
max: '2033-01-01' as const,
},
y: {
stacked: true,
},
},
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
enabled: false,
},
legend: {
position: 'bottom' as const,
},
annotation: {
annotations: {
today: {
type: 'line' as const,
xMin: currentDate,
xMax: currentDate,
borderColor: 'black',
borderWidth: 2,
borderDash: [8, 2],
},
},
},
},
},
};
export const MajorReleasesLifecyclesChart = () => {
return (
<Panel>
<PanelMain maxHeight="10rem">
<Bar
data-testid="release-lifecycle-chart"
options={chartMajorVersionCfg.options}
data={chartMajorVersionCfg.data}
/>
</PanelMain>
</Panel>
);
};
const ReleaseLifecycle = () => {
const { getState } = useFormApi();
const { currentStep } = useContext(WizardContext);
const release = getState().values.release;
const [isExpanded, setIsExpanded] = React.useState(true);
const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => {
setIsExpanded(isExpanded);
};
if (release === RHEL_8) {
if (currentStep.name === 'image-output') {
return (
<ExpandableSection
toggleText={
isExpanded
? 'Hide information about release lifecycle'
: 'Show information about release lifecycle'
}
onToggle={onToggle}
isExpanded={isExpanded}
isIndented
>
<FormGroup label="Release lifecycle">
<MajorReleasesLifecyclesChart />
</FormGroup>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={RELEASE_LIFECYCLE_URL}
>
View Red Hat Enterprise Linux Life Cycle dates
</Button>
</ExpandableSection>
);
} else if (currentStep.name === 'review') {
return (
<>
<Text className="pf-v5-u-font-size-sm">
{RELEASES.get(release)} will be supported through{' '}
{toMonthAndYear(RHEL_8_FULL_SUPPORT[1])}, with optional ELS support
through {toMonthAndYear(RHEL_8_MAINTENANCE_SUPPORT[1])}. Consider
building an image with {RELEASES.get(RHEL_9)} to extend the support
period.
</Text>
<FormGroup label="Release lifecycle">
<MajorReleasesLifecyclesChart />
</FormGroup>
<br />
</>
);
}
}
};
export default ReleaseLifecycle;

View file

@ -1,612 +0,0 @@
import React, { useMemo, useState } from 'react';
import {
useFieldApi,
useFormApi,
} from '@data-driven-forms/react-form-renderer';
import {
Alert,
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateVariant,
Pagination,
Panel,
PanelMain,
SearchInput,
Spinner,
Toolbar,
ToolbarContent,
ToolbarItem,
EmptyStateHeader,
EmptyStateFooter,
ToggleGroup,
ToggleGroupItem,
PaginationVariant,
} from '@patternfly/react-core';
import {
Dropdown,
DropdownItem,
DropdownToggle,
DropdownToggleCheckbox,
} from '@patternfly/react-core/deprecated';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { RepositoryIcon } from '@patternfly/react-icons';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import PropTypes from 'prop-types';
import RepositoriesStatus from './RepositoriesStatus';
import RepositoryUnavailable from './RepositoryUnavailable';
import { useListRepositoriesQuery } from '../../../store/contentSourcesApi';
import { releaseToVersion } from '../../../Utilities/releaseToVersion';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
const BulkSelect = ({
selected,
count,
filteredCount,
perPage,
handleSelectAll,
handleSelectPage,
handleDeselectAll,
isDisabled,
}) => {
const [dropdownIsOpen, setDropdownIsOpen] = useState(false);
const numSelected = selected.length;
const allSelected = count !== 0 ? numSelected === count : undefined;
const anySelected = numSelected > 0;
const someChecked = anySelected ? null : false;
const isChecked = allSelected ? true : someChecked;
const items = [
<DropdownItem
key="none"
onClick={handleDeselectAll}
>{`Select none (0 items)`}</DropdownItem>,
<DropdownItem key="page" onClick={handleSelectPage}>{`Select page (${
perPage > filteredCount ? filteredCount : perPage
} items)`}</DropdownItem>,
<DropdownItem
key="all"
onClick={handleSelectAll}
>{`Select all (${count} items)`}</DropdownItem>,
];
const handleDropdownSelect = () => {};
const toggleDropdown = () => setDropdownIsOpen(!dropdownIsOpen);
return (
<Dropdown
onSelect={handleDropdownSelect}
toggle={
<DropdownToggle
id="stacked-example-toggle"
isDisabled={isDisabled}
splitButtonItems={[
<DropdownToggleCheckbox
id="example-checkbox-1"
key="split-checkbox"
aria-label="Select all"
isChecked={isChecked}
onClick={() => {
anySelected ? handleDeselectAll() : handleSelectAll();
}}
/>,
]}
onToggle={toggleDropdown}
>
{numSelected !== 0 ? `${numSelected} selected` : null}
</DropdownToggle>
}
isOpen={dropdownIsOpen}
dropdownItems={items}
/>
);
};
// Utility function to convert from Content Sources to Image Builder payload repo API schema
const convertSchemaToIBPayloadRepo = (repo) => {
const imageBuilderRepo = {
baseurl: repo.url,
rhsm: false,
check_gpg: false,
};
// only include the flag if enabled
if (repo.module_hotfixes) {
imageBuilderRepo.module_hotfixes = repo.module_hotfixes;
}
if (repo.gpg_key) {
imageBuilderRepo.gpgkey = repo.gpg_key;
imageBuilderRepo.check_gpg = true;
imageBuilderRepo.check_repo_gpg = repo.metadata_verification;
}
return imageBuilderRepo;
};
// Utility function to convert from Content Sources to Image Builder custom repo API schema
const convertSchemaToIBCustomRepo = (repo) => {
const imageBuilderRepo = {
id: repo.uuid,
name: repo.name,
baseurl: [repo.url],
check_gpg: false,
};
// only include the flag if enabled
if (repo.module_hotfixes) {
imageBuilderRepo.module_hotfixes = repo.module_hotfixes;
}
if (repo.gpg_key) {
imageBuilderRepo.gpgkey = [repo.gpg_key];
imageBuilderRepo.check_gpg = true;
imageBuilderRepo.check_repo_gpg = repo.metadata_verification;
}
return imageBuilderRepo;
};
// Utility function to convert from Image Builder to Content Sources API schema
const convertSchemaToContentSources = (repo) => {
const contentSourcesRepo = {
url: repo.baseurl,
rhsm: false,
};
if (repo.gpgkey) {
contentSourcesRepo.gpg_key = repo.gpgkey;
contentSourcesRepo.metadata_verification = repo.check_repo_gpg;
}
return contentSourcesRepo;
};
const Repositories = (props) => {
const initializeRepositories = (contentSourcesReposList) => {
// Convert list of repositories into an object where key is repo URL
const contentSourcesRepos = contentSourcesReposList.reduce(
(accumulator, currentValue) => {
accumulator[currentValue.url] = currentValue;
return accumulator;
},
{}
);
// Repositories in the form state can be present when 'Recreate image' is used
// to open the wizard that are not necessarily in content sources.
const formStateReposList =
getState()?.values?.['original-payload-repositories'];
const mergeRepositories = (contentSourcesRepos, formStateReposList) => {
const formStateRepos = {};
for (const repo of formStateReposList) {
formStateRepos[repo.baseurl] = convertSchemaToContentSources(repo);
formStateRepos[repo.baseurl].name = '';
}
// In case of duplicate repo urls, the repo from Content Sources overwrites the
// repo from the form state.
const mergedRepos = { ...formStateRepos, ...contentSourcesRepos };
return mergedRepos;
};
const repositories = formStateReposList
? mergeRepositories(contentSourcesRepos, formStateReposList)
: contentSourcesRepos;
return repositories;
};
const { getState, change } = useFormApi();
const { input } = useFieldApi(props);
const [filterValue, setFilterValue] = useState('');
const [perPage, setPerPage] = useState(10);
const [page, setPage] = useState(1);
const [toggleSelected, setToggleSelected] = useState('toggle-group-all');
const [selected, setSelected] = useState(
getState()?.values?.['payload-repositories']
? getState().values['payload-repositories'].map((repo) => repo.baseurl)
: []
);
const arch = getState().values?.arch;
const release = getState().values?.release;
const version = releaseToVersion(release);
const firstRequest = useListRepositoriesQuery(
{
availableForArch: arch,
availableForVersion: version,
contentType: 'rpm',
origin: 'external',
limit: 100,
offset: 0,
},
// The cached repos may be incorrect, for now refetch on mount to ensure that
// they are accurate when this step loads. Future PR will implement prefetching
// and this can be removed.
{ refetchOnMountOrArgChange: true }
);
const skip =
firstRequest?.data?.meta?.count === undefined ||
firstRequest?.data?.meta?.count <= 100;
// Fetch *all* repositories if there are more than 100 so that typeahead filter works
const followupRequest = useListRepositoriesQuery(
{
availableForArch: arch,
availableForVersion: version,
contentType: 'rpm',
origin: 'external',
limit: firstRequest?.data?.meta?.count,
offset: 0,
},
{
refetchOnMountOrArgChange: true,
skip: skip,
}
);
const { data, isError, isFetching, isLoading, isSuccess, refetch } =
useMemo(() => {
if (firstRequest?.data?.meta?.count > 100) {
return { ...followupRequest };
}
return { ...firstRequest };
}, [firstRequest, followupRequest]);
const repositories = useMemo(() => {
return data ? initializeRepositories(data.data) : {};
}, [firstRequest.data, followupRequest.data]);
const handleToggleClick = (event) => {
const id = event.currentTarget.id;
setPage(1);
setToggleSelected(id);
};
const isRepoSelected = (repoURL) => selected.includes(repoURL);
const handlePerPageSelect = (event, newPerPage, newPage) => {
setPerPage(newPerPage);
setPage(newPage);
};
const handleSetPage = (event, newPage) => {
setPage(newPage);
};
// filter displayed selected packages
const handleFilterRepositories = (_, value) => {
setPage(1);
setFilterValue(value);
};
const filteredRepositoryURLs = useMemo(() => {
const repoUrls = Object.values(repositories).filter((repo) =>
repo.name.toLowerCase().includes(filterValue.toLowerCase())
);
if (toggleSelected === 'toggle-group-all') {
return repoUrls.map((repo) => repo.url);
} else if (toggleSelected === 'toggle-group-selected') {
return repoUrls
.filter((repo) => isRepoSelected(repo.url))
.map((repo) => repo.url);
}
}, [filterValue, repositories, toggleSelected]);
const handleClearFilter = () => {
setFilterValue('');
};
const updateFormState = (selectedRepoURLs) => {
// repositories is stored as an object with repoURLs as keys
const selectedRepos = [];
for (const repoURL of selectedRepoURLs) {
selectedRepos.push(repositories[repoURL]);
}
const payloadRepositories = selectedRepos.map((repo) =>
convertSchemaToIBPayloadRepo(repo)
);
const customRepositories = selectedRepos.map((repo) =>
convertSchemaToIBCustomRepo(repo)
);
input.onChange(payloadRepositories);
change('custom-repositories', customRepositories);
};
const updateSelected = (selectedRepos) => {
setSelected(selectedRepos);
updateFormState(selectedRepos);
};
const handleSelect = (repoURL, rowIndex, isSelecting) => {
if (isSelecting === true) {
updateSelected([...selected, repoURL]);
} else if (isSelecting === false) {
updateSelected(
selected.filter((selectedRepoId) => selectedRepoId !== repoURL)
);
}
};
const handleSelectAll = () => {
updateSelected(Object.keys(repositories));
};
const computeStart = () => perPage * (page - 1);
const computeEnd = () => perPage * page;
const handleSelectPage = () => {
const pageRepos = filteredRepositoryURLs.slice(
computeStart(),
computeEnd()
);
// Filter to avoid adding duplicates
const newSelected = [
...pageRepos.filter((repoId) => !selected.includes(repoId)),
];
updateSelected([...selected, ...newSelected]);
};
const handleDeselectAll = () => {
updateSelected([]);
};
return (
(isError && <Error />) ||
(isLoading && <Loading />) ||
(isSuccess && (
<>
{Object.values(repositories).length === 0 ? (
<Empty refetch={refetch} isFetching={isFetching} />
) : (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem variant="bulk-select">
<BulkSelect
selected={selected}
count={Object.values(repositories).length}
filteredCount={filteredRepositoryURLs.length}
perPage={perPage}
handleSelectAll={handleSelectAll}
handleSelectPage={handleSelectPage}
handleDeselectAll={handleDeselectAll}
isDisabled={isFetching}
/>
</ToolbarItem>
<ToolbarItem variant="search-filter">
<SearchInput
aria-label="Search repositories"
onChange={handleFilterRepositories}
value={filterValue}
onClear={handleClearFilter}
/>
</ToolbarItem>
<ToolbarItem>
<Button
variant="primary"
isInline
onClick={() => refetch()}
isLoading={isFetching}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</ToolbarItem>
<ToolbarItem>
<ToggleGroup aria-label="Filter repositories list">
<ToggleGroupItem
text="All"
aria-label="All repositories"
buttonId="toggle-group-all"
isSelected={toggleSelected === 'toggle-group-all'}
onChange={handleToggleClick}
/>
<ToggleGroupItem
text="Selected"
aria-label="Selected repositories"
buttonId="toggle-group-selected"
isSelected={toggleSelected === 'toggle-group-selected'}
onChange={handleToggleClick}
/>
</ToggleGroup>
</ToolbarItem>
<ToolbarItem variant="pagination">
<Pagination
itemCount={filteredRepositoryURLs.length}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
onPerPageSelect={handlePerPageSelect}
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Panel>
<PanelMain>
<RepositoryUnavailable />
<Table variant="compact" data-testid="repositories-table">
<Thead>
<Tr>
<Th aria-label="Selected" />
<Th width={45}>Name</Th>
<Th width={15}>Architecture</Th>
<Th>Version</Th>
<Th width={10}>Packages</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{filteredRepositoryURLs
.sort((a, b) => {
if (repositories[a].name < repositories[b].name) {
return -1;
} else if (
repositories[b].name < repositories[a].name
) {
return 1;
} else {
return 0;
}
})
.slice(computeStart(), computeEnd())
.map((repoURL, rowIndex) => {
const repo = repositories[repoURL];
const repoExists = repo.name ? true : false;
return (
<Tr key={repo.url}>
<Td
select={{
isSelected: isRepoSelected(repo.url),
rowIndex: rowIndex,
onSelect: (event, isSelecting) =>
handleSelect(repo.url, rowIndex, isSelecting),
isDisabled:
isFetching || repo.status !== 'Valid',
}}
/>
<Td dataLabel={'Name'}>
{repoExists
? repo.name
: 'Repository with the following url is no longer available:'}
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={repo.url}
>
{repo.url}
</Button>
</Td>
<Td dataLabel={'Architecture'}>
{repoExists ? repo.distribution_arch : '-'}
</Td>
<Td dataLabel={'Version'}>
{repoExists ? repo.distribution_versions : '-'}
</Td>
<Td dataLabel={'Packages'}>
{repoExists ? repo.package_count : '-'}
</Td>
<Td dataLabel={'Status'}>
<RepositoriesStatus
repoStatus={
repoExists ? repo.status : 'Unavailable'
}
repoUrl={repo.url}
repoIntrospections={
repo.last_introspection_time
}
repoFailCount={repo.failed_introspections_count}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</PanelMain>
</Panel>
<Pagination
itemCount={filteredRepositoryURLs.length}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</>
)}
</>
))
);
};
const Error = () => {
return (
<Alert title="Repositories unavailable" variant="danger" isPlain isInline>
Repositories cannot be reached, try again later.
</Alert>
);
};
const Loading = () => {
return (
<EmptyState>
<EmptyStateHeader
titleText="Loading"
icon={<EmptyStateIcon icon={Spinner} />}
headingLevel="h4"
/>
</EmptyState>
);
};
const Empty = ({ isFetching, refetch }) => {
const { isBeta } = useGetEnvironment();
return (
<EmptyState variant={EmptyStateVariant.lg} data-testid="empty-state">
<EmptyStateHeader
titleText="No Custom Repositories"
icon={<EmptyStateIcon icon={RepositoryIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>
Repositories can be added in the &quot;Repositories&quot; area of the
console. Once added, refresh this page to see them.
</EmptyStateBody>
<EmptyStateFooter>
<Button
variant="primary"
component="a"
target="_blank"
href={isBeta() ? '/preview/settings/content' : '/settings/content'}
className="pf-u-mr-sm"
>
Go to repositories
</Button>
<Button
variant="secondary"
isInline
onClick={() => refetch()}
isLoading={isFetching}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</EmptyStateFooter>
</EmptyState>
);
};
BulkSelect.propTypes = {
selected: PropTypes.array,
count: PropTypes.number,
filteredCount: PropTypes.number,
perPage: PropTypes.number,
handleSelectAll: PropTypes.func,
handleSelectPage: PropTypes.func,
handleDeselectAll: PropTypes.func,
isDisabled: PropTypes.bool,
};
Empty.propTypes = {
isFetching: PropTypes.bool,
refetch: PropTypes.func,
};
export default Repositories;

View file

@ -1,151 +0,0 @@
import React from 'react';
import {
Alert,
Button,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
Popover,
} from '@patternfly/react-core';
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
ExternalLinkAltIcon,
InProgressIcon,
} from '@patternfly/react-icons';
import { ApiRepositoryResponse } from '../../../store/contentSourcesApi';
import {
convertStringToDate,
timestampToDisplayString,
} from '../../../Utilities/time';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
const getLastIntrospection = (
repoIntrospections: RepositoryStatusProps['repoIntrospections']
) => {
const currentDate = Date.now();
const lastIntrospectionDate = convertStringToDate(repoIntrospections);
const timeDeltaInSeconds = Math.floor(
(currentDate - lastIntrospectionDate) / 1000
);
if (timeDeltaInSeconds <= 60) {
return 'A few seconds ago';
} else if (timeDeltaInSeconds <= 60 * 60) {
return 'A few minutes ago';
} else if (timeDeltaInSeconds <= 60 * 60 * 24) {
return 'A few hours ago';
} else {
return timestampToDisplayString(repoIntrospections);
}
};
type RepositoryStatusProps = {
repoStatus: ApiRepositoryResponse['status'];
repoUrl: ApiRepositoryResponse['url'];
repoIntrospections: ApiRepositoryResponse['last_introspection_time'];
repoFailCount: ApiRepositoryResponse['failed_introspections_count'];
};
const RepositoriesStatus = ({
repoStatus,
repoUrl,
repoIntrospections,
repoFailCount,
}: RepositoryStatusProps) => {
const { isBeta } = useGetEnvironment();
if (repoStatus === 'Valid') {
return (
<>
<CheckCircleIcon className="success" /> {repoStatus}
</>
);
} else if (repoStatus === 'Invalid' || repoStatus === 'Unavailable') {
return (
<>
<Popover
position="bottom"
minWidth="30rem"
bodyContent={
<>
<Alert
variant={repoStatus === 'Invalid' ? 'danger' : 'warning'}
title={repoStatus}
className="pf-u-pb-sm"
isInline
isPlain
/>
<p className="pf-u-pb-md">Cannot fetch {repoUrl}</p>
{(repoIntrospections || repoFailCount) && (
<>
<DescriptionList
columnModifier={{
default: '2Col',
}}
>
{repoIntrospections && (
<DescriptionListGroup>
<DescriptionListTerm>
Last introspection
</DescriptionListTerm>
<DescriptionListDescription>
{getLastIntrospection(repoIntrospections)}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{repoFailCount && (
<DescriptionListGroup>
<DescriptionListTerm>
Failed attempts
</DescriptionListTerm>
<DescriptionListDescription>
{repoFailCount}
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
<br />
</>
)}
<Button
component="a"
target="_blank"
variant="link"
iconPosition="right"
isInline
icon={<ExternalLinkAltIcon />}
href={
isBeta() ? '/preview/settings/content' : '/settings/content'
}
>
Go to Repositories
</Button>
</>
}
>
<Button variant="link" className="pf-u-p-0 pf-u-font-size-sm">
{repoStatus === 'Invalid' && (
<ExclamationCircleIcon className="error" />
)}
{repoStatus === 'Unavailable' && (
<ExclamationTriangleIcon className="expiring" />
)}{' '}
<span className="failure-button">{repoStatus}</span>
</Button>
</Popover>
</>
);
} else if (repoStatus === 'Pending') {
return (
<>
<InProgressIcon className="pending" /> {repoStatus}
</>
);
}
};
export default RepositoriesStatus;

View file

@ -1,42 +0,0 @@
import React from 'react';
import { Alert, Button } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { useCheckRepositoriesAvailability } from '../../../Utilities/checkRepositoriesAvailability';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
const RepositoryUnavailable = () => {
const { isBeta } = useGetEnvironment();
if (useCheckRepositoriesAvailability()) {
return (
<Alert
variant="warning"
title="Previously added custom repository unavailable"
isInline
>
A repository that was used to build this image previously is not
available. Address the error found in the last introspection and
validate that the repository is still accessible.
<br />
<br />
<Button
component="a"
target="_blank"
variant="link"
iconPosition="right"
isInline
icon={<ExternalLinkAltIcon />}
href={isBeta() ? '/preview/settings/content' : '/settings/content'}
>
Go to Repositories
</Button>
</Alert>
);
} else {
return;
}
};
export default RepositoryUnavailable;

View file

@ -1,211 +0,0 @@
import React, { useEffect, useState } from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
ExpandableSection,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
import OscapProfileInformation from './OscapProfileInformation';
import RepositoryUnavailable from './RepositoryUnavailable';
import {
ContentList,
FSCList,
ImageDetailsList,
ImageOutputList,
RegisterLaterList,
RegisterNowList,
TargetEnvAWSList,
TargetEnvAzureList,
TargetEnvGCPList,
TargetEnvOciList,
TargetEnvOtherList,
} from './ReviewStepTextLists';
import UsrSubDirectoriesDisabled from './UsrSubDirectoriesDisabled';
import isRhel from '../../../Utilities/isRhel';
const ReviewStep = () => {
const { auth } = useChrome();
const [isExpandedImageOutput, setIsExpandedImageOutput] = useState(false);
const [isExpandedTargetEnvs, setIsExpandedTargetEnvs] = useState(false);
const [isExpandedFSC, setIsExpandedFSC] = useState(false);
const [isExpandedContent, setIsExpandedContent] = useState(false);
const [isExpandedRegistration, setIsExpandedRegistration] = useState(false);
const [isExpandedImageDetail, setIsExpandedImageDetail] = useState(false);
const [isExpandedOscapDetail, setIsExpandedOscapDetail] = useState(false);
const { change, getState } = useFormApi();
useEffect(() => {
const registerSystem = getState()?.values?.['register-system'];
if (registerSystem?.startsWith('register-now')) {
(async () => {
const userData = await auth?.getUser();
const id = userData?.identity?.internal?.org_id;
change('subscription-organization-id', id);
})();
}
});
const onToggleImageOutput = (isExpandedImageOutput) =>
setIsExpandedImageOutput(isExpandedImageOutput);
const onToggleTargetEnvs = (isExpandedTargetEnvs) =>
setIsExpandedTargetEnvs(isExpandedTargetEnvs);
const onToggleFSC = (isExpandedFSC) => setIsExpandedFSC(isExpandedFSC);
const onToggleContent = (isExpandedContent) =>
setIsExpandedContent(isExpandedContent);
const onToggleRegistration = (isExpandedRegistration) =>
setIsExpandedRegistration(isExpandedRegistration);
const onToggleImageDetail = (isExpandedImageDetail) =>
setIsExpandedImageDetail(isExpandedImageDetail);
const onToggleOscapDetails = (isExpandedOscapDetail) =>
setIsExpandedOscapDetail(isExpandedOscapDetail);
return (
<>
<RepositoryUnavailable />
{getState()?.values?.['file-system-configuration']?.find((mp) =>
mp.mountpoint.includes('/usr')
) && <UsrSubDirectoriesDisabled />}
<ExpandableSection
toggleContent={'Image output'}
onToggle={(_event, isExpandedImageOutput) =>
onToggleImageOutput(isExpandedImageOutput)
}
isExpanded={isExpandedImageOutput}
isIndented
data-testid="image-output-expandable"
>
<ImageOutputList />
</ExpandableSection>
<ExpandableSection
toggleContent={'Target environments'}
onToggle={(_event, isExpandedTargetEnvs) =>
onToggleTargetEnvs(isExpandedTargetEnvs)
}
isExpanded={isExpandedTargetEnvs}
isIndented
data-testid="target-environments-expandable"
>
{getState()?.values?.['target-environment']?.aws && (
<TargetEnvAWSList />
)}
{getState()?.values?.['target-environment']?.gcp && (
<TargetEnvGCPList />
)}
{getState()?.values?.['target-environment']?.azure && (
<TargetEnvAzureList />
)}
{getState()?.values?.['target-environment']?.oci && (
<TargetEnvOciList />
)}
{getState()?.values?.['target-environment']?.vsphere && (
<TextContent>
<Text component={TextVariants.h3}>VMware vSphere (.vmdk)</Text>
<TargetEnvOtherList />
</TextContent>
)}
{getState()?.values?.['target-environment']?.['vsphere-ova'] && (
<TextContent>
<Text component={TextVariants.h3}>VMware vSphere (.ova)</Text>
<TargetEnvOtherList />
</TextContent>
)}
{getState()?.values?.['target-environment']?.['guest-image'] && (
<TextContent>
<Text component={TextVariants.h3}>
Virtualization - Guest image (.qcow2)
</Text>
<TargetEnvOtherList />
</TextContent>
)}
{getState()?.values?.['target-environment']?.['image-installer'] && (
<TextContent>
<Text component={TextVariants.h3}>
Bare metal - Installer (.iso)
</Text>
<TargetEnvOtherList />
</TextContent>
)}
{getState()?.values?.['target-environment']?.wsl && (
<TextContent>
<Text component={TextVariants.h3}>
WSL - Windows Subsystem for Linux (.tar.gz)
</Text>
<TargetEnvOtherList />
</TextContent>
)}
</ExpandableSection>
<ExpandableSection
toggleContent={'File system configuration'}
onToggle={(_event, isExpandedFSC) => onToggleFSC(isExpandedFSC)}
isExpanded={isExpandedFSC}
isIndented
data-testid="file-system-configuration-expandable"
>
<FSCList />
</ExpandableSection>
<ExpandableSection
toggleContent={'Content'}
onToggle={(_event, isExpandedContent) =>
onToggleContent(isExpandedContent)
}
isExpanded={isExpandedContent}
isIndented
data-testid="content-expandable"
>
<ContentList />
</ExpandableSection>
{isRhel(getState()?.values?.release) && (
<ExpandableSection
toggleContent={'Registration'}
onToggle={(_event, isExpandedRegistration) =>
onToggleRegistration(isExpandedRegistration)
}
isExpanded={isExpandedRegistration}
isIndented
data-testid="registration-expandable"
>
{getState()?.values?.['register-system'] === 'register-later' && (
<RegisterLaterList />
)}
{getState()?.values?.['register-system']?.startsWith(
'register-now'
) && <RegisterNowList />}
</ExpandableSection>
)}
{(getState()?.values?.['image-name'] ||
getState()?.values?.['image-description']) && (
<ExpandableSection
toggleContent={'Image details'}
onToggle={(_event, isExpandedImageDetail) =>
onToggleImageDetail(isExpandedImageDetail)
}
isExpanded={isExpandedImageDetail}
isIndented
data-testid="image-details-expandable"
>
<ImageDetailsList />
</ExpandableSection>
)}
{getState()?.values?.['oscap-profile'] && (
<ExpandableSection
toggleContent={'OpenSCAP'}
onToggle={(_event, isExpandedOscapDetail) =>
onToggleOscapDetails(isExpandedOscapDetail)
}
isExpanded={isExpandedOscapDetail}
isIndented
data-testid="oscap-detail-expandable"
>
<OscapProfileInformation />
</ExpandableSection>
)}
</>
);
};
export default ReviewStep;

View file

@ -1,138 +0,0 @@
import React from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import { Alert, Panel, PanelMain, Spinner } from '@patternfly/react-core';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import PropTypes from 'prop-types';
import { UNIT_GIB, UNIT_MIB } from '../../../constants';
import { useListRepositoriesQuery } from '../../../store/contentSourcesApi';
const RepoName = ({ repoUrl }) => {
const { data, isSuccess, isFetching, isError } = useListRepositoriesQuery({
url: repoUrl,
contentType: 'rpm',
origin: 'external',
});
const errorLoading = () => {
return (
<Alert
variant="danger"
isInline
isPlain
title="Error loading repository name"
/>
);
};
return (
<>
{/*
this might be a tad bit hacky
"isSuccess" indicates only that the query fetched successfuly, but it
doesn't differentiate between a scenario when the repository was found
in the response and when it was not
for this reason I've split the "isSuccess" into two paths:
- query finished and the repo was found -> render the name of the repo
- query finished, but the repo was not found -> render an error
*/}
{isSuccess && data.data?.[0]?.name && <p>{data.data?.[0].name}</p>}
{isSuccess && !data.data?.[0]?.name && errorLoading()}
{isFetching && <Spinner size="md" />}
{isError && errorLoading()}
</>
);
};
export const FSReviewTable = () => {
const { getState } = useFormApi();
const fsc = getState().values['file-system-configuration'];
return (
<Panel isScrollable>
<PanelMain maxHeight="30ch">
<Table aria-label="File system configuration table" variant="compact">
<Thead>
<Tr>
<Th>Mount point</Th>
<Th>File system type</Th>
<Th>Minimum size</Th>
</Tr>
</Thead>
<Tbody data-testid="file-system-configuration-tbody-review">
{fsc.map((partition, partitionIndex) => (
<Tr key={partitionIndex}>
<Td className="pf-m-width-30">{partition.mountpoint}</Td>
<Td className="pf-m-width-30">xfs</Td>
<Td className="pf-m-width-30">
{partition.size}{' '}
{partition.unit === UNIT_GIB
? 'GiB'
: partition.unit === UNIT_MIB
? 'MiB'
: 'KiB'}
</Td>
</Tr>
))}
</Tbody>
</Table>
</PanelMain>
</Panel>
);
};
export const PackagesTable = () => {
const { getState } = useFormApi();
const packages = getState()?.values['selected-packages'];
return (
<Panel isScrollable>
<PanelMain maxHeight="30ch">
<Table aria-label="Packages table" variant="compact">
<Thead>
<Tr>
<Th>Name</Th>
</Tr>
</Thead>
<Tbody data-testid="packages-tbody-review">
{packages.map((pkg, pkgIndex) => (
<Tr key={pkgIndex}>
<Td className="pf-m-width-30">{pkg.name}</Td>
</Tr>
))}
</Tbody>
</Table>
</PanelMain>
</Panel>
);
};
export const RepositoriesTable = () => {
const { getState } = useFormApi();
const repositories = getState()?.values?.['payload-repositories'];
return (
<Panel isScrollable>
<PanelMain maxHeight="30ch">
<Table aria-label="Custom repositories table" variant="compact">
<Thead>
<Tr>
<Th>Name</Th>
</Tr>
</Thead>
<Tbody data-testid="repositories-tbody-review">
{repositories.map((repo, repoIndex) => (
<Tr key={repoIndex}>
<Td className="pf-m-width-60">
<RepoName repoUrl={repo.baseurl} />
</Td>
</Tr>
))}
</Tbody>
</Table>
</PanelMain>
</Panel>
);
};
RepoName.propTypes = {
repoUrl: PropTypes.string,
};

View file

@ -1,616 +0,0 @@
import React from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import {
Alert,
Button,
Popover,
Spinner,
Text,
TextContent,
TextList,
TextListItem,
TextListVariants,
TextListItemVariants,
TextVariants,
} from '@patternfly/react-core';
import { ExclamationTriangleIcon, HelpIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import ActivationKeyInformation from './ActivationKeyInformation';
import { AwsAccountId } from './AwsAccountId';
import ReleaseLifecycle from './ReleaseLifecycle';
import {
FSReviewTable,
PackagesTable,
RepositoriesTable,
} from './ReviewStepTables';
import { RELEASES, UNIT_GIB } from '../../../constants';
import { extractProvisioningList } from '../../../store/helpers';
import { useGetSourceListQuery } from '../../../store/provisioningApi';
import { useShowActivationKeyQuery } from '../../../store/rhsmApi';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
import { googleAccType } from '../steps/googleCloud';
const ExpirationWarning = () => {
return (
<div className="pf-u-mr-sm pf-u-font-size-sm pf-u-warning-color-100">
<ExclamationTriangleIcon /> Expires 14 days after creation
</div>
);
};
export const ImageOutputList = () => {
const { getState } = useFormApi();
return (
<TextContent>
<ReleaseLifecycle />
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Release
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{RELEASES.get(getState()?.values?.release)}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Architecture
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.arch}
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const TargetEnvAWSList = () => {
const { data: rawAWSSources, isSuccess } = useGetSourceListQuery({
provider: 'aws',
});
const awsSources = extractProvisioningList(rawAWSSources);
const { isBeta } = useGetEnvironment();
const { getState } = useFormApi();
return (
<TextContent>
<Text component={TextVariants.h3}>AWS</Text>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Image type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Red Hat hosted image
<br />
<ExpirationWarning />
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Shared to account
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{!isBeta() && getState()?.values?.['aws-account-id']}
{isBeta() &&
getState()?.values?.['aws-target-type'] ===
'aws-target-type-source' &&
isSuccess && (
<AwsAccountId
sourceId={getState()?.values?.['aws-sources-select']}
/>
)}
{isBeta() &&
getState()?.values?.['aws-target-type'] ===
'aws-target-type-account-id' &&
getState()?.values?.['aws-account-id']}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
{getState()?.values?.['aws-target-type'] === 'aws-target-type-source'
? 'Source'
: null}
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{isSuccess &&
getState()?.values?.['aws-target-type'] === 'aws-target-type-source'
? awsSources.find(
(source) =>
source.id === getState()?.values?.['aws-sources-select']
)?.name
: null}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Default region
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
us-east-1
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const TargetEnvGCPList = () => {
const { getState } = useFormApi();
return (
<TextContent>
<Text component={TextVariants.h3}>GCP</Text>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Image type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Red Hat hosted image
<br />
<ExpirationWarning />
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Account type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{googleAccType?.[getState()?.values?.['google-account-type']]}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
{googleAccType?.[getState()?.values?.['google-account-type']] ===
'Domain'
? 'Domain'
: 'Principal'}
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['google-email'] ||
getState()?.values?.['google-domain']}
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const TargetEnvAzureList = () => {
const { getState } = useFormApi();
const { data: rawAzureSources, isSuccess: isSuccessAzureSources } =
useGetSourceListQuery({ provider: 'azure' });
const azureSources = extractProvisioningList(rawAzureSources);
return (
<TextContent>
<Text component={TextVariants.h3}>Microsoft Azure</Text>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Image type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Red Hat hosted image
<br />
<ExpirationWarning />
</TextListItem>
{getState()?.values?.['azure-type'] === 'azure-type-source' &&
isSuccessAzureSources && (
<>
<TextListItem component={TextListItemVariants.dt}>
Azure Source
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{
azureSources.find(
(source) =>
source.id === getState()?.values?.['azure-sources-select']
)?.name
}
</TextListItem>
</>
)}
{getState()?.values?.['azure-type'] === 'azure-type-manual' && (
<>
<TextListItem component={TextListItemVariants.dt}>
Azure Tenant ID
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-tenant-id']}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Subscription ID
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-subscription-id']}
</TextListItem>
</>
)}
<TextListItem component={TextListItemVariants.dt}>
Resource group
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-resource-group']}
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const TargetEnvOciList = () => {
return (
<TextContent>
<Text component={TextVariants.h3}>Oracle Cloud Infrastructure</Text>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Object Storage URL
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
The URL for the built image will be ready to copy
<br />
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const TargetEnvOtherList = () => {
return (
<>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Image type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Built image will be available for download
</TextListItem>
</TextList>
<br />
</>
);
};
export const FSCList = () => {
const { getState } = useFormApi();
const isManual =
getState()?.values?.['file-system-config-radio'] === 'manual';
const partitions = getState()?.values?.['file-system-configuration'];
return (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Configuration type
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="partitioning-auto-manual"
>
{isManual ? 'Manual' : 'Automatic'}
{isManual && (
<>
{' '}
<Popover
position="bottom"
headerContent="Partitions"
hasAutoWidth
minWidth="30rem"
bodyContent={<FSReviewTable />}
>
<Button
data-testid="file-system-configuration-popover"
variant="link"
aria-label="File system configuration info"
aria-describedby="file-system-configuration-info"
className="pf-u-pt-0 pf-u-pb-0"
>
View partitions
</Button>
</Popover>
</>
)}
</TextListItem>
{isManual && (
<>
<TextListItem component={TextListItemVariants.dt}>
Image size (minimum)
<Popover
hasAutoWidth
bodyContent={
<TextContent>
<Text>
Image Builder may extend this size based on requirements,
selected packages, and configurations.
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="File system configuration info"
aria-describedby="file-system-configuration-info"
className="pf-c-form__group-label-help"
>
<HelpIcon />
</Button>
</Popover>
</TextListItem>
<MinSize isManual={isManual} partitions={partitions} />
</>
)}
</TextList>
<br />
</TextContent>
);
};
export const MinSize = ({ isManual, partitions }) => {
let minSize = '';
if (isManual && partitions) {
let size = 0;
for (const partition of partitions) {
size += partition.size * partition.unit;
}
size = (size / UNIT_GIB).toFixed(1);
if (size < 1) {
minSize = `Less than 1 GiB`;
} else {
minSize = `${size} GiB`;
}
}
return (
<TextListItem component={TextListItemVariants.dd}> {minSize} </TextListItem>
);
};
MinSize.propTypes = {
isManual: PropTypes.bool,
partitions: PropTypes.arrayOf(PropTypes.object),
};
export const ContentList = () => {
const { getState } = useFormApi();
return (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Additional Red Hat
<br />
and 3rd party packages
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="chosen-packages-count"
>
{getState()?.values?.['selected-packages']?.length > 0 ? (
<Popover
position="bottom"
headerContent="Additional packages"
hasAutoWidth
minWidth="30rem"
bodyContent={<PackagesTable />}
>
<Button
variant="link"
aria-label="About packages key"
className="pf-u-p-0"
>
{getState()?.values?.['selected-packages']?.length}
</Button>
</Popover>
) : (
0
)}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Custom repositories
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="custom-repositories-count"
>
{getState()?.values?.['payload-repositories']?.length > 0 ? (
<Popover
position="bottom"
headerContent="Custom repositories"
hasAutoWidth
minWidth="30rem"
bodyContent={<RepositoriesTable />}
>
<Button
variant="link"
aria-label="About custom repositories"
className="pf-u-p-0"
>
{getState()?.values?.['payload-repositories']?.length || 0}
</Button>
</Popover>
) : (
0
)}
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const RegisterLaterList = () => {
return (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Registration type
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Register the system later
</TextListItem>
</TextList>
<br />
</TextContent>
);
};
export const RegisterNowList = () => {
const { getState } = useFormApi();
const activationKey = getState()?.values?.['subscription-activation-key'];
const { isError } = useShowActivationKeyQuery(
{ name: activationKey },
{
skip: !activationKey,
}
);
return (
<>
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Registration type
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="review-registration"
>
<TextList isPlain>
{getState()?.values?.['register-system']?.startsWith(
'register-now'
) && (
<TextListItem>
Register with Red Hat Subscription Manager (RHSM)
<br />
</TextListItem>
)}
{(getState()?.values?.['register-system'] ===
'register-now-insights' ||
getState()?.values?.['register-system'] ===
'register-now-rhc') && (
<TextListItem>
Connect to Red Hat Insights
<br />
</TextListItem>
)}
{getState()?.values?.['register-system'] ===
'register-now-rhc' && (
<TextListItem>
Use remote host configuration (rhc) utility
<br />
</TextListItem>
)}
</TextList>
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Activation key
<Popover
bodyContent={
<TextContent>
<Text>
Activation keys enable you to register a system with
appropriate subscriptions, system purpose, and repositories
attached.
<br />
<br />
If using an activation key with command line registration,
you must provide your organization&apos;s ID. Your
organization&apos;s ID is{' '}
{getState()?.values?.['subscription-organization-id'] !==
undefined ? (
getState()?.values?.['subscription-organization-id']
) : (
<Spinner size="md" />
)}
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About activation key"
className="pf-u-pl-sm pf-u-pt-0 pf-u-pb-0"
size="sm"
>
<HelpIcon />
</Button>
</Popover>
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
<ActivationKeyInformation />
</TextListItem>
</TextList>
<br />
</TextContent>
{isError && (
<Alert
title="Information about the activation key unavailable"
variant="danger"
isPlain
isInline
>
Information about the activation key cannot be loaded. Please check
the key was not removed and try again later.
</Alert>
)}
</>
);
};
export const ImageDetailsList = () => {
const { getState } = useFormApi();
const imageName = getState()?.values?.['image-name'];
const imageDescription = getState()?.values?.['image-description'];
return (
<TextContent>
<TextList component={TextListVariants.dl}>
{imageName && (
<>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Image name
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{imageName}
</TextListItem>
</>
)}
{imageDescription && (
<>
<TextListItem
component={TextListItemVariants.dt}
className="pf-u-min-width"
>
Description
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{imageDescription}
</TextListItem>
</>
)}
</TextList>
<br />
</TextContent>
);
};

View file

@ -1,84 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Grid, GridItem, TextInput } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../../constants';
const SizeUnit = ({ ...props }) => {
const [isOpen, setIsOpen] = useState(false);
const [unit, setUnit] = useState(props.unit || UNIT_GIB);
const [size, setSize] = useState(props.size || 1);
useEffect(() => {
props.onChange(size, unit);
}, [unit, size]);
const onToggle = (isOpen) => {
setIsOpen(isOpen);
};
const onSelect = (event, selection) => {
switch (selection) {
case 'KiB':
setUnit(UNIT_KIB);
break;
case 'MiB':
setUnit(UNIT_MIB);
break;
case 'GiB':
setUnit(UNIT_GIB);
break;
// no default
}
setIsOpen(false);
};
return (
// TODO make these stack vertically for xs viewport
<Grid>
<GridItem span={6}>
<TextInput
ouiaId="size"
type="text"
value={size}
aria-label="Size text input"
onChange={(_event, v) =>
setSize(isNaN(parseInt(v)) ? 0 : parseInt(v))
}
/>
</GridItem>
<GridItem span={6}>
<Select
ouiaId="unit"
isOpen={isOpen}
onToggle={(_event, isOpen) => onToggle(isOpen)}
onSelect={onSelect}
selections={
unit === UNIT_KIB ? 'KiB' : unit === UNIT_MIB ? 'MiB' : 'GiB'
}
variant={SelectVariant.single}
aria-label="Unit select"
>
{['KiB', 'MiB', 'GiB'].map((u, index) => {
return <SelectOption key={index} value={u} />;
})}
</Select>
</GridItem>
</Grid>
);
};
SizeUnit.propTypes = {
size: PropTypes.number.isRequired,
unit: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
};
export default SizeUnit;

View file

@ -1,401 +0,0 @@
import React, { useEffect, useState } from 'react';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import {
Alert,
Bullseye,
Checkbox,
FormGroup,
Popover,
Radio,
Spinner,
Text,
TextContent,
TextVariants,
Tile,
} from '@patternfly/react-core';
import { HelpIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import { useField } from 'react-final-form';
import { useSearchParams } from 'react-router-dom';
import { useGetArchitecturesQuery } from '../../../store/imageBuilderApi';
import { provisioningApi } from '../../../store/provisioningApi';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
const useGetAllowedTargets = ({ architecture, release }) => {
const { data, isFetching, isSuccess, isError } = useGetArchitecturesQuery({
distribution: release,
});
let image_types = [];
if (isSuccess && data) {
data.forEach((elem) => {
if (elem.arch === architecture) {
image_types = elem.image_types;
}
});
}
return {
data: image_types,
isFetching: isFetching,
isSuccess: isSuccess,
isError: isError,
};
};
const TargetEnvironment = ({ label, isRequired, ...props }) => {
const { getState, change } = useFormApi();
const { input } = useFieldApi({ label, isRequired, ...props });
const [environment, setEnvironment] = useState({
aws: false,
azure: false,
gcp: false,
oci: false,
'vsphere-ova': false,
vsphere: false,
'guest-image': false,
'image-installer': false,
wsl: false,
});
const prefetchSources = provisioningApi.usePrefetch('getSourceList');
const { isBeta } = useGetEnvironment();
const release = getState()?.values?.release;
const [searchParams] = useSearchParams();
// Set the target via search parameter
// Used by Insights assistant or external hyperlinks (access.redhat.com, developers.redhat.com)
const preloadTarget = searchParams.get('target');
useEffect(() => {
preloadTarget === 'iso' && handleSetEnvironment('image-installer', true);
preloadTarget === 'qcow2' && handleSetEnvironment('guest-image', true);
}, [preloadTarget]);
useEffect(() => {
if (getState()?.values?.[input.name]) {
setEnvironment(getState().values[input.name]);
}
}, [getState, input.name]);
const handleSetEnvironment = (env, checked) =>
setEnvironment((prevEnv) => {
const newEnv = {
...prevEnv,
[env]: checked,
};
change(input.name, newEnv);
return newEnv;
});
const handleKeyDown = (e, env, checked) => {
if (e.key === ' ') {
handleSetEnvironment(env, checked);
}
};
// Load all the allowed targets from the backend
const architecture = useField('arch').input.value;
const {
data: allowedTargets,
isFetching,
isSuccess,
isError,
} = useGetAllowedTargets({
architecture: architecture,
release: release,
});
if (isFetching) {
return (
<Bullseye>
<Spinner size="lg" />
</Bullseye>
);
}
if (isError || !isSuccess) {
return (
<Alert
variant={'danger'}
isPlain
isInline
title={'Allowed targets unavailable'}
>
Allowed targets cannot be reached, try again later.
</Alert>
);
}
// If the user already made a choice for some targets but then changes their
// architecture or distribution, only keep the target choices that are still
// compatible.
const allTargets = [
'aws',
'gcp',
'azure',
'vsphere',
'vsphere-ova',
'guest-image',
'image-installer',
'wsl',
];
allTargets.forEach((target) => {
if (environment[target] && !allowedTargets.includes(target)) {
handleSetEnvironment(target, false);
}
});
// each item the user can select is depending on what's compatible with the
// architecture and the distribution they previously selected
return (
<FormGroup
isRequired={isRequired}
label={label}
data-testid="target-select"
>
<FormGroup
label={<Text component={TextVariants.small}>Public cloud</Text>}
data-testid="target-public"
>
<div className="tiles">
{allowedTargets.includes('aws') && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-aws"
title="Amazon Web Services"
icon={
<img
className="provider-icon"
src={'/apps/frontend-assets/partners-icons/aws.svg'}
alt="Amazon Web Services logo"
/>
}
onClick={() => handleSetEnvironment('aws', !environment.aws)}
onKeyDown={(e) => handleKeyDown(e, 'aws', !environment.aws)}
onMouseEnter={() => prefetchSources({ provider: 'aws' })}
isSelected={environment.aws}
isStacked
isDisplayLarge
/>
)}
{allowedTargets.includes('gcp') && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-google"
title="Google Cloud Platform"
icon={
<img
className="provider-icon"
src={
'/apps/frontend-assets/partners-icons/google-cloud-short.svg'
}
alt="Google Cloud Platform logo"
/>
}
onClick={() => handleSetEnvironment('gcp', !environment.gcp)}
isSelected={environment.gcp}
onKeyDown={(e) => handleKeyDown(e, 'gcp', !environment.gcp)}
onMouseEnter={() => prefetchSources({ provider: 'gcp' })}
isStacked
isDisplayLarge
/>
)}
{allowedTargets.includes('azure') && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-azure"
title="Microsoft Azure"
icon={
<img
className="provider-icon"
src={
'/apps/frontend-assets/partners-icons/microsoft-azure-short.svg'
}
alt="Microsoft Azure logo"
/>
}
onClick={() => handleSetEnvironment('azure', !environment.azure)}
onKeyDown={(e) => handleKeyDown(e, 'azure', !environment.azure)}
onMouseEnter={() => prefetchSources({ provider: 'azure' })}
isSelected={environment.azure}
isStacked
isDisplayLarge
/>
)}
{allowedTargets.includes('oci') && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-oci"
title="Oracle Cloud Infrastructure"
icon={
<img
className="provider-icon"
src={'/apps/frontend-assets/partners-icons/oracle-short.svg'}
alt="Oracle Cloud Infrastructure logo"
/>
}
onClick={() => handleSetEnvironment('oci', !environment.oci)}
onKeyDown={(e) => handleKeyDown(e, 'oci', !environment.oci)}
isSelected={environment.oci}
isStacked
isDisplayLarge
/>
)}
</div>
</FormGroup>
{allowedTargets.includes('vsphere') && (
<FormGroup
label={<Text component={TextVariants.small}>Private cloud</Text>}
className="pf-u-mt-sm"
data-testid="target-private"
>
<Checkbox
label="VMware vSphere"
isChecked={environment.vsphere || environment['vsphere-ova']}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', checked);
handleSetEnvironment('vsphere', false);
}}
aria-label="VMware checkbox"
id="checkbox-vmware"
name="VMware"
data-testid="checkbox-vmware"
/>
</FormGroup>
)}
{allowedTargets.includes('vsphere') && (
<FormGroup
className="pf-u-mt-sm pf-u-mb-sm pf-u-ml-xl"
data-testid="target-private-vsphere-radio"
>
{allowedTargets.includes('vsphere-ova') && (
<Radio
name="vsphere-radio"
aria-label="VMware vSphere radio button OVA"
id="vsphere-radio-ova"
label={
<>
Open virtualization format (.ova)
<Popover
maxWidth="30rem"
position="right"
bodyContent={
<TextContent>
<Text>
An OVA file is a virtual appliance used by
virtualization platforms such as VMware vSphere. It is
a package that contains files used to describe a
virtual machine, which includes a VMDK image, OVF
descriptor file and a manifest file.
</Text>
</TextContent>
}
>
<HelpIcon className="pf-u-ml-sm" />
</Popover>
</>
}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', checked);
handleSetEnvironment('vsphere', !checked);
}}
isChecked={environment['vsphere-ova']}
isDisabled={!(environment.vsphere || environment['vsphere-ova'])}
/>
)}
<Radio
className="pf-u-mt-sm"
name="vsphere-radio"
aria-label="VMware vSphere radio button VMDK"
id="vsphere-radio-vmdk"
label={
<>
Virtual disk (.vmdk)
<Popover
maxWidth="30rem"
position="right"
bodyContent={
<TextContent>
<Text>
A VMDK file is a virtual disk that stores the contents
of a virtual machine. This disk has to be imported into
vSphere using govc import.vmdk, use the OVA version when
using the vSphere UI.
</Text>
</TextContent>
}
>
<HelpIcon className="pf-u-ml-sm" />
</Popover>
</>
}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', !checked);
handleSetEnvironment('vsphere', checked);
}}
isChecked={environment.vsphere}
isDisabled={!(environment.vsphere || environment['vsphere-ova'])}
/>
</FormGroup>
)}
<FormGroup
label={<Text component={TextVariants.small}>Other</Text>}
data-testid="target-other"
>
{allowedTargets.includes('guest-image') && (
<Checkbox
label="Virtualization - Guest image (.qcow2)"
isChecked={environment['guest-image']}
onChange={(_event, checked) =>
handleSetEnvironment('guest-image', checked)
}
aria-label="Virtualization guest image checkbox"
id="checkbox-guest-image"
name="Virtualization guest image"
data-testid="checkbox-guest-image"
/>
)}
{allowedTargets.includes('image-installer') && (
<Checkbox
label="Bare metal - Installer (.iso)"
isChecked={environment['image-installer']}
onChange={(_event, checked) =>
handleSetEnvironment('image-installer', checked)
}
aria-label="Bare metal installer checkbox"
id="checkbox-image-installer"
name="Bare metal installer"
data-testid="checkbox-image-installer"
/>
)}
{allowedTargets.includes('wsl') && isBeta() && (
<Checkbox
label="WSL - Windows Subsystem for Linux (.tar.gz)"
isChecked={environment['wsl']}
onChange={(_event, checked) => handleSetEnvironment('wsl', checked)}
aria-label="windows subsystem for linux checkbox"
id="checkbox-wsl"
name="WSL"
data-testid="checkbox-wsl"
/>
)}
</FormGroup>
</FormGroup>
);
};
TargetEnvironment.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
};
TargetEnvironment.defaultProps = {
label: '',
isRequired: false,
};
export default TargetEnvironment;

View file

@ -1,19 +0,0 @@
import React from 'react';
import { Alert } from '@patternfly/react-core';
const UsrSubDirectoriesDisabled = () => {
return (
<Alert
variant="warning"
title="Sub-directories for the /usr mount point are no longer supported"
isInline
>
Please note that including sub-directories in the /usr path is no longer
supported. Previously included mount points with /usr sub-directory are
replaced by /usr when recreating an image.
</Alert>
);
};
export default UsrSubDirectoriesDisabled;

View file

@ -1,189 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import {
Button,
HelperText,
HelperTextItem,
Title,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
import { DEFAULT_AWS_REGION } from '../../../constants';
import CustomButtons from '../formComponents/CustomButtons';
const SourcesButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Create and manage sources here
</Button>
);
};
const awsStep = {
StepTemplate,
id: 'wizard-target-aws',
title: 'Amazon Web Services',
customTitle: (
<Title headingLevel="h1" size="xl">
Target environment - Amazon Web Services
</Title>
),
name: 'aws-target-env',
substepOf: 'Target environment',
nextStep: ({ values }) => nextStepMapper(values, { skipAws: true }),
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'plain-text-component',
label: (
<p>
Your image will be uploaded to AWS and shared with the account you
provide below.
</p>
),
},
{
component: componentTypes.PLAIN_TEXT,
name: 'plain-text-component',
label: (
<p>
<b>The shared image will expire within 14 days.</b> To permanently
access the image, copy the image, which will be shared to your account
by Red Hat, to your own AWS account.
</p>
),
},
{
component: componentTypes.RADIO,
label: 'Share method:',
name: 'aws-target-type',
initialValue: 'aws-target-type-source',
autoFocus: true,
options: [
{
label: 'Use an account configured from Sources.',
description:
'Use a configured source to launch environments directly from the console.',
value: 'aws-target-type-source',
'data-testid': 'aws-radio-source',
autoFocus: true,
},
{
label: 'Manually enter an account ID.',
value: 'aws-target-type-account-id',
'data-testid': 'aws-radio-account-id',
className: 'pf-u-mt-sm',
},
],
},
{
component: 'aws-sources-select',
name: 'aws-sources-select',
className: 'pf-u-max-width',
label: 'Source Name',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
condition: {
when: 'aws-target-type',
is: 'aws-target-type-source',
},
},
{
component: componentTypes.PLAIN_TEXT,
name: 'aws-sources-select-description',
label: <SourcesButton />,
condition: {
when: 'aws-target-type',
is: 'aws-target-type-source',
},
},
{
component: componentTypes.TEXT_FIELD,
name: 'aws-account-id',
className: 'pf-u-w-25',
'data-testid': 'aws-account-id',
type: 'text',
label: 'AWS account ID',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.EXACT_LENGTH,
threshold: 12,
},
],
condition: {
when: 'aws-target-type',
is: 'aws-target-type-account-id',
},
},
{
name: 'gallery-layout',
component: 'gallery-layout',
minWidths: { default: '12.5rem' },
maxWidths: { default: '12.5rem' },
fields: [
{
component: componentTypes.TEXT_FIELD,
name: 'aws-default-region',
value: DEFAULT_AWS_REGION,
'data-testid': 'aws-default-region',
type: 'text',
label: 'Default Region',
isReadOnly: true,
isRequired: true,
helperText: (
<HelperText>
<HelperTextItem component="div" variant="indeterminate">
Images are built in the default region but can be copied to
other regions later.
</HelperTextItem>
</HelperText>
),
},
{
component: componentTypes.TEXT_FIELD,
name: 'aws-associated-account-id',
'data-testid': 'aws-associated-account-id',
type: 'text',
label: 'Associated Account ID',
isReadOnly: true,
isRequired: true,
helperText: (
<HelperText>
<HelperTextItem component="div" variant="indeterminate">
This is the account associated with the source.
</HelperTextItem>
</HelperText>
),
condition: {
when: 'aws-target-type',
is: 'aws-target-type-source',
},
},
],
},
],
};
export default awsStep;

View file

@ -1,136 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import {
Button,
Label,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import StepTemplate from './stepTemplate';
import { FILE_SYSTEM_CUSTOMIZATION_URL } from '../../../constants';
import FileSystemConfigButtons from '../formComponents/FileSystemConfigButtons';
export const reinitFileSystemConfiguratioStep = (change) => {
change('file-system-configuration', undefined);
change('file-system-config-radio', 'automatic');
};
const fileSystemConfigurationStep = {
StepTemplate,
id: 'wizard-systemconfiguration-filesystem',
title: 'File system configuration',
name: 'File system configuration',
buttons: FileSystemConfigButtons,
nextStep: 'packages',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'file-system-configuration-text-component',
label: (
<>
<Text>Define the partitioning of the image</Text>
</>
),
},
{
component: componentTypes.RADIO,
name: 'file-system-config-radio',
initialValue: 'automatic',
options: [
{
label: (
<>
<Text>
<Label isCompact color="blue">
Recommended
</Label>{' '}
Use automatic partitioning
</Text>
</>
),
description:
'Automatically partition your image to what is best, depending on the target environment(s)',
value: 'automatic',
'data-testid': 'file-system-config-radio-automatic',
autoFocus: true,
},
{
label: 'Manually configure partitions',
description:
'Manually configure the file system of your image by adding, removing, and editing partitions',
value: 'manual',
'data-testid': 'file-system-config-radio-manual',
className: 'pf-u-mt-sm',
},
],
condition: {
when: 'oscap-profile',
is: undefined,
},
},
{
component: 'file-system-configuration',
name: 'file-system-configuration',
label: 'File system configurations',
validate: [
{ type: 'fileSystemConfigurationValidator' },
{ type: validatorTypes.REQUIRED },
],
condition: {
or: [
{
when: 'file-system-config-radio',
is: 'manual',
},
{ not: [{ when: 'oscap-profile', is: undefined }] },
],
},
},
{
component: componentTypes.PLAIN_TEXT,
name: 'automatic-partitioning-info',
label: (
<TextContent>
<Text component={TextVariants.h3}>Automatic partitioning</Text>
<Text>
Red Hat will automatically partition your image to what is best,
depending on the target environment(s).
</Text>
<Text>
The target environment sometimes dictates the partitioning scheme or
parts of it, and sometimes the target environment is unknown (e.g.,
for the .qcow2 generic cloud image).
</Text>
<Text>
Using automatic partitioning will apply the most current supported
configuration.
<br></br>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
href={FILE_SYSTEM_CUSTOMIZATION_URL}
className="pf-u-pl-0"
>
Customizing file systems during the image creation
</Button>
</Text>
</TextContent>
),
condition: {
when: 'file-system-config-radio',
is: 'automatic',
},
},
],
};
export default fileSystemConfigurationStep;

View file

@ -1,225 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import {
Button,
Popover,
Text,
TextContent,
TextList,
TextListItem,
Title,
} from '@patternfly/react-core';
import { HelpIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
import CustomButtons from '../formComponents/CustomButtons';
export const googleAccType = {
googleAccount: 'Google account',
serviceAccount: 'Service account',
googleGroup: 'Google group',
domain: 'Domain',
};
const PopoverInfo = ({ appendTo }) => {
return (
<Popover
appendTo={appendTo}
hasAutoWidth
maxWidth="35rem"
headerContent={'Valid account types'}
flipBehavior={['right', 'bottom', 'top', 'left']}
bodyContent={
<TextContent>
<Text>
The following account types can have an image shared with them:
</Text>
<TextList className="pf-u-ml-0">
<TextListItem>
<strong>Google account:</strong> A Google account represents a
developer, an administrator, or any other person who interacts
with Google Cloud. For example: <em>`alice@gmail.com`</em>.
</TextListItem>
<TextListItem>
<strong>Service account:</strong> A service account is an account
for an application instead of an individual end user. For example:{' '}
<em>`myapp@appspot.gserviceaccount.com`</em>.
</TextListItem>
<TextListItem>
<strong>Google group:</strong> A Google group is a named
collection of Google accounts and service accounts. For example:{' '}
<em>`admins@example.com`</em>.
</TextListItem>
<TextListItem>
<strong>Google Workspace domain or Cloud Identity domain:</strong>{' '}
A Google workspace or cloud identity domain represents a virtual
group of all the Google accounts in an organization. These domains
represent your organization&apos;s internet domain name. For
example: <em>`mycompany.com`</em>.
</TextListItem>
</TextList>
</TextContent>
}
>
<Button
variant="plain"
aria-label="Account info"
aria-describedby="google-account-type"
className="pf-c-form__group-label-help"
>
<HelpIcon />
</Button>
</Popover>
);
};
PopoverInfo.propTypes = {
appendTo: PropTypes.any,
};
const googleCloudStep = {
StepTemplate,
id: 'wizard-target-gcp',
title: 'Google Cloud Platform',
customTitle: (
<Title headingLevel="h1" size="xl">
Target environment - Google Cloud Platform
</Title>
),
name: 'google-cloud-target-env',
substepOf: 'Target environment',
nextStep: ({ values }) =>
nextStepMapper(values, { skipGoogle: true, skipAws: true }),
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'google-cloud-text-component',
label: (
<p>
Select how to share your image. The image you create can be used to
launch instances on GCP, regardless of which method you select.
</p>
),
},
{
component: componentTypes.RADIO,
label: 'Select image sharing',
isRequired: true,
name: 'image-sharing',
initialValue: 'gcp-account',
autoFocus: true,
options: [
{
label: 'Share image with a Google account',
'data-testid': 'account-sharing',
autoFocus: true,
description: (
<p>
Your image will be uploaded to GCP and shared with the account you
provide below.
<b>The image expires in 14 days.</b> To keep permanent access to
your image, copy it to your GCP project.
</p>
),
value: 'gcp-account',
},
{
label: 'Share image with Red Hat Insights only',
'data-testid': 'insights-only-sharing',
description: (
<p>
Your image will be uploaded to GCP and shared with Red Hat
Insights.
<b> The image expires in 14 days.</b> You cannot access or
recreate this image in your GCP project.
</p>
),
value: 'insights',
autoFocus: true,
},
],
},
{
component: 'radio-popover',
label: 'Account type',
isRequired: true,
Popover: PopoverInfo,
name: 'google-account-type',
initialValue: 'googleAccount',
options: Object.entries(googleAccType).map(([value, label]) => ({
label:
value === 'domain'
? 'Google Workspace domain or Cloud Identity domain'
: label,
value,
autoFocus: value === 'googleAccount' ? true : false,
})),
validate: [
{
type: validatorTypes.REQUIRED,
},
],
condition: {
when: 'image-sharing',
is: 'gcp-account',
},
},
{
component: componentTypes.TEXT_FIELD,
name: 'google-email',
'data-testid': 'input-google-email',
type: 'text',
label: 'Principal (e.g. e-mail address)',
condition: {
and: [
{ when: 'image-sharing', is: 'gcp-account' },
{
or: [
{ when: 'google-account-type', is: 'googleAccount' },
{ when: 'google-account-type', is: 'serviceAccount' },
{ when: 'google-account-type', is: 'googleGroup' },
{ when: 'google-account-type', is: null },
],
},
],
},
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.PATTERN,
pattern: '^[a-z0-9._%+-]+@[a-z0-9.-]+.[a-z]{2,}$',
message: 'Please enter a valid email address',
},
],
},
{
component: componentTypes.TEXT_FIELD,
name: 'google-domain',
type: 'text',
label: 'Domain',
condition: {
and: [
{ when: 'image-sharing', is: 'gcp-account' },
{ when: 'google-account-type', is: 'domain' },
],
},
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
},
],
};
export default googleCloudStep;

View file

@ -1,75 +0,0 @@
import React from 'react';
import { useFormApi } from '@data-driven-forms/react-form-renderer';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import { Flex, FlexItem, Text } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
import CustomButtons from '../formComponents/CustomButtons';
const CharacterCount = () => {
const { getState } = useFormApi();
const description = getState().values?.['image-description'];
return <h1>{description?.length || 0}/250</h1>;
};
const imageNameStep = {
StepTemplate,
id: 'wizard-details',
name: 'details',
title: 'Details',
nextStep: 'review',
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'plain-text-component',
label: (
<p>
Optionally enter a name to identify your image later quickly. If you
do not provide one, the UUID will be used as the name.
</p>
),
},
{
component: componentTypes.TEXT_FIELD,
name: 'image-name',
type: 'text',
label: 'Image Name',
placeholder: 'Image Name',
helperText:
'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.',
autoFocus: true,
validate: [
{
type: validatorTypes.PATTERN,
pattern: /^[a-z][-a-z0-9]{1,61}[a-z0-9]$/,
message:
'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.',
},
],
},
{
component: componentTypes.TEXTAREA,
name: 'image-description',
type: 'text',
label: (
<Flex justifyContent={{ default: 'justifyContentSpaceBetween' }}>
<FlexItem>
<Text component={'b'}>Description</Text>
</FlexItem>
<FlexItem>
<CharacterCount />
</FlexItem>
</Flex>
),
placeholder: 'Add Description',
resizeOrientation: 'vertical',
validate: [{ type: validatorTypes.MAX_LENGTH, threshold: 250 }],
},
],
};
export default imageNameStep;

View file

@ -1,90 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import { Text } from '@patternfly/react-core';
import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
import { RHEL_9, X86_64 } from '../../../constants';
import DocumentationButton from '../../sharedComponents/DocumentationButton';
import CustomButtons from '../formComponents/CustomButtons';
const imageOutputStep = {
StepTemplate,
id: 'wizard-imageoutput',
title: 'Image output',
name: 'image-output',
nextStep: ({ values }) => nextStepMapper(values),
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'image-output-plain-text',
label: (
<Text>
Image builder allows you to create a custom image and push it to
target environments.
<br />
<DocumentationButton />
</Text>
),
},
{
component: 'image-output-release-select',
label: 'Release',
name: 'release',
initialValue: RHEL_9,
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
},
{
component: 'image-output-release-lifecycle',
label: 'Release lifecycle',
name: 'release-lifecycle',
},
{
component: 'image-output-arch-select',
label: 'Architecture',
name: 'arch',
initialValue: X86_64,
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
},
{
component: 'centos-acknowledgement',
name: 'centos-acknowledgement',
condition: {
when: 'release',
pattern: /centos-*/,
then: { set: { 'register-system': null } },
else: { visible: false },
},
},
{
component: 'output',
name: 'target-environment',
label: 'Select target environments',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: 'targetEnvironmentValidator',
},
],
},
],
};
export default imageOutputStep;

View file

@ -1,28 +0,0 @@
import isRhel from '../../../Utilities/isRhel';
const imageOutputStepMapper = (
{ 'target-environment': targetEnv, release, enableOscap } = {},
{ skipAws, skipGoogle, skipAzure } = {}
) => {
if (!skipAws && targetEnv?.aws) {
return 'aws-target-env';
}
if (!skipGoogle && targetEnv?.gcp) {
return 'google-cloud-target-env';
}
if (!skipAzure && targetEnv?.azure) {
return 'ms-azure-target-env';
}
if (isRhel(release)) {
return 'registration';
}
if (enableOscap) {
return 'Compliance';
}
return 'File system configuration';
};
export default imageOutputStepMapper;

View file

@ -1,13 +0,0 @@
export { default as awsTarget } from './aws';
export { default as googleCloudTarget } from './googleCloud';
export { default as msAzureTarget } from './msAzure';
export { default as oscap } from './oscap';
export { default as packages } from './packages';
export { default as packagesContentSources } from './packagesContentSources';
export { default as registration } from './registration';
export { default as repositories } from './repositories';
export { default as review } from './review';
export { default as imageOutput } from './imageOutput';
export { default as nextStepMapper } from './imageOutputStepMapper';
export { default as fileSystemConfiguration } from './fileSystemConfiguration';
export { default as imageName } from './imageName';

View file

@ -1,265 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import { Button, Text, TextContent, Title } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
import { AZURE_AUTH_URL } from '../../../constants';
import CustomButtons from '../formComponents/CustomButtons';
const SourcesButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Create and manage sources here
</Button>
);
};
const msAzureStep = {
StepTemplate,
id: 'wizard-target-msazure',
title: 'Microsoft Azure',
customTitle: (
<Title headingLevel="h1" size="xl">
Target environment - Microsoft Azure
</Title>
),
name: 'ms-azure-target-env',
substepOf: 'Target environment',
nextStep: ({ values }) =>
nextStepMapper(values, {
skipAws: true,
skipGoogle: true,
skipAzure: true,
}),
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-description',
label: (
<TextContent>
<Text>
Upon build, Image Builder sends the image to the selected authorized
Azure account. The image will be uploaded to the resource group in
the subscription you specify.
</Text>
<Text>
To authorize Image Builder to push images to Microsoft Azure, the
account owner must configure Image Builder as an authorized
application for a specific tenant ID and give it the role of
&quot;Contributor&quot; for the resource group you want to upload
to. This applies even when defining target by Source selection.
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={AZURE_AUTH_URL}
>
Learn more about OAuth 2.0
</Button>
</Text>
</TextContent>
),
},
{
component: componentTypes.RADIO,
label: 'Share method:',
name: 'azure-type',
initialValue: 'azure-type-source',
autoFocus: true,
options: [
{
label: 'Use an account configured from Sources.',
description:
'Use a configured source to launch environments directly from the console.',
value: 'azure-type-source',
'data-testid': 'azure-radio-source',
autoFocus: true,
},
{
label: 'Manually enter the account information.',
value: 'azure-type-manual',
'data-testid': 'azure-radio-manual',
className: 'pf-u-mt-sm',
},
],
},
{
component: 'azure-sources-select',
name: 'azure-sources-select',
className: 'pf-u-max-width',
label: 'Source Name',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
condition: {
when: 'azure-type',
is: 'azure-type-source',
},
},
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-sources-select-description',
label: <SourcesButton />,
condition: {
when: 'azure-type',
is: 'azure-type-source',
},
},
{
name: 'gallery-layout',
component: 'gallery-layout',
minWidths: { default: '12.5rem' },
maxWidths: { default: '12.5rem' },
fields: [
{
component: componentTypes.TEXT_FIELD,
name: 'azure-tenant-id',
'data-testid': 'azure-tenant-id-source',
type: 'text',
label: 'Azure Tenant GUID',
isRequired: true,
isReadOnly: true,
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-subscription-id',
'data-testid': 'azure-subscription-id-source',
type: 'text',
label: 'Subscription ID',
isRequired: true,
isReadOnly: true,
condition: {
when: 'azure-type',
is: 'azure-type-source',
},
},
],
condition: {
when: 'azure-type',
is: 'azure-type-source',
},
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-tenant-id',
className: 'pf-u-w-50',
'data-testid': 'azure-tenant-id-manual',
type: 'text',
label: 'Azure Tenant GUID',
isRequired: true,
autoFocus: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.PATTERN,
pattern:
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
message: 'Please enter a valid tenant ID',
},
],
condition: {
when: 'azure-type',
is: 'azure-type-manual',
},
},
{
component: 'azure-auth-button',
name: 'azure-auth-button',
'data-testid': 'azure-auth-button',
required: true,
isRequired: true,
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-subscription-id',
className: 'pf-u-w-50',
'data-testid': 'azure-subscription-id-manual',
type: 'text',
label: 'Subscription ID',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.PATTERN,
pattern:
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
message: 'Please enter a valid subscription ID',
},
],
condition: {
when: 'azure-type',
is: 'azure-type-manual',
},
},
{
component: 'azure-resource-groups',
name: 'azure-resource-group',
className: 'pf-u-max-width',
'data-testid': 'azure-resource-group-select',
label: 'Resource group',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
condition: {
when: 'azure-type',
is: 'azure-type-source',
},
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-resource-group',
className: 'pf-u-w-50',
'data-testid': 'azure-resource-group-manual',
type: 'text',
label: 'Resource group',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.PATTERN,
pattern: /^[-\w._()]+[-\w_()]$/,
message:
'Resource group names only allow alphanumeric characters, ' +
'periods, underscores, hyphens, and parenthesis and cannot end in a period',
},
],
condition: {
when: 'azure-type',
is: 'azure-type-manual',
},
},
// TODO check oauth2 thing too here?
],
};
export default msAzureStep;

View file

@ -1,42 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { Text, Title } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
import DocumentationButton from '../../sharedComponents/DocumentationButton';
import CustomButtons from '../formComponents/CustomButtons';
const oscapStep = {
StepTemplate,
id: 'wizard-systemconfiguration-oscap',
title: 'OpenSCAP',
name: 'Compliance',
customTitle: (
<Title headingLevel="h1" size="xl">
OpenSCAP profile
</Title>
),
nextStep: 'File system configuration',
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'oscap-text-component',
label: (
<Text>
Use OpenSCAP to monitor the adherence of your registered RHEL systems
to a selected regulatory compliance profile. <DocumentationButton />
</Text>
),
},
{
component: 'oscap-profile-selector',
name: 'oscap-profile',
label: 'Available profiles for the distribution',
},
],
};
export default oscapStep;

View file

@ -1,46 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { Text } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
import CustomButtons from '../formComponents/CustomButtons';
export const reinitPackagesStep = (change) => {
change('selected-packages', undefined);
};
const packagesStep = {
StepTemplate,
id: 'wizard-systemconfiguration-packages',
title: 'Additional Red Hat packages',
name: 'packages',
substepOf: 'Content',
nextStep: ({ values }) => {
if (values.contentSourcesEnabled) {
return 'repositories';
} else {
return 'details';
}
},
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'packages-text-component',
label: (
<Text>
Images built with Image Builder include all required packages.
</Text>
),
},
{
component: 'package-selector',
name: 'selected-packages',
label: 'Available options',
},
],
};
export default packagesStep;

View file

@ -1,37 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { Text } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
import CustomButtons from '../formComponents/CustomButtons';
const packagesContentSourcesStep = {
StepTemplate,
id: 'wizard-systemconfiguration-content-sources-packages',
title: 'Additional custom packages',
name: 'packages-content-sources',
substepOf: 'Content',
nextStep: 'details',
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'packages-text-component',
label: (
<Text>
The available packages will return results from all repositories
chosen on the previous page.
</Text>
),
},
{
component: 'package-selector-content-sources',
name: 'selected-packages-content-sources',
label: 'Available options',
},
],
};
export default packagesContentSourcesStep;

View file

@ -1,206 +0,0 @@
import React, { useEffect, useState } from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import {
Button,
Popover,
Text,
TextContent,
TextVariants,
Title,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
import StepTemplate from './stepTemplate';
import {
ACTIVATION_KEYS_PROD_URL,
ACTIVATION_KEYS_STAGE_URL,
RHC_URL,
} from '../../../constants';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
import CustomButtons from '../formComponents/CustomButtons';
const ManageKeysButton = () => {
const { isProd } = useGetEnvironment();
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={isProd() ? ACTIVATION_KEYS_PROD_URL : ACTIVATION_KEYS_STAGE_URL}
>
Activation keys page
</Button>
);
};
const PopoverActivation = () => {
const [orgId, setOrgId] = useState(null);
const { auth } = useChrome();
useEffect(() => {
(async () => {
const userData = await auth?.getUser();
const id = userData?.identity?.internal?.org_id;
setOrgId(id);
})();
});
return (
<Popover
hasAutoWidth
maxWidth="35rem"
bodyContent={
<TextContent>
<Text>
Activation keys enable you to register a system with appropriate
subscriptions, system purpose, and repositories attached.
</Text>
<Text>
If using an activation key with command line registration, you must
provide your organization&apos;s ID.
{orgId && <br />}
{orgId && "Your organization's ID is " + orgId}
</Text>
</TextContent>
}
>
<Button
variant="plain"
aria-label="Activation key popover"
aria-describedby="subscription-activation-key"
className="pf-c-form__group-label-help"
>
<HelpIcon />
</Button>
</Popover>
);
};
const registrationStep = {
StepTemplate,
id: 'wizard-registration',
title: 'Register',
customTitle: (
<Title headingLevel="h1" size="xl">
Register systems using this image
</Title>
),
name: 'registration',
nextStep: ({ values }) => {
if (values.enableOscap) {
return 'Compliance';
} else {
return 'File system configuration';
}
},
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'registration-general-description',
label:
'Automatically register your systems with Red Hat to enhance security and track your spending.',
},
{
name: 'register-system',
component: 'registration',
label: 'Registration method',
initialValue: 'register-now-rhc',
},
{
component: 'activation-keys',
name: 'subscription-activation-key',
required: true,
label: (
<>
Activation key to use for this image
<PopoverActivation />
</>
),
condition: {
or: [
{ when: 'register-system', is: 'register-now-rhc' },
{ when: 'register-system', is: 'register-now-insights' },
{ when: 'register-system', is: 'register-now' },
],
},
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
},
{
component: componentTypes.PLAIN_TEXT,
name: 'subscription-activation-description',
label: (
<span>
By default, activation key is generated and preset for you. Admins can
create and manage keys by visiting the&nbsp;
<ManageKeysButton />
</span>
),
condition: {
or: [
{ when: 'register-system', is: 'register-now-rhc' },
{ when: 'register-system', is: 'register-now-insights' },
{ when: 'register-system', is: 'register-now' },
],
},
},
{
component: componentTypes.PLAIN_TEXT,
name: 'subscription-register-later',
label: (
<TextContent>
<Text component={TextVariants.h3}>Register Later</Text>
<Text>
On initial boot, systems will need to be registered manually before
having access to updates or Red Hat services. Registering and
connecting your systems during the image creation is recommended.
</Text>
<Text>
If you prefer to register later, review the instructions for manual
registration with remote host configuration.
</Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={RHC_URL}
>
Registering with remote host configuration
</Button>
</TextContent>
),
condition: {
or: [{ when: 'register-system', is: 'register-later' }],
},
},
{
component: 'activation-key-information',
name: 'subscription-activation-key-information',
label: 'Selected activation key',
valueReference: 'subscription-activation-key',
condition: {
or: [
{ when: 'register-system', is: 'register-now-rhc' },
{ when: 'register-system', is: 'register-now-insights' },
{ when: 'register-system', is: 'register-now' },
],
},
},
],
};
export default registrationStep;

View file

@ -1,59 +0,0 @@
import React from 'react';
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
import { Button, Text } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import nextStepMapper from './repositoriesStepMapper';
import StepTemplate from './stepTemplate';
import { useGetEnvironment } from '../../../Utilities/useGetEnvironment';
import CustomButtons from '../formComponents/CustomButtons';
const VisitButton = () => {
const { isBeta } = useGetEnvironment();
return (
<Button
component="a"
target="_blank"
variant="link"
iconPosition="right"
isInline
icon={<ExternalLinkAltIcon />}
href={isBeta() ? '/preview/settings/content' : '/settings/content'}
>
Create and manage repositories here
</Button>
);
};
const repositoriesStep = {
StepTemplate,
id: 'wizard-repositories',
title: 'Custom repositories',
name: 'repositories',
substepOf: 'Content',
nextStep: ({ values }) => nextStepMapper(values),
buttons: CustomButtons,
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'packages-text-component',
label: (
<Text>
Select from linked custom repositories from which to search and add
packages to this image.
<br />
<VisitButton />
</Text>
),
},
{
component: 'repositories-table',
name: 'payload-repositories',
label: 'Custom repositories',
},
],
};
export default repositoriesStep;

View file

@ -1,11 +0,0 @@
const repositoriesStepMapper = ({
'payload-repositories': customRepositories,
} = {}) => {
if (customRepositories?.length > 0) {
return 'packages-content-sources';
}
return 'details';
};
export default repositoriesStepMapper;

View file

@ -1,19 +0,0 @@
import StepTemplate from './stepTemplate';
import CustomButtons from '../formComponents/CustomButtons';
const reviewStep = {
StepTemplate,
id: 'wizard-review',
name: 'review',
title: 'Review',
buttons: CustomButtons,
fields: [
{
name: 'review',
component: 'review',
},
],
};
export default reviewStep;

View file

@ -1,44 +0,0 @@
import React from 'react';
import { Title } from '@patternfly/react-core';
import PropTypes from 'prop-types';
const StepTemplate = ({
id,
formFields,
formRef,
title,
customTitle,
showTitle,
showTitles,
}) => (
<div id={id} ref={formRef} className="pf-c-form">
{((showTitles && showTitle !== false) || showTitle) &&
(customTitle ? (
customTitle
) : (
<Title headingLevel="h1" size="xl">
{title}
</Title>
))}
{formFields}
</div>
);
StepTemplate.propTypes = {
id: PropTypes.string,
title: PropTypes.node,
customTitle: PropTypes.node,
formFields: PropTypes.array.isRequired,
formOptions: PropTypes.shape({
renderForm: PropTypes.func.isRequired,
}).isRequired,
showTitles: PropTypes.bool,
showTitle: PropTypes.bool,
formRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
};
export default StepTemplate;

View file

@ -1,32 +0,0 @@
const FileSystemConfigurationValidator = () => (fsc) => {
if (!fsc) {
return undefined;
}
const mpFreqs = {};
for (const fs of fsc) {
const mp = fs.mountpoint;
if (mp in mpFreqs) {
mpFreqs[mp]++;
} else {
mpFreqs[mp] = 1;
}
}
const duplicates = [];
for (const [k, v] of Object.entries(mpFreqs)) {
if (v > 1) {
duplicates.push(k);
}
}
const root = mpFreqs['/'] >= 1;
return duplicates.length === 0 && root
? undefined
: {
duplicates: duplicates === [] ? undefined : duplicates,
root,
};
};
export default FileSystemConfigurationValidator;

View file

@ -1,2 +0,0 @@
export { default as fileSystemConfigurationValidator } from './fileSystemConfigurationValidator';
export { default as targetEnvironmentValidator } from './targetEnvironmentValidator';

View file

@ -1,17 +0,0 @@
const TargetEnvironmentValidator = () => (targets) => {
if (!targets) {
return undefined;
}
// at least one of the target environments must
// be set to true. This reduces the value to
// a single boolean which is a flag for whether
// at least one target has been selected or not
const valid = Object.values(targets).reduce(
(prev, curr) => curr || prev,
false
);
return !valid ? 'Please select an image' : undefined;
};
export default TargetEnvironmentValidator;

View file

@ -191,7 +191,7 @@ const CreateImageWizard = ({ isEdit }: CreateImageWizardProps) => {
return (
<>
<ImageBuilderHeader />
<ImageBuilderHeader inWizard />
<section className="pf-l-page__main-section pf-c-page__main-section">
<Wizard
startIndex={startIndex}

View file

@ -17,14 +17,11 @@ import {
PlusCircleIcon,
SearchIcon,
} from '@patternfly/react-icons';
import { Link } from 'react-router-dom';
import {
CREATING_IMAGES_WITH_IB_SERVICE_URL,
MANAGING_WITH_DNF_URL,
} from '../../constants';
import { resolveRelPath } from '../../Utilities/path';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
import { BuildImagesButton } from '../Blueprints/BuildImagesButton';
type ImagesEmptyStateProps = {
@ -57,84 +54,58 @@ const EmptyBlueprintsImagesTable = () => (
);
const EmptyImagesTable = () => {
const experimentalFlag = useExperimentalFlag();
return (
<Bullseye>
<EmptyState variant={EmptyStateVariant.lg} data-testid="empty-state">
{experimentalFlag ? (
<>
<EmptyStateHeader
titleText="No images"
icon={<EmptyStateIcon icon={SearchIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>
<Text>Images are BLANK. Create blueprints to create images.</Text>
</EmptyStateBody>
</>
) : (
<>
<EmptyStateHeader
titleText="Create an RPM-DNF image"
icon={<EmptyStateIcon icon={PlusCircleIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>
<Text>
Image builder is a tool for creating deployment-ready customized
system images: installation disks, virtual machines, cloud
vendor-specific images, and others. By using image builder, you
can create these images faster than with manual procedures
because it eliminates the specific configurations required for
each output type.
</Text>
<br />
<Text>
With RPM-DNF, you can manage the system software by using the
DNF package manager and updated RPM packages. This is a simple
and adaptive method of managing and modifying the system over
its lifecycle.
</Text>
<br />
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={MANAGING_WITH_DNF_URL}
>
Learn more about managing images with DNF
</Button>
</Text>
</EmptyStateBody>
<EmptyStateFooter>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action-empty-state"
<>
<EmptyStateHeader
titleText="No images"
icon={<EmptyStateIcon icon={SearchIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>
<Text>
Image builder is a tool for creating deployment-ready customized
system images: installation disks, virtual machines, cloud
vendor-specific images, and others. By using image builder, you
can create these images faster than with manual procedures because
it eliminates the specific configurations required for each output
type.
</Text>
<Text>
There are no images yet. Create a blueprint to create images.
</Text>
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={MANAGING_WITH_DNF_URL}
>
Create image
</Link>
<EmptyStateActions>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={CREATING_IMAGES_WITH_IB_SERVICE_URL}
className="pf-u-pt-md"
>
Image builder for RPM-DNF documentation
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</>
)}
Learn more about managing images with DNF
</Button>
</Text>
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={CREATING_IMAGES_WITH_IB_SERVICE_URL}
className="pf-u-pt-md"
>
Image builder for RPM-DNF documentation
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</>
</EmptyState>
</Bullseye>
);

View file

@ -68,7 +68,6 @@ import {
timestampToDisplayString,
timestampToDisplayStringDetailed,
} from '../../Utilities/time';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
const ImagesTable = () => {
const [page, setPage] = useState(1);
@ -93,7 +92,6 @@ const ImagesTable = () => {
}),
}
);
const experimentalFlag = useExperimentalFlag();
const onSetPage: OnSetPage = (_, page) => setPage(page);
const onPerPageSelect: OnSetPage = (_, perPage) => {
@ -198,7 +196,7 @@ const ImagesTable = () => {
<Th>Updated</Th>
<Th>OS</Th>
<Th>Target</Th>
{experimentalFlag && <Th>Version</Th>}
<Th>Version</Th>
<Th>Status</Th>
<Th>Instance</Th>
<Th aria-label="Actions menu" />
@ -407,7 +405,6 @@ type AwsRowPropTypes = {
const AwsRow = ({ compose, composeStatus, rowIndex }: AwsRowPropTypes) => {
const navigate = useNavigate();
const experimentalFlag = useExperimentalFlag();
const target = <AwsTarget compose={compose} />;
@ -418,9 +415,7 @@ const AwsRow = ({ compose, composeStatus, rowIndex }: AwsRowPropTypes) => {
const details = <AwsDetails compose={compose} />;
const actions = (
<ActionsColumn
items={awsActions(compose, composeStatus, navigate, experimentalFlag)}
/>
<ActionsColumn items={awsActions(compose, composeStatus, navigate)} />
);
return (
@ -457,8 +452,6 @@ const Row = ({
}: RowPropTypes) => {
const [isExpanded, setIsExpanded] = useState(false);
const handleToggle = () => setIsExpanded(!isExpanded);
const experimentalFlag = useExperimentalFlag();
const navigate = useNavigate();
return (
<Tbody key={compose.id} isExpanded={isExpanded}>
@ -483,20 +476,16 @@ const Row = ({
<Td dataLabel="Target">
{target ? target : <Target compose={compose} />}
</Td>
{experimentalFlag && (
<Td dataLabel="Version">
<Badge isRead>{compose.blueprint_version || 'N/A'}</Badge>
</Td>
)}
<Td dataLabel="Version">
<Badge isRead>{compose.blueprint_version || 'N/A'}</Badge>
</Td>
<Td dataLabel="Status">{status}</Td>
<Td dataLabel="Instance">{instance}</Td>
<Td>
{actions ? (
actions
) : (
<ActionsColumn
items={defaultActions(compose, navigate, experimentalFlag)}
/>
<ActionsColumn items={defaultActions(compose)} />
)}
</Td>
</Tr>
@ -509,21 +498,7 @@ const Row = ({
);
};
const defaultActions = (
compose: ComposesResponseItem,
navigate: NavigateFunction,
experimentalFlag: boolean
) => [
...(experimentalFlag
? []
: [
{
title: 'Recreate image',
onClick: () => {
navigate(resolveRelPath(`imagewizard/${compose.id}`));
},
},
]),
const defaultActions = (compose: ComposesResponseItem) => [
{
title: (
<a
@ -542,15 +517,14 @@ const defaultActions = (
const awsActions = (
compose: ComposesResponseItem,
status: ComposeStatus | undefined,
navigate: NavigateFunction,
experimentalFlag: boolean
navigate: NavigateFunction
) => [
{
title: 'Share to new region',
onClick: () => navigate(resolveRelPath(`share/${compose.id}`)),
isDisabled: status?.image_status.status === 'success' ? false : true,
},
...defaultActions(compose, navigate, experimentalFlag),
...defaultActions(compose),
];
export default ImagesTable;

View file

@ -8,7 +8,6 @@ import {
ToolbarItem,
Title,
} from '@patternfly/react-core';
import { Link } from 'react-router-dom';
import {
selectSelectedBlueprintId,
@ -21,8 +20,6 @@ import {
useGetBlueprintsQuery,
useGetBlueprintComposesQuery,
} from '../../store/imageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
import { BlueprintActionsMenu } from '../Blueprints/BlueprintActionsMenu';
import BlueprintVersionFilter from '../Blueprints/BlueprintVersionFilter';
import { BuildImagesButton } from '../Blueprints/BuildImagesButton';
@ -44,7 +41,6 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
setPage,
onPerPageSelect,
}: imagesTableToolbarProps) => {
const experimentalFlag = useExperimentalFlag();
const [showDeleteModal, setShowDeleteModal] = useState(false);
const selectedBlueprintId = useAppSelector(selectSelectedBlueprintId);
const blueprintSearchInput = useAppSelector(selectBlueprintSearchInput);
@ -98,27 +94,6 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
/>
);
if (!experimentalFlag) {
return (
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
</ToolbarItem>
<ToolbarItem variant="pagination" align={{ default: 'alignRight' }}>
{pagination}
</ToolbarItem>
</ToolbarContent>
</Toolbar>
);
}
const isBlueprintDistroCentos8 = () => {
if (isSuccessBlueprintsCompose) {
return blueprintsComposes.data[0].request.distribution === 'centos-8';
@ -139,7 +114,7 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
: 'All images'}
</Title>
</ToolbarContent>
{itemCount > 0 && experimentalFlag && isBlueprintOutSync && (
{itemCount > 0 && isBlueprintOutSync && (
<Alert
style={{
margin:
@ -164,25 +139,28 @@ const ImagesTableToolbar: React.FC<imagesTableToolbarProps> = ({
ouiaId="centos-8-blueprint-alert"
/>
)}
{selectedBlueprintId && (
<ToolbarContent>
<ToolbarItem>
<BlueprintVersionFilter onFilterChange={() => setPage(1)} />
</ToolbarItem>
<ToolbarItem>
<BuildImagesButton />
</ToolbarItem>
<ToolbarItem>
<EditBlueprintButton />
</ToolbarItem>
<ToolbarItem>
<BlueprintActionsMenu setShowDeleteModal={setShowDeleteModal} />
</ToolbarItem>
<ToolbarItem variant="pagination" align={{ default: 'alignRight' }}>
{pagination}
</ToolbarItem>
</ToolbarContent>
)}
<ToolbarContent>
{selectedBlueprintId && (
<>
<ToolbarItem>
<BlueprintVersionFilter onFilterChange={() => setPage(1)} />
</ToolbarItem>
<ToolbarItem>
<BuildImagesButton />
</ToolbarItem>
<ToolbarItem>
<EditBlueprintButton />
</ToolbarItem>
<ToolbarItem>
<BlueprintActionsMenu setShowDeleteModal={setShowDeleteModal} />
</ToolbarItem>
</>
)}
<ToolbarItem variant="pagination" align={{ default: 'alignRight' }}>
{pagination}
</ToolbarItem>
</ToolbarContent>
</Toolbar>
</>
);

View file

@ -40,7 +40,6 @@ import {
isOciUploadStatus,
} from '../../store/typeGuards';
import { resolveRelPath } from '../../Utilities/path';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
import useProvisioningPermissions from '../../Utilities/useProvisioningPermissions';
type CloudInstancePropTypes = {
@ -347,9 +346,6 @@ export const AwsS3Instance = ({
composeId: compose.id,
});
const navigate = useNavigate();
const experimentalFlag = useExperimentalFlag();
if (!isSuccess) {
return <Skeleton />;
}
@ -388,18 +384,6 @@ export const AwsS3Instance = ({
)
</Button>
);
} else if (isExpired && !experimentalFlag) {
return (
<Button
component="a"
target="_blank"
variant="link"
onClick={() => navigate(resolveRelPath(`imagewizard/${compose.id}`))}
isInline
>
Recreate image
</Button>
);
} else {
return (
<Button

View file

@ -24,12 +24,10 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import './LandingPage.scss';
import { NewAlert } from './NewAlert';
import Quickstarts from './Quickstarts';
import { MANAGING_WITH_DNF_URL, OSTREE_URL } from '../../constants';
import { manageEdgeImagesUrlName } from '../../Utilities/edge';
import { resolveRelPath } from '../../Utilities/path';
import { useExperimentalFlag } from '../../Utilities/useExperimentalFlag';
import BlueprintsSidebar from '../Blueprints/BlueprintsSideBar';
import EdgeImagesTable from '../edge/ImagesTable';
import ImagesTable from '../ImagesTable/ImagesTable';
@ -58,20 +56,8 @@ export const LandingPage = () => {
};
const edgeParityFlag = useFlag('edgeParity.image-list');
const experimentalFlag = useExperimentalFlag();
const traditionalImageList = (
<>
<PageSection>
<Quickstarts />
</PageSection>
<PageSection>
<ImagesTable />
</PageSection>
</>
);
const experimentalImageList = (
const imageList = (
<>
<PageSection isWidthLimited>
{showAlert && <NewAlert setShowAlert={setShowAlert} />}
@ -98,16 +84,9 @@ export const LandingPage = () => {
</>
);
const imageList = experimentalFlag
? experimentalImageList
: traditionalImageList;
return (
<>
<ImageBuilderHeader
experimentalFlag={experimentalFlag}
activeTab={activeTabKey}
/>
<ImageBuilderHeader activeTab={activeTabKey} />
{edgeParityFlag ? (
<Tabs
className="pf-c-tabs pf-c-page-header pf-c-table"

View file

@ -32,8 +32,8 @@ import './ImageBuilderHeader.scss';
import { ImportBlueprintModal } from '../Blueprints/ImportBlueprintModal';
type ImageBuilderHeaderPropTypes = {
experimentalFlag?: boolean;
activeTab?: number;
inWizard?: boolean;
};
const AboutImageBuilderPopover = () => {
@ -92,8 +92,8 @@ const AboutImageBuilderPopover = () => {
};
export const ImageBuilderHeader = ({
experimentalFlag,
activeTab,
inWizard,
}: ImageBuilderHeaderPropTypes) => {
const navigate = useNavigate();
const importExportFlag = useFlag('image-builder.import.enabled');
@ -122,7 +122,7 @@ export const ImageBuilderHeader = ({
}
/>
</FlexItem>
{experimentalFlag && (
{!inWizard && (
<>
<FlexItem align={{ default: 'alignRight' }}>
<Button

View file

@ -6,12 +6,8 @@ import { Route, Routes } from 'react-router-dom';
import EdgeImageDetail from './Components/edge/ImageDetails';
import ShareImageModal from './Components/ShareImageModal/ShareImageModal';
import { manageEdgeImagesUrlName } from './Utilities/edge';
import { useExperimentalFlag } from './Utilities/useExperimentalFlag';
const LandingPage = lazy(() => import('./Components/LandingPage/LandingPage'));
const CreateImageWizard = lazy(
() => import('./Components/CreateImageWizard/CreateImageWizard')
);
const ImportImageWizard = lazy(
() => import('./Components/CreateImageWizardV2/ImportImageWizard')
);
@ -22,7 +18,6 @@ const CreateImageWizardV2 = lazy(
export const Router = () => {
const edgeParityFlag = useFlag('edgeParity.image-list');
const importExportFlag = useFlag('image-builder.import.enabled');
const experimentalFlag = useExperimentalFlag();
return (
<Routes>
<Route
@ -36,7 +31,7 @@ export const Router = () => {
<Route path="share/:composeId" element={<ShareImageModal />} />
</Route>
{importExportFlag && experimentalFlag && (
{importExportFlag && (
<Route
path="imagewizard/import"
element={
@ -50,7 +45,7 @@ export const Router = () => {
path="imagewizard/:composeId?"
element={
<Suspense>
{experimentalFlag ? <CreateImageWizardV2 /> : <CreateImageWizard />}
<CreateImageWizardV2 />
</Suspense>
}
/>

View file

@ -1,26 +0,0 @@
import { useFlag } from '@unleash/proxy-client-react';
import { useGetEnvironment } from './useGetEnvironment';
/**
* @example
* // returns true
* toBoolean('true');
* @example
* // returns false
* toBoolean('FALSE');
* @example
* // returns false
* toBoolean(undefined);
*/
const toBoolean = (environmentVariable: string | undefined): boolean => {
return environmentVariable?.toLowerCase() === 'true';
};
export const useExperimentalFlag = () => {
const { isBeta } = useGetEnvironment();
const isExperimental = toBoolean(process.env.EXPERIMENTAL?.toString());
return (
useFlag('image-builder.new-wizard.stable') || isBeta() || isExperimental
);
};

View file

@ -1,258 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import CreateImageWizard from '../../../Components/CreateImageWizard/CreateImageWizard';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import { PROVISIONING_API } from '../../../constants';
import { server } from '../../mocks/server';
import {
clickBack,
clickNext,
getNextButton,
renderCustomRoutesWithReduxRouter,
verifyCancelButton,
} from '../../testUtils';
const routes = [
{
path: 'insights/image-builder/*',
element: <div />,
},
{
path: 'insights/image-builder/imagewizard/:composeId?',
element: <CreateImageWizard />,
},
{
path: 'insights/image-builder/share/:composeId',
element: <ShareImageModal />,
},
];
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
auth: {
getUser: () => {
return {
identity: {
internal: {
org_id: 5,
},
},
};
},
},
isBeta: () => false,
isProd: () => true,
getEnvironment: () => 'prod',
}),
}));
let router = undefined;
beforeAll(() => {
// scrollTo is not defined in jsdom
window.HTMLElement.prototype.scrollTo = function () {};
});
afterEach(() => {
jest.clearAllMocks();
router = undefined;
server.resetHandlers();
});
const getSourceDropdown = async () => {
const sourceDropdown = await screen.findByRole('textbox', {
name: /select source/i,
});
// Wait for isSuccess === true, dropdown is disabled while isSuccess === false
await waitFor(() => expect(sourceDropdown).toBeEnabled());
return sourceDropdown;
};
describe('Step Upload to Azure', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select Azure as upload destination
await user.click(await screen.findByTestId('upload-azure'));
await clickNext();
await screen.findByRole('heading', {
name: 'Target environment - Microsoft Azure',
});
};
test('clicking Next loads Registration', async () => {
await setUp();
await user.click(await screen.findByTestId('azure-radio-manual'));
// Randomly generated GUID
await user.type(
await screen.findByTestId('azure-tenant-id-manual'),
'b8f86d22-4371-46ce-95e7-65c415f3b1e2'
);
await user.type(
await screen.findByTestId('azure-subscription-id-manual'),
'60631143-a7dc-4d15-988b-ba83f3c99711'
);
await user.type(
await screen.findByTestId('azure-resource-group-manual'),
'testResourceGroup'
);
await clickNext();
await screen.findByRole('textbox', {
name: 'Select activation key',
});
await screen.findByText(
'Automatically register and enable advanced capabilities'
);
});
test('clicking Back loads Release', async () => {
await setUp();
await clickBack();
await screen.findByTestId('upload-azure');
});
test('clicking Cancel loads landing page', async () => {
await setUp();
await verifyCancelButton(router);
});
test('azure step basics works', async () => {
await setUp();
const nextButton = await getNextButton();
expect(nextButton).toHaveClass('pf-m-disabled');
expect(await screen.findByTestId('azure-radio-source')).toBeChecked();
await user.click(await screen.findByTestId('azure-radio-manual'));
expect(await screen.findByTestId('azure-radio-manual')).toBeChecked();
expect(nextButton).toHaveClass('pf-m-disabled');
const tenantId = await screen.findByTestId('azure-tenant-id-manual');
expect(tenantId).toHaveValue('');
expect(tenantId).toBeEnabled();
await user.type(tenantId, 'c983c2cd-94d7-44e1-9c6e-9cfa3a40995f');
const subscription = await screen.findByTestId(
'azure-subscription-id-manual'
);
expect(subscription).toHaveValue('');
expect(subscription).toBeEnabled();
await user.type(subscription, 'f8f200aa-6234-4bfb-86c2-163d33dffc0c');
const resourceGroup = await screen.findByTestId(
'azure-resource-group-manual'
);
expect(resourceGroup).toHaveValue('');
expect(resourceGroup).toBeEnabled();
await user.type(resourceGroup, 'testGroup');
expect(nextButton).not.toHaveClass('pf-m-disabled');
await user.click(await screen.findByTestId('azure-radio-source'));
await waitFor(() => expect(nextButton).toHaveClass('pf-m-disabled'));
const sourceDropdown = await getSourceDropdown();
// manual values should be cleared out
expect(await screen.findByTestId('azure-tenant-id-source')).toHaveValue('');
expect(
await screen.findByTestId('azure-subscription-id-source')
).toHaveValue('');
await user.click(sourceDropdown);
await user.click(
await screen.findByRole('option', {
name: /azureSource1/i,
})
);
// wait for fetching the upload info
await waitFor(() =>
expect(screen.getByTestId('azure-tenant-id-source')).not.toHaveValue('')
);
await user.click(
await screen.findByRole('textbox', {
name: /select resource group/i,
})
);
const groups = screen.getAllByLabelText(/^Resource group/);
expect(groups).toHaveLength(2);
await user.click(
await screen.findByLabelText('Resource group myResourceGroup1')
);
expect(nextButton).not.toHaveClass('pf-m-disabled');
}, 10000);
test('handles change of selected Source', async () => {
await setUp();
const sourceDropdown = await getSourceDropdown();
await user.click(sourceDropdown);
await user.click(
await screen.findByRole('option', {
name: /azureSource1/i,
})
);
await waitFor(() =>
expect(screen.getByTestId('azure-tenant-id-source')).not.toHaveValue('')
);
await user.click(sourceDropdown);
await user.click(
await screen.findByRole('option', {
name: /azureSource2/i,
})
);
await waitFor(() => {
expect(screen.getByTestId('azure-tenant-id-source')).toHaveValue(
'73d5694c-7a28-417e-9fca-55840084f508'
);
});
await user.click(
await screen.findByRole('textbox', {
name: /select resource group/i,
})
);
const groups = await screen.findByLabelText(/^Resource group/);
expect(groups).toBeInTheDocument();
expect(
await screen.findByLabelText('Resource group theirGroup2')
).toBeVisible();
});
test('component renders error state correctly', async () => {
server.use(
rest.get(`${PROVISIONING_API}/sources`, (req, res, ctx) =>
res(ctx.status(500))
)
);
await setUp();
await screen.findByText(
/Sources cannot be reached, try again later or enter an account info for upload manually\./i
);
});
// set test timeout to 15 seconds
}, 15000);

View file

@ -1,211 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateImageWizard from '../../../Components/CreateImageWizard/CreateImageWizard';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import {
clickNext,
renderCustomRoutesWithReduxRouter,
renderWithReduxRouter,
} from '../../testUtils';
const routes = [
{
path: 'insights/image-builder/*',
element: <div />,
},
{
path: 'insights/image-builder/imagewizard/:composeId?',
element: <CreateImageWizard />,
},
{
path: 'insights/image-builder/share/:composeId',
element: <ShareImageModal />,
},
];
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
auth: {
getUser: () => {
return {
identity: {
internal: {
org_id: 5,
},
},
};
},
},
isBeta: () => true,
isProd: () => false,
getEnvironment: () => 'stage',
}),
}));
jest.mock('@unleash/proxy-client-react', () => ({
useUnleashContext: () => jest.fn(),
useFlag: jest.fn((flag) =>
flag === 'image-builder.wizard.oscap.enabled' ? true : false
),
}));
beforeAll(() => {
// scrollTo is not defined in jsdom
window.HTMLElement.prototype.scrollTo = function () {};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Step Compliance', () => {
const user = userEvent.setup();
const setup = async () => {
renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
};
test('create an image with None oscap profile', async () => {
await setup();
// select aws as upload destination
await user.click(await screen.findByTestId('upload-aws'));
await clickNext();
// aws step
await user.click(
await screen.findByRole('radio', {
name: /manually enter an account id\./i,
})
);
await user.type(
await screen.findByTestId('aws-account-id'),
'012345678901'
);
await clickNext();
// skip registration
await user.click(await screen.findByLabelText('Register later'));
await clickNext();
// Now we should be in the Compliance step
await screen.findByRole('heading', { name: /OpenSCAP/i });
await user.click(
await screen.findByRole('textbox', { name: /select a profile/i })
);
await user.click(await screen.findByText(/none/i));
// check that the FSC does not contain a /tmp partition
await clickNext();
await screen.findByRole('heading', { name: /File system configuration/i });
expect(
screen.queryByRole('cell', {
name: /tmp/i,
})
).not.toBeInTheDocument();
// check that there are no Packages contained when selecting the "None" profile option
await clickNext();
await screen.findByRole('heading', {
name: /Additional Red Hat packages/i,
});
await screen.findByText(/no packages added/i);
});
test('create an image with an oscap profile', async () => {
await setup();
// select aws as upload destination
await user.click(await screen.findByTestId('upload-aws'));
await clickNext();
// aws step
await user.click(
await screen.findByRole('radio', {
name: /manually enter an account id\./i,
})
);
await user.type(
await screen.findByTestId('aws-account-id'),
'012345678901'
);
await clickNext();
// skip registration
await user.click(await screen.findByLabelText('Register later'));
await clickNext();
// Now we should be at the OpenSCAP step
await screen.findByRole('heading', { name: /OpenSCAP/i });
await user.click(
await screen.findByRole('textbox', {
name: /select a profile/i,
})
);
await user.click(
await screen.findByText(
/cis red hat enterprise linux 8 benchmark for level 1 - workstation/i
)
);
await screen.findByText(/kernel arguments:/i);
await screen.findByText(/audit_backlog_limit=8192 audit=1/i);
await screen.findByText(/disabled services:/i);
await screen.findByText(/nfs-server/i);
await screen.findByText(/enabled services:/i);
await screen.findByText(/crond/i);
// check that the FSC contains a /tmp partition
await clickNext();
await screen.findByRole('heading', { name: /File system configuration/i });
await screen.findByText(/tmp/i);
// check that the Packages contains correct packages
await clickNext();
await screen.findByRole('heading', {
name: /Additional Red Hat packages/i,
});
await screen.findByText(/aide/i);
await screen.findByText(/neovim/i);
});
});
describe('On Recreate', () => {
const setup = async () => {
renderWithReduxRouter('imagewizard/1679d95b-8f1d-4982-8c53-8c2afa4ab04c');
};
test('with oscap profile', async () => {
const user = userEvent.setup();
await setup();
await screen.findByRole('button', {
name: /review/i,
});
const createImageButton = await screen.findByRole('button', {
name: /create image/i,
});
await waitFor(() => expect(createImageButton).toBeEnabled());
// check that the FSC contains a /tmp partition
// There are two buttons with the same name but cannot easily select the DDF rendered sidenav.
// The sidenav will be the first node found out of all buttons.
const buttonsFSC = await screen.findAllByRole('button', {
name: /file system configuration/i,
});
await user.click(buttonsFSC[0]);
await screen.findByRole('heading', { name: /file system configuration/i });
await screen.findByText('/tmp');
// check that the Packages contain a nftable package
await clickNext();
await screen.findByRole('heading', {
name: /Additional Red Hat packages/i,
});
await screen.findByText('nftables');
});
});

View file

@ -1,676 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import api from '../../../api.js';
import CreateImageWizard from '../../../Components/CreateImageWizard/CreateImageWizard';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import { mockPkgResultAlphaContentSources } from '../../fixtures/packages';
import {
clickBack,
clickNext,
renderCustomRoutesWithReduxRouter,
renderWithReduxRouter,
} from '../../testUtils';
const routes = [
{
path: 'insights/image-builder/*',
element: <div />,
},
{
path: 'insights/image-builder/imagewizard/:composeId?',
element: <CreateImageWizard />,
},
{
path: 'insights/image-builder/share/:composeId',
element: <ShareImageModal />,
},
];
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
auth: {
getUser: () => {
return {
identity: {
internal: {
org_id: 5,
},
},
};
},
},
isBeta: () => false,
isProd: () => true,
getEnvironment: () => 'prod',
}),
}));
const searchForAvailablePackages = async (searchbox, searchTerm) => {
const user = userEvent.setup();
await user.type(searchbox, searchTerm);
await user.click(
await screen.findByRole('button', {
name: /search button for available packages/i,
})
);
};
const searchForChosenPackages = async (searchbox, searchTerm) => {
const user = userEvent.setup();
if (!searchTerm) {
await user.clear(searchbox);
} else {
await user.type(searchbox, searchTerm);
}
};
let mockContentSourcesEnabled;
jest.mock('@unleash/proxy-client-react', () => ({
useUnleashContext: () => jest.fn(),
useFlag: jest.fn((flag) =>
flag === 'image-builder.enable-content-sources'
? mockContentSourcesEnabled
: false
),
}));
beforeAll(() => {
// scrollTo is not defined in jsdom
window.HTMLElement.prototype.scrollTo = function () {};
mockContentSourcesEnabled = true;
});
afterEach(() => {
jest.clearAllMocks();
mockContentSourcesEnabled = true;
});
describe('Step Packages', () => {
describe('with Content Sources', () => {
const user = userEvent.setup();
const setUp = async () => {
renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select aws as upload destination
await waitFor(
async () => await user.click(await screen.findByTestId('upload-aws'))
);
await clickNext();
// aws step
await user.click(
await screen.findByRole('radio', {
name: /manually enter an account id\./i,
})
);
await user.type(
await screen.findByTestId('aws-account-id'),
'012345678901'
);
await clickNext();
// skip registration
await screen.findByRole('textbox', {
name: 'Select activation key',
});
const registerLaterRadio = await screen.findByTestId(
'registration-radio-later'
);
await user.click(registerLaterRadio);
await clickNext();
// skip fsc
await clickNext();
};
test('search results should be sorted with most relevant results first', async () => {
await setUp();
const view = await screen.findByTestId('search-available-pkgs-input');
const searchbox = await within(view).findByRole('textbox', {
name: /search input/i,
});
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'test');
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = await within(
availablePackagesList
).findAllByRole('option');
expect(availablePackagesItems).toHaveLength(3);
const [firstItem, secondItem, thirdItem] = availablePackagesItems;
expect(firstItem).toHaveTextContent('testsummary for test package');
expect(secondItem).toHaveTextContent('testPkgtest package summary');
expect(thirdItem).toHaveTextContent('lib-testlib-test package summary');
});
test('search results should be sorted after selecting them and then deselecting them', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'test');
await user.click(await screen.findByTestId('available-pkgs-testPkg'));
await user.click(
await screen.findByRole('button', { name: /Add selected/ })
);
await user.click(await screen.findByTestId('selected-pkgs-testPkg'));
await user.click(
await screen.findByRole('button', { name: /Remove selected/ })
);
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = within(availablePackagesList).getAllByRole(
'option'
);
expect(availablePackagesItems).toHaveLength(3);
const [firstItem, secondItem, thirdItem] = availablePackagesItems;
expect(firstItem).toHaveTextContent('testsummary for test package');
expect(secondItem).toHaveTextContent('testPkgtest package summary');
expect(thirdItem).toHaveTextContent('lib-testlib-test package summary');
});
test('search results should be sorted after adding and then removing all packages', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'test');
await user.click(await screen.findByRole('button', { name: /Add all/ }));
await user.click(
await screen.findByRole('button', { name: /Remove all/ })
);
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = await within(
availablePackagesList
).findAllByRole('option');
expect(availablePackagesItems).toHaveLength(3);
const [firstItem, secondItem, thirdItem] = availablePackagesItems;
expect(firstItem).toHaveTextContent('testsummary for test package');
// TODO
expect(thirdItem).toHaveTextContent('lib-testlib-test package summary');
expect(secondItem).toHaveTextContent('testPkgtest package summary');
});
test('removing a single package updates the state correctly', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'test');
await user.click(await screen.findByRole('button', { name: /Add all/ }));
// remove a single package
await user.click(await screen.findByTestId('selected-pkgs-lib-test'));
await user.click(
await screen.findByRole('button', { name: /Remove selected/ })
);
// skip Custom repositories page
clickNext();
// skip name page
clickNext();
// review page
clickNext();
const chosen = await screen.findByTestId('chosen-packages-count');
expect(chosen).toHaveTextContent('2');
});
test('should display empty available state on failed search', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'asdf');
await screen.findByText('No results found');
});
test('should display empty chosen state on failed search', async () => {
await setUp();
const searchboxAvailable = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
const searchboxChosen = screen.getAllByRole('textbox')[1];
await waitFor(() => expect(searchboxAvailable).toBeEnabled());
await user.click(searchboxAvailable);
await searchForAvailablePackages(searchboxAvailable, 'test');
await user.click(await screen.findByRole('button', { name: /Add all/ }));
await user.click(searchboxChosen);
await user.type(searchboxChosen, 'asdf');
expect(await screen.findByText('No packages found')).toBeInTheDocument();
// We need to clear this input in order to not have sideeffects on other tests
await searchForChosenPackages(searchboxChosen, '');
});
test('search results should be sorted alphabetically', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0]; // searching by id doesn't update the input ref
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
const getPackages = jest
.spyOn(api, 'getPackagesContentSources')
.mockImplementation(() =>
Promise.resolve(mockPkgResultAlphaContentSources)
);
await searchForAvailablePackages(searchbox, 'test');
await waitFor(() => expect(getPackages).toHaveBeenCalledTimes(1));
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = within(availablePackagesList).getAllByRole(
'option'
);
expect(availablePackagesItems).toHaveLength(3);
const [firstItem, secondItem, thirdItem] = availablePackagesItems;
expect(firstItem).toHaveTextContent('testsummary for test package');
expect(secondItem).toHaveTextContent('lib-testlib-test package summary');
expect(thirdItem).toHaveTextContent('Z-testZ-test package summary');
});
test('available packages can be reset', async () => {
await setUp();
const searchbox = screen.getAllByRole('textbox')[0];
await waitFor(() => expect(searchbox).toBeEnabled());
await user.click(searchbox);
await searchForAvailablePackages(searchbox, 'test');
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = await within(
availablePackagesList
).findAllByRole('option');
expect(availablePackagesItems).toHaveLength(3);
await user.click(
await screen.findByRole('button', {
name: /clear available packages search/i,
})
);
await screen.findByText(
'Search above to add additionalpackages to your image'
);
});
test('chosen packages can be reset after filtering', async () => {
await setUp();
const availableSearchbox = screen.getAllByRole('textbox')[0];
await waitFor(() => expect(availableSearchbox).toBeEnabled());
await user.click(availableSearchbox);
await searchForAvailablePackages(availableSearchbox, 'test');
const availablePackagesList = await screen.findByTestId(
'available-pkgs-list'
);
const availablePackagesItems = await within(
availablePackagesList
).findAllByRole('option');
expect(availablePackagesItems).toHaveLength(3);
await user.click(await screen.findByRole('button', { name: /Add all/ }));
const chosenPackagesList = await screen.findByTestId('chosen-pkgs-list');
let chosenPackagesItems = await within(chosenPackagesList).findAllByRole(
'option'
);
expect(chosenPackagesItems).toHaveLength(3);
const chosenSearchbox = screen.getAllByRole('textbox')[1];
await user.click(chosenSearchbox);
await searchForChosenPackages(chosenSearchbox, 'lib');
chosenPackagesItems = within(chosenPackagesList).getAllByRole('option');
expect(chosenPackagesItems).toHaveLength(1);
await user.click(
await screen.findByRole('button', {
name: /clear chosen packages search/i,
})
);
chosenPackagesItems = await within(chosenPackagesList).findAllByRole(
'option'
);
await waitFor(() => expect(chosenPackagesItems).toHaveLength(3));
});
});
});
describe('Step Custom repositories', () => {
const user = userEvent.setup();
const setUp = async () => {
renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select aws as upload destination
await user.click(await screen.findByTestId('upload-aws'));
await clickNext();
// aws step
await user.click(
await screen.findByRole('radio', {
name: /manually enter an account id\./i,
})
);
await user.type(
await screen.findByTestId('aws-account-id'),
'012345678901'
);
await clickNext();
// skip registration
await screen.findByRole('textbox', {
name: 'Select activation key',
});
await user.click(await screen.findByLabelText('Register later'));
await clickNext();
// skip fsc
await clickNext();
// skip packages
await clickNext();
};
test('selected repositories stored in and retrieved from form state', async () => {
await setUp();
const getFirstRepoCheckbox = async () =>
await screen.findByRole('checkbox', {
name: /select row 0/i,
});
let firstRepoCheckbox = await getFirstRepoCheckbox();
expect(firstRepoCheckbox.checked).toEqual(false);
await user.click(firstRepoCheckbox);
await waitFor(() => expect(firstRepoCheckbox.checked).toEqual(true));
await clickNext();
await clickBack();
firstRepoCheckbox = await getFirstRepoCheckbox();
await waitFor(() => expect(firstRepoCheckbox.checked).toEqual(true));
});
test('correct number of repositories is fetched', async () => {
await setUp();
await user.click(
await screen.findByRole('button', {
name: /^select$/i,
})
);
await screen.findByText(/select all \(1016 items\)/i);
});
test('filter works', async () => {
await setUp();
await user.type(
await screen.findByRole('textbox', { name: /search repositories/i }),
'2zmya'
);
const table = await screen.findByTestId('repositories-table');
const { getAllByRole } = within(table);
const getRows = () => getAllByRole('row');
let rows = getRows();
// remove first row from list since it is just header labels
rows.shift();
expect(rows).toHaveLength(1);
// clear filter
await user.click(await screen.findByRole('button', { name: /reset/i }));
rows = getRows();
// remove first row from list since it is just header labels
rows.shift();
await waitFor(() => expect(rows).toHaveLength(10));
});
test('press on Selected button to see selected repositories list', async () => {
await setUp();
const getFirstRepoCheckbox = async () =>
await screen.findByRole('checkbox', {
name: /select row 0/i,
});
const firstRepoCheckbox = await getFirstRepoCheckbox();
expect(firstRepoCheckbox.checked).toEqual(false);
await user.click(firstRepoCheckbox);
expect(firstRepoCheckbox.checked).toEqual(true);
const getSelectedButton = async () =>
await screen.findByRole('button', {
name: /selected repositories/i,
});
const selectedButton = await getSelectedButton();
await user.click(selectedButton);
expect(firstRepoCheckbox.checked).toEqual(true);
await clickNext();
clickBack();
expect(firstRepoCheckbox.checked).toEqual(true);
});
test('press on All button to see all repositories list', async () => {
await setUp();
const getFirstRepoCheckbox = async () =>
await screen.findByRole('checkbox', {
name: /select row 0/i,
});
const firstRepoCheckbox = await getFirstRepoCheckbox();
const getSecondRepoCheckbox = async () =>
await screen.findByRole('checkbox', {
name: /select row 1/i,
});
const secondRepoCheckbox = await getSecondRepoCheckbox();
expect(firstRepoCheckbox.checked).toEqual(false);
expect(secondRepoCheckbox.checked).toEqual(false);
await user.click(firstRepoCheckbox);
expect(firstRepoCheckbox.checked).toEqual(true);
expect(secondRepoCheckbox.checked).toEqual(false);
const getAllButton = async () =>
await screen.findByRole('button', {
name: /all repositories/i,
});
const allButton = await getAllButton();
await user.click(allButton);
expect(firstRepoCheckbox.checked).toEqual(true);
expect(secondRepoCheckbox.checked).toEqual(false);
await clickNext();
clickBack();
expect(firstRepoCheckbox.checked).toEqual(true);
expect(secondRepoCheckbox.checked).toEqual(false);
});
test('press on Selected button to see selected repositories list at the second page and filter checked repo', async () => {
await setUp();
const getFirstRepoCheckbox = async () =>
await screen.findByRole('checkbox', {
name: /select row 0/i,
});
const firstRepoCheckbox = await getFirstRepoCheckbox();
const getNextPageButton = async () =>
await screen.findAllByRole('button', {
name: /go to next page/i,
});
const nextPageButton = await getNextPageButton();
expect(firstRepoCheckbox.checked).toEqual(false);
await user.click(firstRepoCheckbox);
expect(firstRepoCheckbox.checked).toEqual(true);
await user.click(nextPageButton[0]);
const getSelectedButton = async () =>
await screen.findByRole('button', {
name: /selected repositories/i,
});
const selectedButton = await getSelectedButton();
await user.click(selectedButton);
expect(firstRepoCheckbox.checked).toEqual(true);
await user.type(
await screen.findByRole('textbox', { name: /search repositories/i }),
'13lk3'
);
expect(firstRepoCheckbox.checked).toEqual(true);
await clickNext();
clickBack();
expect(firstRepoCheckbox.checked).toEqual(true);
await user.click(firstRepoCheckbox);
expect(firstRepoCheckbox.checked).toEqual(false);
});
});
describe('On Recreate', () => {
const user = userEvent.setup();
const setUp = async () => {
renderWithReduxRouter('imagewizard/hyk93673-8dcc-4a61-ac30-e9f4940d8346');
};
const setUpUnavailableRepo = async () => {
renderWithReduxRouter('imagewizard/b7193673-8dcc-4a5f-ac30-e9f4940d8346');
};
test('with valid repositories', async () => {
await setUp();
await screen.findByRole('heading', { name: /review/i });
expect(
screen.queryByText('Previously added custom repository unavailable')
).not.toBeInTheDocument();
const createImageButton = await screen.findByRole('button', {
name: /create image/i,
});
await waitFor(() => expect(createImageButton).toBeEnabled());
await user.click(
await screen.findByRole('button', { name: /custom repositories/i })
);
await screen.findByRole('heading', { name: /custom repositories/i });
expect(
screen.queryByText('Previously added custom repository unavailable')
).not.toBeInTheDocument();
const table = await screen.findByTestId('repositories-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const availableRepo = rows[1].cells[1];
expect(availableRepo).toHaveTextContent(
'13lk3http://yum.theforeman.org/releases/3.4/el8/x86_64/'
);
const availableRepoCheckbox = await screen.findByRole('checkbox', {
name: /select row 0/i,
});
await waitFor(() => expect(availableRepoCheckbox).toBeEnabled());
});
test('with repositories that are no longer available', async () => {
await setUpUnavailableRepo();
await screen.findByRole('heading', { name: /review/i });
await screen.findByText('Previously added custom repository unavailable');
const createImageButton = await screen.findByRole('button', {
name: /create image/i,
});
expect(createImageButton).toBeDisabled();
await user.click(
await screen.findByRole('button', { name: /custom repositories/i })
);
await screen.findByRole('heading', { name: /custom repositories/i });
await screen.findByText('Previously added custom repository unavailable');
const table = await screen.findByTestId('repositories-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const unavailableRepo = rows[1].cells[1];
expect(unavailableRepo).toHaveTextContent(
'Repository with the following url is no longer available:http://unreachable.link.to.repo.org/x86_64/'
);
const unavailableRepoCheckbox = await screen.findByRole('checkbox', {
name: /select row 0/i,
});
expect(unavailableRepoCheckbox).toBeDisabled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,260 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateImageWizard from '../../../../Components/CreateImageWizard/CreateImageWizard';
import { AARCH64, RHEL_8, RHEL_9, X86_64 } from '../../../../constants';
import { mockArchitecturesByDistro } from '../../../fixtures/architectures';
import { server } from '../../../mocks/server';
import { renderCustomRoutesWithReduxRouter } from '../../../testUtils';
const routes = [
{
path: 'insights/image-builder/imagewizard/:composeId?',
element: <CreateImageWizard />,
},
];
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
auth: {
getUser: () => {
return {
identity: {
internal: {
org_id: 5,
},
},
};
},
},
isBeta: () => true,
isProd: () => true,
getEnvironment: () => 'prod',
}),
}));
beforeAll(() => {
// scrollTo is not defined in jsdom
window.HTMLElement.prototype.scrollTo = function () {};
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
describe('Check that the target filtering is in accordance to mock content', () => {
test('rhel9 x86_64', async () => {
const user = userEvent.setup();
await renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select x86_64
const archMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[1];
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'x86_64' }));
// make sure this test is in SYNC with the mocks
let images_types = [];
mockArchitecturesByDistro(RHEL_9).forEach((elem) => {
if (elem.arch === X86_64) {
images_types = elem.image_types;
}
});
expect(images_types).toContain('aws');
expect(images_types).toContain('gcp');
expect(images_types).toContain('azure');
expect(images_types).toContain('guest-image');
expect(images_types).toContain('image-installer');
expect(images_types).toContain('vsphere');
expect(images_types).toContain('vsphere-ova');
expect(images_types).not.toContain('wsl');
// make sure the UX conforms to the mocks
await waitFor(async () => await screen.findByTestId('upload-aws'));
await screen.findByTestId('upload-google');
await screen.findByTestId('upload-azure');
await screen.findByTestId('checkbox-guest-image');
await screen.findByTestId('checkbox-image-installer');
await screen.findByText(/vmware vsphere/i);
await screen.findByText(/open virtualization format \(\.ova\)/i);
expect(
screen.queryByText(/wsl - windows subsystem for linux \(\.tar\.gz\)/i)
).not.toBeInTheDocument();
});
test('rhel8 x86_64', async () => {
const user = userEvent.setup();
await renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select rhel8
const releaseMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[0];
await user.click(releaseMenu);
await user.click(
await screen.findByRole('option', {
name: /Red Hat Enterprise Linux \(RHEL\) 8/,
})
);
// select x86_64
const archMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[1];
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'x86_64' }));
// make sure this test is in SYNC with the mocks
let images_types = [];
mockArchitecturesByDistro(RHEL_8).forEach((elem) => {
if (elem.arch === X86_64) {
images_types = elem.image_types;
}
});
expect(images_types).toContain('aws');
expect(images_types).toContain('gcp');
expect(images_types).toContain('azure');
expect(images_types).toContain('guest-image');
expect(images_types).toContain('image-installer');
expect(images_types).toContain('vsphere');
expect(images_types).toContain('vsphere-ova');
expect(images_types).toContain('wsl');
// make sure the UX conforms to the mocks
await waitFor(async () => await screen.findByTestId('upload-aws'));
await screen.findByTestId('upload-google');
await screen.findByTestId('upload-azure');
await screen.findByTestId('checkbox-guest-image');
await screen.findByTestId('checkbox-image-installer');
await screen.findByText(/vmware vsphere/i);
await screen.findByText(/open virtualization format \(\.ova\)/i);
await screen.findByText(/wsl - windows subsystem for linux \(\.tar\.gz\)/i);
});
test('rhel9 aarch64', async () => {
const user = userEvent.setup();
await renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select aarch64
const archMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[1];
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'aarch64' }));
// make sure this test is in SYNC with the mocks
let images_types = [];
mockArchitecturesByDistro(RHEL_9).forEach((elem) => {
if (elem.arch === AARCH64) {
images_types = elem.image_types;
}
});
expect(images_types).toContain('aws');
expect(images_types).not.toContain('gcp');
expect(images_types).not.toContain('azure');
expect(images_types).toContain('guest-image');
expect(images_types).toContain('image-installer');
expect(images_types).not.toContain('vsphere');
expect(images_types).not.toContain('vsphere-ova');
expect(images_types).not.toContain('wsl');
// make sure the UX conforms to the mocks
await waitFor(async () => await screen.findByTestId('upload-aws'));
expect(screen.queryByTestId('upload-google')).not.toBeInTheDocument();
expect(screen.queryByTestId('upload-azure')).not.toBeInTheDocument();
await screen.findByTestId('checkbox-guest-image');
await screen.findByTestId('checkbox-image-installer');
expect(screen.queryByText(/vmware vsphere/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/open virtualization format \(\.ova\)/i)
).not.toBeInTheDocument();
expect(
screen.queryByText(/wsl - windows subsystem for linux \(\.tar\.gz\)/i)
).not.toBeInTheDocument();
});
test('rhel8 aarch64', async () => {
const user = userEvent.setup();
await renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select rhel8
const releaseMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[0];
await user.click(releaseMenu);
await user.click(
await screen.findByRole('option', {
name: /Red Hat Enterprise Linux \(RHEL\) 8/,
})
);
// select x86_64
const archMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[1];
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'aarch64' }));
// make sure this test is in SYNC with the mocks
let images_types = [];
mockArchitecturesByDistro(RHEL_8).forEach((elem) => {
if (elem.arch === AARCH64) {
images_types = elem.image_types;
}
});
expect(images_types).toContain('aws');
expect(images_types).not.toContain('gcp');
expect(images_types).not.toContain('azure');
expect(images_types).toContain('guest-image');
expect(images_types).toContain('image-installer');
expect(images_types).not.toContain('vsphere');
expect(images_types).not.toContain('vsphere-ova');
expect(images_types).not.toContain('wsl');
// make sure the UX conforms to the mocks
await waitFor(async () => await screen.findByTestId('upload-aws'));
expect(screen.queryByTestId('upload-google')).not.toBeInTheDocument();
expect(screen.queryByTestId('upload-azure')).not.toBeInTheDocument();
await screen.findByTestId('checkbox-guest-image');
await screen.findByTestId('checkbox-image-installer');
expect(screen.queryByText(/vmware vsphere/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/open virtualization format \(\.ova\)/i)
).not.toBeInTheDocument();
expect(
screen.queryByText(/wsl - windows subsystem for linux \(\.tar\.gz\)/i)
).not.toBeInTheDocument();
});
});
describe('Check step consistency', () => {
test('going back and forth with selected options only keeps the one compatible', async () => {
const user = userEvent.setup();
await renderCustomRoutesWithReduxRouter('imagewizard', {}, routes);
// select x86_64
const archMenu = screen.getAllByRole('button', {
name: /options menu/i,
})[1];
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'x86_64' }));
await waitFor(async () => await screen.findByTestId('upload-aws'));
// select GCP, it's available for x86_64
await user.click(await screen.findByTestId('upload-google'));
const next = await screen.findByRole('button', { name: /Next/ });
await waitFor(() => expect(next).toBeEnabled());
// Change to aarch
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'aarch64' }));
await waitFor(async () => await screen.findByTestId('upload-aws'));
// GCP not being compatible with arch, the next button is disabled
await waitFor(() => expect(next).toBeDisabled());
// clicking on AWS the user can go next
await user.click(await screen.findByTestId('upload-aws'));
await waitFor(() => expect(next).toBeEnabled());
// and going back to x86_64 the user should keep the next button visible
await user.click(archMenu);
await user.click(await screen.findByRole('option', { name: 'x86_64' }));
await waitFor(() => expect(next).toBeEnabled());
});
});

View file

@ -1066,7 +1066,7 @@ describe('Step Review', () => {
test('has 3 buttons', async () => {
await setUp();
await screen.findByRole('button', { name: /Create/ });
await screen.findByRole('button', { name: /Create blueprint/ });
await screen.findByRole('button', { name: /Back/ });
await screen.findByRole('button', { name: /Cancel/ });
});

View file

@ -23,10 +23,6 @@ jest.mock('@unleash/proxy-client-react', () => ({
switch (flag) {
case 'edgeParity.image-list':
return false;
case 'image-builder.new-wizard.enabled':
return false;
case 'image-builder.new-wizard.stable':
return false;
default:
return true;
}
@ -60,8 +56,9 @@ describe('Images Table', () => {
expect(headerCells[2]).toHaveTextContent('Updated');
expect(headerCells[3]).toHaveTextContent('OS');
expect(headerCells[4]).toHaveTextContent('Target');
expect(headerCells[5]).toHaveTextContent('Status');
expect(headerCells[6]).toHaveTextContent('Instance');
expect(headerCells[5]).toHaveTextContent('Version');
expect(headerCells[6]).toHaveTextContent('Status');
expect(headerCells[7]).toHaveTextContent('Instance');
const imageNameValues = mockComposes.map((compose) =>
compose.image_name ? compose.image_name : compose.id
@ -79,34 +76,6 @@ describe('Images Table', () => {
// TODO Test remaining table content.
});
test('check recreate action', async () => {
const { router } = await renderWithReduxRouter('', {});
// get rows
const table = await screen.findByTestId('images-table');
const { findAllByRole } = within(table);
const rows = await findAllByRole('row');
const actionsButton = await within(rows[1]).findByRole('button', {
name: 'Kebab toggle',
});
expect(actionsButton).toBeEnabled();
await user.click(actionsButton);
const recreateButton = await screen.findByRole('menuitem', {
name: 'Recreate image',
});
await user.click(recreateButton);
await waitFor(() =>
expect(router.state.location.pathname).toBe(
'/insights/image-builder/imagewizard/1579d95b-8f1d-4982-8c53-8c2afa4ab04c'
)
);
});
test('check download compose request action', async () => {
await renderWithReduxRouter('', {});

View file

@ -20,8 +20,6 @@ jest.mock('@unleash/proxy-client-react', () => ({
switch (flag) {
case 'edgeParity.image-list':
return false;
case 'image-builder.new-wizard.stable':
return false;
default:
return true;
}
@ -33,7 +31,8 @@ describe('Landing Page', () => {
renderWithReduxRouter('', {});
// check heading
await screen.findByRole('heading', { name: /Images/i });
const heading = await screen.findByText('Images');
expect(heading).toHaveRole('heading');
});
test('renders EmptyState child component', async () => {
@ -44,9 +43,6 @@ describe('Landing Page', () => {
);
renderWithReduxRouter('', {});
// check action loads
await screen.findByTestId('create-image-action-empty-state');
// check table loads
await screen.findByTestId('empty-state');
});

View file

@ -6,7 +6,7 @@ import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CreateImageWizard from '../Components/CreateImageWizard/CreateImageWizard';
import ImportImageWizard from '../Components/CreateImageWizardV2/ImportImageWizard';
import LandingPage from '../Components/LandingPage/LandingPage';
import ShareImageModal from '../Components/ShareImageModal/ShareImageModal';
import { middleware, reducer } from '../store';
@ -44,8 +44,8 @@ export const renderWithReduxRouter = async (
element: <LandingPage />,
},
{
path: 'insights/image-builder/imagewizard/:composeId?',
element: <CreateImageWizard />,
path: 'insights/image-builder/imagewizard/import',
element: <ImportImageWizard />,
},
{
path: 'insights/image-builder/share/:composeId',