update style across the project

The eslint updates require style changes in all components.
This commit is contained in:
Jacob Kozol 2022-05-23 11:38:16 +02:00 committed by Sanne Raymaekers
parent 7959f2a563
commit 4fa71cede8
56 changed files with 5973 additions and 5177 deletions

View file

@ -4,38 +4,46 @@ const { GitRevisionPlugin } = require('git-revision-webpack-plugin');
const config = require('@redhat-cloud-services/frontend-components-config');
const webpackProxy = {
useProxy: true,
proxyVerbose: true,
env: `${process.env.STAGE ? 'stage' : 'prod'}-${process.env.BETA ? 'beta' : 'stable'}`,
appUrl: process.env.BETA ? '/beta/insights/image-builder' : '/insights/image-builder',
useProxy: true,
proxyVerbose: true,
env: `${process.env.STAGE ? 'stage' : 'prod'}-${
process.env.BETA ? 'beta' : 'stable'
}`,
appUrl: process.env.BETA
? '/beta/insights/image-builder'
: '/insights/image-builder',
};
const { config: webpackConfig, plugins } = config({
rootFolder: resolve(__dirname, '../'),
debug: true,
modules: [ 'image_builder' ],
useFileHash: false,
sassPrefix: '.imageBuilder, .image_builder',
deployment: process.env.BETA ? 'beta/apps' : 'apps',
...(process.env.PROXY ? webpackProxy : {})
rootFolder: resolve(__dirname, '../'),
debug: true,
modules: ['image_builder'],
useFileHash: false,
sassPrefix: '.imageBuilder, .image_builder',
deployment: process.env.BETA ? 'beta/apps' : 'apps',
...(process.env.PROXY ? webpackProxy : {}),
});
plugins.push(
require('@redhat-cloud-services/frontend-components-config/federated-modules')({
root: resolve(__dirname, '../'),
useFileHash: false,
moduleName: 'image_builder',
exposes: {
'./RootApp': resolve(__dirname, '../src/AppEntry.js'),
},
})
require('@redhat-cloud-services/frontend-components-config/federated-modules')(
{
root: resolve(__dirname, '../'),
useFileHash: false,
moduleName: 'image_builder',
exposes: {
'./RootApp': resolve(__dirname, '../src/AppEntry.js'),
},
}
)
);
plugins.push(new DefinePlugin({
COMMITHASH: JSON.stringify((new GitRevisionPlugin()).commithash()),
}));
plugins.push(
new DefinePlugin({
COMMITHASH: JSON.stringify(new GitRevisionPlugin().commithash()),
})
);
module.exports = {
...webpackConfig,
plugins
...webpackConfig,
plugins,
};

View file

@ -3,26 +3,30 @@ const { resolve } = require('path');
const { GitRevisionPlugin } = require('git-revision-webpack-plugin');
const config = require('@redhat-cloud-services/frontend-components-config');
const { config: webpackConfig, plugins } = config({
rootFolder: resolve(__dirname, '../'),
modules: [ 'image_builder' ],
sassPrefix: '.imageBuilder, .image_builder',
rootFolder: resolve(__dirname, '../'),
modules: ['image_builder'],
sassPrefix: '.imageBuilder, .image_builder',
});
plugins.push(
require('@redhat-cloud-services/frontend-components-config/federated-modules')({
root: resolve(__dirname, '../'),
moduleName: 'image_builder',
exposes: {
'./RootApp': resolve(__dirname, '../src/AppEntry.js'),
},
})
require('@redhat-cloud-services/frontend-components-config/federated-modules')(
{
root: resolve(__dirname, '../'),
moduleName: 'image_builder',
exposes: {
'./RootApp': resolve(__dirname, '../src/AppEntry.js'),
},
}
)
);
plugins.push(new DefinePlugin({
COMMITHASH: JSON.stringify((new GitRevisionPlugin()).commithash()),
}));
plugins.push(
new DefinePlugin({
COMMITHASH: JSON.stringify(new GitRevisionPlugin().commithash()),
})
);
module.exports = {
...webpackConfig,
plugins
...webpackConfig,
plugins,
};

View file

@ -8,28 +8,28 @@ import NotificationsPortal from '@redhat-cloud-services/frontend-components-noti
import { notificationsReducer } from '@redhat-cloud-services/frontend-components-notifications/redux';
const App = (props) => {
const navigate = useNavigate();
const navigate = useNavigate();
useEffect(() => {
const registry = getRegistry();
registry.register({ notifications: notificationsReducer });
document.title = 'Image Builder | Red Hat Insights';
insights.chrome.init();
insights.chrome.identifyApp('image-builder');
const unregister = insights.chrome.on('APP_NAVIGATION', (event) =>
navigate(`/${event.navId}`)
);
return () => {
unregister();
};
}, []);
return (
<React.Fragment>
<NotificationsPortal />
<Router childProps={ props } />
</React.Fragment>
useEffect(() => {
const registry = getRegistry();
registry.register({ notifications: notificationsReducer });
document.title = 'Image Builder | Red Hat Insights';
insights.chrome.init();
insights.chrome.identifyApp('image-builder');
const unregister = insights.chrome.on('APP_NAVIGATION', (event) =>
navigate(`/${event.navId}`)
);
return () => {
unregister();
};
}, []);
return (
<React.Fragment>
<NotificationsPortal />
<Router childProps={props} />
</React.Fragment>
);
};
export default App;

View file

@ -7,11 +7,16 @@ import { getBaseName } from '@redhat-cloud-services/frontend-components-utilitie
import logger from 'redux-logger';
const ImageBuilder = () => (
<Provider store={ init({}, ...[ process.env.NODE_ENV !== 'production' ? logger : undefined ]).getStore() }>
<Router basename={ getBaseName(window.location.pathname) }>
<App />
</Router>
</Provider>
<Provider
store={init(
{},
...[process.env.NODE_ENV !== 'production' ? logger : undefined]
).getStore()}
>
<Router basename={getBaseName(window.location.pathname)}>
<App />
</Router>
</Provider>
);
export default ImageBuilder;

View file

@ -12,469 +12,506 @@ import { composeAdded } from '../../store/actions/actions';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import {
review,
awsTarget,
registration,
googleCloudTarger,
msAzureTarget,
packages,
imageOutput,
fileSystemConfiguration,
imageName
review,
awsTarget,
registration,
googleCloudTarger,
msAzureTarget,
packages,
imageOutput,
fileSystemConfiguration,
imageName,
} from './steps';
import {
fileSystemConfigurationValidator,
targetEnvironmentValidator,
fileSystemConfigurationValidator,
targetEnvironmentValidator,
} from './validators';
const handleKeyDown = (e, handleClose) => {
if (e.key === 'Escape') {
handleClose();
}
if (e.key === 'Escape') {
handleClose();
}
};
const onSave = (values) => {
let customizations = {
packages: values['selected-packages']?.map(p => p.name),
let customizations = {
packages: values['selected-packages']?.map((p) => p.name),
};
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': 'subscription.rhsm.redhat.com',
'base-url': 'https://cdn.redhat.com/',
};
} 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': 'subscription.rhsm.redhat.com',
'base-url': 'https://cdn.redhat.com/',
};
}
if (values['file-system-config-toggle'] === 'manual') {
customizations.filesystem = [];
for (let fsc of values['file-system-configuration']) {
customizations.filesystem.push({
mountpoint: fsc.mountpoint,
min_size: fsc.size * fsc.unit,
});
}
}
let requests = [];
if (values['target-environment']?.aws) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {
share_with_accounts: [values['aws-account-id']],
},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
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;
}
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: [share],
},
},
},
],
customizations,
};
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': 'subscription.rhsm.redhat.com',
'base-url': 'https://cdn.redhat.com/',
};
} 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': 'subscription.rhsm.redhat.com',
'base-url': 'https://cdn.redhat.com/',
};
}
requests.push(request);
}
if (values['file-system-config-toggle'] === 'manual') {
customizations.filesystem = [];
for (let fsc of values['file-system-configuration']) {
customizations.filesystem.push({
mountpoint: fsc.mountpoint,
min_size: fsc.size * fsc.unit,
});
}
}
if (values['target-environment']?.azure) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
resource_group: values['azure-resource-group'],
},
},
},
],
customizations,
};
requests.push(request);
}
let requests = [];
if (values['target-environment']?.aws) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {
share_with_accounts: [ values['aws-account-id'] ],
},
},
}],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.vsphere) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.gcp) {
let share = '';
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;
}
if (values['target-environment']?.['guest-image']) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: [ share ],
},
},
}],
customizations,
};
if (values['target-environment']?.['image-installer']) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations,
};
requests.push(request);
}
requests.push(request);
}
if (values['target-environment']?.azure) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {
tenant_id: values['azure-tenant-id'],
subscription_id: values['azure-subscription-id'],
resource_group: values['azure-resource-group'],
},
},
}],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.vsphere) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
type: 'aws.s3',
options: {}
}
}],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['guest-image']) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {}
}
}],
customizations,
};
requests.push(request);
}
if (values['target-environment']?.['image-installer']) {
let request = {
distribution: values.release,
image_name: values?.['image-name'],
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {}
}
}],
customizations,
};
requests.push(request);
}
return requests;
return requests;
};
const parseSizeUnit = (bytesize) => {
let size;
let unit;
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;
}
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 ];
return [size, unit];
};
const getPackageDescription = async (release, arch, packageName) => {
const args = [
release,
arch,
packageName
];
let { data, meta } = await api.getPackages(...args);
let summary;
const args = [release, arch, packageName];
let { data, meta } = await api.getPackages(...args);
let summary;
// the package should be found in the 0 index
// if not then fetch all package matches and search for the package
if (data[0]?.name === packageName) {
summary = data[0]?.summary;
} else {
if (data?.length !== meta.count) {
({ data } = await api.getPackages(...args, meta.count));
}
const pack = data.find(pack => packageName === pack.name);
summary = pack?.summary;
// the package should be found in the 0 index
// if not then fetch all package matches and search for the package
if (data[0]?.name === packageName) {
summary = data[0]?.summary;
} else {
if (data?.length !== meta.count) {
({ data } = await api.getPackages(...args, meta.count));
}
// if no matching package is found return an empty string for description
return summary || '';
} ;
const pack = data.find((pack) => packageName === pack.name);
summary = pack?.summary;
}
// if no matching package is found return an empty string for description
return summary || '';
};
// map the compose request object to the expected form state
const requestToState = (composeRequest) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
let formState = {};
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
let formState = {};
formState['image-name'] = composeRequest.image_name;
formState['image-name'] = composeRequest.image_name;
formState.release = composeRequest?.distribution;
// set defaults for target environment first
formState['target-environment'] = {
aws: false,
azure: false,
gcp: 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') {
targetEnvironment = imageRequest.image_type;
} else {
targetEnvironment = uploadRequest.type;
}
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
formState['aws-account-id'] = uploadRequest?.options?.share_with_accounts[0];
} else if (targetEnvironment === 'azure') {
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;
}
}
// customizations
// packages
let packs = [];
composeRequest?.customizations?.packages?.forEach(async (packName) => {
const packageDescription = await getPackageDescription(composeRequest?.distribution, imageRequest?.architecture, packName);
const pack = ({
name: packName,
summary: packageDescription
});
packs.push(pack);
});
formState['selected-packages'] = packs;
// filesystem
const fs = composeRequest?.customizations?.filesystem;
if (fs) {
formState['file-system-config-toggle'] = 'manual';
let fileSystemConfiguration = [];
for (let fsc of fs) {
const [ size, unit ] = parseSizeUnit(fsc.min_size);
fileSystemConfiguration.push({
mountpoint: fsc.mountpoint,
size,
unit
});
}
formState['file-system-configuration'] = fileSystemConfiguration;
}
// subscription
const subscription = composeRequest?.customizations?.subscription;
if (subscription) {
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;
} else {
formState['register-system'] = 'register-later';
}
return formState;
formState.release = composeRequest?.distribution;
// set defaults for target environment first
formState['target-environment'] = {
aws: false,
azure: false,
gcp: 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') {
targetEnvironment = imageRequest.image_type;
} else {
return;
targetEnvironment = uploadRequest.type;
}
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
formState['aws-account-id'] =
uploadRequest?.options?.share_with_accounts[0];
} else if (targetEnvironment === 'azure') {
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;
}
}
// customizations
// packages
let packs = [];
composeRequest?.customizations?.packages?.forEach(async (packName) => {
const packageDescription = await getPackageDescription(
composeRequest?.distribution,
imageRequest?.architecture,
packName
);
const pack = {
name: packName,
summary: packageDescription,
};
packs.push(pack);
});
formState['selected-packages'] = packs;
// filesystem
const fs = composeRequest?.customizations?.filesystem;
if (fs) {
formState['file-system-config-toggle'] = 'manual';
let fileSystemConfiguration = [];
for (let fsc of fs) {
const [size, unit] = parseSizeUnit(fsc.min_size);
fileSystemConfiguration.push({
mountpoint: fsc.mountpoint,
size,
unit,
});
}
formState['file-system-configuration'] = fileSystemConfiguration;
}
// subscription
const subscription = composeRequest?.customizations?.subscription;
if (subscription) {
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;
} else {
formState['register-system'] = 'register-later';
}
return formState;
} else {
return;
}
};
const formStepHistory = (composeRequest) => {
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
let steps = [
'image-output'
];
if (composeRequest) {
const imageRequest = composeRequest.image_requests[0];
const uploadRequest = imageRequest.upload_request;
let steps = ['image-output'];
if (uploadRequest.type === 'aws') {
steps.push('aws-target-env');
} else if (uploadRequest.type === 'azure') {
steps.push('azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
steps = steps.concat([
'File system configuration',
'packages',
'image-name'
]);
return steps;
} else {
return [];
if (uploadRequest.type === 'aws') {
steps.push('aws-target-env');
} else if (uploadRequest.type === 'azure') {
steps.push('azure-target-env');
} else if (uploadRequest.type === 'gcp') {
steps.push('google-cloud-target-env');
}
if (isRhel(composeRequest?.distribution)) {
steps.push('registration');
}
steps = steps.concat([
'File system configuration',
'packages',
'image-name',
]);
return steps;
} else {
return [];
}
};
const CreateImageWizard = () => {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const composeRequest = location?.state?.composeRequest;
const initialState = requestToState(composeRequest);
const stepHistory = formStepHistory(composeRequest);
const composeRequest = location?.state?.composeRequest;
const initialState = requestToState(composeRequest);
const stepHistory = formStepHistory(composeRequest);
const handleClose = () => navigate('/');
const handleClose = () => navigate('/');
return <ImageCreator
onClose={ handleClose }
onSubmit={ ({ values, setIsSaving }) => {
setIsSaving(() => true);
const requests = onSave(values);
Promise.all(requests.map(request => api.composeImage(request).then((response) => {
dispatch(composeAdded({
return (
<ImageCreator
onClose={handleClose}
onSubmit={({ values, setIsSaving }) => {
setIsSaving(() => true);
const requests = onSave(values);
Promise.all(
requests.map((request) =>
api.composeImage(request).then((response) => {
dispatch(
composeAdded(
{
...response,
request,
image_status: { status: 'pending' }
}, true));
})))
.then(() => {
navigate('/');
dispatch(addNotification({
variant: 'success',
title: 'Your image is being created',
}));
image_status: { status: 'pending' },
},
true
)
);
})
)
)
.then(() => {
navigate('/');
dispatch(
addNotification({
variant: 'success',
title: 'Your image is being created',
})
);
setIsSaving(false);
})
.catch((err) => {
dispatch(addNotification({
variant: 'danger',
title: 'Your image could not be created',
description: 'Status code ' + err.response.status + ': ' + err.response.statusText,
}));
setIsSaving(false);
})
.catch((err) => {
dispatch(
addNotification({
variant: 'danger',
title: 'Your image could not be created',
description:
'Status code ' +
err.response.status +
': ' +
err.response.statusText,
})
);
setIsSaving(false);
});
} }
defaultArch="x86_64"
customValidatorMapper={ { fileSystemConfigurationValidator, targetEnvironmentValidator } }
schema={ {
setIsSaving(false);
});
}}
defaultArch="x86_64"
customValidatorMapper={{
fileSystemConfigurationValidator,
targetEnvironmentValidator,
}}
schema={{
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'image_builder',
isDynamic: true,
inModal: true,
onKeyDown: (e) => {
handleKeyDown(e, handleClose);
},
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
title: 'Create image',
crossroads: ['target-environment', 'release'],
description: (
<>
Image builder allows you to create a custom image and push it to
target environments. <DocumentationButton />
</>
),
// order in this array does not reflect order in wizard nav, this order is managed inside
// of each step by `nextStep` property!
fields: [
{
component: componentTypes.WIZARD,
name: 'image-builder-wizard',
className: 'image_builder',
isDynamic: true,
inModal: true,
onKeyDown: (e) => { handleKeyDown(e, handleClose); },
buttonLabels: {
submit: 'Create image',
},
showTitles: true,
title: 'Create image',
crossroads: [ 'target-environment', 'release' ],
description: <>Image builder allows you to create a custom image and push it to target environments. <DocumentationButton /></>,
// 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,
googleCloudTarger,
msAzureTarget,
registration,
packages,
fileSystemConfiguration,
imageName,
review,
],
initialState: {
activeStep: location?.state?.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 } />;
imageOutput,
awsTarget,
googleCloudTarger,
msAzureTarget,
registration,
packages,
fileSystemConfiguration,
imageName,
review,
],
initialState: {
activeStep: location?.state?.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}
/>
);
};
export default CreateImageWizard;

View file

@ -16,52 +16,71 @@ import FileSystemConfiguration from './formComponents/FileSystemConfiguration';
import FileSystemConfigToggle from './formComponents/FileSystemConfigToggle';
import ImageOutputReleaseSelect from './formComponents/ImageOutputReleaseSelect';
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: Packages,
defaultArch
},
'radio-popover': RadioWithPopover,
'azure-auth-expandable': AzureAuthExpandable,
'azure-auth-button': AzureAuthButton,
'activation-keys': ActivationKeys,
'file-system-config-toggle': FileSystemConfigToggle,
'file-system-configuration': FileSystemConfiguration,
'image-output-release-select': ImageOutputReleaseSelect,
...customComponentMapper,
} }
onCancel={ onClose }
{ ...props } /> : <Spinner />;
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: Packages,
defaultArch,
},
'radio-popover': RadioWithPopover,
'azure-auth-expandable': AzureAuthExpandable,
'azure-auth-button': AzureAuthButton,
'activation-keys': ActivationKeys,
'file-system-config-toggle': FileSystemConfigToggle,
'file-system-configuration': FileSystemConfiguration,
'image-output-release-select': ImageOutputReleaseSelect,
...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
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,71 +1,86 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { FormGroup, Spinner, Select, SelectOption, SelectVariant } from '@patternfly/react-core';
import {
FormGroup,
Spinner,
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import api from '../../../api';
const ActivationKeys = ({ label, isRequired, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [ activationKeys, setActivationKeys ] = useState([]);
const [ isOpen, setIsOpen ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ activationKeySelected, selectActivationKey ] = useState(getState()?.values?.['subscription-activation-key']);
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [activationKeys, setActivationKeys] = useState([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [activationKeySelected, selectActivationKey] = useState(
getState()?.values?.['subscription-activation-key']
);
useEffect(() => {
setIsLoading(true);
const data = api.getActivationKeys();
data.then(keys => {
setActivationKeys(keys);
setIsLoading(false);
});
}, []);
useEffect(() => {
setIsLoading(true);
const data = api.getActivationKeys();
data.then((keys) => {
setActivationKeys(keys);
setIsLoading(false);
});
}, []);
const setActivationKey = (_, selection) => {
selectActivationKey(selection);
setIsOpen(false);
change(input.name, selection);
};
const setActivationKey = (_, selection) => {
selectActivationKey(selection);
setIsOpen(false);
change(input.name, selection);
};
const handleClear = () => {
selectActivationKey();
change(input.name, undefined);
};
const handleClear = () => {
selectActivationKey();
change(input.name, undefined);
};
return (
<FormGroup isRequired={ isRequired } label={ label } data-testid='subscription-activation-key'>
<Select
variant={ SelectVariant.typeahead }
onToggle={ () => setIsOpen(!isOpen) }
onSelect={ setActivationKey }
onClear={ handleClear }
selections={ activationKeySelected }
isOpen={ isOpen }
placeholderText="Select activation key"
typeAheadAriaLabel="Select activation key">
{isLoading &&
<SelectOption isNoResultsOption={ true } data-testid='activation-keys-loading'>
<Spinner isSVG size="lg" />
</SelectOption>
}
{activationKeys.map((key, index) => (
<SelectOption
key={ index }
value={ key.name } />
))}
</Select>
</FormGroup>);
return (
<FormGroup
isRequired={isRequired}
label={label}
data-testid="subscription-activation-key"
>
<Select
variant={SelectVariant.typeahead}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setActivationKey}
onClear={handleClear}
selections={activationKeySelected}
isOpen={isOpen}
placeholderText="Select activation key"
typeAheadAriaLabel="Select activation key"
>
{isLoading && (
<SelectOption
isNoResultsOption={true}
data-testid="activation-keys-loading"
>
<Spinner isSVG size="lg" />
</SelectOption>
)}
{activationKeys.map((key, index) => (
<SelectOption key={index} value={key.name} />
))}
</Select>
</FormGroup>
);
};
ActivationKeys.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool
label: PropTypes.node,
isRequired: PropTypes.bool,
};
ActivationKeys.defaultProps = {
label: '',
isRequired: false
label: '',
isRequired: false,
};
export default ActivationKeys;

View file

@ -3,24 +3,32 @@ import { Button, FormGroup } from '@patternfly/react-core';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
const AzureAuthButton = () => {
const { getState } = useFormApi();
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');
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>);
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

@ -3,34 +3,41 @@ import { Button, ExpandableSection, Text, Title } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
const AzureAuthExpandable = () => {
const [ expanded, setExpanded ] = useState(true);
const [expanded, setExpanded] = useState(true);
return (
<>
<ExpandableSection
className='azureAuthExpandable'
toggleText={ <Title headingLevel="h3">Authorizing an Azure account</Title> }
onToggle={ () => setExpanded(!expanded) }
isExpanded={ expanded }>
<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; to at least one resource group.<br />
</Text>
<small>
<Button
component="a"
target="_blank"
variant="link"
icon={ <ExternalLinkAltIcon /> }
iconPosition="right"
isInline
href="https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow">
Learn more about OAuth 2.0
</Button>
</small>
</ExpandableSection>
</>);
return (
<>
<ExpandableSection
className="azureAuthExpandable"
toggleText={
<Title headingLevel="h3">Authorizing an Azure account</Title>
}
onToggle={() => setExpanded(!expanded)}
isExpanded={expanded}
>
<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; to at least one resource group.
<br />
</Text>
<small>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href="https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow"
>
Learn more about OAuth 2.0
</Button>
</small>
</ExpandableSection>
</>
);
};
export default AzureAuthExpandable;

View file

@ -4,45 +4,62 @@ import { FormSpy } from '@data-driven-forms/react-form-renderer';
import WizardContext from '@data-driven-forms/react-form-renderer/wizard-context';
import PropTypes from 'prop-types';
const CustomButtons = ({ buttonLabels: { cancel, submit, back }}) => {
const [ isSaving, setIsSaving ] = useState(false);
const { handlePrev, formOptions } = useContext(WizardContext);
return <FormSpy>
{() => (
<React.Fragment>
<Button
variant="primary"
type="button"
isDisabled={ !formOptions.valid || formOptions.getState().validating || isSaving }
isLoading={ isSaving }
onClick={ () => {
formOptions.onSubmit({
values: formOptions.getState().values,
setIsSaving
});
} }>
{ isSaving ? 'Creating image' : submit}
</Button>
<Button type="button" variant="secondary" onClick={ handlePrev } isDisabled={ isSaving }>
{back}
</Button>
<div className="pf-c-wizard__footer-cancel">
<Button type="button" variant="link" onClick={ formOptions.onCancel } isDisabled={ isSaving }>
{cancel}
</Button>
</div>
</React.Fragment>
)}
</FormSpy>;
const CustomButtons = ({ buttonLabels: { cancel, submit, back } }) => {
const [isSaving, setIsSaving] = useState(false);
const { handlePrev, formOptions } = useContext(WizardContext);
return (
<FormSpy>
{() => (
<React.Fragment>
<Button
variant="primary"
type="button"
isDisabled={
!formOptions.valid ||
formOptions.getState().validating ||
isSaving
}
isLoading={isSaving}
onClick={() => {
formOptions.onSubmit({
values: formOptions.getState().values,
setIsSaving,
});
}}
>
{isSaving ? 'Creating image' : submit}
</Button>
<Button
type="button"
variant="secondary"
onClick={handlePrev}
isDisabled={isSaving}
>
{back}
</Button>
<div className="pf-c-wizard__footer-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,
}),
isSaving: PropTypes.bool
buttonLabels: PropTypes.shape({
cancel: PropTypes.node,
submit: PropTypes.node,
back: PropTypes.node,
}),
isSaving: PropTypes.bool,
};
export default CustomButtons;

View file

@ -1,48 +1,46 @@
import React, {
useState,
useEffect,
} from 'react';
import {
ToggleGroup,
ToggleGroupItem,
} from '@patternfly/react-core';
import React, { useState, useEffect } from 'react';
import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
const FileSystemConfigToggle = ({ ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [ selected, setSelected ] =
useState(getState()?.values?.['file-system-config-toggle'] || 'auto');
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [selected, setSelected] = useState(
getState()?.values?.['file-system-config-toggle'] || 'auto'
);
useEffect(() => {
change(input.name, selected);
}, [ selected ]);
useEffect(() => {
change(input.name, selected);
}, [selected]);
const onClick = (_, evt) => {
setSelected(evt.currentTarget.id);
};
const onClick = (_, evt) => {
setSelected(evt.currentTarget.id);
};
return (
<>
<ToggleGroup
data-testid="fsc-paritioning-toggle"
aria-label="Automatic partitioning toggle">
<ToggleGroupItem
onChange={ onClick }
text="Use automatic partitioning"
buttonId="auto"
isSelected={ selected === 'auto' } />
<ToggleGroupItem
onChange={ onClick }
text="Manually configure partitions"
buttonId="manual"
isSelected={ selected === 'manual' }
data-testid="file-system-config-toggle-manual" />
</ToggleGroup>
</>
);
return (
<>
<ToggleGroup
data-testid="fsc-paritioning-toggle"
aria-label="Automatic partitioning toggle"
>
<ToggleGroupItem
onChange={onClick}
text="Use automatic partitioning"
buttonId="auto"
isSelected={selected === 'auto'}
/>
<ToggleGroupItem
onChange={onClick}
text="Manually configure partitions"
buttonId="manual"
isSelected={selected === 'manual'}
data-testid="file-system-config-toggle-manual"
/>
</ToggleGroup>
</>
);
};
export default FileSystemConfigToggle;

View file

@ -1,24 +1,24 @@
import React, {
useEffect,
useState,
useRef,
} from 'react';
import { HelpIcon, MinusCircleIcon, PlusCircleIcon } from '@patternfly/react-icons';
import React, { useEffect, useState, useRef } from 'react';
import {
Alert,
Button,
Popover,
Text,
TextContent,
TextVariants,
HelpIcon,
MinusCircleIcon,
PlusCircleIcon,
} from '@patternfly/react-icons';
import {
Alert,
Button,
Popover,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import {
TableComposable,
Thead,
Tbody,
Tr,
Th,
Td,
TableComposable,
Thead,
Tbody,
Tr,
Th,
Td,
} from '@patternfly/react-table';
import styles from '@patternfly/react-styles/css/components/Table/table';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
@ -30,298 +30,352 @@ import MountPoint from './MountPoint';
import SizeUnit from './SizeUnit';
let initialRow = {
id: uuidv4(),
mountpoint: '/',
fstype: 'xfs',
size: 10,
unit: UNIT_GIB,
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 { 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]);
useEffect(() => {
const fsc = getState()?.values?.['file-system-configuration'];
if (!fsc) {
return;
useEffect(() => {
const fsc = getState()?.values?.['file-system-configuration'];
if (!fsc) {
return;
}
const newRows = [];
const newOrder = [];
fsc.map((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);
}, []);
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,
};
}
}
const newRows = [];
const newOrder = [];
fsc.map(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);
}, []);
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,
};
}
}
}));
}, [ rows, itemOrder ]);
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 => {
let removeIndex = rows.map(e => e.id).indexOf(id);
let newRows = [ ...rows ];
newRows.splice(removeIndex, 1);
let removeOrderIndex = itemOrder.indexOf(id);
let 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) => {
let newRows = [ ...rows ];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
let newRow = { ...newRows[i] };
newRow.mountpoint = mp;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
const setSize = (id, s, u) => {
let newRows = [ ...rows ];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
let newRow = { ...newRows[i] };
newRow.size = s;
newRow.unit = u;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
return (
<>
<TextContent>
<Text component={ TextVariants.h3 }>Configure partitions</Text>
</TextContent>
{ rows.length > 1 && getState()?.errors?.['file-system-configuration']?.duplicates &&
<Alert variant="danger" isInline
title="Duplicate mount points: All mount points must be unique. Remove the duplicate or choose a new mount point." />
}
{ rows.length >= 1 && getState()?.errors?.['file-system-configuration']?.root === false &&
<Alert variant="danger" isInline
title="No root partition configured." />
}
<TextContent>
<Text>
Partitions have been generated and given default values based on best practices from Red Hat,
and your selections in previous steps of the wizard.
</Text>
</TextContent>
<TableComposable aria-label="File system table" className={ isDragging && styles.modifiers.dragOver } variant="compact">
<Thead>
<Tr>
<Th />
<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 />
</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 &&
getState().errors['file-system-configuration']?.duplicates.indexOf(row.mountpoint) !== -1 &&
<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) } />
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
<TextContent>
<Button
data-testid="file-system-add-partition"
className="pf-u-text-align-left"
variant="link"
icon={ <PlusCircleIcon /> }
onClick={ addRow }>
Add partition
</Button>
</TextContent>
</>
})
);
}, [rows, itemOrder]);
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) => {
let removeIndex = rows.map((e) => e.id).indexOf(id);
let newRows = [...rows];
newRows.splice(removeIndex, 1);
let removeOrderIndex = itemOrder.indexOf(id);
let 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) => {
let newRows = [...rows];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
let newRow = { ...newRows[i] };
newRow.mountpoint = mp;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
const setSize = (id, s, u) => {
let newRows = [...rows];
for (let i = 0; i < newRows.length; i++) {
if (newRows[i].id === id) {
let newRow = { ...newRows[i] };
newRow.size = s;
newRow.unit = u;
newRows.splice(i, 1, newRow);
break;
}
}
setRows(newRows);
};
return (
<>
<TextContent>
<Text component={TextVariants.h3}>Configure partitions</Text>
</TextContent>
{rows.length > 1 &&
getState()?.errors?.['file-system-configuration']?.duplicates && (
<Alert
variant="danger"
isInline
title="Duplicate mount points: All mount points must be unique. Remove the duplicate or choose a new mount point."
/>
)}
{rows.length >= 1 &&
getState()?.errors?.['file-system-configuration']?.root === false && (
<Alert
variant="danger"
isInline
title="No root partition configured."
/>
)}
<TextContent>
<Text>
Partitions have been generated and given default values based on best
practices from Red Hat, and your selections in previous steps of the
wizard.
</Text>
</TextContent>
<TableComposable
aria-label="File system table"
className={isDragging && styles.modifiers.dragOver}
variant="compact"
>
<Thead>
<Tr>
<Th />
<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 />
</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 &&
getState().errors[
'file-system-configuration'
]?.duplicates.indexOf(row.mountpoint) !== -1 && (
<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)}
/>
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
<TextContent>
<Button
data-testid="file-system-add-partition"
className="pf-u-text-align-left"
variant="link"
icon={<PlusCircleIcon />}
onClick={addRow}
>
Add partition
</Button>
</TextContent>
</>
);
};
export default FileSystemConfiguration;

