wizard/image_output: add the image output step

Port the image output step to the new wizard v2.

This is also the first step in the new redesigned wizard and this commit
message is an opportunity to detail a bit the organisation of it:

The code is organised as followed:
- CreateImageWizard is the root and contains all the code associated
  with the PF wizard. (i.e Wizard and WizardStep).
- Each step has its code under the step/ sub directory.
- The step directory is named after the step (i.e ImageOutput for the
  Image Output step
- At the root of the step directory there is a file which contains the
  code of the step (i.e ImageOutput.tsx).
- The main component of the step can access many subcomponents and they
  have to be stored alongside it in its directory.

State management:
- The state management is only done with react use states, prop drilling
  and state lifting. This is to ensure simplicity as it makes the wizard
- If necessary in the future, there might be needs for context and
  reducers, but it's quite not the case RN. And using them come at the
  cost of making exploring the code harder.
- CreateImageWizard will declare all the states
This commit is contained in:
Thomas Lavocat 2023-11-06 17:59:32 +01:00 committed by Klara Simickova
parent 74afa2073a
commit 9c78456c57
7 changed files with 911 additions and 2 deletions

View file

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

View file

@ -1,7 +1,144 @@
import React from 'react';
import React, { useState } from 'react';
import {
Button,
Wizard,
WizardFooterWrapper,
WizardStep,
useWizardContext,
} from '@patternfly/react-core';
import { useNavigate } from 'react-router-dom';
import {
EnvironmentStateType,
filterEnvironment,
hasUserSelectedAtLeastOneEnv,
useGetAllowedTargets,
} from './steps/ImageOutput/Environment';
import ImageOutputStep from './steps/ImageOutput/ImageOutput';
import { RHEL_9, X86_64 } from '../../constants';
import './CreateImageWizard.scss';
import { ArchitectureItem, Distributions } from '../../store/imageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
import { ImageBuilderHeader } from '../sharedComponents/ImageBuilderHeader';
/**
* @return true if the array in prevAllowedTargets is equivalent to the array
* allowedTargets, false otherwise
*/
const isIdenticalToPrev = (
prevAllowedTargets: string[],
allowedTargets: string[]
) => {
let identicalToPrev = true;
if (allowedTargets.length === prevAllowedTargets.length) {
allowedTargets.forEach((elem) => {
if (!prevAllowedTargets.includes(elem)) {
identicalToPrev = false;
}
});
} else {
identicalToPrev = false;
}
return identicalToPrev;
};
type CustomWizardFooterPropType = {
isNextDisabled: boolean;
};
/**
* The custom wizard footer is only switching the order of the buttons compared
* to the default wizard footer from the PF5 library.
*/
const CustomWizardFooter = ({ isNextDisabled }: CustomWizardFooterPropType) => {
const { goToNextStep, goToPrevStep, close } = useWizardContext();
return (
<WizardFooterWrapper>
<Button
variant="primary"
onClick={goToNextStep}
isDisabled={isNextDisabled}
>
Next
</Button>
<Button variant="secondary" onClick={goToPrevStep}>
Back
</Button>
<Button variant="link" onClick={close}>
Cancel
</Button>
</WizardFooterWrapper>
);
};
const CreateImageWizard = () => {
return <></>;
const navigate = useNavigate();
// Image output step states
const [release, setRelease] = useState<Distributions>(RHEL_9);
const [arch, setArch] = useState<ArchitectureItem['arch']>(X86_64);
const {
data: allowedTargets,
isFetching,
isSuccess,
isError,
} = useGetAllowedTargets({
architecture: arch,
release: release,
});
const [environment, setEnvironment] = useState<EnvironmentStateType>(
filterEnvironment(
{
aws: { selected: false, authorized: false },
azure: { selected: false, authorized: false },
gcp: { selected: false, authorized: false },
oci: { selected: false, authorized: false },
'vsphere-ova': { selected: false, authorized: false },
vsphere: { selected: false, authorized: false },
'guest-image': { selected: false, authorized: false },
'image-installer': { selected: false, authorized: false },
wsl: { selected: false, authorized: false },
},
allowedTargets
)
);
// Update of the environment when the architecture and release are changed.
// This pattern prevents the usage of a useEffect See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
const [prevAllowedTargets, setPrevAllowedTargets] = useState(allowedTargets);
if (!isIdenticalToPrev(prevAllowedTargets, allowedTargets)) {
setPrevAllowedTargets(allowedTargets);
setEnvironment(filterEnvironment(environment, allowedTargets));
}
return (
<>
<ImageBuilderHeader />
<section className="pf-l-page__main-section pf-c-page__main-section">
<Wizard onClose={() => navigate(resolveRelPath(''))} isVisitRequired>
<WizardStep
name="Image output"
id="step-image-output"
footer={
<CustomWizardFooter
isNextDisabled={!hasUserSelectedAtLeastOneEnv(environment)}
/>
}
>
<ImageOutputStep
release={release}
setRelease={setRelease}
arch={arch}
setArch={setArch}
environment={environment}
setEnvironment={setEnvironment}
isFetching={isFetching}
isError={isError}
isSuccess={isSuccess}
/>
</WizardStep>
</Wizard>
</section>
</>
);
};
export default CreateImageWizard;

View file

@ -0,0 +1,45 @@
import React, { Dispatch, FormEvent, SetStateAction } from 'react';
import {
FormGroup,
FormSelect,
FormSelectOption,
} from '@patternfly/react-core';
import { ARCHS } from '../../../../constants';
import { ArchitectureItem } from '../../../../store/imageBuilderApi';
type ArchSelectType = {
setArch: Dispatch<SetStateAction<ArchitectureItem['arch']>>;
arch: ArchitectureItem['arch'];
};
/**
* Allows the user to pick the architecture to build
*/
const ArchSelect = ({ setArch, arch }: ArchSelectType) => {
const onChange = (_event: FormEvent<HTMLSelectElement>, value: string) => {
setArch(value);
};
return (
<FormGroup
isRequired={true}
label="Architecture"
data-testid="architecture-select"
>
<FormSelect
value={arch}
onChange={onChange}
aria-label="Architecture"
ouiaId="arch_select"
>
{ARCHS.map((arch, index) => (
<FormSelectOption key={index} value={arch} label={arch} />
))}
</FormSelect>
</FormGroup>
);
};
export default ArchSelect;

View file

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

View file

@ -0,0 +1,406 @@
import React, { Dispatch, SetStateAction } from 'react';
import {
Checkbox,
FormGroup,
Popover,
Radio,
Text,
TextContent,
TextVariants,
Tile,
} from '@patternfly/react-core';
import { HelpIcon } from '@patternfly/react-icons';
import {
useGetArchitecturesQuery,
Distributions,
ArchitectureItem,
} from '../../../../store/imageBuilderApi';
import { provisioningApi } from '../../../../store/provisioningApi';
import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment';
type useGetAllowedTargetsPropType = {
architecture: ArchitectureItem['arch'];
release: Distributions;
};
/**
* Contacts the backend to get a list of valid targets based on the user
* requirements (release & architecture)
*
* @return an array of strings which contains the names of the authorized
* targets. Alongside the array, a couple of flags indicate the status of the
* request. isFetching stays true while the data are in transit. isError is set
* to true if anything wrong happened. isSuccess is set to true otherwise.
*
* @param architecture the selected arch (x86_64 or aarch64)
* @param release the selected release (see RELEASES in constants)
*/
export const useGetAllowedTargets = ({
architecture,
release,
}: useGetAllowedTargetsPropType) => {
const { data, isFetching, isSuccess, isError } = useGetArchitecturesQuery({
distribution: release,
});
let image_types: string[] = [];
if (isSuccess && data) {
data.forEach((elem) => {
if (elem.arch === architecture) {
image_types = elem.image_types;
}
});
}
return {
data: image_types,
isFetching: isFetching,
isSuccess: isSuccess,
isError: isError,
};
};
/**
* Type to represent the state of a target.
* A target can be selected and/or authorized. An authorized target means the
* target can be displayed to the user, selected means the user has selected
* the target.
*/
type TargetType = {
selected: boolean;
authorized: boolean;
};
/**
* Defines all the possible targets a user can build.
*/
export type EnvironmentStateType = {
aws: TargetType;
azure: TargetType;
gcp: TargetType;
oci: TargetType;
'vsphere-ova': TargetType;
vsphere: TargetType;
'guest-image': TargetType;
'image-installer': TargetType;
wsl: TargetType;
};
/**
* Takes an environment, a list of allowedTargets and updates the authorized
* status of each targets in the environment accordingly.
*
* @param environment the environment to update
* @param allowedTargets the list of targets authorized to get built
* @return an updated environment
*/
export const filterEnvironment = (
environment: EnvironmentStateType,
allowedTargets: string[]
) => {
const newEnv = { ...environment };
Object.keys(environment).forEach((target) => {
newEnv[target as keyof EnvironmentStateType].authorized =
allowedTargets.includes(target);
});
return newEnv;
};
/**
* @return true if at least one target has both its flags selected and
* authorized set to true
* @param env the environment to scan
*/
export const hasUserSelectedAtLeastOneEnv = (
env: EnvironmentStateType
): boolean => {
let atLeastOne = false;
Object.values(env).forEach(({ selected, authorized }) => {
atLeastOne = atLeastOne || (selected && authorized);
});
return atLeastOne;
};
type EnvironmentPropType = {
environment: EnvironmentStateType;
setEnvironment: Dispatch<SetStateAction<EnvironmentStateType>>;
};
/**
* Displays a component that allows the user to pick the target they want
* to build on.
*/
const Environment = ({ setEnvironment, environment }: EnvironmentPropType) => {
const prefetchSources = provisioningApi.usePrefetch('getSourceList');
const { isBeta } = useGetEnvironment();
const handleSetEnvironment = (env: string, checked: boolean) =>
setEnvironment((prevEnv) => {
const newEnv: EnvironmentStateType = {
...prevEnv,
};
newEnv[env as keyof EnvironmentStateType].selected = checked;
return newEnv;
});
// each item the user can select is depending on what's compatible with the
// architecture and the distribution they previously selected. That's why
// every sub parts are conditional to the `authorized` status of its
// corresponding key in the state.
return (
<FormGroup
isRequired={true}
label="Select target environments"
data-testid="target-select"
>
<FormGroup
label={<Text component={TextVariants.small}>Public cloud</Text>}
data-testid="target-public"
>
<div className="tiles">
{environment['aws'].authorized && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-aws"
title="Amazon Web Services"
icon={
<img
className="provider-icon"
src={'/apps/frontend-assets/partners-icons/aws.svg'}
alt="Amazon Web Services logo"
/>
}
onClick={() =>
handleSetEnvironment('aws', !environment.aws.selected)
}
onMouseEnter={() => prefetchSources({ provider: 'aws' })}
isSelected={environment.aws.selected}
isStacked
isDisplayLarge
/>
)}
{environment['gcp'].authorized && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-google"
title="Google Cloud Platform"
icon={
<img
className="provider-icon"
src={
'/apps/frontend-assets/partners-icons/google-cloud-short.svg'
}
alt="Google Cloud Platform logo"
/>
}
onClick={() =>
handleSetEnvironment('gcp', !environment.gcp.selected)
}
isSelected={environment.gcp.selected}
onMouseEnter={() => prefetchSources({ provider: 'gcp' })}
isStacked
isDisplayLarge
/>
)}
{environment['azure'].authorized && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-azure"
title="Microsoft Azure"
icon={
<img
className="provider-icon"
src={
'/apps/frontend-assets/partners-icons/microsoft-azure-short.svg'
}
alt="Microsoft Azure logo"
/>
}
onClick={() =>
handleSetEnvironment('azure', !environment.azure.selected)
}
onMouseEnter={() => prefetchSources({ provider: 'azure' })}
isSelected={environment.azure.selected}
isStacked
isDisplayLarge
/>
)}
{environment.oci.authorized && isBeta() && (
<Tile
className="tile pf-u-mr-sm"
data-testid="upload-oci"
title="Oracle Cloud Infrastructure"
icon={
<img
className="provider-icon"
src={'/apps/frontend-assets/partners-icons/oracle-short.svg'}
alt="Oracle Cloud Infrastructure logo"
/>
}
onClick={() =>
handleSetEnvironment('oci', !environment.oci.selected)
}
isSelected={environment.oci.selected}
isStacked
isDisplayLarge
/>
)}
</div>
</FormGroup>
{environment['vsphere'].authorized && (
<FormGroup
label={<Text component={TextVariants.small}>Private cloud</Text>}
className="pf-u-mt-sm"
data-testid="target-private"
>
<Checkbox
label="VMWare vSphere"
isChecked={
environment.vsphere.selected ||
environment['vsphere-ova'].selected
}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', checked);
handleSetEnvironment('vsphere', false);
}}
aria-label="VMWare checkbox"
id="checkbox-vmware"
name="VMWare"
data-testid="checkbox-vmware"
/>
</FormGroup>
)}
{environment['vsphere'].authorized && (
<FormGroup
className="pf-u-mt-sm pf-u-mb-sm pf-u-ml-xl"
data-testid="target-private-vsphere-radio"
>
{environment['vsphere-ova'].authorized && (
<Radio
name="vsphere-radio"
aria-label="VMWare vSphere radio button OVA"
id="vsphere-radio-ova"
label={
<>
Open virtualization format (.ova)
<Popover
maxWidth="30rem"
position="right"
bodyContent={
<TextContent>
<Text>
An OVA file is a virtual appliance used by
virtualization platforms such as VMWare vSphere. It is
a package that contains files used to describe a
virtual machine, which includes a VMDK image, OVF
descriptor file and a manifest file.
</Text>
</TextContent>
}
>
<HelpIcon className="pf-u-ml-sm" />
</Popover>
</>
}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', checked);
handleSetEnvironment('vsphere', !checked);
}}
isChecked={environment['vsphere-ova'].selected}
isDisabled={
!(
environment.vsphere.selected ||
environment['vsphere-ova'].selected
)
}
/>
)}
<Radio
className="pf-u-mt-sm"
name="vsphere-radio"
aria-label="VMWare vSphere radio button VMDK"
id="vsphere-radio-vmdk"
label={
<>
Virtual disk (.vmdk)
<Popover
maxWidth="30rem"
position="right"
bodyContent={
<TextContent>
<Text>
A VMDK file is a virtual disk that stores the contents
of a virtual machine. This disk has to be imported into
vSphere using govc import.vmdk, use the OVA version when
using the vSphere UI.
</Text>
</TextContent>
}
>
<HelpIcon className="pf-u-ml-sm" />
</Popover>
</>
}
onChange={(_event, checked) => {
handleSetEnvironment('vsphere-ova', !checked);
handleSetEnvironment('vsphere', checked);
}}
isChecked={environment.vsphere.selected}
isDisabled={
!(
environment.vsphere.selected ||
environment['vsphere-ova'].selected
)
}
/>
</FormGroup>
)}
<FormGroup
label={<Text component={TextVariants.small}>Other</Text>}
data-testid="target-other"
>
{environment['guest-image'].authorized && (
<Checkbox
label="Virtualization - Guest image (.qcow2)"
isChecked={environment['guest-image'].selected}
onChange={(_event, checked) =>
handleSetEnvironment('guest-image', checked)
}
aria-label="Virtualization guest image checkbox"
id="checkbox-guest-image"
name="Virtualization guest image"
data-testid="checkbox-guest-image"
/>
)}
{environment['image-installer'].authorized && (
<Checkbox
label="Bare metal - Installer (.iso)"
isChecked={environment['image-installer'].selected}
onChange={(_event, checked) =>
handleSetEnvironment('image-installer', checked)
}
aria-label="Bare metal installer checkbox"
id="checkbox-image-installer"
name="Bare metal installer"
data-testid="checkbox-image-installer"
/>
)}
{environment['wsl'].authorized && isBeta() && (
<Checkbox
label="WSL - Windows Subsystem for Linux (.tar.gz)"
isChecked={environment['wsl'].selected}
onChange={(_event, checked) => handleSetEnvironment('wsl', checked)}
aria-label="windows subsystem for linux checkbox"
id="checkbox-wsl"
name="WSL"
data-testid="checkbox-wsl"
/>
)}
</FormGroup>
</FormGroup>
);
};
export default Environment;

