From a474163343689357dbf707485f13759f0836f097 Mon Sep 17 00:00:00 2001
From: lucasgarfield
Date: Mon, 27 Feb 2023 14:37:02 +0100
Subject: [PATCH] Wizard: Add ability to specify AWS target using sources
This commit adds the ability to specify AWS targets using the sources
service on insights.
This is the first commit to the codebase that makes use of the new RTK
Query endpoints, so I will provide a bit of additional context here:
The sources are obtained by calling the `useGetAWSSourcesQuery()` hook.
This hook can be called in any component where information about the
sources is needed.
A few tricks are used to make the user experience as responsive as
possible.
The `prefetch()` hook provided by RTK Query is called when the user
clicks on the AWS button on the image output step. This triggers the
initial request for the sources, which will then (hopefully) be ready by the
time the user clicks to the next step (the AWS target environment step)
where they are needed.
Because we anticipate a common user workflow to involve using the Create
image wizard in one browser tab and the sources service in another tab,
sources are also refetched every time the source dropdown is opened.
This means that if a user adds a source while in the middle of using the
wizard, they will be able to see it in the wizard's sources dropdown
without refreshing their browser.
Finally, because of the `Recreate image` feature, the
`useGetAWSSourcesQuery` hook also needs to be called on the review step.
---
.../CreateImageWizard/CreateImageWizard.js | 23 ++-
.../CreateImageWizard/CreateImageWizard.scss | 4 +
.../CreateImageWizard/ImageCreator.js | 6 +
.../formComponents/AWSSourcesSelect.js | 120 ++++++++++++++
.../formComponents/FieldListener.js | 36 +++++
.../formComponents/GalleryLayout.js | 25 +++
.../formComponents/ReviewStep.js | 34 +++-
.../formComponents/TargetEnvironment.js | 9 ++
src/Components/CreateImageWizard/steps/aws.js | 147 +++++++++++++++---
9 files changed, 375 insertions(+), 29 deletions(-)
create mode 100644 src/Components/CreateImageWizard/formComponents/AWSSourcesSelect.js
create mode 100644 src/Components/CreateImageWizard/formComponents/FieldListener.js
create mode 100644 src/Components/CreateImageWizard/formComponents/GalleryLayout.js
diff --git a/src/Components/CreateImageWizard/CreateImageWizard.js b/src/Components/CreateImageWizard/CreateImageWizard.js
index a20a497d..fad339d0 100644
--- a/src/Components/CreateImageWizard/CreateImageWizard.js
+++ b/src/Components/CreateImageWizard/CreateImageWizard.js
@@ -79,6 +79,10 @@ const onSave = (values) => {
const requests = [];
if (values['target-environment']?.aws) {
+ const options =
+ values['aws-target-type'] === 'aws-target-type-source'
+ ? { share_with_sources: [values['aws-sources-select']] }
+ : { share_with_accounts: [values['aws-account-id']] };
const request = {
distribution: values.release,
image_name: values?.['image-name'],
@@ -88,9 +92,7 @@ const onSave = (values) => {
image_type: 'aws',
upload_request: {
type: 'aws',
- options: {
- share_with_accounts: [values['aws-account-id']],
- },
+ options: options,
},
},
],
@@ -297,8 +299,19 @@ const requestToState = (composeRequest) => {
formState['target-environment'][targetEnvironment] = true;
if (targetEnvironment === 'aws') {
- formState['aws-account-id'] =
- uploadRequest?.options?.share_with_accounts[0];
+ const shareWithSource = uploadRequest?.options?.share_with_sources?.[0];
+ const shareWithAccount = uploadRequest?.options?.share_with_accounts?.[0];
+ formState['aws-sources-select'] = shareWithSource;
+ formState['aws-account-id'] = shareWithAccount;
+ if (shareWithAccount && !shareWithSource) {
+ formState['aws-target-type'] = 'aws-target-type-account-id';
+ } else {
+ // if both shareWithAccount & shareWithSource are present, set radio
+ // to sources - this is essentially an arbitrary decision
+ // additionally, note that the source is not validated against the actual
+ // sources
+ formState['aws-target-type'] = 'aws-target-type-source';
+ }
} else if (targetEnvironment === 'azure') {
formState['azure-tenant-id'] = uploadRequest?.options?.tenant_id;
formState['azure-subscription-id'] =
diff --git a/src/Components/CreateImageWizard/CreateImageWizard.scss b/src/Components/CreateImageWizard/CreateImageWizard.scss
index 508867e1..cb979124 100644
--- a/src/Components/CreateImageWizard/CreateImageWizard.scss
+++ b/src/Components/CreateImageWizard/CreateImageWizard.scss
@@ -48,3 +48,7 @@
.pf-u-min-width {
--pf-u-min-width--MinWidth: 11ch;
}
+
+.pf-u-max-width {
+ --pf-u-max-width--MaxWidth: 26rem;
+}
diff --git a/src/Components/CreateImageWizard/ImageCreator.js b/src/Components/CreateImageWizard/ImageCreator.js
index ee5bce44..b3040a15 100644
--- a/src/Components/CreateImageWizard/ImageCreator.js
+++ b/src/Components/CreateImageWizard/ImageCreator.js
@@ -8,9 +8,12 @@ import { Spinner } from '@patternfly/react-core';
import PropTypes from 'prop-types';
import ActivationKeys from './formComponents/ActivationKeys';
+import { AWSSourcesSelect } from './formComponents/AWSSourcesSelect';
import AzureAuthButton from './formComponents/AzureAuthButton';
import CentOSAcknowledgement from './formComponents/CentOSAcknowledgement';
+import FieldListenerWrapper from './formComponents/FieldListener';
import FileSystemConfiguration from './formComponents/FileSystemConfiguration';
+import GalleryLayout from './formComponents/GalleryLayout';
import ImageOutputReleaseSelect from './formComponents/ImageOutputReleaseSelect';
import {
ContentSourcesPackages,
@@ -63,6 +66,9 @@ const ImageCreator = ({
'image-output-release-select': ImageOutputReleaseSelect,
'centos-acknowledgement': CentOSAcknowledgement,
'repositories-table': Repositories,
+ 'aws-sources-select': AWSSourcesSelect,
+ 'gallery-layout': GalleryLayout,
+ 'field-listener': FieldListenerWrapper,
...customComponentMapper,
}}
onCancel={onClose}
diff --git a/src/Components/CreateImageWizard/formComponents/AWSSourcesSelect.js b/src/Components/CreateImageWizard/formComponents/AWSSourcesSelect.js
new file mode 100644
index 00000000..e1f22480
--- /dev/null
+++ b/src/Components/CreateImageWizard/formComponents/AWSSourcesSelect.js
@@ -0,0 +1,120 @@
+import React, { useState } from 'react';
+
+import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
+import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
+import { Alert } from '@patternfly/react-core';
+import {
+ FormGroup,
+ Select,
+ SelectOption,
+ SelectVariant,
+ Spinner,
+} from '@patternfly/react-core';
+import PropTypes from 'prop-types';
+
+import { useGetAWSSourcesQuery } from '../../../store/apiSlice';
+
+export const AWSSourcesSelect = ({
+ label,
+ isRequired,
+ className,
+ ...props
+}) => {
+ const { change, getState } = useFormApi();
+ const { input } = useFieldApi(props);
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedSourceId, setSelectedSourceId] = useState(
+ getState()?.values?.['aws-sources-select']
+ );
+
+ const {
+ data: sources,
+ isFetching,
+ isSuccess,
+ isError,
+ refetch,
+ } = useGetAWSSourcesQuery();
+
+ const handleSelect = (_, sourceName) => {
+ const sourceId = sources.find((source) => source.name === sourceName).id;
+ setSelectedSourceId(sourceId);
+ setIsOpen(false);
+ change(input.name, sourceId);
+ };
+
+ const handleClear = () => {
+ setSelectedSourceId();
+ change(input.name, undefined);
+ };
+
+ const handleToggle = () => {
+ // Refetch upon opening (but not upon closing)
+ if (!isOpen) {
+ refetch();
+ }
+
+ setIsOpen(!isOpen);
+ };
+
+ return (
+ <>
+
+
+
+ <>
+ {isError && (
+
+ Sources cannot be reached, try again later or enter an AWS account
+ ID manually.
+
+ )}
+ >
+ >
+ );
+};
+
+AWSSourcesSelect.propTypes = {
+ className: PropTypes.string,
+ label: PropTypes.node,
+ isRequired: PropTypes.bool,
+};
diff --git a/src/Components/CreateImageWizard/formComponents/FieldListener.js b/src/Components/CreateImageWizard/formComponents/FieldListener.js
new file mode 100644
index 00000000..26a8053c
--- /dev/null
+++ b/src/Components/CreateImageWizard/formComponents/FieldListener.js
@@ -0,0 +1,36 @@
+import React, { useEffect } from 'react';
+
+import { FormSpy, useFormApi } from '@data-driven-forms/react-form-renderer';
+
+import { useGetAWSSourcesQuery } from '../../../store/apiSlice';
+
+const FieldListener = () => {
+ // This listener synchronizes the value of the AWS account ID text field with the
+ // value of the AWS source select field on the AWS target step.
+ // Using a listener to set the value of one field according to the value of another
+ // is a recommended pattern for Data Driven Forms:
+ // https://www.data-driven-forms.org/examples/value-listener
+ const { getState, change } = useFormApi();
+ const awsSourcesSelect = getState().values['aws-sources-select'];
+ const { data: awsSources } = useGetAWSSourcesQuery();
+
+ useEffect(() => {
+ if (awsSourcesSelect) {
+ const awsAccountId = awsSources.find(
+ (source) => source.id === getState()?.values?.['aws-sources-select']
+ )?.account_id;
+
+ change('aws-associated-account-id', awsAccountId);
+ } else {
+ change('aws-associated-account-id', undefined);
+ }
+ }, [awsSourcesSelect]);
+
+ return null;
+};
+
+const FieldListenerWrapper = () => (
+ {() => }
+);
+
+export default FieldListenerWrapper;
diff --git a/src/Components/CreateImageWizard/formComponents/GalleryLayout.js b/src/Components/CreateImageWizard/formComponents/GalleryLayout.js
new file mode 100644
index 00000000..2e32bc29
--- /dev/null
+++ b/src/Components/CreateImageWizard/formComponents/GalleryLayout.js
@@ -0,0 +1,25 @@
+import React from 'react';
+
+import { useFormApi } from '@data-driven-forms/react-form-renderer';
+import { Gallery, GalleryItem } from '@patternfly/react-core';
+import PropTypes from 'prop-types';
+
+const GalleryLayout = ({ fields, minWidths, maxWidths }) => {
+ const { renderForm } = useFormApi();
+
+ return (
+
+ {fields.map((field) => (
+ {renderForm([field])}
+ ))}
+
+ );
+};
+
+GalleryLayout.propTypes = {
+ fields: PropTypes.array,
+ maxWidths: PropTypes.object,
+ minWidths: PropTypes.object,
+};
+
+export default GalleryLayout;
diff --git a/src/Components/CreateImageWizard/formComponents/ReviewStep.js b/src/Components/CreateImageWizard/formComponents/ReviewStep.js
index 32de2715..7138616d 100644
--- a/src/Components/CreateImageWizard/formComponents/ReviewStep.js
+++ b/src/Components/CreateImageWizard/formComponents/ReviewStep.js
@@ -36,6 +36,7 @@ import PropTypes from 'prop-types';
import ActivationKeyInformation from './ActivationKeyInformation';
import { RELEASES, UNIT_GIB, UNIT_MIB } from '../../../constants';
+import { useGetAWSSourcesQuery } from '../../../store/apiSlice';
import isRhel from '../../../Utilities/isRhel';
import { googleAccType } from '../steps/googleCloud';
@@ -81,6 +82,9 @@ const ReviewStep = () => {
const [minSize, setMinSize] = useState();
const { change, getState } = useFormApi();
+ const { data: awsSources, isSuccess: isSuccessAWSSources } =
+ useGetAWSSourcesQuery();
+
useEffect(() => {
const registerSystem = getState()?.values?.['register-system'];
if (
@@ -163,6 +167,26 @@ const ReviewStep = () => {
Amazon Web Services
+
+ {getState()?.values?.['aws-target-type'] ===
+ 'aws-target-type-source'
+ ? 'Source'
+ : null}
+
+
+ {isSuccessAWSSources &&
+ getState()?.values?.['aws-target-type'] ===
+ 'aws-target-type-source'
+ ? awsSources.find(
+ (source) =>
+ source.id ===
+ getState()?.values?.['aws-sources-select']
+ )?.name
+ : null}
+
{
Account ID
- {getState()?.values?.['aws-account-id']}
+ {isSuccessAWSSources &&
+ getState()?.values?.['aws-target-type'] ===
+ 'aws-target-type-source'
+ ? awsSources.find(
+ (source) =>
+ source.id ===
+ getState()?.values?.['aws-sources-select']
+ )?.account_id
+ : getState()?.values?.['aws-account-id']}
Default Region
diff --git a/src/Components/CreateImageWizard/formComponents/TargetEnvironment.js b/src/Components/CreateImageWizard/formComponents/TargetEnvironment.js
index 1eb4564f..56d65542 100644
--- a/src/Components/CreateImageWizard/formComponents/TargetEnvironment.js
+++ b/src/Components/CreateImageWizard/formComponents/TargetEnvironment.js
@@ -11,6 +11,8 @@ import {
} from '@patternfly/react-core';
import PropTypes from 'prop-types';
+import { usePrefetch } from '../../../store/apiSlice';
+
const TargetEnvironment = ({ label, isRequired, ...props }) => {
const { getState, change } = useFormApi();
const { input } = useFieldApi({ label, isRequired, ...props });
@@ -22,6 +24,7 @@ const TargetEnvironment = ({ label, isRequired, ...props }) => {
'guest-image': false,
'image-installer': false,
});
+ const prefetchAWSSources = usePrefetch('getAWSSources');
useEffect(() => {
if (getState()?.values?.[input.name]) {
@@ -29,6 +32,12 @@ const TargetEnvironment = ({ label, isRequired, ...props }) => {
}
}, []);
+ useEffect(() => {
+ if (environment['aws'] === true) {
+ prefetchAWSSources();
+ }
+ }, [environment]);
+
const handleSetEnvironment = (env) =>
setEnvironment((prevEnv) => {
const newEnv = {
diff --git a/src/Components/CreateImageWizard/steps/aws.js b/src/Components/CreateImageWizard/steps/aws.js
index 31eb6a3c..07ec8349 100644
--- a/src/Components/CreateImageWizard/steps/aws.js
+++ b/src/Components/CreateImageWizard/steps/aws.js
@@ -2,7 +2,13 @@ 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 { HelperText, HelperTextItem, Title } from '@patternfly/react-core';
+import {
+ Button,
+ HelperText,
+ HelperTextItem,
+ Title,
+} from '@patternfly/react-core';
+import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import nextStepMapper from './imageOutputStepMapper';
import StepTemplate from './stepTemplate';
@@ -10,6 +16,22 @@ import StepTemplate from './stepTemplate';
import { DEFAULT_AWS_REGION } from '../../../constants';
import CustomButtons from '../formComponents/CustomButtons';
+const SourcesButton = () => {
+ return (
+ }
+ iconPosition="right"
+ isInline
+ href={'settings/sources'}
+ >
+ Create and manage sources here
+
+ );
+};
+
export default {
StepTemplate,
id: 'wizard-target-aws',
@@ -45,6 +67,54 @@ export default {
),
},
+ {
+ component: componentTypes.RADIO,
+ label: 'Share method:',
+ name: 'aws-target-type',
+ initialValue: 'aws-target-type-source',
+ autoFocus: true,
+ options: [
+ {
+ label: 'Use an account configured from Sources.',
+ description:
+ 'Use a configured source to launch environments directly from the console.',
+ value: 'aws-target-type-source',
+ 'data-testid': 'aws-radio-source',
+ autoFocus: true,
+ },
+ {
+ label: 'Manually enter an account ID.',
+ value: 'aws-target-type-account-id',
+ 'data-testid': 'aws-radio-account-id',
+ className: 'pf-u-mt-sm',
+ },
+ ],
+ },
+ {
+ component: 'aws-sources-select',
+ name: 'aws-sources-select',
+ className: 'pf-u-max-width',
+ label: 'Source Name',
+ isRequired: true,
+ validate: [
+ {
+ type: validatorTypes.REQUIRED,
+ },
+ ],
+ condition: {
+ when: 'aws-target-type',
+ is: 'aws-target-type-source',
+ },
+ },
+ {
+ component: componentTypes.PLAIN_TEXT,
+ name: 'aws-sources-select-description',
+ label: ,
+ condition: {
+ when: 'aws-target-type',
+ is: 'aws-target-type-source',
+ },
+ },
{
component: componentTypes.TEXT_FIELD,
name: 'aws-account-id',
@@ -53,7 +123,6 @@ export default {
type: 'text',
label: 'AWS account ID',
isRequired: true,
- autoFocus: true,
validate: [
{
type: validatorTypes.REQUIRED,
@@ -63,29 +132,61 @@ export default {
threshold: 12,
},
],
+ condition: {
+ when: 'aws-target-type',
+ is: 'aws-target-type-account-id',
+ },
},
{
- component: componentTypes.TEXT_FIELD,
- name: 'aws-default-region',
- className: 'pf-u-w-25',
- 'data-testid': 'aws-default-region',
- type: 'text',
- label: 'Default Region',
- value: DEFAULT_AWS_REGION,
- isReadOnly: true,
- isRequired: true,
- helperText: (
-
-
- Images are built in the default region but can be copied to other
- regions later.
-
-
- ),
+ name: 'gallery-layout',
+ component: 'gallery-layout',
+ minWidths: { default: '12.5rem' },
+ maxWidths: { default: '12.5rem' },
+ fields: [
+ {
+ component: componentTypes.TEXT_FIELD,
+ name: 'aws-default-region',
+ value: DEFAULT_AWS_REGION,
+ 'data-testid': 'aws-default-region',
+ type: 'text',
+ label: 'Default Region',
+ isReadOnly: true,
+ isRequired: true,
+ helperText: (
+
+
+ Images are built in the default region but can be copied to
+ other regions later.
+
+
+ ),
+ },
+ {
+ component: componentTypes.TEXT_FIELD,
+ name: 'aws-associated-account-id',
+ 'data-testid': 'aws-associated-account-id',
+ type: 'text',
+ label: 'Associated Account ID',
+ isReadOnly: true,
+ isRequired: true,
+ helperText: (
+
+
+ This is the account associated with the source.
+
+
+ ),
+ condition: {
+ when: 'aws-target-type',
+ is: 'aws-target-type-source',
+ },
+ },
+ {
+ component: 'field-listener',
+ name: 'aws-associated-account-id-listener',
+ hideField: true,
+ },
+ ],
},
],
};