View file

@ -1,58 +1,64 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { FormGroup, Select, SelectOption, SelectVariant } from '@patternfly/react-core';
import {
FormGroup,
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import { RELEASES } from '../../../constants';
import isRhel from '../../../Utilities/isRhel';
const ImageOutputReleaseSelect = ({ label, isRequired, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [ isOpen, setIsOpen ] = useState(false);
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const setRelease = (_, selection) => {
change(input.name, selection);
setIsOpen(false);
};
const setRelease = (_, selection) => {
change(input.name, selection);
setIsOpen(false);
};
const handleClear = () => {
change(input.name, null);
};
const handleClear = () => {
change(input.name, null);
};
return (
<FormGroup isRequired={ isRequired } label={ label }>
<Select
variant={ SelectVariant.single }
onToggle={ () => setIsOpen(!isOpen) }
onSelect={ setRelease }
onClear={ handleClear }
selections={ RELEASES[getState()?.values?.[input.name]] }
isOpen={ isOpen }>
{
Object.entries(RELEASES)
.filter(([ key ]) => {
// Only show non-RHEL distros in beta
if (insights.chrome.isBeta()) {
return true;
}
return (
<FormGroup isRequired={isRequired} label={label}>
<Select
variant={SelectVariant.single}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setRelease}
onClear={handleClear}
selections={RELEASES[getState()?.values?.[input.name]]}
isOpen={isOpen}
>
{Object.entries(RELEASES)
.filter(([key]) => {
// Only show non-RHEL distros in beta
if (insights.chrome.isBeta()) {
return true;
}
return isRhel(key);
})
.map(([ key, release ], index) => {
return <SelectOption key={ index } value={ key }>
{ release }
</SelectOption>;
})
}
</Select>
</FormGroup>
);
return isRhel(key);
})
.map(([key, release], index) => {
return (
<SelectOption key={index} value={key}>
{release}
</SelectOption>
);
})}
</Select>
</FormGroup>
);
};
ImageOutputReleaseSelect.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool
label: PropTypes.node,
isRequired: PropTypes.bool,
};
export default ImageOutputReleaseSelect;

View file