View file

@ -0,0 +1,90 @@
import React, { Dispatch, SetStateAction } from 'react';
import {
Text,
Alert,
Bullseye,
Form,
Spinner,
Title,
} from '@patternfly/react-core';
import ArchSelect from './ArchSelect';
import CentOSAcknowledgement from './CentOSAcknowledgement';
import Environment, { EnvironmentStateType } from './Environment';
import ReleaseSelect from './ReleaseSelect';
import {
ArchitectureItem,
Distributions,
} from '../../../../store/imageBuilderApi';
import DocumentationButton from '../../../sharedComponents/DocumentationButton';
type ImageOutputPropTypes = {
release: Distributions;
setRelease: Dispatch<SetStateAction<Distributions>>;
arch: ArchitectureItem['arch'];
setArch: Dispatch<SetStateAction<ArchitectureItem['arch']>>;
environment: EnvironmentStateType;
setEnvironment: Dispatch<SetStateAction<EnvironmentStateType>>;
isFetching: boolean;
isError: boolean;
isSuccess: boolean;
};
/**
* Manages the form for the image output step by providing the user with a
* choice for:
* - a distribution
* - a release
* - a set of environments
*/
const ImageOutputStep = ({
release,
setRelease,
arch,
setArch,
setEnvironment,
environment,
isFetching,
isError,
isSuccess,
}: ImageOutputPropTypes) => {
return (
<Form>
<Title headingLevel="h2">Image output</Title>
<Text>
Image builder allows you to create a custom image and push it to target
environments.
<br />
<DocumentationButton />
</Text>
<ReleaseSelect setRelease={setRelease} release={release} />
<ArchSelect setArch={setArch} arch={arch} />
{release.match('centos-*') && <CentOSAcknowledgement />}
{isFetching && (
<Bullseye>
<Spinner size="lg" />
</Bullseye>
)}
{isError && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Environments unavailable'}
>
API cannot be reached, try again later.
</Alert>
)}
{isSuccess && !isFetching && (
<Environment
setEnvironment={setEnvironment}
environment={environment}
/>
)}
</Form>
);
};
export default ImageOutputStep;

