V2Wizard: add all necessary components

This commit is contained in:
mgold1234 2024-02-26 18:56:42 +02:00 committed by Lucas Garfield
parent fa016c3228
commit f77cc39135
9 changed files with 445 additions and 150 deletions

View file

@ -17,9 +17,13 @@ import RepositoriesStep from './steps/Repositories';
import ReviewStep from './steps/Review';
import ReviewWizardFooter from './steps/Review/Footer';
import Aws from './steps/TargetEnvironment/Aws';
import Azure from './steps/TargetEnvironment/Azure';
import Gcp from './steps/TargetEnvironment/Gcp';
import {
isAwsAccountIdValid,
isAzureTenantGUIDValid,
isAzureSubscriptionIdValid,
isAzureResourceGroupValid,
isBlueprintDescriptionValid,
isBlueprintNameValid,
isGcpEmailValid,
@ -33,6 +37,11 @@ import {
selectAwsAccountId,
selectAwsShareMethod,
selectAwsSource,
selectAzureResourceGroup,
selectAzureShareMethod,
selectAzureSource,
selectAzureSubscriptionId,
selectAzureTenantId,
selectBlueprintDescription,
selectBlueprintName,
selectGcpEmail,
@ -100,6 +109,18 @@ const CreateImageWizard = () => {
// GCP
const gcpShareMethod = useAppSelector((state) => selectGcpShareMethod(state));
const gcpEmail = useAppSelector((state) => selectGcpEmail(state));
// AZURE
const azureShareMethod = useAppSelector((state) =>
selectAzureShareMethod(state)
);
const azureTenantId = useAppSelector((state) => selectAzureTenantId(state));
const azureSubscriptionId = useAppSelector((state) =>
selectAzureSubscriptionId(state)
);
const azureResourceGroup = useAppSelector((state) =>
selectAzureResourceGroup(state)
);
const azureSource = useAppSelector((state) => selectAzureSource(state));
const registrationType = useAppSelector((state) =>
selectRegistrationType(state)
@ -169,6 +190,29 @@ const CreateImageWizard = () => {
>
<Gcp />
</WizardStep>,
<WizardStep
name="Azure"
id="wizard-target-azure"
key="wizard-target-azure"
footer={
<CustomWizardFooter
disableNext={
azureShareMethod === 'manual'
? !isAzureTenantGUIDValid(azureTenantId) ||
!isAzureSubscriptionIdValid(azureSubscriptionId) ||
!isAzureResourceGroupValid(azureResourceGroup)
: azureShareMethod === 'sources'
? !isAzureTenantGUIDValid(azureTenantId) ||
!isAzureSubscriptionIdValid(azureSubscriptionId) ||
!isAzureResourceGroupValid(azureResourceGroup)
: azureSource === undefined
}
/>
}
isHidden={!targetEnvironments.includes('azure')}
>
<Azure />
</WizardStep>,
]}
/>
<WizardStep

View file

@ -31,14 +31,6 @@ import { isAwsAccountIdValid } from '../../../validators';
export type AwsShareMethod = 'manual' | 'sources';
// The Sources API only defines a V1ListSourceResponseItem[] type
export type V1ListSourceResponseItem = {
id?: string;
name?: string;
source_type_id?: string;
uid?: string;
};
const SourcesButton = () => {
return (
<Button

View file

@ -1,12 +1,12 @@
import React from 'react';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Button, FormGroup } from '@patternfly/react-core';
const AzureAuthButton = () => {
const { getState } = useFormApi();
import { useAppSelector } from '../../../../../store/hooks';
import { selectAzureTenantId } from '../../../../../store/wizardSlice';
const tenantId = getState()?.values?.['azure-tenant-id'];
export const AzureAuthButton = () => {
const tenantId = useAppSelector((state) => selectAzureTenantId(state));
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'
@ -31,5 +31,3 @@ const AzureAuthButton = () => {
</FormGroup>
);
};
export default AzureAuthButton;

View file

@ -1,75 +1,83 @@
import React, { useState } from 'react';
import FormSpy from '@data-driven-forms/react-form-renderer/form-spy';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { FormGroup, Spinner } from '@patternfly/react-core';
import {
Select,
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { useGetSourceUploadInfoQuery } from '../../../store/provisioningApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import { useGetSourceUploadInfoQuery } from '../../../../../store/provisioningApi';
import {
changeAzureResourceGroup,
selectAzureResourceGroup,
selectAzureSource,
} from '../../../../../store/wizardSlice';
const AzureResourceGroups = ({ label, isRequired, className, ...props }) => {
const { change, getState } = useFormApi();
const { input } = useFieldApi(props);
const [isOpen, setIsOpen] = useState(false);
const [sourceId, setSourceId] = useState(
getState()?.values?.['azure-sources-select']
export const AzureResourceGroups = () => {
const azureSource = useAppSelector((state) => selectAzureSource(state));
const azureResourceGroup = useAppSelector((state) =>
selectAzureResourceGroup(state)
);
const onFormChange = ({ values }) => {
setSourceId(values['azure-sources-select']);
};
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const { data: sourceDetails, isFetching } = useGetSourceUploadInfoQuery(
{ id: sourceId },
// @ts-ignore
{ id: azureSource },
{
skip: !sourceId,
skip: !azureSource,
}
);
const resourceGroups =
(sourceId && sourceDetails?.azure?.resource_groups) || [];
const setResourceGroup = (_, selection) => {
const resourceGroups =
(azureSource && sourceDetails?.azure?.resource_groups) || [];
const setResourceGroup = (
_event: React.MouseEvent<Element, MouseEvent>,
selection: string
) => {
const resource =
resourceGroups?.find((resource) => resource === selection) || '';
setIsOpen(false);
change(input.name, selection);
dispatch(changeAzureResourceGroup(resource));
};
const handleClear = () => {
change(input.name, undefined);
dispatch(changeAzureResourceGroup(''));
};
const options: JSX.Element[] = [];
if (isFetching) {
options.push(
<SelectOption
isNoResultsOption={true}
data-testid="azure-resource-groups-loading"
>
<Spinner size="lg" />
</SelectOption>
);
}
return (
<FormGroup
isRequired={isRequired}
label={label}
isRequired
label={'Resource group'}
data-testid="azure-resource-groups"
>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<Select
ouiaId="resource_group_select"
variant={SelectVariant.typeahead}
className={className}
onToggle={() => setIsOpen(!isOpen)}
onSelect={setResourceGroup}
onClear={handleClear}
selections={input.value}
selections={azureResourceGroup}
isOpen={isOpen}
placeholderText="Select resource group"
typeAheadAriaLabel="Select resource group"
>
{isFetching && (
<SelectOption
isNoResultsOption={true}
data-testid="azure-resource-groups-loading"
>
<Spinner size="lg" />
</SelectOption>
)}
{resourceGroups.map((name, index) => (
{resourceGroups.map((name: string, index: number) => (
<SelectOption
key={index}
value={name}
@ -80,17 +88,3 @@ const AzureResourceGroups = ({ label, isRequired, className, ...props }) => {
</FormGroup>
);
};
AzureResourceGroups.propTypes = {
label: PropTypes.node,
isRequired: PropTypes.bool,
className: PropTypes.string,
};
AzureResourceGroups.defaultProps = {
label: '',
isRequired: false,
className: '',
};
export default AzureResourceGroups;

View file

@ -1,8 +1,5 @@
import React, { useState, useEffect } from 'react';
import FormSpy from '@data-driven-forms/react-form-renderer/form-spy';
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
import { Alert } from '@patternfly/react-core';
import { FormGroup, Spinner } from '@patternfly/react-core';
import {
@ -10,19 +7,24 @@ import {
SelectOption,
SelectVariant,
} from '@patternfly/react-core/deprecated';
import PropTypes from 'prop-types';
import { extractProvisioningList } from '../../../store/helpers';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
useGetSourceListQuery,
useGetSourceUploadInfoQuery,
} from '../../../store/provisioningApi';
} from '../../../../../store/provisioningApi';
import {
changeAzureResourceGroup,
changeAzureSource,
changeAzureSubscriptionId,
changeAzureTenantId,
selectAzureSource,
} from '../../../../../store/wizardSlice';
const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
const { change } = useFormApi();
const { input } = useFieldApi(props);
export const AzureSourcesSelect = () => {
const azureSource = useAppSelector((state) => selectAzureSource(state));
const dispatch = useAppDispatch();
const [isOpen, setIsOpen] = useState(false);
const selectedSourceId = input.value;
const {
data: rawSources,
@ -31,7 +33,6 @@ const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
isError,
refetch,
} = useGetSourceListQuery({ provider: 'azure' });
const sources = extractProvisioningList(rawSources);
const {
data: sourceDetails,
@ -39,43 +40,42 @@ const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
isSuccess: isSuccessDetails,
isError: isErrorDetails,
} = useGetSourceUploadInfoQuery(
{ id: selectedSourceId },
{ id: parseInt(azureSource as string) },
{
skip: !selectedSourceId,
skip: !azureSource,
}
);
useEffect(() => {
if (isFetchingDetails || !isSuccessDetails) return;
change('azure-tenant-id', sourceDetails?.azure?.tenant_id);
change('azure-subscription-id', sourceDetails?.azure?.subscription_id);
dispatch(changeAzureTenantId(sourceDetails?.azure?.tenant_id || ''));
dispatch(
changeAzureSubscriptionId(sourceDetails?.azure?.subscription_id || '')
);
}, [
isFetchingDetails,
isSuccessDetails,
sourceDetails?.azure?.subscription_id,
sourceDetails?.azure?.tenant_id,
change,
sourceDetails?.azure?.subscription_id,
dispatch,
]);
const onFormChange = ({ values }) => {
if (
values['azure-type'] !== 'azure-type-source' ||
values[input.name] === undefined
) {
change(input.name, undefined);
change('azure-tenant-id', undefined);
change('azure-subscription-id', undefined);
}
};
const handleSelect = (_, sourceName) => {
const sourceId = sources.find((source) => source.name === sourceName).id;
change(input.name, sourceId);
const handleSelect = (
_event: React.MouseEvent<Element, MouseEvent>,
sourceName: string
) => {
const sourceId = rawSources?.data?.find(
(source) => source?.name === sourceName
)?.id;
dispatch(changeAzureSource(sourceId || ''));
setIsOpen(false);
};
const handleClear = () => {
change(input.name, undefined);
dispatch(changeAzureSource(''));
dispatch(changeAzureTenantId(''));
dispatch(changeAzureSubscriptionId(''));
dispatch(changeAzureResourceGroup(''));
};
const handleToggle = () => {
@ -86,25 +86,33 @@ const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
setIsOpen(!isOpen);
};
const selectOptions = rawSources?.data?.map((source) => (
<SelectOption key={source.id} value={source.name} />
));
if (isSuccess) {
if (isFetching) {
selectOptions?.push(
<SelectOption key="loading" isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
);
}
}
return (
<>
<FormSpy subscription={{ values: true }} onChange={onFormChange} />
<FormGroup
isRequired={isRequired}
label={label}
data-testid="azure-sources"
>
<FormGroup isRequired label={'Source Name'} data-testid="azure-sources">
<Select
ouiaId="source_select"
variant={SelectVariant.typeahead}
className={className}
onToggle={handleToggle}
onSelect={handleSelect}
onClear={handleClear}
selections={
selectedSourceId
? sources.find((source) => source.id === selectedSourceId)?.name
azureSource
? rawSources?.data?.find((source) => source.id === azureSource)
?.name
: undefined
}
isOpen={isOpen}
@ -114,50 +122,33 @@ const AzureSourcesSelect = ({ label, isRequired, className, ...props }) => {
maxHeight="25rem"
isDisabled={!isSuccess}
>
{isSuccess &&
sources.map((source) => (
<SelectOption key={source.id} value={source.name} />
))}
{isFetching && (
<SelectOption isNoResultsOption={true}>
<Spinner size="lg" />
</SelectOption>
)}
{selectOptions}
</Select>
</FormGroup>
<>
{isError && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Sources unavailable'}
>
Sources cannot be reached, try again later or enter an account info
for upload manually.
</Alert>
)}
{!isError && isErrorDetails && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Azure details unavailable'}
>
Could not fetch Tenant id and Subscription id from Azure for given
Source. Check Sources page for the source availability or select a
different Source.
</Alert>
)}
</>
{isError && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Sources unavailable'}
>
Sources cannot be reached, try again later or enter an account info
for upload manually.
</Alert>
)}
{!isError && isErrorDetails && (
<Alert
variant={'danger'}
isPlain
isInline
title={'Azure details unavailable'}
>
Could not fetch Tenant id and Subscription id from Azure for given
Source. Check Sources page for the source availability or select a
different Source.
</Alert>
)}
<></>
</>
);
};
AzureSourcesSelect.propTypes = {
className: PropTypes.string,
label: PropTypes.node,
isRequired: PropTypes.bool,
};
export default AzureSourcesSelect;

View file

@ -0,0 +1,197 @@
import React from 'react';
import {
Radio,
Text,
Form,
Title,
FormGroup,
TextInput,
Gallery,
GalleryItem,
Button,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { AzureAuthButton } from './AzureAuthButton';
import { AzureResourceGroups } from './AzureResourceGroups';
import { AzureSourcesSelect } from './AzureSourcesSelect';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
changeAzureResourceGroup,
changeAzureShareMethod,
changeAzureSource,
changeAzureSubscriptionId,
changeAzureTenantId,
selectAzureResourceGroup,
selectAzureShareMethod,
selectAzureSubscriptionId,
selectAzureTenantId,
} from '../../../../../store/wizardSlice';
import { ValidatedTextInput } from '../../../ValidatedTextInput';
import {
isAzureResourceGroupValid,
isAzureSubscriptionIdValid,
isAzureTenantGUIDValid,
} from '../../../validators';
export type AzureShareMethod = 'manual' | 'sources';
const SourcesButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Create and manage sources here
</Button>
);
};
const Azure = () => {
const dispatch = useAppDispatch();
const shareMethod = useAppSelector((state) => selectAzureShareMethod(state));
const tenantId = useAppSelector((state) => selectAzureTenantId(state));
const subscriptionId = useAppSelector((state) =>
selectAzureSubscriptionId(state)
);
const resourceGroup = useAppSelector((state) =>
selectAzureResourceGroup(state)
);
return (
<Form>
<Title headingLevel="h2">Target environment - Microsoft Azure</Title>
<Text>
Upon build, Image Builder sends the image to the selected authorized
Azure account. The image will be uploaded to the resource group in the
subscription you specify.
</Text>
<Text>
To authorize Image Builder to push images to Microsoft Azure, the
account owner must configure Image Builder as an authorized application
for a specific tenant ID and give it the role of &quot;Contributor&quot;
for the resource group you want to upload to. This applies even when
defining target by Source selection.
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href="https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow"
>
Learn more about OAuth 2.0
</Button>
</Text>
<FormGroup label="Share method:">
<Radio
id="radio-with-description"
label="Use an account configured from Sources."
name="radio-7"
description="Use a configured sources to launch environments directly from the console."
isChecked={shareMethod === 'sources'}
onChange={() => {
dispatch(changeAzureSource(''));
dispatch(changeAzureTenantId(''));
dispatch(changeAzureSubscriptionId(''));
dispatch(changeAzureShareMethod('sources'));
dispatch(changeAzureResourceGroup(''));
}}
/>
<Radio
id="radio"
label="Manually enter the account information."
name="radio-8"
isChecked={shareMethod === 'manual'}
onChange={() => {
dispatch(changeAzureSource(''));
dispatch(changeAzureTenantId(''));
dispatch(changeAzureSubscriptionId(''));
dispatch(changeAzureShareMethod('manual'));
dispatch(changeAzureResourceGroup(''));
}}
/>
</FormGroup>
{shareMethod === 'sources' && (
<>
<AzureSourcesSelect />
<SourcesButton />
<Gallery hasGutter>
<GalleryItem>
<FormGroup label="Azure Tenant GUID" isRequired>
<TextInput
aria-label="Azure Tenant GUID"
readOnlyVariant="default"
isRequired
id="tenant id"
value={tenantId}
/>
</FormGroup>
</GalleryItem>
<GalleryItem>
<FormGroup label="Subscription ID" isRequired>
<TextInput
aria-label="Subscription ID"
label="Subscription ID"
readOnlyVariant="default"
isRequired
id="subscription id"
value={subscriptionId}
/>
</FormGroup>
</GalleryItem>
</Gallery>
<AzureAuthButton />
<AzureResourceGroups />
</>
)}
{shareMethod === 'manual' && (
<>
<FormGroup label="Azure Tenant GUID" isRequired>
<ValidatedTextInput
ariaLabel="Azure Tenant GUID"
value={tenantId || ''}
validator={isAzureTenantGUIDValid}
onChange={(_event, value) => dispatch(changeAzureTenantId(value))}
helperText="Please enter a valid tenant ID"
/>
</FormGroup>
<AzureAuthButton />
<FormGroup label="Subscription ID" isRequired>
<ValidatedTextInput
ariaLabel="subscription id"
value={subscriptionId}
validator={isAzureSubscriptionIdValid}
onChange={(_event, value) =>
dispatch(changeAzureSubscriptionId(value))
}
helperText="Please enter a valid subscription ID"
/>
</FormGroup>
<FormGroup label="Resource group" isRequired>
<ValidatedTextInput
ariaLabel="resource group"
value={resourceGroup}
validator={isAzureResourceGroupValid}
onChange={(_event, value) =>
dispatch(changeAzureResourceGroup(value))
}
helperText="Resource group names only allow alphanumeric characters, periods, underscores, hyphens, and parenthesis and cannot end in a period"
/>
</FormGroup>
</>
)}
</Form>
);
};
export default Azure;

View file

@ -0,0 +1,7 @@
// The Sources API only defines a V1ListSourceResponseItem[] type
export type V1ListSourceResponseItem = {
id?: string;
name?: string;
source_type_id?: string;
uid?: string;
};

View file

@ -6,6 +6,22 @@ export const isAwsAccountIdValid = (awsAccountId: string | undefined) => {
);
};
export const isAzureTenantGUIDValid = (azureTenantGUID: string) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureTenantGUID
);
};
export const isAzureSubscriptionIdValid = (azureSubscriptionId: string) => {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
azureSubscriptionId
);
};
export const isAzureResourceGroupValid = (azureResourceGroup: string) => {
return /^[-\w._()]+[-\w_()]$/.test(azureResourceGroup);
};
export const isGcpEmailValid = (gcpShareWithAccount: string | undefined) => {
return (
gcpShareWithAccount !== undefined &&