@ -1,86 +1,95 @@
import React, {
useEffect,
useState,
} from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Select,
SelectOption,
SelectVariant,
TextInput,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import path from 'path';
const MountPoint = ({ ...props }) => {
// check '/' last!
const validPrefixes = [ '/app', '/data', '/home', '/opt', '/srv', '/tmp', '/usr', '/usr/local', '/var', '/' ];
const [ isOpen, setIsOpen ] = useState(false);
const [ prefix, setPrefix ] = useState('/');
const [ suffix, setSuffix ] = useState('');
// check '/' last!
const validPrefixes = [
'/app',
'/data',
'/home',
'/opt',
'/srv',
'/tmp',
'/usr',
'/usr/local',
'/var',
'/',
];
const [isOpen, setIsOpen] = useState(false);
const [prefix, setPrefix] = useState('/');
const [suffix, setSuffix] = useState('');
// split
useEffect(() => {
for (let p of validPrefixes) {
if (props.mountpoint.startsWith(p)) {
setPrefix(p);
setSuffix(props.mountpoint.substring(p.length));
return;
}
}
}, []);
// split
useEffect(() => {
for (let p of validPrefixes) {
if (props.mountpoint.startsWith(p)) {
setPrefix(p);
setSuffix(props.mountpoint.substring(p.length));
return;
}
}
}, []);
useEffect(() => {
let suf = suffix;
let mp = prefix;
if (suf) {
if (mp !== '/' && suf[0] !== '/') {
suf = '/' + suf;
}
useEffect(() => {
let suf = suffix;
let mp = prefix;
if (suf) {
if (mp !== '/' && suf[0] !== '/') {
suf = '/' + suf;
}
mp += suf;
}
mp += suf;
}
props.onChange(path.normalize(mp));
}, [ prefix, suffix ]);
props.onChange(path.normalize(mp));
}, [prefix, suffix]);
const onToggle = (isOpen) => {
setIsOpen(isOpen);
};
const onToggle = (isOpen) => {
setIsOpen(isOpen);
};
const onSelect = (event, selection) => {
setPrefix(selection);
setIsOpen(false);
};
const onSelect = (event, selection) => {
setPrefix(selection);
setIsOpen(false);
};
return (
<>
<Select
className="pf-u-w-50"
isOpen={ isOpen }
onToggle={ onToggle }
onSelect={ onSelect }
selections={ prefix }
variant={ SelectVariant.single }>
{validPrefixes.map((pfx, index) => {
return <SelectOption key={ index } value={ pfx } />;
})
}
</Select>
{ prefix !== '/' &&
<TextInput
className="pf-u-w-50"
type="text"
value={ suffix }
aria-label="Mount point suffix text input"
onChange={ v => setSuffix(v) } />
}
</>
);
return (
<>
<Select
className="pf-u-w-50"
isOpen={isOpen}
onToggle={onToggle}
onSelect={onSelect}
selections={prefix}
variant={SelectVariant.single}
>
{validPrefixes.map((pfx, index) => {
return <SelectOption key={index} value={pfx} />;
})}
</Select>
{prefix !== '/' && (
<TextInput
className="pf-u-w-50"
type="text"
value={suffix}
aria-label="Mount point suffix text input"
onChange={(v) => setSuffix(v)}
/>
)}
</>
);
};
MountPoint.propTypes = {
mountpoint: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
mountpoint: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default MountPoint;

View file

@ -4,391 +4,459 @@ import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import api from '../../../api';
import PropTypes from 'prop-types';
import {
DualListSelector,
DualListSelectorPane,
DualListSelectorList,
DualListSelectorListItem,
DualListSelectorControlsWrapper,
DualListSelectorControl,
SearchInput,
TextContent
DualListSelector,
DualListSelectorPane,
DualListSelectorList,
DualListSelectorListItem,
DualListSelectorControlsWrapper,
DualListSelectorControl,
SearchInput,
TextContent,
} from '@patternfly/react-core';
import { AngleDoubleLeftIcon, AngleLeftIcon, AngleDoubleRightIcon, AngleRightIcon } from '@patternfly/react-icons';
import {
AngleDoubleLeftIcon,
AngleLeftIcon,
AngleDoubleRightIcon,
AngleRightIcon,
} from '@patternfly/react-icons';
// the fields isHidden and isSelected should not be included in the package list sent for image creation
const removePackagesDisplayFields = (packages) => packages.map((pack) => ({
const removePackagesDisplayFields = (packages) =>
packages.map((pack) => ({
name: pack.name,
summary: pack.summary,
}));
}));
const Packages = ({ defaultArch, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [ packagesSearchName, setPackagesSearchName ] = useState(undefined);
const [ filterAvailable, setFilterAvailable ] = useState(undefined);
const [ filterChosen, setFilterChosen ] = useState(undefined);
const [ packagesAvailable, setPackagesAvailable ] = useState([]);
const [ packagesAvailableFound, setPackagesAvailableFound ] = useState(true);
const [ packagesChosen, setPackagesChosen ] = useState([]);
const [ packagesChosenFound, setPackagesChosenFound ] = useState(true);
const [ focus, setFocus ] = useState('');
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [packagesSearchName, setPackagesSearchName] = useState(undefined);
const [filterAvailable, setFilterAvailable] = useState(undefined);
const [filterChosen, setFilterChosen] = useState(undefined);
const [packagesAvailable, setPackagesAvailable] = useState([]);
const [packagesAvailableFound, setPackagesAvailableFound] = useState(true);
const [packagesChosen, setPackagesChosen] = useState([]);
const [packagesChosenFound, setPackagesChosenFound] = useState(true);
const [focus, setFocus] = useState('');
// this effect only triggers on mount
useEffect(() => {
const selectedPackages = getState()?.values?.['selected-packages'];
if (selectedPackages) {
setPackagesChosen(selectedPackages);
}
}, []);
// this effect only triggers on mount
useEffect(() => {
const selectedPackages = getState()?.values?.['selected-packages'];
if (selectedPackages) {
setPackagesChosen(selectedPackages);
}
}, []);
const searchResultsComparator = useCallback((searchTerm) => {
return (a, b) => {
a = a.name.toLowerCase();
b = b.name.toLowerCase();
const searchResultsComparator = useCallback((searchTerm) => {
return (a, b) => {
a = a.name.toLowerCase();
b = b.name.toLowerCase();
// check exact match first
if (a === searchTerm) {
return -1;
}
// check exact match first
if (a === searchTerm) {
return -1;
}
if (b === 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;
}
// 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 (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 both (or neither) start with the search term
// sort alphabetically
if (a < b) {
return -1;
}
if (b < a) {
return 1;
}
if (b < a) {
return 1;
}
return 0;
};
return 0;
};
});
const setPackagesAvailableSorted = (
packageList,
filter = filterAvailable
) => {
const sortResults = packageList.sort(searchResultsComparator(filter));
setPackagesAvailable(sortResults);
};
const setPackagesChosenSorted = (packageList) => {
const sortResults = packageList.sort(searchResultsComparator(filterChosen));
setPackagesChosen(sortResults);
};
// filter the packages by name
const filterPackagesAvailable = (packageList) => {
return packageList.filter((availablePackage) => {
// returns true if no packages in the available or chosen list have the same name
return !packagesChosen.some(
(chosenPackage) => availablePackage.name === chosenPackage.name
);
});
};
const getAllPackages = async () => {
const args = [
getState()?.values?.release,
getState()?.values?.architecture || defaultArch,
packagesSearchName,
];
let { data, meta } = await api.getPackages(...args);
if (data?.length === meta.count) {
return data;
} else if (data) {
({ data } = await api.getPackages(...args, meta.count));
return data;
}
};
// call api to list available packages
const handlePackagesAvailableSearch = async () => {
setFilterAvailable(packagesSearchName);
const packageList = await getAllPackages();
if (packageList) {
const packagesAvailableFiltered = filterPackagesAvailable(packageList);
setPackagesAvailableSorted(packagesAvailableFiltered, packagesSearchName);
setPackagesAvailableFound(
packagesAvailableFiltered.length ? true : false
);
} else {
setPackagesAvailable([]);
setPackagesAvailableFound(false);
}
};
// filter displayed selected packages
const handlePackagesChosenSearch = (val) => {
let found = false;
const filteredPackagesChosen = packagesChosen.map((pack) => {
if (!pack.name.includes(val)) {
pack.isHidden = true;
} else {
pack.isHidden = false;
found = true;
}
return pack;
});
const setPackagesAvailableSorted = (packageList, filter = filterAvailable) => {
const sortResults = packageList.sort(searchResultsComparator(filter));
setPackagesAvailable(sortResults);
setFilterChosen(val);
setPackagesChosenFound(found);
setPackagesChosenSorted(filteredPackagesChosen);
};
const keydownHandler = (event) => {
if (event.key === 'Enter') {
if (focus === 'available') {
event.stopPropagation();
handlePackagesAvailableSearch();
}
}
};
useEffect(() => {
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
};
});
const setPackagesChosenSorted = (packageList) => {
const sortResults = packageList.sort(searchResultsComparator(filterChosen));
setPackagesChosen(sortResults);
};
const areFound = (filter, packageList) => {
if (filter === undefined) {
return true;
} else if (packageList.some((pack) => pack.name.includes(filter))) {
return true;
} else {
return false;
}
};
// filter the packages by name
const filterPackagesAvailable = (packageList) => {
return packageList.filter((availablePackage) => {
// returns true if no packages in the available or chosen list have the same name
return !packagesChosen.some((chosenPackage) => availablePackage.name === chosenPackage.name);
});
};
const isHidden = (filter, pack) =>
filter && !pack.name.includes(filter) ? true : false;
const getAllPackages = async () => {
const args = [
getState()?.values?.release,
getState()?.values?.architecture || defaultArch,
packagesSearchName
];
let { data, meta } = await api.getPackages(...args);
if (data?.length === meta.count) {
return data;
} else if (data) {
({ data } = await api.getPackages(...args, meta.count));
return data;
}
};
// call api to list available packages
const handlePackagesAvailableSearch = async () => {
setFilterAvailable(packagesSearchName);
const packageList = await getAllPackages();
if (packageList) {
const packagesAvailableFiltered = filterPackagesAvailable(packageList);
setPackagesAvailableSorted(packagesAvailableFiltered, packagesSearchName);
setPackagesAvailableFound(packagesAvailableFiltered.length ? true : false);
} else {
setPackagesAvailable([]);
setPackagesAvailableFound(false);
}
};
// filter displayed selected packages
const handlePackagesChosenSearch = (val) => {
let found = false;
const filteredPackagesChosen = packagesChosen.map((pack) => {
if (!pack.name.includes(val)) {
pack.isHidden = true;
} else {
pack.isHidden = false;
found = true;
}
return pack;
});
setFilterChosen(val);
setPackagesChosenFound(found);
setPackagesChosenSorted(filteredPackagesChosen);
};
const keydownHandler = (event) => {
if (event.key === 'Enter') {
if (focus === 'available') {
event.stopPropagation();
handlePackagesAvailableSearch();
}
}
};
useEffect(() => {
document.addEventListener('keydown', keydownHandler, true);
return () => {
document.removeEventListener('keydown', keydownHandler, true);
};
});
const areFound = (filter, packageList) => {
if (filter === undefined) {
return true;
} else if (packageList.some(pack => pack.name.includes(filter))) {
return true;
} else {
return false;
}
};
const isHidden = (filter, pack) => filter && !pack.name.includes(filter) ? true : false;
const updateState = (updatedPackagesAvailable, updatedPackagesChosen) => {
setPackagesChosenSorted(updatedPackagesChosen);
setPackagesAvailableSorted(updatedPackagesAvailable);
setPackagesAvailableFound(areFound(filterAvailable, updatedPackagesAvailable));
setPackagesChosenFound(areFound(filterChosen, updatedPackagesChosen));
// set the steps field to the current chosen packages list
change(input.name, removePackagesDisplayFields(updatedPackagesChosen));
};
const moveSelectedToChosen = () => {
const newPackagesChosen = [];
const updatedPackagesAvailable = packagesAvailable.filter((pack) => {
if (pack.selected) {
pack.selected = false;
pack.isHidden = isHidden(filterChosen, pack);
newPackagesChosen.push(pack);
return false;
}
return true;
});
const updatedPackagesChosen = [ ...newPackagesChosen, ...packagesChosen ];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveSelectedToAvailable = () => {
const newPackagesAvailable = [];
const updatedPackagesChosen = packagesChosen.filter((pack) => {
if (pack.selected) {
pack.selected = false;
pack.isHidden = false;
pack.name.includes(filterAvailable) ? newPackagesAvailable.push(pack) : null;
return false;
}
return true;
});
const updatedPackagesAvailable = [ ...newPackagesAvailable, ...packagesAvailable ];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveAllToChosen = () => {
const newPackagesChosen = packagesAvailable.map(pack => {
return { ...pack, selected: false, isHidden: isHidden(filterChosen, pack) };
});
const updatedPackagesAvailable = [];
const updatedPackagesChosen = [ ...newPackagesChosen, ...packagesChosen ];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveAllToAvailable = () => {
const updatedPackagesChosen = packagesChosen.filter(pack => pack.isHidden);
const newPackagesAvailable = filterAvailable === undefined ? [] :
packagesChosen
.filter(pack => !pack.isHidden && pack.name.includes(filterAvailable))
.map(pack => { return { ...pack, selected: false };});
const updatedPackagesAvailable = [ ...newPackagesAvailable, ...packagesAvailable ];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const onOptionSelect = (event, index, isChosen) => {
if (isChosen) {
const newChosen = [ ...packagesChosen ];
newChosen[index].selected = !packagesChosen[index].selected;
setPackagesChosenSorted(newChosen);
} else {
const newAvailable = [ ...packagesAvailable ];
newAvailable[index].selected = !packagesAvailable[index].selected;
setPackagesAvailableSorted(newAvailable);
}
};
const firstInputElement = useRef(null);
useEffect(() => {
firstInputElement.current?.focus();
}, []);
const handleClearAvailableSearch = () => {
setPackagesSearchName(undefined);
setFilterAvailable(undefined);
setPackagesAvailable([]);
setPackagesAvailableFound(true);
};
const handleClearChosenSearch = () => {
setFilterChosen(undefined);
setPackagesChosenSorted(packagesChosen.map(pack => {
return { ...pack, isHidden: false };}));
setPackagesChosenFound(true);
};
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={ handlePackagesAvailableSearch }
resetButtonLabel="Clear available packages search"
onClear={ handleClearAvailableSearch } /> }>
<DualListSelectorList data-testid="available-pkgs-list">
{!packagesAvailable.length ? (
<p className="pf-u-text-align-center pf-u-mt-md">
{!packagesAvailableFound
? 'No packages found'
: <>Search above to add additional<br />packages to your image</>
}
</p>
) : (packagesAvailable.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={ index }
isSelected={ pack.selected }
onOptionSelect={ (e) => onOptionSelect(e, index, false) }>
<TextContent key={ `${pack.name}-${index}` }>
<span className="pf-c-dual-list-selector__item-text">{ pack.name }</span>
<small>{ pack.summary }</small>
</TextContent>
</DualListSelectorListItem>
) : null;
}))}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper
aria-label="Selector controls">
<DualListSelectorControl
isDisabled={ !packagesAvailable.some(option => option.selected) }
onClick={ () => moveSelectedToChosen() }
aria-label="Add selected"
tooltipContent="Add selected">
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={ !packagesAvailable.length }
onClick={ () => moveAllToChosen() }
aria-label="Add all"
tooltipContent="Add all">
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={ !packagesChosen.length || !packagesChosenFound }
onClick={ () => moveAllToAvailable() }
aria-label="Remove all"
tooltipContent="Remove all">
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
onClick={ () => moveSelectedToAvailable() }
isDisabled={ !packagesChosen.some(option => option.selected) || !packagesChosenFound }
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) => handlePackagesChosenSearch(val) }
resetButtonLabel="Clear chosen packages search"
onClear={ handleClearChosenSearch } /> }
isChosen>
<DualListSelectorList data-testid="chosen-pkgs-list">
{!packagesChosen.length ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages added
</p>
) : !packagesChosenFound ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages found
</p>
) : (packagesChosen.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={ index }
isSelected={ pack.selected }
onOptionSelect={ (e) => onOptionSelect(e, index, true) }>
<TextContent key={ `${pack.name}-${index}` }>
<span className="pf-c-dual-list-selector__item-text">{ pack.name }</span>
<small>{ pack.summary }</small>
</TextContent>
</DualListSelectorListItem>
) : null;
}))}
</DualListSelectorList>
</DualListSelectorPane>
</DualListSelector>
const updateState = (updatedPackagesAvailable, updatedPackagesChosen) => {
setPackagesChosenSorted(updatedPackagesChosen);
setPackagesAvailableSorted(updatedPackagesAvailable);
setPackagesAvailableFound(
areFound(filterAvailable, updatedPackagesAvailable)
);
setPackagesChosenFound(areFound(filterChosen, updatedPackagesChosen));
// set the steps field to the current chosen packages list
change(input.name, removePackagesDisplayFields(updatedPackagesChosen));
};
const moveSelectedToChosen = () => {
const newPackagesChosen = [];
const updatedPackagesAvailable = packagesAvailable.filter((pack) => {
if (pack.selected) {
pack.selected = false;
pack.isHidden = isHidden(filterChosen, pack);
newPackagesChosen.push(pack);
return false;
}
return true;
});
const updatedPackagesChosen = [...newPackagesChosen, ...packagesChosen];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveSelectedToAvailable = () => {
const newPackagesAvailable = [];
const updatedPackagesChosen = packagesChosen.filter((pack) => {
if (pack.selected) {
pack.selected = false;
pack.isHidden = false;
pack.name.includes(filterAvailable)
? newPackagesAvailable.push(pack)
: null;
return false;
}
return true;
});
const updatedPackagesAvailable = [
...newPackagesAvailable,
...packagesAvailable,
];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveAllToChosen = () => {
const newPackagesChosen = packagesAvailable.map((pack) => {
return {
...pack,
selected: false,
isHidden: isHidden(filterChosen, pack),
};
});
const updatedPackagesAvailable = [];
const updatedPackagesChosen = [...newPackagesChosen, ...packagesChosen];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const moveAllToAvailable = () => {
const updatedPackagesChosen = packagesChosen.filter(
(pack) => pack.isHidden
);
const newPackagesAvailable =
filterAvailable === undefined
? []
: packagesChosen
.filter(
(pack) => !pack.isHidden && pack.name.includes(filterAvailable)
)
.map((pack) => {
return { ...pack, selected: false };
});
const updatedPackagesAvailable = [
...newPackagesAvailable,
...packagesAvailable,
];
updateState(updatedPackagesAvailable, updatedPackagesChosen);
};
const onOptionSelect = (event, index, isChosen) => {
if (isChosen) {
const newChosen = [...packagesChosen];
newChosen[index].selected = !packagesChosen[index].selected;
setPackagesChosenSorted(newChosen);
} else {
const newAvailable = [...packagesAvailable];
newAvailable[index].selected = !packagesAvailable[index].selected;
setPackagesAvailableSorted(newAvailable);
}
};
const firstInputElement = useRef(null);
useEffect(() => {
firstInputElement.current?.focus();
}, []);
const handleClearAvailableSearch = () => {
setPackagesSearchName(undefined);
setFilterAvailable(undefined);
setPackagesAvailable([]);
setPackagesAvailableFound(true);
};
const handleClearChosenSearch = () => {
setFilterChosen(undefined);
setPackagesChosenSorted(
packagesChosen.map((pack) => {
return { ...pack, isHidden: false };
})
);
setPackagesChosenFound(true);
};
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={handlePackagesAvailableSearch}
resetButtonLabel="Clear available packages search"
onClear={handleClearAvailableSearch}
/>
}
>
<DualListSelectorList data-testid="available-pkgs-list">
{!packagesAvailable.length ? (
<p className="pf-u-text-align-center pf-u-mt-md">
{!packagesAvailableFound ? (
'No packages found'
) : (
<>
Search above to add additional
<br />
packages to your image
</>
)}
</p>
) : (
packagesAvailable.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={index}
isSelected={pack.selected}
onOptionSelect={(e) => onOptionSelect(e, index, false)}
>
<TextContent key={`${pack.name}-${index}`}>
<span className="pf-c-dual-list-selector__item-text">
{pack.name}
</span>
<small>{pack.summary}</small>
</TextContent>
</DualListSelectorListItem>
) : null;
})
)}
</DualListSelectorList>
</DualListSelectorPane>
<DualListSelectorControlsWrapper aria-label="Selector controls">
<DualListSelectorControl
isDisabled={!packagesAvailable.some((option) => option.selected)}
onClick={() => moveSelectedToChosen()}
aria-label="Add selected"
tooltipContent="Add selected"
>
<AngleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={!packagesAvailable.length}
onClick={() => moveAllToChosen()}
aria-label="Add all"
tooltipContent="Add all"
>
<AngleDoubleRightIcon />
</DualListSelectorControl>
<DualListSelectorControl
isDisabled={!packagesChosen.length || !packagesChosenFound}
onClick={() => moveAllToAvailable()}
aria-label="Remove all"
tooltipContent="Remove all"
>
<AngleDoubleLeftIcon />
</DualListSelectorControl>
<DualListSelectorControl
onClick={() => moveSelectedToAvailable()}
isDisabled={
!packagesChosen.some((option) => option.selected) ||
!packagesChosenFound
}
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) => handlePackagesChosenSearch(val)}
resetButtonLabel="Clear chosen packages search"
onClear={handleClearChosenSearch}
/>
}
isChosen
>
<DualListSelectorList data-testid="chosen-pkgs-list">
{!packagesChosen.length ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages added
</p>
) : !packagesChosenFound ? (
<p className="pf-u-text-align-center pf-u-mt-md">
No packages found
</p>
) : (
packagesChosen.map((pack, index) => {
return !pack.isHidden ? (
<DualListSelectorListItem
key={index}
isSelected={pack.selected}
onOptionSelect={(e) => onOptionSelect(e, index, true)}
>
<TextContent key={`${pack.name}-${index}`}>
<span className="pf-c-dual-list-selector__item-text">
{pack.name}
</span>
<small>{pack.summary}</small>
</TextContent>
</DualListSelectorListItem>
) : null;
})
)}
</DualListSelectorList>
</DualListSelectorPane>
</DualListSelector>
);
};
Packages.propTypes = {
defaultArch: PropTypes.string,
defaultArch: PropTypes.string,
};
export default Packages;

View file

@ -3,15 +3,23 @@ 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> } />;
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
Popover: PropTypes.elementType.isRequired,
label: PropTypes.node,
};
export default RadioWithPopover;

View file