View file

@ -0,0 +1,117 @@
import React, {
Dispatch,
ReactElement,
SetStateAction,
useRef,
useState,
} from 'react';
import {
Select,
SelectOption,
SelectList,
MenuToggle,
FormGroup,
} from '@patternfly/react-core';
import { RELEASES } from '../../../../constants';
import { Distributions } from '../../../../store/imageBuilderApi';
import isRhel from '../../../../Utilities/isRhel';
type ReleaseSelectType = {
setRelease: Dispatch<SetStateAction<Distributions>>;
release: Distributions;
};
/**
* Allows the user to choose the release they want to build.
* Follows the PF5 pattern: https://www.patternfly.org/components/menus/select#view-more
*/
const ReleaseSelect = ({ setRelease, release }: ReleaseSelectType) => {
// By default the component doesn't show the Centos releases and only the RHEL
// ones. The user has the option to click on a button to make them appear.
const [showDevelopmentOptions, setShowDevelopmentOptions] = useState(false);
const releaseOptions = () => {
const options: ReactElement[] = [];
const filteredRhel = new Map<string, string>();
RELEASES.forEach((value, key) => {
// Only show non-RHEL distros if expanded
if (showDevelopmentOptions || isRhel(key)) {
filteredRhel.set(key, value);
}
});
filteredRhel.forEach((value, key) => {
if (value && key) {
options.push(
<SelectOption key={value} value={key} label={key}>
{RELEASES.get(key)}
</SelectOption>
);
}
});
return options;
};
const [isOpen, setIsOpen] = useState(false);
const onToggleClick = () => {
setIsOpen(!isOpen);
};
const viewMoreRef = useRef<HTMLLIElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const onSelect = (
_event: React.MouseEvent<Element, MouseEvent> | undefined,
value: string | number | undefined
) => {
if (value !== 'loader') {
if (typeof value === 'string') {
setRelease(value as Distributions);
}
setIsOpen(false);
toggleRef?.current?.focus(); // Only focus the toggle when a non-loader option is selected
}
};
const toggle = (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
isFullWidth
>
{RELEASES.get(release)}
</MenuToggle>
);
return (
<FormGroup isRequired={true} label="Release" data-testid="release-select">
<Select
ouiaId="release_select"
id="release_select"
isOpen={isOpen}
selected={release}
onSelect={onSelect}
onOpenChange={(isOpen) => setIsOpen(isOpen)}
toggle={{ toggleNode: toggle, toggleRef }}
>
<SelectList>
{releaseOptions()}
<SelectOption
{...(!showDevelopmentOptions && { isLoadButton: true })}
onClick={() => setShowDevelopmentOptions(true)}
value="loader"
ref={viewMoreRef}
>
{!showDevelopmentOptions
? 'Show options for further development of RHEL'
: undefined}
</SelectOption>
</SelectList>
</Select>
</FormGroup>
);
};
export default ReleaseSelect;