@ -1,21 +1,33 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Button,
DescriptionList, DescriptionListTerm, DescriptionListGroup, DescriptionListDescription,
List, ListItem,
Popover,
Spinner,
Tabs, Tab, TabTitleText,
Text, TextContent, TextVariants, TextList, TextListVariants, TextListItem, TextListItemVariants,
Button,
DescriptionList,
DescriptionListTerm,
DescriptionListGroup,
DescriptionListDescription,
List,
ListItem,
Popover,
Spinner,
Tabs,
Tab,
TabTitleText,
Text,
TextContent,
TextVariants,
TextList,
TextListVariants,
TextListItem,
TextListItemVariants,
} from '@patternfly/react-core';
import {
TableComposable,
Thead,
Tbody,
Tr,
Th,
Td,
TableComposable,
Thead,
Tbody,
Tr,
Th,
Td,
} from '@patternfly/react-table';
import { HelpIcon } from '@patternfly/react-icons';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
@ -24,308 +36,386 @@ import { RELEASES, UNIT_GIB, UNIT_MIB } from '../../../constants';
import isRhel from '../../../Utilities/isRhel';
const FSReviewTable = ({ ...props }) => {
return (
<TableComposable
aria-label="File system configuration table"
variant="compact">
<Thead>
<Tr>
<Th>Mount point</Th>
<Th>Type</Th>
<Th>Minimum size</Th>
</Tr>
</Thead>
<Tbody data-testid="file-system-configuration-tbody-review">
{props.fsc.map((r, ri) =>
<Tr key={ ri }>
<Td className="pf-m-width-60">{ r.mountpoint }</Td>
<Td className="pf-m-width-10">xfs</Td>
<Td className="pf-m-width-30">{ r.size } { r.unit === UNIT_GIB ? 'GiB' : r.unit === UNIT_MIB ? 'MiB' : 'KiB' }</Td>
</Tr>
)}
</Tbody>
</TableComposable>
);
return (
<TableComposable
aria-label="File system configuration table"
variant="compact"
>
<Thead>
<Tr>
<Th>Mount point</Th>
<Th>Type</Th>
<Th>Minimum size</Th>
</Tr>
</Thead>
<Tbody data-testid="file-system-configuration-tbody-review">
{props.fsc.map((r, ri) => (
<Tr key={ri}>
<Td className="pf-m-width-60">{r.mountpoint}</Td>
<Td className="pf-m-width-10">xfs</Td>
<Td className="pf-m-width-30">
{r.size}{' '}
{r.unit === UNIT_GIB
? 'GiB'
: r.unit === UNIT_MIB
? 'MiB'
: 'KiB'}
</Td>
</Tr>
))}
</Tbody>
</TableComposable>
);
};
FSReviewTable.propTypes = {
fsc: PropTypes.arrayOf(PropTypes.object).isRequired,
fsc: PropTypes.arrayOf(PropTypes.object).isRequired,
};
const ReviewStep = () => {
const [ activeTabKey, setActiveTabKey ] = useState(0);
const [ orgId, setOrgId ] = useState();
const [ minSize, setMinSize ] = useState();
const { change, getState } = useFormApi();
const [activeTabKey, setActiveTabKey] = useState(0);
const [orgId, setOrgId] = useState();
const [minSize, setMinSize] = useState();
const { change, getState } = useFormApi();
useEffect(() => {
const registerSystem = getState()?.values?.['register-system'];
if (registerSystem === 'register-now' || registerSystem === 'register-now-insights') {
(async () => {
const userData = await insights?.chrome?.auth?.getUser();
const id = userData?.identity?.internal?.org_id;
setOrgId(id);
change('subscription-organization-id', id);
})();
}
useEffect(() => {
const registerSystem = getState()?.values?.['register-system'];
if (
registerSystem === 'register-now' ||
registerSystem === 'register-now-insights'
) {
(async () => {
const userData = await insights?.chrome?.auth?.getUser();
const id = userData?.identity?.internal?.org_id;
setOrgId(id);
change('subscription-organization-id', id);
})();
}
if (getState()?.values?.['file-system-config-toggle'] === 'manual' &&
getState()?.values?.['file-system-configuration']) {
let size = 0;
for (const fsc of getState().values['file-system-configuration']) {
size += (fsc.size * fsc.unit);
}
if (
getState()?.values?.['file-system-config-toggle'] === 'manual' &&
getState()?.values?.['file-system-configuration']
) {
let size = 0;
for (const fsc of getState().values['file-system-configuration']) {
size += fsc.size * fsc.unit;
}
size = (size / UNIT_GIB).toFixed(1);
if (size < 1) {
setMinSize(`Less than 1 GiB`);
} else {
setMinSize(`${size} GiB`);
}
}
});
size = (size / UNIT_GIB).toFixed(1);
if (size < 1) {
setMinSize(`Less than 1 GiB`);
} else {
setMinSize(`${size} GiB`);
}
}
});
const handleTabClick = (event, tabIndex) => {
setActiveTabKey(tabIndex);
};
const handleTabClick = (event, tabIndex) => {
setActiveTabKey(tabIndex);
};
return (
<>
<Text>
Review the information and click &quot;Create image&quot;
to create the image using the following criteria.
</Text>
<DescriptionList isCompact isHorizontal>
<DescriptionListGroup>
{getState()?.values?.['image-name'] &&
<>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{getState()?.values?.['image-name']}
</DescriptionListDescription>
</>
}
<DescriptionListTerm>Release</DescriptionListTerm>
<DescriptionListDescription>
{RELEASES[getState()?.values?.release]}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
<Tabs isFilled activeKey={ activeTabKey } onSelect={ handleTabClick } className="pf-u-w-75">
<Tab eventKey={ 0 } title={ <TabTitleText>Target environment</TabTitleText> } data-testid='tab-target' autoFocus>
<List isPlain iconSize="large">
{getState()?.values?.['target-environment']?.aws &&
<ListItem icon={ <img className='provider-icon' src='/apps/frontend-assets/partners-icons/aws.svg' /> }>
<TextContent>
<Text component={ TextVariants.h3 }>
Amazon Web Services
</Text>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>Account ID</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['aws-account-id']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
}
{getState()?.values?.['target-environment']?.gcp &&
<ListItem
className='pf-c-list__item pf-u-mt-md'
icon={ <img className='provider-icon' src='/apps/frontend-assets/partners-icons/google-cloud-short.svg' /> }>
<TextContent>
<Text component={ TextVariants.h3 }>Google Cloud Platform</Text>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
{googleAccType?.[getState()?.values?.['google-account-type']]}
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['google-email'] || getState()?.values?.['google-domain']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
}
{getState()?.values?.['target-environment']?.azure &&
<ListItem
className='pf-c-list__item pf-u-mt-md'
icon={ <img className='provider-icon' src='/apps/frontend-assets/partners-icons/microsoft-azure-short.svg' /> }>
<TextContent>
<Text component={ TextVariants.h3 }>Microsoft Azure</Text>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
Subscription ID
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['azure-subscription-id']}
</TextListItem>
<TextListItem component={ TextListItemVariants.dt }>
Tenant ID
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['azure-tenant-id']}
</TextListItem>
<TextListItem component={ TextListItemVariants.dt }>
Resource group
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['azure-resource-group']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
}
{getState()?.values?.['target-environment']?.vsphere &&
<ListItem>
<TextContent>
<Text component={ TextVariants.h3 }>
VMWare
</Text>
</TextContent>
</ListItem>
}
{getState()?.values?.['target-environment']?.['guest-image'] &&
<ListItem>
<TextContent>
<Text component={ TextVariants.h3 }>
Virtualization - Guest image
</Text>
</TextContent>
</ListItem>
}
{getState()?.values?.['target-environment']?.['image-installer'] &&
<ListItem>
<TextContent>
<Text component={ TextVariants.h3 }>
Bare metal - Installer
</Text>
</TextContent>
</ListItem>
}
</List>
</Tab>
{isRhel(getState()?.values?.release) &&
<Tab eventKey={ 1 } title={ <TabTitleText>Registration</TabTitleText> } data-testid='tab-registration'>
{getState()?.values?.['register-system'] === 'register-later' &&
<TextContent>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
Subscription
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
Register the system later
</TextListItem>
</TextList>
</TextContent>
}
{(getState()?.values?.['register-system'] === 'register-now' ||
getState()?.values?.['register-system'] === 'register-now-insights') &&
<TextContent>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
Subscription
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['register-system'] === 'register-now-insights' &&
'Register with Subscriptions and Red Hat Insights'
}
{getState()?.values?.['register-system'] === 'register-now' &&
'Register with Subscriptions'
}
</TextListItem>
<TextListItem component={ TextListItemVariants.dt }>
Activation key
</TextListItem>
<TextListItem component={ TextListItemVariants.dd }>
{getState()?.values?.['subscription-activation-key']}
</TextListItem>
<TextListItem component={ TextListItemVariants.dt }>
Organization ID
</TextListItem>
{orgId !== undefined ? (
<TextListItem component={ TextListItemVariants.dd } data-testid='organization-id'>
{orgId}
</TextListItem>
) : (
<TextListItem component={ TextListItemVariants.dd }>
<Spinner />
</TextListItem>
)}
</TextList>
</TextContent>
}
</Tab>
return (
<>
<Text>
Review the information and click &quot;Create image&quot; to create the
image using the following criteria.
</Text>
<DescriptionList isCompact isHorizontal>
<DescriptionListGroup>
{getState()?.values?.['image-name'] && (
<>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{getState()?.values?.['image-name']}
</DescriptionListDescription>
</>
)}
<DescriptionListTerm>Release</DescriptionListTerm>
<DescriptionListDescription>
{RELEASES[getState()?.values?.release]}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
<Tabs
isFilled
activeKey={activeTabKey}
onSelect={handleTabClick}
className="pf-u-w-75"
>
<Tab
eventKey={0}
title={<TabTitleText>Target environment</TabTitleText>}
data-testid="tab-target"
autoFocus
>
<List isPlain iconSize="large">
{getState()?.values?.['target-environment']?.aws && (
<ListItem
icon={
<img
className="provider-icon"
src="/apps/frontend-assets/partners-icons/aws.svg"
/>
}
<Tab eventKey={ 2 } title={ <TabTitleText>System configuration</TabTitleText> } data-testid='tab-system'>
<TextContent>
<Text component={ TextVariants.h3 }>File system configuration</Text>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
Partitioning
</TextListItem>
<TextListItem component={ TextListItemVariants.dd } data-testid='partitioning-auto-manual'>
{getState()?.values?.['file-system-config-toggle'] === 'manual' ? 'Manual' : 'Automatic'}
{getState()?.values?.['file-system-config-toggle'] === 'manual' &&
<>
{' '}
<Popover
position="bottom"
headerContent="Partitions"
hasAutoWidth
minWidth="30rem"
bodyContent={ <FSReviewTable fsc={ getState().values['file-system-configuration'] } /> }>
<Button
data-testid='file-system-configuration-popover'
variant="link"
aria-label="File system configuration info"
aria-describedby="file-system-configuration-info">
View partitions
</Button>
</Popover>
</>
}
</TextListItem>
{getState()?.values?.['file-system-config-toggle'] === 'manual' &&
<>
<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>
<TextListItem component={ TextListItemVariants.dd }>
{ minSize }
</TextListItem>
</>
}
</TextList>
<Text component={ TextVariants.h3 }>Packages</Text>
<TextList component={ TextListVariants.dl }>
<TextListItem component={ TextListItemVariants.dt }>
Chosen
</TextListItem>
<TextListItem component={ TextListItemVariants.dd } data-testid='chosen-packages-count'>
{getState()?.values?.['selected-packages']?.length || 0}
</TextListItem>
</TextList>
</TextContent>
</Tab>
</Tabs>
</>
);
>
<TextContent>
<Text component={TextVariants.h3}>Amazon Web Services</Text>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Account ID
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['aws-account-id']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
)}
{getState()?.values?.['target-environment']?.gcp && (
<ListItem
className="pf-c-list__item pf-u-mt-md"
icon={
<img
className="provider-icon"
src="/apps/frontend-assets/partners-icons/google-cloud-short.svg"
/>
}
>
<TextContent>
<Text component={TextVariants.h3}>Google Cloud Platform</Text>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
{
googleAccType?.[
getState()?.values?.['google-account-type']
]
}
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['google-email'] ||
getState()?.values?.['google-domain']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
)}
{getState()?.values?.['target-environment']?.azure && (
<ListItem
className="pf-c-list__item pf-u-mt-md"
icon={
<img
className="provider-icon"
src="/apps/frontend-assets/partners-icons/microsoft-azure-short.svg"
/>
}
>
<TextContent>
<Text component={TextVariants.h3}>Microsoft Azure</Text>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Subscription ID
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-subscription-id']}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Tenant ID
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-tenant-id']}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Resource group
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['azure-resource-group']}
</TextListItem>
</TextList>
</TextContent>
</ListItem>
)}
{getState()?.values?.['target-environment']?.vsphere && (
<ListItem>
<TextContent>
<Text component={TextVariants.h3}>VMWare</Text>
</TextContent>
</ListItem>
)}
{getState()?.values?.['target-environment']?.['guest-image'] && (
<ListItem>
<TextContent>
<Text component={TextVariants.h3}>
Virtualization - Guest image
</Text>
</TextContent>
</ListItem>
)}
{getState()?.values?.['target-environment']?.[
'image-installer'
] && (
<ListItem>
<TextContent>
<Text component={TextVariants.h3}>
Bare metal - Installer
</Text>
</TextContent>
</ListItem>
)}
</List>
</Tab>
{isRhel(getState()?.values?.release) && (
<Tab
eventKey={1}
title={<TabTitleText>Registration</TabTitleText>}
data-testid="tab-registration"
>
{getState()?.values?.['register-system'] === 'register-later' && (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Subscription
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
Register the system later
</TextListItem>
</TextList>
</TextContent>
)}
{(getState()?.values?.['register-system'] === 'register-now' ||
getState()?.values?.['register-system'] ===
'register-now-insights') && (
<TextContent>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Subscription
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['register-system'] ===
'register-now-insights' &&
'Register with Subscriptions and Red Hat Insights'}
{getState()?.values?.['register-system'] ===
'register-now' && 'Register with Subscriptions'}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Activation key
</TextListItem>
<TextListItem component={TextListItemVariants.dd}>
{getState()?.values?.['subscription-activation-key']}
</TextListItem>
<TextListItem component={TextListItemVariants.dt}>
Organization ID
</TextListItem>
{orgId !== undefined ? (
<TextListItem
component={TextListItemVariants.dd}
data-testid="organization-id"
>
{orgId}
</TextListItem>
) : (
<TextListItem component={TextListItemVariants.dd}>
<Spinner />
</TextListItem>
)}
</TextList>
</TextContent>
)}
</Tab>
)}
<Tab
eventKey={2}
title={<TabTitleText>System configuration</TabTitleText>}
data-testid="tab-system"
>
<TextContent>
<Text component={TextVariants.h3}>File system configuration</Text>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Partitioning
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="partitioning-auto-manual"
>
{getState()?.values?.['file-system-config-toggle'] === 'manual'
? 'Manual'
: 'Automatic'}
{getState()?.values?.['file-system-config-toggle'] ===
'manual' && (
<>
{' '}
<Popover
position="bottom"
headerContent="Partitions"
hasAutoWidth
minWidth="30rem"
bodyContent={
<FSReviewTable
fsc={getState().values['file-system-configuration']}
/>
}
>
<Button
data-testid="file-system-configuration-popover"
variant="link"
aria-label="File system configuration info"
aria-describedby="file-system-configuration-info"
>
View partitions
</Button>
</Popover>
</>
)}
</TextListItem>
{getState()?.values?.['file-system-config-toggle'] ===
'manual' && (
<>
<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>
<TextListItem component={TextListItemVariants.dd}>
{minSize}
</TextListItem>
</>
)}
</TextList>
<Text component={TextVariants.h3}>Packages</Text>
<TextList component={TextListVariants.dl}>
<TextListItem component={TextListItemVariants.dt}>
Chosen
</TextListItem>
<TextListItem
component={TextListItemVariants.dd}
data-testid="chosen-packages-count"
>
{getState()?.values?.['selected-packages']?.length || 0}
</TextListItem>
</TextList>
</TextContent>
</Tab>
</Tabs>
</>
);
};
export default ReviewStep;

View file

@ -1,74 +1,75 @@
import React, {
useEffect,
useState,
} from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Select,
SelectOption,
SelectVariant,
TextInput,
Select,
SelectOption,
SelectVariant,
TextInput,
} from '@patternfly/react-core';
import { UNIT_KIB, UNIT_MIB, UNIT_GIB } 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);
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 ]);
useEffect(() => {
props.onChange(size, unit);
}, [unit, size]);
const onToggle = (isOpen) => {
setIsOpen(isOpen);
};
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;
const onSelect = (event, selection) => {
switch (selection) {
case 'KiB':
setUnit(UNIT_KIB);
break;
case 'MiB':
setUnit(UNIT_MIB);
break;
case 'GiB':
setUnit(UNIT_GIB);
break;
}
setIsOpen(false);
};
return (
<>
<TextInput
className="pf-u-w-50"
type="text"
value={size}
aria-label="Size text input"
onChange={(v) => setSize(isNaN(parseInt(v)) ? 0 : parseInt(v))}
/>
<Select
className="pf-u-w-50"
isOpen={isOpen}
onToggle={onToggle}
onSelect={onSelect}
selections={
unit === UNIT_KIB ? 'KiB' : unit === UNIT_MIB ? 'MiB' : 'GiB'
}
setIsOpen(false);
};
return (
<>
<TextInput
className="pf-u-w-50"
type="text"
value={ size }
aria-label="Size text input"
onChange={ v => setSize(isNaN(parseInt(v)) ? 0 : parseInt(v)) } />
<Select
className="pf-u-w-50"
isOpen={ isOpen }
onToggle={ onToggle }
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>
</>
);
variant={SelectVariant.single}
aria-label="Unit select"
>
{['KiB', 'MiB', 'GiB'].map((u, index) => {
return <SelectOption key={index} value={u} />;
})}
</Select>
</>
);
};
SizeUnit.propTypes = {
size: PropTypes.number.isRequired,
unit: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
size: PropTypes.number.isRequired,
unit: PropTypes.number.isRequired,
onChange: PropTypes.func.isRequired,
};
export default SizeUnit;

View file

@ -2,125 +2,164 @@ import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import { Checkbox, FormGroup, Text, TextVariants, Tile } from '@patternfly/react-core';
import {
Checkbox,
FormGroup,
Text,
TextVariants,
Tile,
} from '@patternfly/react-core';
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,
vsphere: false,
'guest-image': false,
'image-installer': false,
const { getState, change } = useFormApi();
const { input } = useFieldApi({ label, isRequired, ...props });
const [environment, setEnvironment] = useState({
aws: false,
azure: false,
gcp: false,
vsphere: false,
'guest-image': false,
'image-installer': false,
});
useEffect(() => {
if (getState()?.values?.[input.name]) {
setEnvironment(getState().values[input.name]);
}
}, []);
const handleSetEnvironment = (env) =>
setEnvironment((prevEnv) => {
const newEnv = {
...prevEnv,
[env]: !prevEnv[env],
};
change(input.name, newEnv);
return newEnv;
});
useEffect(() => {
if (getState()?.values?.[input.name]) {
setEnvironment(getState().values[input.name]);
}
}, []);
const handleKeyDown = (e, env) => {
if (e.key === ' ') {
handleSetEnvironment(env);
}
};
const handleSetEnvironment = (env) => setEnvironment((prevEnv) => {
const newEnv = ({
...prevEnv,
[env]: !prevEnv[env]
});
change(input.name, newEnv);
return newEnv;
});
const handleKeyDown = (e, env) => {
if (e.key === ' ') {
handleSetEnvironment(env);
}
};
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">
<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' } /> }
onClick={ () => handleSetEnvironment('aws') }
onKeyDown = { (e) => handleKeyDown(e, 'aws') }
isSelected={ environment.aws }
isStacked
isDisplayLarge />
<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' } /> }
onClick={ () => handleSetEnvironment('gcp') }
isSelected={ environment.gcp }
onKeyDown = { (e) => handleKeyDown(e, 'gcp') }
isStacked
isDisplayLarge />
<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' } /> }
onClick={ () => handleSetEnvironment('azure') }
onKeyDown = { (e) => handleKeyDown(e, 'azure') }
isSelected={ environment.azure }
isStacked
isDisplayLarge />
</div>
</FormGroup>
<FormGroup label={ <Text component={ TextVariants.small }>Private cloud</Text> } data-testid="target-private">
<Checkbox
label="VMWare"
isChecked={ environment.vsphere }
onChange={ () => handleSetEnvironment('vsphere') }
aria-label="VMWare checkbox"
id="checkbox-vmware"
name="VMWare"
data-testid="checkbox-vmware" />
</FormGroup>
<FormGroup label={ <Text component={ TextVariants.small }>Other</Text> } data-testid="target-other">
<Checkbox
label="Virtualization - Guest image"
isChecked={ environment['guest-image'] }
onChange={ () => handleSetEnvironment('guest-image') }
aria-label="Virtualization guest image checkbox"
id="checkbox-guest-image"
name="Virtualization guest image"
data-testid="checkbox-guest-image" />
<Checkbox
label="Bare metal - Installer"
isChecked={ environment['image-installer'] }
onChange={ () => handleSetEnvironment('image-installer') }
aria-label="Bare metal installer checkbox"
id="checkbox-image-installer"
name="Bare metal installer"
data-testid="checkbox-image-installer" />
</FormGroup>
</FormGroup>
</>
);
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">
<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'}
/>
}
onClick={() => handleSetEnvironment('aws')}
onKeyDown={(e) => handleKeyDown(e, 'aws')}
isSelected={environment.aws}
isStacked
isDisplayLarge
/>
<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'
}
/>
}
onClick={() => handleSetEnvironment('gcp')}
isSelected={environment.gcp}
onKeyDown={(e) => handleKeyDown(e, 'gcp')}
isStacked
isDisplayLarge
/>
<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'
}
/>
}
onClick={() => handleSetEnvironment('azure')}
onKeyDown={(e) => handleKeyDown(e, 'azure')}
isSelected={environment.azure}
isStacked
isDisplayLarge
/>
</div>
</FormGroup>
<FormGroup
label={<Text component={TextVariants.small}>Private cloud</Text>}
data-testid="target-private"
>
<Checkbox
label="VMWare"
isChecked={environment.vsphere}
onChange={() => handleSetEnvironment('vsphere')}
aria-label="VMWare checkbox"
id="checkbox-vmware"
name="VMWare"
data-testid="checkbox-vmware"
/>
</FormGroup>
<FormGroup
label={<Text component={TextVariants.small}>Other</Text>}
data-testid="target-other"
>
<Checkbox
label="Virtualization - Guest image"
isChecked={environment['guest-image']}
onChange={() => handleSetEnvironment('guest-image')}
aria-label="Virtualization guest image checkbox"
id="checkbox-guest-image"
name="Virtualization guest image"
data-testid="checkbox-guest-image"
/>
<Checkbox
label="Bare metal - Installer"
isChecked={environment['image-installer']}
onChange={() => handleSetEnvironment('image-installer')}
aria-label="Bare metal installer checkbox"
id="checkbox-image-installer"
name="Bare metal installer"
data-testid="checkbox-image-installer"
/>
</FormGroup>
</FormGroup>
</>
);
};
TargetEnvironment.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool
label: PropTypes.node,
isRequired: PropTypes.bool,
};
TargetEnvironment.defaultProps = {
label: '',
isRequired: false
label: '',
isRequired: false,
};
export default TargetEnvironment;

View file

@ -6,40 +6,47 @@ import { Title } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-target-aws',
title: 'Amazon Web Services',
customTitle: <Title headingLevel="h1" size="xl">Target environment - Amazon Web Service</Title>,
name: 'aws-target-env',
substepOf: 'Target environment',
nextStep: ({ values }) => nextStepMapper(values, { skipAws: true }),
fields: [
StepTemplate,
id: 'wizard-target-aws',
title: 'Amazon Web Services',
customTitle: (
<Title headingLevel="h1" size="xl">
Target environment - Amazon Web Service
</Title>
),
name: 'aws-target-env',
substepOf: 'Target environment',
nextStep: ({ values }) => nextStepMapper(values, { skipAws: true }),
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. <br />
The image should be copied to your account within 14 days.
</p>
),
},
{
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,
autoFocus: true,
validate: [
{
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. <br />
The image should be copied to your account within 14 days.
</p>
type: validatorTypes.REQUIRED,
},
{
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,
autoFocus: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
type: validatorTypes.EXACT_LENGTH,
threshold: 12
}
],
}
]
type: validatorTypes.EXACT_LENGTH,
threshold: 12,
},
],
},
],
};

View file

@ -6,37 +6,46 @@ import { Text } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-systemconfiguration-filesystem',
title: 'File system configuration',
name: 'File system configuration',
substepOf: 'System Configuration',
nextStep: 'packages',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'file-system-configuration-text-component',
label: <>
<Text>Red Hat recommends using automatic partitioning for most installations.</Text>
<Text>
Alternatively, you may manually configure the file system of your image by adding, removing, and editing partitions.
</Text>
</>,
},
{
component: 'file-system-config-toggle',
name: 'file-system-config-toggle',
label: 'File system configurations toggle',
},
{
component: 'file-system-configuration',
name: 'file-system-configuration',
label: 'File system configurations',
validate: [{ type: 'fileSystemConfigurationValidator' }, { type: validatorTypes.REQUIRED }],
condition: {
when: 'file-system-config-toggle',
is: 'manual',
},
},
]
StepTemplate,
id: 'wizard-systemconfiguration-filesystem',
title: 'File system configuration',
name: 'File system configuration',
substepOf: 'System Configuration',
nextStep: 'packages',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'file-system-configuration-text-component',
label: (
<>
<Text>
Red Hat recommends using automatic partitioning for most
installations.
</Text>
<Text>
Alternatively, you may manually configure the file system of your
image by adding, removing, and editing partitions.
</Text>
</>
),
},
{
component: 'file-system-config-toggle',
name: 'file-system-config-toggle',
label: 'File system configurations toggle',
},
{
component: 'file-system-configuration',
name: 'file-system-configuration',
label: 'File system configurations',
validate: [
{ type: 'fileSystemConfigurationValidator' },
{ type: validatorTypes.REQUIRED },
],
condition: {
when: 'file-system-config-toggle',
is: 'manual',
},
},
],
};

View file

@ -3,136 +3,168 @@ import componentTypes from '@data-driven-forms/react-form-renderer/component-typ
import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types';
import { HelpIcon } from '@patternfly/react-icons';
import nextStepMapper from './imageOutputStepMapper';
import { Title, Text, Popover, TextContent, TextList, TextListItem, Button } from '@patternfly/react-core';
import {
Title,
Text,
Popover,
TextContent,
TextList,
TextListItem,
Button,
} from '@patternfly/react-core';
import PropTypes from 'prop-types';
import StepTemplate from './stepTemplate';
export const googleAccType = {
googleAccount: 'Google account',
serviceAccount: 'Service account',
googleGroup: 'Google group',
domain: 'Domain'
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. e.g., <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. e.g., <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. e.g., <em>`admins@example.com`</em>.
</TextListItem>
<TextListItem>
<strong>Google Workspace domain/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. e.g., <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>;
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. e.g., <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. e.g.,{' '}
<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. e.g.,{' '}
<em>`admins@example.com`</em>.
</TextListItem>
<TextListItem>
<strong>Google Workspace domain/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. e.g.,{' '}
<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
appendTo: PropTypes.any,
};
export default {
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 }),
fields: [
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 }),
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'google-cloud-text-component',
label: (
<Text>
Your image will be uploaded to Google Cloud Platform and shared with
the email you provide below. <br />
The image should be copied to your account within 14 days.
</Text>
),
},
{
component: 'radio-popover',
label: '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: [
{
component: componentTypes.PLAIN_TEXT,
name: 'google-cloud-text-component',
label: <Text>
Your image will be uploaded to Google Cloud Platform and shared with the email you provide below. <br />
The image should be copied to your account within 14 days.
</Text>
type: validatorTypes.REQUIRED,
},
],
},
{
component: componentTypes.TEXT_FIELD,
name: 'google-email',
'data-testid': 'input-google-email',
type: 'text',
label: 'Email address',
condition: {
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,
},
{
component: 'radio-popover',
label: '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,
},
],
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: {
when: 'google-account-type',
is: 'domain',
},
isRequired: true,
validate: [
{
component: componentTypes.TEXT_FIELD,
name: 'google-email',
'data-testid': 'input-google-email',
type: 'text',
label: 'Email address',
condition: {
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'
}
],
type: validatorTypes.REQUIRED,
},
{
component: componentTypes.TEXT_FIELD,
name: 'google-domain',
type: 'text',
label: 'Domain',
condition: {
when: 'google-account-type',
is: 'domain'
},
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
}
]
],
},
],
};

View file

@ -4,29 +4,33 @@ import validatorTypes from '@data-driven-forms/react-form-renderer/validator-typ
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-details',
name: 'image-name',
title: 'Name image',
nextStep: 'review',
fields: [
StepTemplate,
id: 'wizard-details',
name: 'image-name',
title: 'Name image',
nextStep: 'review',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'plain-text-component',
label: (
<p>
Optionally enter a name for your image. All images will have a UUID.
</p>
),
},
{
component: componentTypes.TEXT_FIELD,
name: 'image-name',
type: 'text',
label: 'Image name',
autoFocus: true,
validate: [
{
component: componentTypes.PLAIN_TEXT,
name: 'plain-text-component',
label: <p>Optionally enter a name for your image. All images will have a UUID.</p>
type: validatorTypes.MAX_LENGTH,
threshold: 100,
},
{
component: componentTypes.TEXT_FIELD,
name: 'image-name',
type: 'text',
label: 'Image name',
autoFocus: true,
validate: [
{
type: validatorTypes.MAX_LENGTH,
threshold: 100
}
],
}
]
],
},
],
};

View file

@ -8,42 +8,49 @@ import DocumentationButton from '../../sharedComponents/DocumentationButton';
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-imageoutput',
title: 'Image output',
name: 'image-output',
nextStep: ({ values }) => nextStepMapper(values),
fields: [
StepTemplate,
id: 'wizard-imageoutput',
title: 'Image output',
name: 'image-output',
nextStep: ({ values }) => nextStepMapper(values),
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: [
{
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>
type: validatorTypes.REQUIRED,
},
],
},
{
component: 'output',
name: 'target-environment',
label: 'Select target environments',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
component: 'image-output-release-select',
label: 'Release',
name: 'release',
initialValue: RHEL_9,
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED
}
],
type: 'targetEnvironmentValidator',
},
{
component: 'output',
name: 'target-environment',
label: 'Select target environments',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED
},
{
type: 'targetEnvironmentValidator'
}
],
}
]
],
},
],
};

View file

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

View file

@ -6,103 +6,119 @@ import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
export default {
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 }),
fields: [
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,
}),
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-description',
label: (
<Text>
Image Builder sends an image to an authorized Azure account.
</Text>
),
},
{
component: 'azure-auth-expandable',
name: 'azure-auth-expandable',
},
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-destination',
label: (
<>
<Title headingLevel="h2">Destination</Title>
<Text>
Your image will be uploaded to the resource group in the
subscription you specify.
</Text>
</>
),
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-tenant-id',
className: 'pf-u-w-50',
'data-testid': 'azure-tenant-id',
type: 'text',
label: 'Tenant ID',
required: true,
isRequired: true,
autoFocus: true,
validate: [
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-description',
label: <Text>
Image Builder sends an image to an authorized Azure account.
</Text>
type: validatorTypes.REQUIRED,
},
{
component: 'azure-auth-expandable',
name: 'azure-auth-expandable'
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',
},
],
},
{
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',
type: 'text',
label: 'Subscription ID',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
component: componentTypes.PLAIN_TEXT,
name: 'azure-destination',
label: <>
<Title headingLevel="h2">Destination</Title>
<Text>
Your image will be uploaded to the resource group in the subscription you specify.
</Text>
</>
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',
},
],
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-resource-group',
className: 'pf-u-w-50',
'data-testid': 'azure-resource-group',
type: 'text',
label: 'Resource group',
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-tenant-id',
className: 'pf-u-w-50',
'data-testid': 'azure-tenant-id',
type: 'text',
label: 'Tenant ID',
required: true,
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',
}
],
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',
},
{
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',
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',
},
],
},
{
component: componentTypes.TEXT_FIELD,
name: 'azure-resource-group',
className: 'pf-u-w-50',
'data-testid': 'azure-resource-group',
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',
},
],
}
// TODO check oauth2 thing too here?
]
],
},
// TODO check oauth2 thing too here?
],
};

View file

@ -4,22 +4,27 @@ import { Text } from '@patternfly/react-core';
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-systemconfiguration-packages',
title: 'Packages',
name: 'packages',
substepOf: 'System Configuration',
nextStep: 'image-name',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'packages-text-component',
label: <Text>Add optional additional packages to your image by searching available packages.</Text>
},
{
component: 'package-selector',
name: 'selected-packages',
label: 'Available options'
}
]
StepTemplate,
id: 'wizard-systemconfiguration-packages',
title: 'Packages',
name: 'packages',
substepOf: 'System Configuration',
nextStep: 'image-name',
fields: [
{
component: componentTypes.PLAIN_TEXT,
name: 'packages-text-component',
label: (
<Text>
Add optional additional packages to your image by searching available
packages.
</Text>
),
},
{
component: 'package-selector',
name: 'selected-packages',
label: 'Available options',
},
],
};

View file

@ -1,133 +1,142 @@
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, TextVariants } from '@patternfly/react-core';
import {
Button,
Popover,
Text,
TextContent,
TextVariants,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, HelpIcon } from '@patternfly/react-icons';
import StepTemplate from './stepTemplate';
const PopoverActivation = () => {
return <Popover
hasAutoWidth
maxWidth='35rem'
bodyContent={ <TextContent>
<Text>
Activation keys allow you to register a system with
appropriate subscriptions and system purpose attached.
</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>;
return (
<Popover
hasAutoWidth
maxWidth="35rem"
bodyContent={
<TextContent>
<Text>
Activation keys allow you to register a system with appropriate
subscriptions and system purpose attached.
</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>
);
};
export default {
StepTemplate,
id: 'wizard-registration',
title: 'Registration',
name: 'registration',
nextStep: 'File system configuration',
fields: [
StepTemplate,
id: 'wizard-registration',
title: 'Registration',
name: 'registration',
nextStep: 'File system configuration',
fields: [
{
component: componentTypes.RADIO,
label: 'Register images with Red Hat',
name: 'register-system',
initialValue: 'register-now-insights',
options: [
{
component: componentTypes.RADIO,
label: 'Register images with Red Hat',
name: 'register-system',
initialValue: 'register-now-insights',
options: [
{
label: 'Register and connect image instances with Red Hat',
description: 'Includes Subscriptions and Red Hat Insights',
value: 'register-now-insights',
'data-testid': 'radio-register-now-insights',
autoFocus: true,
},
{
label: 'Register image instances only',
description: 'Includes Subscriptions only',
value: 'register-now',
className: 'pf-u-mt-sm',
'data-testid': 'radio-register-now',
},
{
label: 'Register later',
value: 'register-later',
className: 'pf-u-mt-sm',
'data-testid': 'radio-register-later',
},
]
label: 'Register and connect image instances with Red Hat',
description: 'Includes Subscriptions and Red Hat Insights',
value: 'register-now-insights',
'data-testid': 'radio-register-now-insights',
autoFocus: true,
},
{
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-insights' },
{ when: 'register-system', is: 'register-now' },
]
},
isRequired: true,
validate: [
{
type: validatorTypes.REQUIRED,
},
],
label: 'Register image instances only',
description: 'Includes Subscriptions only',
value: 'register-now',
className: 'pf-u-mt-sm',
'data-testid': 'radio-register-now',
},
{
component: componentTypes.PLAIN_TEXT,
name: 'subscription-activation-description',
label: (
<>
Create and manage activation keys in the&nbsp;
<Button
component="a"
target="_blank"
variant="link"
icon={ <ExternalLinkAltIcon /> }
iconPosition="right"
isInline
href="https://access.redhat.com/">
Customer Portal
</Button>
</>
),
condition: {
or: [
{ when: 'register-system', is: 'register-now-insights' },
{ when: 'register-system', is: 'register-now' },
]
},
label: 'Register later',
value: 'register-later',
className: 'pf-u-mt-sm',
'data-testid': 'radio-register-later',
},
],
},
{
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-insights' },
{ when: 'register-system', is: 'register-now' },
],
},
isRequired: true,
validate: [
{
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.
</Text>
<Text>
Registering now is recommended.
</Text>
</TextContent>
),
condition: {
or: [
{ when: 'register-system', is: 'register-later' },
]
},
}
]
type: validatorTypes.REQUIRED,
},
],
},
{
component: componentTypes.PLAIN_TEXT,
name: 'subscription-activation-description',
label: (
<>
Create and manage activation keys in the&nbsp;
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href="https://access.redhat.com/"
>
Customer Portal
</Button>
</>
),
condition: {
or: [
{ 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.
</Text>
<Text>Registering now is recommended.</Text>
</TextContent>
),
condition: {
or: [{ when: 'register-system', is: 'register-later' }],
},
},
],
};

View file

@ -2,15 +2,15 @@ import CustomButtons from '../formComponents/CustomSubmitButtons';
import StepTemplate from './stepTemplate';
export default {
StepTemplate,
id: 'wizard-review',
name: 'review',
title: 'Review',
buttons: CustomButtons,
fields: [
{
name: 'review',
component: 'review'
}
]
StepTemplate,
id: 'wizard-review',
name: 'review',
title: 'Review',
buttons: CustomButtons,
fields: [
{
name: 'review',
component: 'review',
},
],
};

View file

@ -2,31 +2,42 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Title } from '@patternfly/react-core';
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>
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) }) ]),
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,30 +1,32 @@
const FileSystemConfigurationValidator = () => fsc => {
if (!fsc) {
return undefined;
}
const FileSystemConfigurationValidator = () => (fsc) => {
if (!fsc) {
return undefined;
}
let mpFreqs = {};
for (const fs of fsc) {
const mp = fs.mountpoint;
if (mp in mpFreqs) {
mpFreqs[mp]++;
} else {
mpFreqs[mp] = 1;
}
let mpFreqs = {};
for (const fs of fsc) {
const mp = fs.mountpoint;
if (mp in mpFreqs) {
mpFreqs[mp]++;
} else {
mpFreqs[mp] = 1;
}
}
let duplicates = [];
for (const [ k, v ] of Object.entries(mpFreqs)) {
if (v > 1) {
duplicates.push(k);
}
let duplicates = [];
for (const [k, v] of Object.entries(mpFreqs)) {
if (v > 1) {
duplicates.push(k);
}
}
let root = mpFreqs['/'] >= 1;
return duplicates.length === 0 && root ? undefined : {
let root = mpFreqs['/'] >= 1;
return duplicates.length === 0 && root
? undefined
: {
duplicates: duplicates === [] ? undefined : duplicates,
root,
};
};
};
export default FileSystemConfigurationValidator;

View file

@ -1,14 +1,17 @@
const TargetEnvironmentValidator = () => targets => {
if (!targets) {
return undefined;
}
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
let valid = Object.values(targets).reduce((prev, curr) => curr || prev, false);
return !valid ? 'Please select an image' : 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
let valid = Object.values(targets).reduce(
(prev, curr) => curr || prev,
false
);
return !valid ? 'Please select an image' : undefined;
};
export default TargetEnvironmentValidator;

View file

@ -3,35 +3,35 @@ import PropTypes from 'prop-types';
import { Alert } from '@patternfly/react-core';
const useGetErrorReason = (err) => {
if (!err?.reason) {
return 'An unknown error occured';
}
if (!err?.reason) {
return 'An unknown error occured';
}
if (err.details?.reason) {
return err.details.reason;
}
if (err.details?.reason) {
return err.details.reason;
}
return err.reason;
return err.reason;
};
const ErrorDetails = ({ status }) => {
if (!status || status.status !== 'failure') {
return <></>;
}
if (!status || status.status !== 'failure') {
return <></>;
}
const reason = useGetErrorReason(status.error);
const reason = useGetErrorReason(status.error);
return (
<div className='pf-u-mt-sm'>
<strong>Status</strong>
<Alert variant="danger" title="Image build failed" isInline isPlain />
<p className='pf-u-danger-color-200 pf-u-w-33-on-md'>{reason}</p>
</div>
);
return (
<div className="pf-u-mt-sm">
<strong>Status</strong>
<Alert variant="danger" title="Image build failed" isInline isPlain />
<p className="pf-u-danger-color-200 pf-u-w-33-on-md">{reason}</p>
</div>
);
};
ErrorDetails.propTypes = {
status: PropTypes.object,
status: PropTypes.object,
};
export default ErrorDetails;

View file

@ -2,72 +2,76 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Flex } from '@patternfly/react-core';
import { CheckCircleIcon, PendingIcon, ExclamationCircleIcon, InProgressIcon } from '@patternfly/react-icons';
import {
CheckCircleIcon,
PendingIcon,
ExclamationCircleIcon,
InProgressIcon,
} from '@patternfly/react-icons';
import './ImageBuildStatus.scss';
const ImageBuildStatus = (props) => {
const messages = {
success: [
{
icon: <CheckCircleIcon className="success" />,
text: 'Ready'
}
],
failure: [
{
icon: <ExclamationCircleIcon className="error" />,
text: 'Image build failed'
}
],
pending: [
{
icon: <PendingIcon />,
text: 'Image build is pending'
}
],
// Keep "running" for backward compatibility
running: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress'
}
],
building: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress'
}
],
uploading: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image upload in progress'
}
],
registering: [
{
icon: <InProgressIcon className="pending" />,
text: 'Cloud registration in progress'
}
]
};
return (
<React.Fragment>
{messages[props.status] &&
messages[props.status].map((message, key) => (
<Flex key={ key } className="pf-u-align-items-baseline pf-m-nowrap">
<div className="pf-u-mr-sm">{message.icon}</div>
<small>{message.text}</small>
</Flex>
))
}
</React.Fragment>
);
const messages = {
success: [
{
icon: <CheckCircleIcon className="success" />,
text: 'Ready',
},
],
failure: [
{
icon: <ExclamationCircleIcon className="error" />,
text: 'Image build failed',
},
],
pending: [
{
icon: <PendingIcon />,
text: 'Image build is pending',
},
],
// Keep "running" for backward compatibility
running: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress',
},
],
building: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress',
},
],
uploading: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image upload in progress',
},
],
registering: [
{
icon: <InProgressIcon className="pending" />,
text: 'Cloud registration in progress',
},
],
};
return (
<React.Fragment>
{messages[props.status] &&
messages[props.status].map((message, key) => (
<Flex key={key} className="pf-u-align-items-baseline pf-m-nowrap">
<div className="pf-u-mr-sm">{message.icon}</div>
<small>{message.text}</small>
</Flex>
))}
</React.Fragment>
);
};
ImageBuildStatus.propTypes = {
status: PropTypes.string,
status: PropTypes.string,
};
export default ImageBuildStatus;

View file

@ -1,112 +1,129 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, TextContent, Text, TextVariants, Popover } from '@patternfly/react-core';
import {
Button,
TextContent,
Text,
TextVariants,
Popover,
} from '@patternfly/react-core';
import { DownloadIcon, ExternalLinkAltIcon } from '@patternfly/react-icons';
const ImageLink = (props) => {
const fileExtensions = {
vsphere: '.vmdk',
'guest-image': '.qcow2',
'image-installer': '.iso',
};
const fileExtensions = {
vsphere: '.vmdk',
'guest-image': '.qcow2',
'image-installer': '.iso',
};
const uploadStatus = props.imageStatus ? props.imageStatus.upload_status : undefined;
if (uploadStatus) {
if (uploadStatus.type === 'aws') {
const url = 'https://console.aws.amazon.com/ec2/v2/home?region=' +
uploadStatus.options.region +
'#LaunchInstanceWizard:ami=' +
uploadStatus.options.ami;
return (
<Button
component="a"
target="_blank"
variant="link"
icon={ <ExternalLinkAltIcon /> }
iconPosition="right"
isInline
href={ url }>
Launch instance
</Button>
);
} else if (uploadStatus.type === 'azure') {
const url = 'https://portal.azure.com/#@' + props.uploadOptions.tenant_id +
'/resource/subscriptions/' + props.uploadOptions.subscription_id +
'/resourceGroups/' + props.uploadOptions.resource_group +
'/providers/Microsoft.Compute/images/' + uploadStatus.options.image_name;
return (
<Button
component="a"
target="_blank"
variant="link"
icon={ <ExternalLinkAltIcon /> }
iconPosition="right"
isInline
href={ url }>
View uploaded image
</Button>
);
} else if (uploadStatus.type === 'gcp') {
return (
<Popover
aria-label="Popover with google cloud platform image details"
maxWidth='30rem'
headerContent={ 'GCP image details' }
bodyContent={ <TextContent>
<Text component={ TextVariants.p }>
To use an Image Builder created Google Cloud Platform (GCP) image in your project,
specify the project ID and image name in your templates and configurations.
</Text>
<Text>
<strong>Project ID</strong>
<br />
{uploadStatus.options.project_id}
</Text>
<Text>
<strong>Image Name</strong>
<br />
{uploadStatus.options.image_name}
</Text>
<Text>
<strong>Shared with</strong>
<br />
{/* the account the image is shared with is stored in the form type:account so this extracts the account */}
{props.uploadOptions.share_with_accounts[0].split(':')[1]}
</Text>
</TextContent> }>
<Button
component="a"
target="_blank"
variant="link"
isInline>
Image details
</Button>
</Popover>
);
} else if (uploadStatus.type === 'aws.s3') {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={ <DownloadIcon /> }
iconPosition="right"
isInline
href={ uploadStatus.options.url }>
Download {fileExtensions[props.imageType]}
</Button>
);
}
const uploadStatus = props.imageStatus
? props.imageStatus.upload_status
: undefined;
if (uploadStatus) {
if (uploadStatus.type === 'aws') {
const url =
'https://console.aws.amazon.com/ec2/v2/home?region=' +
uploadStatus.options.region +
'#LaunchInstanceWizard:ami=' +
uploadStatus.options.ami;
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={url}
>
Launch instance
</Button>
);
} else if (uploadStatus.type === 'azure') {
const url =
'https://portal.azure.com/#@' +
props.uploadOptions.tenant_id +
'/resource/subscriptions/' +
props.uploadOptions.subscription_id +
'/resourceGroups/' +
props.uploadOptions.resource_group +
'/providers/Microsoft.Compute/images/' +
uploadStatus.options.image_name;
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={url}
>
View uploaded image
</Button>
);
} else if (uploadStatus.type === 'gcp') {
return (
<Popover
aria-label="Popover with google cloud platform image details"
maxWidth="30rem"
headerContent={'GCP image details'}
bodyContent={
<TextContent>
<Text component={TextVariants.p}>
To use an Image Builder created Google Cloud Platform (GCP)
image in your project, specify the project ID and image name in
your templates and configurations.
</Text>
<Text>
<strong>Project ID</strong>
<br />
{uploadStatus.options.project_id}
</Text>
<Text>
<strong>Image Name</strong>
<br />
{uploadStatus.options.image_name}
</Text>
<Text>
<strong>Shared with</strong>
<br />
{/* the account the image is shared with is stored in the form type:account so this extracts the account */}
{props.uploadOptions.share_with_accounts[0].split(':')[1]}
</Text>
</TextContent>
}
>
<Button component="a" target="_blank" variant="link" isInline>
Image details
</Button>
</Popover>
);
} else if (uploadStatus.type === 'aws.s3') {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<DownloadIcon />}
iconPosition="right"
isInline
href={uploadStatus.options.url}
>
Download {fileExtensions[props.imageType]}
</Button>
);
}
}
return null;
return null;
};
ImageLink.propTypes = {
imageStatus: PropTypes.object,
imageType: PropTypes.string,
uploadOptions: PropTypes.object,
imageStatus: PropTypes.object,
imageType: PropTypes.string,
uploadOptions: PropTypes.object,
};
export default ImageLink;

View file

@ -2,11 +2,28 @@ import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import { TableComposable, Thead, Tr, Th, Tbody, Td, ActionsColumn, ExpandableRowContent } from '@patternfly/react-table';
import { EmptyState, EmptyStateVariant, EmptyStateIcon, EmptyStateBody, EmptyStateSecondaryActions,
Pagination,
Toolbar, ToolbarContent, ToolbarItem,
Title } from '@patternfly/react-core';
import {
TableComposable,
Thead,
Tr,
Th,
Tbody,
Td,
ActionsColumn,
ExpandableRowContent,
} from '@patternfly/react-table';
import {
EmptyState,
EmptyStateVariant,
EmptyStateIcon,
EmptyStateBody,
EmptyStateSecondaryActions,
Pagination,
Toolbar,
ToolbarContent,
ToolbarItem,
Title,
} from '@patternfly/react-core';
import { PlusCircleIcon } from '@patternfly/react-icons';
import './ImagesTable.scss';
import { composesGet, composeGetStatus } from '../../store/actions/actions';
@ -18,196 +35,255 @@ import ImageLink from './ImageLink';
import ErrorDetails from './ImageBuildErrorDetails';
const ImagesTable = () => {
const [ page, setPage ] = useState(1);
const [ perPage, setPerPage ] = useState(10);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [ expandedComposeIds, setExpandedComposeIds ] = useState([]);
const isExpanded = compose => expandedComposeIds.includes(compose.id);
const [expandedComposeIds, setExpandedComposeIds] = useState([]);
const isExpanded = (compose) => expandedComposeIds.includes(compose.id);
const handleToggle = (compose, isExpanding) => {
if (isExpanding) {
setExpandedComposeIds([ ...expandedComposeIds, compose.id ]);
} else {
setExpandedComposeIds(expandedComposeIds.filter(id => id !== compose.id));
}};
const handleToggle = (compose, isExpanding) => {
if (isExpanding) {
setExpandedComposeIds([...expandedComposeIds, compose.id]);
} else {
setExpandedComposeIds(
expandedComposeIds.filter((id) => id !== compose.id)
);
}
};
const composes = useSelector((state) => state.composes);
const dispatch = useDispatch();
const composes = useSelector((state) => state.composes);
const dispatch = useDispatch();
const navigate = useNavigate();
const navigate = useNavigate();
const pollComposeStatuses = () => {
Object.entries(composes.byId).map(([ id, compose ]) => {
/* Skip composes that have been complete */
if (compose.image_status.status === 'success' || compose.image_status.status === 'failure') {
return;
}
const pollComposeStatuses = () => {
Object.entries(composes.byId).map(([id, compose]) => {
/* Skip composes that have been complete */
if (
compose.image_status.status === 'success' ||
compose.image_status.status === 'failure'
) {
return;
}
dispatch(composeGetStatus(id));
});
};
dispatch(composeGetStatus(id));
});
};
useEffect(() => {
dispatch(composesGet(perPage, 0));
const intervalId = setInterval(() => pollComposeStatuses(), 8000);
useEffect(() => {
dispatch(composesGet(perPage, 0));
const intervalId = setInterval(() => pollComposeStatuses(), 8000);
// clean up interval on unmount
return () => clearInterval(intervalId);
}, []);
// clean up interval on unmount
return () => clearInterval(intervalId);
}, []);
const onSetPage = (_, page) => {
// if the next page's composes haven't been fetched from api yet
// then fetch them with proper page index and offset
if (composes.count > composes.allIds.length) {
const pageIndex = page - 1;
const offset = pageIndex * perPage;
dispatch(composesGet(perPage, offset));
}
const onSetPage = (_, page) => {
// if the next page's composes haven't been fetched from api yet
// then fetch them with proper page index and offset
if (composes.count > composes.allIds.length) {
const pageIndex = page - 1;
const offset = pageIndex * perPage;
dispatch(composesGet(perPage, offset));
}
setPage(page);
};
setPage(page);
};
const onPerPageSelect = (_, perPage) => {
// if the new per page quantity is greater than the number of already fetched composes fetch more composes
// if all composes haven't already been fetched
if (composes.count > composes.allIds.length && perPage > composes.allIds.length) {
dispatch(composesGet(perPage, 0));
}
const onPerPageSelect = (_, perPage) => {
// if the new per page quantity is greater than the number of already fetched composes fetch more composes
// if all composes haven't already been fetched
if (
composes.count > composes.allIds.length &&
perPage > composes.allIds.length
) {
dispatch(composesGet(perPage, 0));
}
// page should be reset to the first page when the page size is changed.
setPerPage(perPage);
setPage(1);
};
// page should be reset to the first page when the page size is changed.
setPerPage(perPage);
setPage(1);
};
const timestampToDisplayString = (ts) => {
// timestamp has format 2021-04-27 12:31:12.794809 +0000 UTC
// must be converted to ms timestamp and then reformatted to Apr 27, 2021
if (!ts) {
return '';
}
const timestampToDisplayString = (ts) => {
// timestamp has format 2021-04-27 12:31:12.794809 +0000 UTC
// must be converted to ms timestamp and then reformatted to Apr 27, 2021
if (!ts) {
return '';
}
// get YYYY-MM-DD format
const date = ts.slice(0, 10);
const ms = Date.parse(date);
const options = { month: 'short', day: 'numeric', year: 'numeric' };
const tsDisplay = new Intl.DateTimeFormat('en-US', options).format(ms);
return tsDisplay;
};
// get YYYY-MM-DD format
const date = ts.slice(0, 10);
const ms = Date.parse(date);
const options = { month: 'short', day: 'numeric', year: 'numeric' };
const tsDisplay = new Intl.DateTimeFormat('en-US', options).format(ms);
return tsDisplay;
};
const actions = (compose) => [
{
title: 'Recreate image',
onClick: () => navigate(
'/imagewizard',
{ state: { composeRequest: compose.request, initialStep: 'review' }}
)
}
];
const actions = (compose) => [
{
title: 'Recreate image',
onClick: () =>
navigate('/imagewizard', {
state: { composeRequest: compose.request, initialStep: 'review' },
}),
},
];
// the state.page is not an index so must be reduced by 1 get the starting index
const itemsStartInclusive = (page - 1) * perPage;
const itemsEndExclusive = itemsStartInclusive + perPage;
// the state.page is not an index so must be reduced by 1 get the starting index
const itemsStartInclusive = (page - 1) * perPage;
const itemsEndExclusive = itemsStartInclusive + perPage;
return (
return (
<React.Fragment>
{(composes.allIds.length === 0 && (
<EmptyState variant={EmptyStateVariant.large} data-testid="empty-state">
<EmptyStateIcon icon={PlusCircleIcon} />
<Title headingLevel="h4" size="lg">
Create an image
</Title>
<EmptyStateBody>
Create OS images for deployment in Amazon Web Services, Microsoft
Azure and Google Cloud Platform. Images can include a custom package
set and an activation key to automate the registration process.
</EmptyStateBody>
<Link
to="/imagewizard"
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
<EmptyStateSecondaryActions>
<DocumentationButton />
</EmptyStateSecondaryActions>
</EmptyState>
)) || (
<React.Fragment>
{ composes.allIds.length === 0 && (
<EmptyState variant={ EmptyStateVariant.large } data-testid="empty-state">
<EmptyStateIcon icon={ PlusCircleIcon } />
<Title headingLevel="h4" size="lg">
Create an image
</Title>
<EmptyStateBody>
Create OS images for deployment in Amazon Web Services,
Microsoft Azure and Google Cloud Platform. Images can
include a custom package set and an activation key to
automate the registration process.
</EmptyStateBody>
<Link to="/imagewizard" className="pf-c-button pf-m-primary" data-testid="create-image-action">
Create image
</Link>
<EmptyStateSecondaryActions>
<DocumentationButton />
</EmptyStateSecondaryActions>
</EmptyState>
) || (
<React.Fragment>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Link to="/imagewizard" className="pf-c-button pf-m-primary" data-testid="create-image-action">
Create image
</Link>
</ToolbarItem>
<ToolbarItem variant="pagination" align={ { default: 'alignRight' } }>
<Pagination
itemCount={ composes.count }
perPage={ perPage }
page={ page }
onSetPage={ onSetPage }
onPerPageSelect={ onPerPageSelect }
widgetId="compose-pagination"
data-testid="images-pagination"
isCompact />
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<TableComposable variant="compact" data-testid="images-table">
<Thead>
<Tr>
<Th />
<Th>Image name</Th>
<Th>Created</Th>
<Th>Release</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Instance</Th>
<Th />
</Tr>
</Thead>
{ composes.allIds.slice(itemsStartInclusive, itemsEndExclusive).map((id, rowIndex) => {
const compose = composes.byId[id];
return (
<Tbody key={ id } isExpanded={ isExpanded(compose) }>
<Tr>
<Td expand={ { rowIndex, isExpanded: isExpanded(compose),
onToggle: () => handleToggle(compose, !isExpanded(compose)) } } />
<Td dataLabel="Image name">{compose.request.image_name || id}</Td>
<Td dataLabel="Created">{timestampToDisplayString(compose.created_at)}</Td>
<Td dataLabel="Release"><Release release={ compose.request.distribution } /></Td>
<Td dataLabel="Target"><Target
uploadType={ compose.request.image_requests[0].upload_request.type }
imageType={ compose.request.image_requests[0].image_type } /></Td>
<Td dataLabel="Status"><ImageBuildStatus
status={ compose.image_status ? compose.image_status.status : '' } /></Td>
<Td dataLabel="Instance"><ImageLink
imageStatus={ compose.image_status }
imageType={ compose.request.image_requests[0].image_type }
uploadOptions={ compose.request.image_requests[0].upload_request.options } /></Td>
<Td><ActionsColumn items={ actions(compose) } /></Td>
</Tr>
<Tr isExpanded={ isExpanded(compose) }>
<Td colSpan={ 8 }>
<ExpandableRowContent>
<strong>UUID</strong>
<div>{ id }</div>
<ErrorDetails status={ compose.image_status } />
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
);
}) }
</TableComposable>
</React.Fragment>
)}
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Link
to="/imagewizard"
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
</ToolbarItem>
<ToolbarItem
variant="pagination"
align={{ default: 'alignRight' }}
>
<Pagination
itemCount={composes.count}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination"
data-testid="images-pagination"
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<TableComposable variant="compact" data-testid="images-table">
<Thead>
<Tr>
<Th />
<Th>Image name</Th>
<Th>Created</Th>
<Th>Release</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Instance</Th>
<Th />
</Tr>
</Thead>
{composes.allIds
.slice(itemsStartInclusive, itemsEndExclusive)
.map((id, rowIndex) => {
const compose = composes.byId[id];
return (
<Tbody key={id} isExpanded={isExpanded(compose)}>
<Tr>
<Td
expand={{
rowIndex,
isExpanded: isExpanded(compose),
onToggle: () =>
handleToggle(compose, !isExpanded(compose)),
}}
/>
<Td dataLabel="Image name">
{compose.request.image_name || id}
</Td>
<Td dataLabel="Created">
{timestampToDisplayString(compose.created_at)}
</Td>
<Td dataLabel="Release">
<Release release={compose.request.distribution} />
</Td>
<Td dataLabel="Target">
<Target
uploadType={
compose.request.image_requests[0].upload_request
.type
}
imageType={
compose.request.image_requests[0].image_type
}
/>
</Td>
<Td dataLabel="Status">
<ImageBuildStatus
status={
compose.image_status
? compose.image_status.status
: ''
}
/>
</Td>
<Td dataLabel="Instance">
<ImageLink
imageStatus={compose.image_status}
imageType={
compose.request.image_requests[0].image_type
}
uploadOptions={
compose.request.image_requests[0].upload_request
.options
}
/>
</Td>
<Td>
<ActionsColumn items={actions(compose)} />
</Td>
</Tr>
<Tr isExpanded={isExpanded(compose)}>
<Td colSpan={8}>
<ExpandableRowContent>
<strong>UUID</strong>
<div>{id}</div>
<ErrorDetails status={compose.image_status} />
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
);
})}
</TableComposable>
</React.Fragment>
);
)}
</React.Fragment>
);
};
ImagesTable.propTypes = {
composes: PropTypes.object,
composesGet: PropTypes.func,
composeGetStatus: PropTypes.func,
composes: PropTypes.object,
composesGet: PropTypes.func,
composeGetStatus: PropTypes.func,
};
export default ImagesTable;

View file

@ -5,18 +5,20 @@ import { Label } from '@patternfly/react-core';
import { RHEL_8, RHEL_9 } from '../../constants.js';
const Release = (props) => {
const releaseOptions = {
[RHEL_8]: 'RHEL 8',
[RHEL_9]: 'RHEL 9',
'centos-8': 'CentOS Stream 8',
'centos-9': 'CentOS Stream 9',
};
const release = releaseOptions[props.release] ? releaseOptions[props.release] : props.release;
return <Label color='blue'>{release}</Label>;
const releaseOptions = {
[RHEL_8]: 'RHEL 8',
[RHEL_9]: 'RHEL 9',
'centos-8': 'CentOS Stream 8',
'centos-9': 'CentOS Stream 9',
};
const release = releaseOptions[props.release]
? releaseOptions[props.release]
: props.release;
return <Label color="blue">{release}</Label>;
};
Release.propTypes = {
release: PropTypes.string,
release: PropTypes.string,
};
export default Release;

View file

@ -2,32 +2,28 @@ import React from 'react';
import PropTypes from 'prop-types';
const Target = (props) => {
const targetOptions = {
aws: 'Amazon Web Services',
azure: 'Microsoft Azure',
gcp: 'Google Cloud Platform',
vsphere: 'VMWare',
'guest-image': 'Virtualization - Guest image',
'image-installer': 'Bare metal - Installer'
};
const targetOptions = {
aws: 'Amazon Web Services',
azure: 'Microsoft Azure',
gcp: 'Google Cloud Platform',
vsphere: 'VMWare',
'guest-image': 'Virtualization - Guest image',
'image-installer': 'Bare metal - Installer',
};
let target;
if (props.uploadType === 'aws.s3') {
target = targetOptions[props.imageType];
} else {
target = targetOptions[props.uploadType];
}
let target;
if (props.uploadType === 'aws.s3') {
target = targetOptions[props.imageType];
} else {
target = targetOptions[props.uploadType];
}
return (
<>
{target}
</>
);
return <>{target}</>;
};
Target.propTypes = {
uploadType: PropTypes.string,
imageType: PropTypes.string
uploadType: PropTypes.string,
imageType: PropTypes.string,
};
export default Target;

View file

@ -2,7 +2,11 @@
import React, { Component } from 'react';
import { PageHeader, PageHeaderTitle } from '@redhat-cloud-services/frontend-components';
// eslint-disable-next-line rulesdir/disallow-fec-relative-imports
import {
PageHeader,
PageHeaderTitle,
} from '@redhat-cloud-services/frontend-components';
import { Button, Popover, TextContent, Text } from '@patternfly/react-core';
import { GithubIcon, HelpIcon } from '@patternfly/react-icons';
@ -12,49 +16,57 @@ import './LandingPage.scss';
import DocumentationButton from '../sharedComponents/DocumentationButton';
class LandingPage extends Component {
constructor(props) {
super(props);
}
constructor(props) {
super(props);
}
render() {
return (
<React.Fragment>
<PageHeader>
<PageHeaderTitle className="title" title="Image Builder" />
<Popover
headerContent={ 'About Image Builder' }
bodyContent={ <TextContent>
<Text>
Image Builder is a service that allows you to create RHEL images
and push them to cloud environments.
</Text>
<DocumentationButton />
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={ <GithubIcon /> }
iconPosition="right"
isInline
href={ 'https://github.com/RedHatInsights/image-builder-frontend/tree/' + COMMITHASH }>
Contribute on GitHub
</Button>
</TextContent> }>
<Button
variant="plain"
aria-label="About image builder"
className="pf-u-pl-sm">
<HelpIcon />
</Button>
</Popover>
</PageHeader>
<section className="pf-l-page__main-section pf-c-page__main-section">
<ImagesTable />
</section>
</React.Fragment>
);
}
render() {
return (
<React.Fragment>
<PageHeader>
<PageHeaderTitle className="title" title="Image Builder" />
<Popover
headerContent={'About Image Builder'}
bodyContent={
<TextContent>
<Text>
Image Builder is a service that allows you to create RHEL
images and push them to cloud environments.
</Text>
<DocumentationButton />
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<GithubIcon />}
iconPosition="right"
isInline
href={
'https://github.com/RedHatInsights/image-builder-frontend/tree/' +
COMMITHASH
}
>
Contribute on GitHub
</Button>
</TextContent>
}
>
<Button
variant="plain"
aria-label="About image builder"
className="pf-u-pl-sm"
>
<HelpIcon />
</Button>
</Popover>
</PageHeader>
<section className="pf-l-page__main-section pf-c-page__main-section">
<ImagesTable />
</section>
</React.Fragment>
);
}
}
export default LandingPage;

View file

@ -3,19 +3,22 @@ import { Button } from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
const DocumentationButton = () => {
const documentationURL =
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/uploading_a_customized_rhel_system_image_to_cloud_environments/index';
const documentationURL =
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/uploading_a_customized_rhel_system_image_to_cloud_environments/index';
return (
<Button
component="a"
target="_blank"
variant="link"
icon={ <ExternalLinkAltIcon /> }
iconPosition="right"
isInline
href={ documentationURL }>Documentation</Button>
);
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={documentationURL}
>
Documentation
</Button>
);
};
export default DocumentationButton;

View file

@ -2,13 +2,15 @@ import React, { lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
const LandingPage = lazy(() => import('./Components/LandingPage/LandingPage'));
const CreateImageWizard = lazy(() => import('./Components/CreateImageWizard/CreateImageWizard'));
const CreateImageWizard = lazy(() =>
import('./Components/CreateImageWizard/CreateImageWizard')
);
export const Router = () => {
return (
<Routes>
<Route path='/imagewizard/*' element={ <CreateImageWizard /> } />
<Route path='*' element={ <LandingPage /> } />
</Routes>
);
return (
<Routes>
<Route path="/imagewizard/*" element={<CreateImageWizard />} />
<Route path="*" element={<LandingPage />} />
</Routes>
);
};

View file

@ -1,15 +1,15 @@
function getBaseName(pathname) {
let release = '/';
const pathName = pathname.split('/');
let release = '/';
const pathName = pathname.split('/');
pathName.shift();
if (pathName[0] === 'beta') {
pathName.shift();
release = `/beta/`;
}
if (pathName[0] === 'beta') {
pathName.shift();
release = `/beta/`;
}
return `${release}${pathName[0]}/${pathName[1] || ''}`;
return `${release}${pathName[0]}/${pathName[1] || ''}`;
}
export default getBaseName;

View file

@ -1,16 +1,13 @@
import {
RHEL_8,
RHEL_9,
} from '../constants';
import { RHEL_8, RHEL_9 } from '../constants';
function isRhel(distro) {
switch (distro) {
case RHEL_8:
case RHEL_9:
return true;
default:
return false;
}
switch (distro) {
case RHEL_8:
case RHEL_9:
return true;
default:
return false;
}
}
export default isRhel;

View file

@ -1,62 +1,63 @@
import axios from 'axios';
import {
IMAGE_BUILDER_API,
RHSM_API,
} from './constants';
import { IMAGE_BUILDER_API, RHSM_API } from './constants';
const postHeaders = { headers: { 'Content-Type': 'application/json' }};
const postHeaders = { headers: { 'Content-Type': 'application/json' } };
async function composeImage(body) {
let path = '/compose';
const request = await axios.post(IMAGE_BUILDER_API.concat(path), body, postHeaders);
return request.data;
let path = '/compose';
const request = await axios.post(
IMAGE_BUILDER_API.concat(path),
body,
postHeaders
);
return request.data;
}
async function getComposes(limit, offset) {
const params = new URLSearchParams({
limit,
offset,
});
let path = '/composes?' + params.toString();
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
const params = new URLSearchParams({
limit,
offset,
});
let path = '/composes?' + params.toString();
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
}
async function getComposeStatus(id) {
let path = '/composes/' + id;
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
let path = '/composes/' + id;
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
}
async function getPackages(distribution, architecture, search, limit) {
const params = new URLSearchParams({
distribution,
architecture,
search,
});
limit && params.append('limit', limit);
let path = '/packages?' + params.toString();
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
const params = new URLSearchParams({
distribution,
architecture,
search,
});
limit && params.append('limit', limit);
let path = '/packages?' + params.toString();
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
}
async function getVersion() {
let path = '/version';
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
let path = '/version';
const request = await axios.get(IMAGE_BUILDER_API.concat(path));
return request.data;
}
async function getActivationKeys() {
const path = '/activation_keys';
const request = await axios.get(RHSM_API.concat(path));
return request.data.body;
const path = '/activation_keys';
const request = await axios.get(RHSM_API.concat(path));
return request.data.body;
}
export default {
composeImage,
getComposes,
getComposeStatus,
getPackages,
getVersion,
getActivationKeys,
composeImage,
getComposes,
getComposeStatus,
getPackages,
getVersion,
getActivationKeys,
};

View file

@ -8,8 +8,8 @@ export const UNIT_MIB = 1024 ** 2;
export const UNIT_GIB = 1024 ** 3;
export const RELEASES = {
[RHEL_8]: 'Red Hat Enterprise Linux (RHEL) 8',
[RHEL_9]: 'Red Hat Enterprise Linux (RHEL) 9',
'centos-8': 'CentOS Stream 8',
'centos-9': 'CentOS Stream 9',
[RHEL_8]: 'Red Hat Enterprise Linux (RHEL) 8',
[RHEL_9]: 'Red Hat Enterprise Linux (RHEL) 9',
'centos-8': 'CentOS Stream 8',
'centos-9': 'CentOS Stream 9',
};

View file

@ -1,13 +1,15 @@
import getBaseName from './Utilities/getBaseName';
describe('Utilities/getBaseName', () => {
it('should find the right base name on Stable ', () => {
expect(getBaseName('/insights/foo/bar/baz')).toEqual('/insights/foo');
expect(getBaseName('/rhcs/bar/bar/baz')).toEqual('/rhcs/bar');
});
it('should find the right base name on Stable ', () => {
expect(getBaseName('/insights/foo/bar/baz')).toEqual('/insights/foo');
expect(getBaseName('/rhcs/bar/bar/baz')).toEqual('/rhcs/bar');
});
it('should find the right base name on Beta ', () => {
expect(getBaseName('/beta/insights/foo/bar/baz')).toEqual('/beta/insights/foo');
expect(getBaseName('/beta/test/fff/bar/baz')).toEqual('/beta/test/fff');
});
it('should find the right base name on Beta ', () => {
expect(getBaseName('/beta/insights/foo/bar/baz')).toEqual(
'/beta/insights/foo'
);
expect(getBaseName('/beta/test/fff/bar/baz')).toEqual('/beta/test/fff');
});
});

View file

@ -2,150 +2,157 @@ import api from '../../api';
import types from '../types';
function composeUpdated(compose) {
return {
type: types.COMPOSE_UPDATED,
payload: { compose },
};
return {
type: types.COMPOSE_UPDATED,
payload: { compose },
};
}
export const composeFailed = (error) => ({
type: types.COMPOSE_FAILED,
payload: { error }
type: types.COMPOSE_FAILED,
payload: { error },
});
export const composeAdded = (compose, insert) => ({
type: types.COMPOSE_ADDED,
payload: { compose, insert },
type: types.COMPOSE_ADDED,
payload: { compose, insert },
});
export const composeStart = (composeRequest) => async dispatch => {
// response will be of the format {id: ''}
const request = api.composeImage(composeRequest);
return request.then(response => {
// add the compose id to the compose object to provide access to the id if iterating through
// composes and add an image status of 'pending' alongside the compose request.
const compose = Object.assign({}, response, { request: composeRequest }, { image_status: { status: 'pending' }});
dispatch(composeAdded(compose, true));
}).catch(err => {
if (err.response.status === 500) {
dispatch(composeFailed('Error: Something went wrong serverside'));
} else {
dispatch(composeFailed('Error: Something went wrong with the compose'));
}
export const composeStart = (composeRequest) => async (dispatch) => {
// response will be of the format {id: ''}
const request = api.composeImage(composeRequest);
return request
.then((response) => {
// add the compose id to the compose object to provide access to the id if iterating through
// composes and add an image status of 'pending' alongside the compose request.
const compose = Object.assign(
{},
response,
{ request: composeRequest },
{ image_status: { status: 'pending' } }
);
dispatch(composeAdded(compose, true));
})
.catch((err) => {
if (err.response.status === 500) {
dispatch(composeFailed('Error: Something went wrong serverside'));
} else {
dispatch(composeFailed('Error: Something went wrong with the compose'));
}
});
};
export const composeUpdatedStatus = (id, status) => ({
type: types.COMPOSE_UPDATED_STATUS,
payload: { id, status }
type: types.COMPOSE_UPDATED_STATUS,
payload: { id, status },
});
export const composeGetStatus = (id) => async dispatch => {
const request = await api.getComposeStatus(id);
dispatch(composeUpdatedStatus(id, request.image_status));
export const composeGetStatus = (id) => async (dispatch) => {
const request = await api.getComposeStatus(id);
dispatch(composeUpdatedStatus(id, request.image_status));
};
export const composesUpdatedCount = (count) => ({
type: types.COMPOSES_UPDATED_COUNT,
payload: { count }
type: types.COMPOSES_UPDATED_COUNT,
payload: { count },
});
export const composesGet = (limit, offset) => async dispatch => {
const request = await api.getComposes(limit, offset);
request.data.map(compose => {
dispatch(composeAdded(compose, false));
dispatch(composeGetStatus(compose.id));
});
dispatch(composesUpdatedCount(request.meta.count));
export const composesGet = (limit, offset) => async (dispatch) => {
const request = await api.getComposes(limit, offset);
request.data.map((compose) => {
dispatch(composeAdded(compose, false));
dispatch(composeGetStatus(compose.id));
});
dispatch(composesUpdatedCount(request.meta.count));
};
function setRelease({ arch, distro }) {
return {
type: types.SET_RELEASE,
payload: {
arch,
distro,
}
};
return {
type: types.SET_RELEASE,
payload: {
arch,
distro,
},
};
}
function setUploadDestinations({ aws, azure, google }) {
return {
type: types.SET_UPLOAD_DESTINATIONS,
payload: {
aws,
azure,
google,
}
};
return {
type: types.SET_UPLOAD_DESTINATIONS,
payload: {
aws,
azure,
google,
},
};
}
function setUploadAWS({ shareWithAccounts }) {
return {
type: types.SET_UPLOAD_AWS,
payload: {
shareWithAccounts,
}
};
return {
type: types.SET_UPLOAD_AWS,
payload: {
shareWithAccounts,
},
};
}
function setUploadAzure({ tenantId, subscriptionId, resourceGroup }) {
return {
type: types.SET_UPLOAD_AZURE,
payload: {
tenantId,
subscriptionId,
resourceGroup,
}
};
return {
type: types.SET_UPLOAD_AZURE,
payload: {
tenantId,
subscriptionId,
resourceGroup,
},
};
}
function setUploadGoogle({ accountType, shareWithAccounts }) {
return {
type: types.SET_UPLOAD_GOOGLE,
payload: {
accountType,
shareWithAccounts,
}
};
return {
type: types.SET_UPLOAD_GOOGLE,
payload: {
accountType,
shareWithAccounts,
},
};
}
function setSelectedPackages(selectedPackages) {
return {
type: types.SET_SELECTED_PACKAGES,
payload: selectedPackages
};
return {
type: types.SET_SELECTED_PACKAGES,
payload: selectedPackages,
};
}
function setSubscription({ activationKey, insights, organization }) {
return {
type: types.SET_SUBSCRIPTION,
payload: {
activationKey,
insights,
organization,
}
};
return {
type: types.SET_SUBSCRIPTION,
payload: {
activationKey,
insights,
organization,
},
};
}
function setSubscribeNow(subscribeNow) {
return {
type: types.SET_SUBSCRIBE_NOW,
payload: subscribeNow
};
return {
type: types.SET_SUBSCRIBE_NOW,
payload: subscribeNow,
};
}
export default {
composesGet,
composeStart,
composeUpdated,
composeGetStatus,
setRelease,
setUploadDestinations,
setUploadAWS,
setUploadAzure,
setUploadGoogle,
setSelectedPackages,
setSubscription,
setSubscribeNow,
composesGet,
composeStart,
composeUpdated,
composeGetStatus,
setRelease,
setUploadDestinations,
setUploadAWS,
setUploadAzure,
setUploadGoogle,
setSelectedPackages,
setSubscription,
setSubscribeNow,
};

View file

@ -7,32 +7,32 @@ import composes from './reducers/composes';
let registry;
export function init (store = {}, ...middleware) {
if (!registry) {
registry = new ReducerRegistry(store, [
promiseMiddleware,
thunk,
...middleware.filter(item => typeof item !== 'undefined'),
]);
export function init(store = {}, ...middleware) {
if (!registry) {
registry = new ReducerRegistry(store, [
promiseMiddleware,
thunk,
...middleware.filter((item) => typeof item !== 'undefined'),
]);
registry.register({
composes,
notifications: notificationsReducer,
});
}
registry.register({
composes,
notifications: notificationsReducer,
});
}
return registry;
return registry;
}
export function getStore () {
return registry.getStore();
export function getStore() {
return registry.getStore();
}
export function register (...args) {
return registry.register(...args);
export function register(...args) {
return registry.register(...args);
}
/* added for testing purposes only */
export function clearStore() {
registry = undefined;
registry = undefined;
}

View file

@ -25,74 +25,78 @@ import types from '../types';
// };
const initialComposesState = {
count: 0,
allIds: [],
byId: {},
error: null,
count: 0,
allIds: [],
byId: {},
error: null,
};
// only add to array if compose does not exist
const updateAllIds = (allIds, id, insert) => {
if (allIds.includes(id)) {
return allIds;
}
if (allIds.includes(id)) {
return allIds;
}
if (insert) {
return [ id ].concat(allIds);
}
if (insert) {
return [id].concat(allIds);
}
return allIds.concat(id);
return allIds.concat(id);
};
export function composes(state = initialComposesState, action) {
switch (action.type) {
case types.COMPOSE_ADDED:
return {
...state,
allIds: updateAllIds(state.allIds, action.payload.compose.id, action.payload.insert),
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
},
error: null,
};
case types.COMPOSE_FAILED:
return {
...state,
error: action.payload.error,
};
case types.COMPOSE_PENDING:
return {
...state,
error: null,
};
case types.COMPOSE_UPDATED:
return {
...state,
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
}
};
case types.COMPOSES_UPDATED_COUNT:
return {
...state,
count: action.payload.count,
};
case types.COMPOSE_UPDATED_STATUS:
return {
...state,
byId: {
...state.byId,
[action.payload.id]: {
...state.byId[action.payload.id],
image_status: action.payload.status,
}
}
};
default:
return state;
}
switch (action.type) {
case types.COMPOSE_ADDED:
return {
...state,
allIds: updateAllIds(
state.allIds,
action.payload.compose.id,
action.payload.insert
),
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
},
error: null,
};
case types.COMPOSE_FAILED:
return {
...state,
error: action.payload.error,
};
case types.COMPOSE_PENDING:
return {
...state,
error: null,
};
case types.COMPOSE_UPDATED:
return {
...state,
byId: {
...state.byId,
[action.payload.compose.id]: action.payload.compose,
},
};
case types.COMPOSES_UPDATED_COUNT:
return {
...state,
count: action.payload.count,
};
case types.COMPOSE_UPDATED_STATUS:
return {
...state,
byId: {
...state.byId,
[action.payload.id]: {
...state.byId[action.payload.id],
image_status: action.payload.status,
},
},
};
default:
return state;
}
}
export default composes;

View file

@ -13,17 +13,17 @@ const SET_SUBSCRIPTION = 'SET_SUBSCRIPTION';
const SET_SUBSCRIBE_NOW = 'SET_SUBSCRIBE_NOW';
export default {
COMPOSE_ADDED,
COMPOSE_FAILED,
COMPOSE_UPDATED,
COMPOSES_UPDATED_COUNT,
COMPOSE_UPDATED_STATUS,
SET_RELEASE,
SET_UPLOAD_DESTINATIONS,
SET_UPLOAD_AWS,
SET_UPLOAD_AZURE,
SET_UPLOAD_GOOGLE,
SET_SELECTED_PACKAGES,
SET_SUBSCRIPTION,
SET_SUBSCRIBE_NOW,
COMPOSE_ADDED,
COMPOSE_FAILED,
COMPOSE_UPDATED,
COMPOSES_UPDATED_COUNT,
COMPOSE_UPDATED_STATUS,
SET_RELEASE,
SET_UPLOAD_DESTINATIONS,
SET_UPLOAD_AWS,
SET_UPLOAD_AZURE,
SET_UPLOAD_GOOGLE,
SET_SELECTED_PACKAGES,
SET_SUBSCRIPTION,
SET_SUBSCRIBE_NOW,
};

File diff suppressed because it is too large Load diff

View file

@ -10,471 +10,498 @@ import { RHEL_8 } from '../../../constants.js';
import userEvent from '@testing-library/user-event';
jest.mock('../../../store/actions/actions', () => {
return {
composesGet: () => ({ type: 'foo' }),
composeGetStatus: () => ({ type: 'bar' })
};
return {
composesGet: () => ({ type: 'foo' }),
composeGetStatus: () => ({ type: 'bar' }),
};
});
const store = {
composes: {
errors: null,
allIds: [
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
'42ad0826-30b5-4f64-a24e-957df26fd564',
'955944a2-e149-4058-8ac1-35b514cb5a16',
'f7a60094-b376-4b58-a102-5c8c82dfd18b',
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
'61b0effa-c901-4ee5-86b9-2010b47f1b22',
'ca03f120-9840-4959-871e-94a5cb49d1f2',
'551de6f6-1533-4b46-a69f-7924051f9bc6',
'77fa8b03-7efb-4120-9a20-da66d68c4494',
],
byId: {
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c': {
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
image_name: 'testImageName',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'success',
upload_status: {
options: {
ami: 'ami-0217b81d9be50e44b',
region: 'us-east-1'
},
status: 'success',
type: 'aws'
}
},
composes: {
errors: null,
allIds: [
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
'42ad0826-30b5-4f64-a24e-957df26fd564',
'955944a2-e149-4058-8ac1-35b514cb5a16',
'f7a60094-b376-4b58-a102-5c8c82dfd18b',
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
'61b0effa-c901-4ee5-86b9-2010b47f1b22',
'ca03f120-9840-4959-871e-94a5cb49d1f2',
'551de6f6-1533-4b46-a69f-7924051f9bc6',
'77fa8b03-7efb-4120-9a20-da66d68c4494',
],
byId: {
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c': {
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
image_name: 'testImageName',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
// kept "running" for backward compatibility
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
id: 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'running',
},
],
},
image_status: {
status: 'success',
upload_status: {
options: {
ami: 'ami-0217b81d9be50e44b',
region: 'us-east-1',
},
'edbae1c2-62bc-42c1-ae0c-3110ab718f58': {
id: 'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'pending',
},
status: 'success',
type: 'aws',
},
},
},
// kept "running" for backward compatibility
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
id: 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'42ad0826-30b5-4f64-a24e-957df26fd564': {
id: '42ad0826-30b5-4f64-a24e-957df26fd564',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'building',
},
],
},
image_status: {
status: 'running',
},
},
'edbae1c2-62bc-42c1-ae0c-3110ab718f58': {
id: 'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'955944a2-e149-4058-8ac1-35b514cb5a16': {
id: '955944a2-e149-4058-8ac1-35b514cb5a16',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'uploading',
},
],
},
image_status: {
status: 'pending',
},
},
'42ad0826-30b5-4f64-a24e-957df26fd564': {
id: '42ad0826-30b5-4f64-a24e-957df26fd564',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'f7a60094-b376-4b58-a102-5c8c82dfd18b': {
id: 'f7a60094-b376-4b58-a102-5c8c82dfd18b',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'registering',
},
],
},
image_status: {
status: 'building',
},
},
'955944a2-e149-4058-8ac1-35b514cb5a16': {
id: '955944a2-e149-4058-8ac1-35b514cb5a16',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'61b0effa-c901-4ee5-86b9-2010b47f1b22': {
id: '61b0effa-c901-4ee5-86b9-2010b47f1b22',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
},
image_status: {
status: 'failure',
error: {
reason: 'A dependency error occured',
details: {
reason: 'Error in depsolve job'
}
}
},
],
},
image_status: {
status: 'uploading',
},
},
'f7a60094-b376-4b58-a102-5c8c82dfd18b': {
id: 'f7a60094-b376-4b58-a102-5c8c82dfd18b',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'ca03f120-9840-4959-871e-94a5cb49d1f2': {
id: 'ca03f120-9840-4959-871e-94a5cb49d1f2',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: [
'serviceAccount:test@email.com'
]
}
}
}
],
},
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-d446d8cb-7c16-4756-bf7d-706293785b05',
project_id: 'red-hat-image-builder'
},
status: 'success',
type: 'gcp'
}
},
],
},
image_status: {
status: 'registering',
},
},
'61b0effa-c901-4ee5-86b9-2010b47f1b22': {
id: '61b0effa-c901-4ee5-86b9-2010b47f1b22',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
'551de6f6-1533-4b46-a69f-7924051f9bc6': {
id: '551de6f6-1533-4b46-a69f-7924051f9bc6',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {}
}
}
],
},
image_status: {
status: 'building',
},
],
},
image_status: {
status: 'failure',
error: {
reason: 'A dependency error occured',
details: {
reason: 'Error in depsolve job',
},
'77fa8b03-7efb-4120-9a20-da66d68c4494': {
id: '77fa8b03-7efb-4120-9a20-da66d68c4494',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {
tenant_id: 'b8f86d22-4371-46ce-95e7-65c415f3b1e2',
subscription_id: 'test-subscription-id',
resource_group: 'test-resource-group'
}
}
}
],
},
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-cc5920c3-5451-4282-aab3-725d3df7f1cb'
},
status: 'success',
type: 'azure'
}
},
},
},
'ca03f120-9840-4959-871e-94a5cb49d1f2': {
id: 'ca03f120-9840-4959-871e-94a5cb49d1f2',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['serviceAccount:test@email.com'],
},
},
},
'b7193673-8dcc-4a5f-ac30-e9f4940d8346': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: 'b7193673-8dcc-4a5f-ac30-e9f4940d8346',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
options: {},
type: 'aws.s3'
}
}
]
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/b7193673-8dcc-4a5f-ac30-e9f4940d8346-disk.vmdk'
},
status: 'success',
type: 'aws.s3'
}
}
],
},
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-d446d8cb-7c16-4756-bf7d-706293785b05',
project_id: 'red-hat-image-builder',
},
'4873fd0f-1851-4b9f-b4fe-4639fce90794': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: '4873fd0f-1851-4b9f-b4fe-4639fce90793',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
options: {},
type: 'aws.s3'
}
}
]
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/4873fd0f-1851-4b9f-b4fe-4639fce90794-installer.iso'
},
status: 'success',
type: 'aws.s3'
}
}
status: 'success',
type: 'gcp',
},
},
},
'551de6f6-1533-4b46-a69f-7924051f9bc6': {
id: '551de6f6-1533-4b46-a69f-7924051f9bc6',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {},
},
},
'7b7d0d51-7106-42ab-98f2-f89872a9d599': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: '7b7d0d51-7106-42ab-98f2-f89872a9d599',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
options: {},
type: 'aws.s3'
}
}
]
],
},
image_status: {
status: 'building',
},
},
'77fa8b03-7efb-4120-9a20-da66d68c4494': {
id: '77fa8b03-7efb-4120-9a20-da66d68c4494',
created_at: '2021-04-27 12:31:12.794809 +0000 UTC',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vhd',
upload_request: {
type: 'azure',
options: {
tenant_id: 'b8f86d22-4371-46ce-95e7-65c415f3b1e2',
subscription_id: 'test-subscription-id',
resource_group: 'test-resource-group',
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/7b7d0d51-7106-42ab-98f2-f89872a9d599-disk.qcow2'
},
status: 'success',
type: 'aws.s3'
}
}
},
},
}
}
],
},
image_status: {
status: 'success',
upload_status: {
options: {
image_name: 'composer-api-cc5920c3-5451-4282-aab3-725d3df7f1cb',
},
status: 'success',
type: 'azure',
},
},
},
'b7193673-8dcc-4a5f-ac30-e9f4940d8346': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: 'b7193673-8dcc-4a5f-ac30-e9f4940d8346',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/b7193673-8dcc-4a5f-ac30-e9f4940d8346-disk.vmdk',
},
status: 'success',
type: 'aws.s3',
},
},
},
'4873fd0f-1851-4b9f-b4fe-4639fce90794': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: '4873fd0f-1851-4b9f-b4fe-4639fce90793',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/4873fd0f-1851-4b9f-b4fe-4639fce90794-installer.iso',
},
status: 'success',
type: 'aws.s3',
},
},
},
'7b7d0d51-7106-42ab-98f2-f89872a9d599': {
created_at: '2022-01-11 13:33:33.767002 +0000 UTC',
id: '7b7d0d51-7106-42ab-98f2-f89872a9d599',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
image_status: {
status: 'success',
upload_status: {
options: {
url: 'https://s3.amazonaws.com/7b7d0d51-7106-42ab-98f2-f89872a9d599-disk.qcow2',
},
status: 'success',
type: 'aws.s3',
},
},
},
},
},
};
describe('Images Table', () => {
test('render ImagesTable', () => {
renderWithReduxRouter(<ImagesTable />, store);
// make sure the empty-state message isn't present
const emptyState = screen.queryByTestId('empty-state');
expect(emptyState).not.toBeInTheDocument();
test('render ImagesTable', () => {
renderWithReduxRouter(<ImagesTable />, store);
// make sure the empty-state message isn't present
const emptyState = screen.queryByTestId('empty-state');
expect(emptyState).not.toBeInTheDocument();
// check table
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
// remove first row from list since it is just header labels
const header = rows.shift();
// test the header has correct labels
expect(header.cells[1]).toHaveTextContent('Image name');
expect(header.cells[2]).toHaveTextContent('Created');
expect(header.cells[3]).toHaveTextContent('Release');
expect(header.cells[4]).toHaveTextContent('Target');
expect(header.cells[5]).toHaveTextContent('Status');
expect(header.cells[6]).toHaveTextContent('Instance');
// check table
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
// remove first row from list since it is just header labels
const header = rows.shift();
// test the header has correct labels
expect(header.cells[1]).toHaveTextContent('Image name');
expect(header.cells[2]).toHaveTextContent('Created');
expect(header.cells[3]).toHaveTextContent('Release');
expect(header.cells[4]).toHaveTextContent('Target');
expect(header.cells[5]).toHaveTextContent('Status');
expect(header.cells[6]).toHaveTextContent('Instance');
// 10 rows for 10 images
expect(rows).toHaveLength(10);
for (const row of rows) {
const col1 = row.cells[1].textContent;
// 10 rows for 10 images
expect(rows).toHaveLength(10);
for (const row of rows) {
const col1 = row.cells[1].textContent;
const composes = Object.values(store.composes.byId);
// find compose with either the user defined image name or the uuid
const compose = composes.find(compose => compose?.image_name === col1 || compose.id === col1);
expect(compose).toBeTruthy();
const composes = Object.values(store.composes.byId);
// find compose with either the user defined image name or the uuid
const compose = composes.find(
(compose) => compose?.image_name === col1 || compose.id === col1
);
expect(compose).toBeTruthy();
// date should match the month day and year of the timestamp.
expect(row.cells[2]).toHaveTextContent('Apr 27, 2021');
// date should match the month day and year of the timestamp.
expect(row.cells[2]).toHaveTextContent('Apr 27, 2021');
// render the expected <ImageBuildStatus /> and compare the text content
let testElement = document.createElement('testElement');
render(<Target
imageType={ compose.request.image_requests[0].image_type }
uploadType={ compose.request.image_requests[0].upload_request.type } />, { container: testElement });
expect(row.cells[4]).toHaveTextContent(testElement.textContent);
// render the expected <ImageBuildStatus /> and compare the text content
let testElement = document.createElement('testElement');
render(
<Target
imageType={compose.request.image_requests[0].image_type}
uploadType={compose.request.image_requests[0].upload_request.type}
/>,
{ container: testElement }
);
expect(row.cells[4]).toHaveTextContent(testElement.textContent);
// render the expected <ImageBuildStatus /> and compare the text content
render(<ImageBuildStatus status={ compose.image_status.status } />, { container: testElement });
expect(row.cells[5]).toHaveTextContent(testElement.textContent);
// render the expected <ImageBuildStatus /> and compare the text content
render(<ImageBuildStatus status={compose.image_status.status} />, {
container: testElement,
});
expect(row.cells[5]).toHaveTextContent(testElement.textContent);
// render the expected <ImageLink /> and compare the text content for a link
render(
<ImageLink imageStatus={ compose.image_status } uploadOptions={ compose.request.image_requests[0].upload_request.options } />,
{ container: testElement }
);
expect(row.cells[6]).toHaveTextContent(testElement.textContent);
}
// render the expected <ImageLink /> and compare the text content for a link
render(
<ImageLink
imageStatus={compose.image_status}
uploadOptions={
compose.request.image_requests[0].upload_request.options
}
/>,
{ container: testElement }
);
expect(row.cells[6]).toHaveTextContent(testElement.textContent);
}
});
test('check recreate action', () => {
const { history } = renderWithReduxRouter(<ImagesTable />, store);
// get rows
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
// first row is header so look at index 1
const imageId = rows[1].cells[1].textContent;
const actionsButton = within(rows[1]).getByRole('button', {
name: 'Actions',
});
userEvent.click(actionsButton);
const recreateButton = screen.getByRole('button', {
name: 'Recreate image',
});
userEvent.click(recreateButton);
expect(history.location.pathname).toBe('/imagewizard');
expect(history.location.state.composeRequest).toStrictEqual(
store.composes.byId[imageId].request
);
expect(history.location.state.initialStep).toBe('review');
});
test('check expandable row toggle', () => {
renderWithReduxRouter(<ImagesTable />, store);
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const toggleButton = within(rows[6]).getByRole('button', {
name: /details/i,
});
test('check recreate action', () => {
const { history } = renderWithReduxRouter(<ImagesTable />, store);
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).not.toBeVisible();
userEvent.click(toggleButton);
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).toBeVisible();
userEvent.click(toggleButton);
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).not.toBeVisible();
});
// get rows
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
test('check error details', () => {
renderWithReduxRouter(<ImagesTable />, store);
// first row is header so look at index 1
const imageId = rows[1].cells[1].textContent;
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const actionsButton = within(rows[1]).getByRole('button', {
name: 'Actions'
});
userEvent.click(actionsButton);
const recreateButton = screen.getByRole('button', {
name: 'Recreate image'
});
userEvent.click(recreateButton);
expect(history.location.pathname).toBe('/imagewizard');
expect(history.location.state.composeRequest).toStrictEqual(store.composes.byId[imageId].request);
expect(history.location.state.initialStep).toBe('review');
const errorToggle = within(rows[7]).getByRole('button', {
name: /details/i,
});
test('check expandable row toggle', () => {
renderWithReduxRouter(<ImagesTable />, store);
expect(
screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1]
).not.toBeVisible();
userEvent.click(errorToggle);
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const toggleButton = within(rows[6]).getByRole('button', { name: /details/i });
expect(screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]).not.toBeVisible();
userEvent.click(toggleButton);
expect(screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]).toBeVisible();
userEvent.click(toggleButton);
expect(screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]).not.toBeVisible();
});
test('check error details', () => {
renderWithReduxRouter(<ImagesTable />, store);
const table = screen.getByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const errorToggle = within(rows[7]).getByRole('button', { name: /details/i });
expect(screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1]).not.toBeVisible();
userEvent.click(errorToggle);
expect(screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1]).toBeVisible();
expect(screen.getAllByText(/Error in depsolve job/i)[0]).toBeVisible();
});
expect(
screen.getAllByText(/61b0effa-c901-4ee5-86b9-2010b47f1b22/i)[1]
).toBeVisible();
expect(screen.getAllByText(/Error in depsolve job/i)[0]).toBeVisible();
});
});
describe('Images Table Toolbar', () => {
test('render toolbar', () => {
renderWithReduxRouter(<ImagesTable />, store);
// check create image button
screen.getByTestId('create-image-action');
test('render toolbar', () => {
renderWithReduxRouter(<ImagesTable />, store);
// check create image button
screen.getByTestId('create-image-action');
// check pagination renders
screen.getByTestId('images-pagination');
});
// check pagination renders
screen.getByTestId('images-pagination');
});
});

View file

@ -5,28 +5,28 @@ import LandingPage from '../../../Components/LandingPage/LandingPage';
import api from '../../../api.js';
jest.mock('../../../store/actions/actions', () => {
return {
composesGet: () => ({ type: 'foo' }),
composeGetStatus: () => ({ type: 'bar' })
};
return {
composesGet: () => ({ type: 'foo' }),
composeGetStatus: () => ({ type: 'bar' }),
};
});
describe('Landing Page', () => {
test('renders page heading', async () => {
renderWithReduxRouter(<LandingPage />);
test('renders page heading', async () => {
renderWithReduxRouter(<LandingPage />);
const composeImage = jest.spyOn(api, 'getVersion');
composeImage.mockResolvedValue({ version: '1.0' });
// check heading
screen.getByRole('heading', { name: /Image Builder/i });
});
const composeImage = jest.spyOn(api, 'getVersion');
composeImage.mockResolvedValue({ version: '1.0' });
// check heading
screen.getByRole('heading', { name: /Image Builder/i });
});
test('renders EmptyState child component', async () => {
renderWithReduxRouter(<LandingPage />);
test('renders EmptyState child component', async () => {
renderWithReduxRouter(<LandingPage />);
// check action loads
screen.getByTestId('create-image-action');
// check table loads
screen.getByTestId('empty-state');
});
// check action loads
screen.getByTestId('create-image-action');
// check table loads
screen.getByTestId('empty-state');
});
});

View file

@ -3,32 +3,32 @@ import types from '../../store/types';
import { RHEL_8 } from '../../constants.js';
const compose = {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'uploading',
'77e4c693-0497-4b85-936d-b2a3ad69571b': {
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
}
},
],
image_status: {
status: 'uploading',
},
},
};
describe('composeUpdated', () => {
test('returns dict', () => {
const result = actions.composeUpdated(compose);
test('returns dict', () => {
const result = actions.composeUpdated(compose);
// this function updates the type attribute and
// returns everything else unchanged
expect(result.type).toBe(types.COMPOSE_UPDATED);
expect(result.payload.compose).toBe(compose);
});
// this function updates the type attribute and
// returns everything else unchanged
expect(result.type).toBe(types.COMPOSE_UPDATED);
expect(result.payload.compose).toBe(compose);
});
});

View file

@ -3,109 +3,105 @@ import types from '../../store/types';
import { RHEL_8 } from '../../constants.js';
const compose = {
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {}
}
}
],
image_status: {
status: 'uploading',
id: '77e4c693-0497-4b85-936d-b2a3ad69571b',
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'ami',
upload_request: {
type: 'aws',
options: {},
},
},
],
image_status: {
status: 'uploading',
},
};
describe('composes', () => {
test('returns state for unknown actions', () => {
const result = composes({}, {
type: 'THIS-IS-UNKNOWN',
});
test('returns state for unknown actions', () => {
const result = composes(
{},
{
type: 'THIS-IS-UNKNOWN',
}
);
expect(result).toEqual({});
expect(result).toEqual({});
});
test('returns updated state for types.COMPOSE_ADDED', () => {
const state = {
allIds: [],
byId: {},
count: 1,
errors: null,
};
const result = composes(state, {
type: types.COMPOSE_ADDED,
payload: { compose },
});
test('returns updated state for types.COMPOSE_ADDED', () => {
const state = {
allIds: [],
byId: {},
count: 1,
errors: null,
};
const result = composes(state, {
type: types.COMPOSE_ADDED,
payload: { compose }
});
expect(result.allIds).toEqual(['77e4c693-0497-4b85-936d-b2a3ad69571b']);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b']).toEqual(
compose
);
expect(result.count).toEqual(1);
expect(result.error).toEqual(null);
});
expect(result.allIds)
.toEqual([ '77e4c693-0497-4b85-936d-b2a3ad69571b' ]);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b'])
.toEqual(compose);
expect(result.count)
.toEqual(1);
expect(result.error)
.toEqual(null);
test('returns updated state for types.COMPOSE_UPDATED', () => {
const state = {
allIds: ['77e4c693-0497-4b85-936d-b2a3ad69571b'],
byId: {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {},
},
count: 2,
error: null,
};
const result = composes(state, {
type: types.COMPOSE_UPDATED,
payload: { compose },
});
test('returns updated state for types.COMPOSE_UPDATED', () => {
const state = {
allIds: [ '77e4c693-0497-4b85-936d-b2a3ad69571b' ],
byId: {
'77e4c693-0497-4b85-936d-b2a3ad69571b': {},
},
count: 2,
error: null,
};
const result = composes(state, {
type: types.COMPOSE_UPDATED,
payload: { compose }
});
expect(result.allIds).toEqual(['77e4c693-0497-4b85-936d-b2a3ad69571b']);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b']).toEqual(
compose
);
expect(result.count).toEqual(2);
expect(result.error).toEqual(null);
});
expect(result.allIds)
.toEqual([ '77e4c693-0497-4b85-936d-b2a3ad69571b' ]);
expect(result.byId['77e4c693-0497-4b85-936d-b2a3ad69571b'])
.toEqual(compose);
expect(result.count)
.toEqual(2);
expect(result.error)
.toEqual(null);
test('returns updated state for types.COMPOSE_FAILED', () => {
const state = {
allIds: [],
byId: {},
count: 0,
error: null,
};
const result = composes(state, {
type: types.COMPOSE_FAILED,
payload: { error: 'test error' },
});
test('returns updated state for types.COMPOSE_FAILED', () => {
const state = {
allIds: [],
byId: {},
count: 0,
error: null,
};
const result = composes(state, {
type: types.COMPOSE_FAILED,
payload: { error: 'test error' }
});
expect(result.error).toEqual('test error');
});
expect(result.error)
.toEqual('test error');
test('returns updated state for types.COMPOSES_UPDATED_COUNT', () => {
const state = {
allIds: [],
byId: {},
count: 0,
error: null,
};
const result = composes(state, {
type: types.COMPOSES_UPDATED_COUNT,
payload: { count: 1 },
});
test('returns updated state for types.COMPOSES_UPDATED_COUNT', () => {
const state = {
allIds: [],
byId: {},
count: 0,
error: null,
};
const result = composes(state, {
type: types.COMPOSES_UPDATED_COUNT,
payload: { count: 1 }
});
expect(result.count)
.toEqual(1);
});
expect(result.count).toEqual(1);
});
});

View file

@ -6,16 +6,18 @@ import { createMemoryHistory } from 'history';
import { init, clearStore } from '../store';
export const renderWithReduxRouter = (component, store = {}, route = '/') => {
const history = createMemoryHistory({ initialEntries: [ route ]});
clearStore();
let reduxStore = init(store);
return {
...render(
<Provider store={ reduxStore.getStore() }>
<Router location={ history.location } navigator={ history }>{component}</Router>
</Provider>
),
history,
reduxStore
};
const history = createMemoryHistory({ initialEntries: [route] });
clearStore();
let reduxStore = init(store);
return {
...render(
<Provider store={reduxStore.getStore()}>
<Router location={history.location} navigator={history}>
{component}
</Router>
</Provider>
),
history,
reduxStore,
};
};