ImagesTable: Convert ImagesTable to Typescript & RTK Query

This commit converts the Images Table to Typescript and converts all API
calls to image-builder to use RTK Query hooks.

This should increase the performance of the app significantly.
Previously our calls to the image-builder API were made in series. They
are now made in parallel. We may want to investigate the possibility of
hitting rate limiting now that we will be issuing requests in much more
rapid succession.

In the tests, moving to RTK Query hooks has allowed us to remove
virtually all Jest mocking. However, this means that some of our
previous tests which tested against implementation details were broken.
Most notably, we no longer check the Redux store to verify that clones
have been added correctly and we no longer check that compose requests
were issued successfully. Test coverage will be restored in a follow-up
PR where the dev-dependency @msw/data is added. Adding a persistent data
layer to the tests using @msw/data will allow us to verify that our POST
requests (creating composes and cloning them) are working by testing
that the Images Table has been updated.
This commit is contained in:
lucasgarfield 2023-08-01 14:09:09 +02:00 committed by Thomas Lavocat
parent 155a0cf57c
commit 7b9e726151
34 changed files with 2397 additions and 2499 deletions

View file

@ -26,10 +26,12 @@ import {
} from './validators';
import './CreateImageWizard.scss';
import api from '../../api';
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../constants';
import { composeAdded } from '../../store/composesSlice';
import { useGetArchitecturesQuery } from '../../store/imageBuilderApi';
import {
useComposeImageMutation,
useGetArchitecturesQuery,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import isRhel from '../../Utilities/isRhel';
import { resolveRelPath } from '../../Utilities/path';
import { useGetEnvironment } from '../../Utilities/useGetEnvironment';
@ -510,17 +512,19 @@ const formStepHistory = (composeRequest, contentSourcesEnabled) => {
};
const CreateImageWizard = () => {
const [composeImage] = useComposeImageMutation();
const dispatch = useDispatch();
const navigate = useNavigate();
// composeId is an optional param that is used for Recreate image
const { composeId } = useParams();
// This is a bit awkward, but will be replaced with an RTKQ hook very soon
// We use useStore() instead of useSelector() because we do not want changes to
// the store to cause re-renders, as the composeId (if present) will never change
const { getState } = useStore();
const compose = getState().composes?.byId?.[composeId];
const composeRequest = compose?.request;
const { data } = useGetComposeStatusQuery(
{ composeId: composeId },
{
skip: composeId ? false : true,
}
);
const composeRequest = composeId ? data?.request : undefined;
const contentSourcesEnabled = useFlag('image-builder.enable-content-sources');
// TODO: This causes an annoying re-render when using Recreate image
@ -534,7 +538,7 @@ const CreateImageWizard = () => {
// Assume that if a request is available that we should start on review step
// This will occur if 'Recreate image' is clicked
const initialStep = compose?.request ? 'review' : undefined;
const initialStep = composeRequest ? 'review' : undefined;
const { isBeta, isProd } = useGetEnvironment();
@ -566,27 +570,19 @@ const CreateImageWizard = () => {
<ImageCreator
onClose={handleClose}
onSubmit={({ values, setIsSaving }) => {
setIsSaving(() => true);
setIsSaving(true);
const requests = onSave(values);
navigate(resolveRelPath(''));
// https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values
// If you want to immediately access the result of a mutation, you need to chain `.unwrap()`
// if you actually want the payload or to catch the error.
// We do this so we can dispatch the appropriate notification (success or failure).
Promise.all(
requests.map((request) =>
api.composeImage(request).then((response) => {
dispatch(
composeAdded({
compose: {
...response,
request,
created_at: currentDate.toISOString(),
image_status: { status: 'pending' },
},
insert: true,
})
);
})
requests.map((composeRequest) =>
composeImage({ composeRequest }).unwrap()
)
)
.then(() => {
navigate(resolveRelPath(''));
dispatch(
addNotification({
variant: 'success',
@ -607,8 +603,6 @@ const CreateImageWizard = () => {
description: 'Status code ' + err.response.status + ': ' + msg,
})
);
setIsSaving(false);
});
}}
defaultArch="x86_64"

View file

@ -1,84 +0,0 @@
import React from 'react';
import { ClipboardCopy } from '@patternfly/react-core';
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { ImageBuildStatus } from './ImageBuildStatus';
import {
selectClonesById,
selectComposeById,
selectImageById,
} from '../../store/composesSlice';
const Row = ({ imageId }) => {
const image = useSelector((state) => selectImageById(state, imageId));
if (!image) {
return null;
}
return (
<Tbody>
<Tr className="no-bottom-border">
<Td dataLabel="AMI">
{image.status === 'success' && (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{image.ami}
</ClipboardCopy>
)}
</Td>
<Td dataLabel="Region">{image.region}</Td>
<Td dataLabel="Status">
<ImageBuildStatus imageId={image.id} imageRegion={image.region} />
</Td>
</Tr>
</Tbody>
);
};
const ClonesTable = ({ composeId }) => {
const parentCompose = useSelector((state) =>
selectComposeById(state, composeId)
);
const clones = useSelector((state) => selectClonesById(state, composeId));
return (
<TableComposable variant="compact" data-testid="clones-table">
<Thead>
<Tr className="no-bottom-border">
<Th className="pf-m-width-60">AMI</Th>
<Th className="pf-m-width-20">Region</Th>
<Th className="pf-m-width-20">Status</Th>
</Tr>
</Thead>
<Row imageId={parentCompose.id} imageType={'compose'} />
{clones.map((clone) => (
<Row imageId={clone.id} key={clone.id} />
))}
</TableComposable>
);
};
Row.propTypes = {
imageId: PropTypes.string,
};
ClonesTable.propTypes = {
composeId: PropTypes.string,
};
export default ClonesTable;

View file

@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import { ClipboardCopy, Skeleton } from '@patternfly/react-core';
import {
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import { StatusClone, AwsDetailsStatus } from './Status';
import {
ClonesResponseItem,
ComposesResponseItem,
UploadStatus,
useGetCloneStatusQuery,
useGetComposeClonesQuery,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
type RowPropTypes = {
ami: JSX.Element;
region: JSX.Element;
status: JSX.Element;
};
type AmiPropTypes = {
status: UploadStatus | undefined;
};
const Ami = ({ status }: AmiPropTypes) => {
switch (status?.status) {
case 'success':
return (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{'ami' in status.options ? status.options.ami : null}
</ClipboardCopy>
);
case 'failure':
return undefined;
default:
return <Skeleton width="12rem" />;
}
};
const ComposeRegion = () => {
return <p>us-east-1</p>;
};
type CloneRegionPropTypes = {
region: string;
};
const CloneRegion = ({ region }: CloneRegionPropTypes) => {
return <p>{region}</p>;
};
const Row = ({ ami, region, status }: RowPropTypes) => {
return (
<Tbody>
<Tr className="no-bottom-border">
<Td dataLabel="AMI">{ami}</Td>
<Td dataLabel="Region">{region}</Td>
<Td dataLabel="Status">{status}</Td>
</Tr>
</Tbody>
);
};
type CloneRowPropTypes = {
clone: ClonesResponseItem;
};
const CloneRow = ({ clone }: CloneRowPropTypes) => {
const [pollingInterval, setPollingInterval] = useState(8000);
const { data: status } = useGetCloneStatusQuery(
{
id: clone.id,
},
{ pollingInterval: pollingInterval }
);
useEffect(() => {
if (status?.status === 'success' || status?.status === 'failure') {
setPollingInterval(0);
} else {
setPollingInterval(8000);
}
}, [setPollingInterval, status]);
return (
<Row
ami={<Ami status={status} />}
region={<CloneRegion region={clone.request.region} />}
status={<StatusClone clone={clone} status={status} />}
/>
);
};
type ComposeRowPropTypes = {
compose: ComposesResponseItem;
};
const ComposeRow = ({ compose }: ComposeRowPropTypes) => {
const { data, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
return isSuccess ? (
<Row
ami={<Ami status={data.image_status.upload_status} />}
region={<ComposeRegion />}
status={<AwsDetailsStatus compose={compose} />}
/>
) : null;
};
export type ReducedClonesByRegion = {
[region: string]: {
clone: ClonesResponseItem;
status: UploadStatus | undefined;
};
};
type ClonesTablePropTypes = {
compose: ComposesResponseItem;
};
const ClonesTable = ({ compose }: ClonesTablePropTypes) => {
const { data } = useGetComposeClonesQuery({ composeId: compose.id });
return (
<TableComposable variant="compact" data-testid="clones-table">
<Thead>
<Tr className="no-bottom-border">
<Th className="pf-m-width-60">AMI</Th>
<Th className="pf-m-width-20">Region</Th>
<Th className="pf-m-width-20">Status</Th>
</Tr>
</Thead>
<ComposeRow compose={compose} />
{data?.data.map((clone) => (
<CloneRow clone={clone} key={clone.id} />
))}
</TableComposable>
);
};
export default ClonesTable;

View file

@ -1,44 +0,0 @@
import React from 'react';
import { Button } from '@patternfly/react-core';
import { CopyIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
const useGetErrorReason = (err) => {
if (!err?.reason) {
return 'An unknown error occured';
}
if (err.details?.reason) {
return err.details.reason;
}
return err.reason;
};
const ErrorDetails = ({ status }) => {
const reason = useGetErrorReason(status.error);
if (!status || status.status !== 'failure') {
return <></>;
}
return (
<div className="pf-u-mt-sm">
<p>{reason}</p>
<Button
variant="link"
onClick={() => navigator.clipboard.writeText(reason)}
className="pf-u-pl-0 pf-u-mt-md"
>
Copy error text to clipboard <CopyIcon />
</Button>
</div>
);
};
ErrorDetails.propTypes = {
status: PropTypes.object,
};
export default ErrorDetails;

View file

@ -1,216 +0,0 @@
import React from 'react';
import {
Alert,
Button,
Flex,
Panel,
PanelMain,
Popover,
} from '@patternfly/react-core';
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InProgressIcon,
OffIcon,
PendingIcon,
} from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import './ImageBuildStatus.scss';
import { useSelector } from 'react-redux';
import ErrorDetails from './ImageBuildErrorDetails';
import { AWS_S3_EXPIRATION_TIME_IN_HOURS } from '../../constants';
import {
selectImageById,
selectImageStatusesById,
} from '../../store/composesSlice';
import { hoursToExpiration } from '../../Utilities/time';
export const ImageBuildStatus = ({
imageId,
isImagesTableRow,
imageStatus,
imageRegion,
}) => {
const image = useSelector((state) => selectImageById(state, imageId));
const remainingHours =
AWS_S3_EXPIRATION_TIME_IN_HOURS - hoursToExpiration(image.created_at);
const cloneErrorMessage = () => {
let region = '';
hasFailedClone ? (region = 'one or more regions') : (region = imageRegion);
return {
error: {
reason: `Failed to share image to ${region}.`,
},
status: 'failure',
};
};
// Messages appear in order of priority
const messages = {
failure: [
{
icon: <ExclamationCircleIcon className="error" />,
text: 'Image build failed',
priority: 6,
},
],
pending: [
{
icon: <PendingIcon />,
text: 'Image build is pending',
priority: 2,
},
],
// Keep "running" for backward compatibility
running: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress',
priority: 1,
},
],
building: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image build in progress',
priority: 3,
},
],
uploading: [
{
icon: <InProgressIcon className="pending" />,
text: 'Image upload in progress',
priority: 4,
},
],
registering: [
{
icon: <InProgressIcon className="pending" />,
text: 'Cloud registration in progress',
priority: 5,
},
],
success: [
{
icon: <CheckCircleIcon className="success" />,
text: 'Ready',
priority: 0,
},
],
expiring: [
{
icon: <ExclamationTriangleIcon className="expiring" />,
text: `Expires in ${remainingHours} ${
remainingHours > 1 ? 'hours' : 'hour'
}`,
},
],
expired: [
{
icon: <OffIcon />,
text: 'Expired',
},
],
};
let hasFailedClone;
let status;
const imageStatuses = useSelector((state) =>
selectImageStatusesById(state, image.id)
);
if (
isImagesTableRow &&
(image.imageType === 'aws' || image.imageType === 'ami')
) {
// The ImageBuildStatus component is used by both the images table and the clones table.
// For 'aws' and 'ami' image rows in the images table, the highest priority status for
// *all* images (the parent image and its clones) should be displayed as the status.
// For instance, the parent and several of its clones may have a success status. But if a single
// clone has a failure status, then the status displayed in the images table row should be
// failure.
if (!imageStatuses.includes('success')) {
hasFailedClone = false;
} else if (!imageStatuses.includes('failure')) {
hasFailedClone = false;
} else {
hasFailedClone = true;
}
const filteredImageStatuses = imageStatuses.filter(
(imageStatus) => imageStatus !== undefined
);
if (filteredImageStatuses.length === 0) {
status = image.status;
} else {
status = filteredImageStatuses.reduce((prev, current) => {
return messages[prev][0].priority > messages[current][0].priority
? prev
: current;
});
}
} else if (image.uploadType === 'aws.s3' && image.status === 'success') {
// Cloud API currently reports expired images status as 'success'
status =
hoursToExpiration(image.created_at) >= AWS_S3_EXPIRATION_TIME_IN_HOURS
? 'expired'
: 'expiring';
} else {
status = image.status;
}
return (
<React.Fragment>
{messages[status] &&
messages[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>
{status === 'failure' ? (
<Popover
position="bottom"
minWidth="30rem"
bodyContent={
<>
<Alert
variant="danger"
title="Image build failed"
isInline
isPlain
/>
<Panel isScrollable>
<PanelMain maxHeight="25rem">
<ErrorDetails
status={
!imageStatus || hasFailedClone
? cloneErrorMessage()
: imageStatus
}
/>
</PanelMain>
</Panel>
</>
}
>
<Button variant="link" className="pf-u-p-0 pf-u-font-size-sm">
<div className="failure-button">{message.text}</div>
</Button>
</Popover>
) : (
message.text
)}
</Flex>
))}
</React.Fragment>
);
};
ImageBuildStatus.propTypes = {
imageId: PropTypes.string,
isImagesTableRow: PropTypes.bool,
imageStatus: PropTypes.object,
imageRegion: PropTypes.string,
};

View file

@ -1,430 +0,0 @@
import React from 'react';
import {
ClipboardCopy,
DescriptionList,
DescriptionListGroup,
DescriptionListDescription,
DescriptionListTerm,
Button,
Spinner,
Popover,
Alert,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import ClonesTable from './ClonesTable';
import { extractProvisioningList } from '../../store/helpers';
import { useGetSourceListQuery } from '../../store/provisioningApi';
const SourceNotFoundPopover = () => {
return (
<Popover
position="bottom"
bodyContent={
<>
<Alert
variant="danger"
title="Source name cannot be loaded"
className="pf-u-pb-md"
isInline
isPlain
/>
<p>
The information about the source cannot be loaded. Please check the
source was not removed and try again later.
</p>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Manage sources here
</Button>
</>
}
>
<Button variant="link" className="pf-u-p-0 pf-u-font-size-sm">
<div className="failure-button">Source name cannot be loaded</div>
</Button>
</Popover>
);
};
const AzureSourceName = ({ id }) => {
const { data: rawSources, isSuccess } = useGetSourceListQuery({
provider: 'azure',
});
const sources = extractProvisioningList(rawSources);
if (isSuccess) {
const sourcename = sources.find((source) => source.id === id);
if (sourcename) {
return <p>{sourcename.name}</p>;
} else {
return <SourceNotFoundPopover />;
}
} else {
return <Spinner isSVG size="md" />;
}
};
const AwsSourceName = ({ id }) => {
const { data: rawSources, isSuccess } = useGetSourceListQuery({
provider: 'aws',
});
const sources = extractProvisioningList(rawSources);
if (isSuccess) {
const sourcename = sources.find((source) => source.id === id);
if (sourcename) {
return <p>{sourcename.name}</p>;
} else {
return <SourceNotFoundPopover />;
}
} else {
return <Spinner isSVG size="md" />;
}
};
const parseGCPSharedWith = (sharedWith) => {
const splitGCPSharedWith = sharedWith[0].split(':');
return splitGCPSharedWith[1];
};
const AWSDetails = ({ id }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="aws-uuid"
>
{id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{compose.request.image_requests[0].upload_request.options
.share_with_sources && (
<DescriptionListGroup>
<DescriptionListTerm>Source</DescriptionListTerm>
<DescriptionListDescription>
<AwsSourceName
id={
compose.request.image_requests[0].upload_request.options
.share_with_sources?.[0]
}
/>
</DescriptionListDescription>
</DescriptionListGroup>
)}
{compose.request.image_requests[0].upload_request.options
.share_with_accounts?.[0] && (
<DescriptionListGroup>
<DescriptionListTerm>Shared with</DescriptionListTerm>
<DescriptionListDescription>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
// the format of an account link is taken from
// https://docs.aws.amazon.com/signin/latest/userguide/sign-in-urls-defined.html
href={`https://${compose.request.image_requests[0].upload_request.options.share_with_accounts[0]}.signin.aws.amazon.com/console/`}
>
{
compose.request.image_requests[0].upload_request.options
.share_with_accounts[0]
}
</Button>
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
);
};
const AWSIdentifiers = ({ id }) => {
return <ClonesTable composeId={id} />;
};
const AzureDetails = ({ id }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="azure-uuid"
>
{id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{compose.request.image_requests[0].upload_request.options.source_id && (
<DescriptionListGroup>
<DescriptionListTerm>Source</DescriptionListTerm>
<DescriptionListDescription>
<AzureSourceName
id={
compose.request.image_requests[0].upload_request.options
.source_id
}
/>
</DescriptionListDescription>
</DescriptionListGroup>
)}
<DescriptionListGroup>
<DescriptionListTerm>Resource Group</DescriptionListTerm>
<DescriptionListDescription>
{
compose.request.image_requests[0].upload_request.options
.resource_group
}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};
const AzureIdentifiers = ({ id }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{compose?.image_status?.status === 'success' ? (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{compose.image_status.upload_status.options.image_name}
</ClipboardCopy>
) : compose?.image_status?.status === 'failure' ? (
<p></p>
) : (
<Spinner isSVG size="md" />
)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};
const GCPDetails = ({ id, sharedWith }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="gcp-uuid"
>
{id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{compose?.image_status?.status === 'success' && (
<DescriptionListGroup>
<DescriptionListTerm>Project ID</DescriptionListTerm>
<DescriptionListDescription>
{compose.image_status.upload_status.options.project_id}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{sharedWith && (
<DescriptionListGroup>
<DescriptionListTerm>Shared with</DescriptionListTerm>
<DescriptionListDescription>
{parseGCPSharedWith(sharedWith)}
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
</>
);
};
const GCPIdentifiers = ({ id }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{compose?.image_status?.status === 'success' ? (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{compose.image_status.upload_status.options.image_name}
</ClipboardCopy>
) : compose?.image_status?.status === 'failure' ? (
<p></p>
) : (
<Spinner isSVG size="md" />
)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};
const ImageDetails = ({ id }) => {
const composes = useSelector((state) => state.composes);
const compose = composes.byId[id];
return (
<>
<div className="pf-u-font-weight-bold pf-u-pb-md">Build Information</div>
{
// the information about the image's target differs between images
// built by api and images built by the service
(compose.request.image_requests[0].image_type === 'aws' ||
compose?.image_status?.upload_status?.type === 'aws') && (
<AWSDetails id={id} />
)
}
{(compose.request.image_requests[0].image_type === 'azure' ||
compose?.image_status?.upload_status?.type === 'azure') && (
<AzureDetails id={id} />
)}
{(compose.request.image_requests[0].image_type === 'gcp' ||
compose?.image_status?.upload_status?.type === 'gcp') && (
<GCPDetails id={id} sharedWith={compose.share_with_accounts} />
)}
{(compose.request.image_requests[0].image_type === 'guest-image' ||
compose.request.image_requests[0].image_type === 'image-installer' ||
compose.request.image_requests[0].image_type === 'wsl' ||
compose.request.image_requests[0].image_type === 'vsphere' ||
compose.request.image_requests[0].image_type === 'vsphere-ova' ||
compose.request.image_requests[0].image_type ===
'rhel-edge-installer' ||
compose.request.image_requests[0].image_type ===
'rhel-edge-commit') && (
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="other-targets-uuid"
>
{id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
)}
{(compose.request.image_requests[0].image_type === 'aws' ||
compose?.image_status?.upload_status?.type === 'aws' ||
compose.request.image_requests[0].image_type === 'gcp' ||
compose?.image_status?.upload_status?.type === 'gcp' ||
compose.request.image_requests[0].image_type === 'azure' ||
compose?.image_status?.upload_status?.type === 'azure') && (
<>
<br />
<div className="pf-u-font-weight-bold pf-u-pb-md">
Cloud Provider Identifiers
</div>
</>
)}
{(compose.request.image_requests[0].image_type === 'aws' ||
compose?.image_status?.upload_status?.type === 'aws') && (
<AWSIdentifiers id={id} />
)}
{(compose.request.image_requests[0].image_type === 'azure' ||
compose?.image_status?.upload_status?.type === 'azure') && (
<AzureIdentifiers id={id} />
)}
{(compose.request.image_requests[0].image_type === 'gcp' ||
compose?.image_status?.upload_status?.type === 'gcp') && (
<GCPIdentifiers id={id} />
)}
</>
);
};
AWSDetails.propTypes = {
id: PropTypes.string,
};
AWSIdentifiers.propTypes = {
id: PropTypes.string,
};
AzureDetails.propTypes = {
id: PropTypes.string,
};
AzureIdentifiers.propTypes = {
id: PropTypes.string,
};
GCPDetails.propTypes = {
id: PropTypes.string,
sharedWith: PropTypes.arrayOf(PropTypes.string),
};
GCPIdentifiers.propTypes = {
id: PropTypes.string,
};
ImageDetails.propTypes = {
id: PropTypes.string,
};
AwsSourceName.propTypes = {
id: PropTypes.string,
};
AzureSourceName.propTypes = {
id: PropTypes.string,
};
export default ImageDetails;

View file

@ -0,0 +1,387 @@
import React from 'react';
import {
ClipboardCopy,
DescriptionList,
DescriptionListGroup,
DescriptionListDescription,
DescriptionListTerm,
Button,
Popover,
Alert,
Skeleton,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import ClonesTable from './ClonesTable';
import { extractProvisioningList } from '../../store/helpers';
import {
ComposesResponseItem,
GcpUploadRequestOptions,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import { useGetSourceListQuery } from '../../store/provisioningApi';
import {
isAwsUploadRequestOptions,
isAzureUploadRequestOptions,
isAzureUploadStatus,
isGcpUploadRequestOptions,
isGcpUploadStatus,
} from '../../store/typeGuards';
const SourceNotFoundPopover = () => {
return (
<Popover
position="bottom"
bodyContent={
<>
<Alert
variant="danger"
title="Source name cannot be loaded"
className="pf-u-pb-md"
isInline
isPlain
/>
<p>
The information about the source cannot be loaded. Please check the
source was not removed and try again later.
</p>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={'settings/sources'}
>
Manage sources here
</Button>
</>
}
>
<Button variant="link" className="pf-u-p-0 pf-u-font-size-sm">
<div className="failure-button">Source name cannot be loaded</div>
</Button>
</Popover>
);
};
type AzureSourceNamePropTypes = {
id: string;
};
const AzureSourceName = ({ id }: AzureSourceNamePropTypes) => {
const { data: rawSources, isSuccess } = useGetSourceListQuery({
provider: 'azure',
});
if (!isSuccess) {
return <Skeleton />;
}
const sources = extractProvisioningList(rawSources);
const sourcename = sources?.find((source) => source.id === id);
if (sourcename) {
return <p>{sourcename.name}</p>;
} else {
return <SourceNotFoundPopover />;
}
};
type AwsSourceNamePropTypes = {
id: string;
};
const AwsSourceName = ({ id }: AwsSourceNamePropTypes) => {
const { data: rawSources, isSuccess } = useGetSourceListQuery({
provider: 'aws',
});
if (!isSuccess) {
return <Skeleton />;
}
const sources = extractProvisioningList(rawSources);
const sourcename = sources?.find((source) => source.id === id);
if (sourcename) {
return <p>{sourcename.name}</p>;
} else {
return <SourceNotFoundPopover />;
}
};
const parseGcpSharedWith = (
sharedWith: GcpUploadRequestOptions['share_with_accounts']
) => {
const splitGCPSharedWith = sharedWith[0].split(':');
return splitGCPSharedWith[1];
};
type AwsDetailsPropTypes = {
compose: ComposesResponseItem;
};
export const AwsDetails = ({ compose }: AwsDetailsPropTypes) => {
const options = compose.request.image_requests[0].upload_request.options;
if (!isAwsUploadRequestOptions(options)) {
throw TypeError(
`Error: options must be of type AwsUploadRequestOptions, not ${typeof options}.`
);
}
return (
<>
<div className="pf-u-font-weight-bold pf-u-pb-md">Build Information</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="aws-uuid"
>
{compose.id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{options.share_with_sources?.[0] && (
<DescriptionListGroup>
<DescriptionListTerm>Source</DescriptionListTerm>
<DescriptionListDescription>
<AwsSourceName id={options.share_with_sources[0]} />
</DescriptionListDescription>
</DescriptionListGroup>
)}
{options.share_with_accounts?.[0] && (
<DescriptionListGroup>
<DescriptionListTerm>Shared with</DescriptionListTerm>
<DescriptionListDescription>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
// the format of an account link is taken from
// https://docs.aws.amazon.com/signin/latest/userguide/sign-in-urls-defined.html
href={`https://${options.share_with_accounts[0]}.signin.aws.amazon.com/console/`}
>
{options.share_with_accounts[0]}
</Button>
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
<>
<br />
<div className="pf-u-font-weight-bold pf-u-pb-md">
Cloud Provider Identifiers
</div>
</>
<ClonesTable compose={compose} />
</>
);
};
type AzureDetailsPropTypes = {
compose: ComposesResponseItem;
};
export const AzureDetails = ({ compose }: AzureDetailsPropTypes) => {
const { data: composeStatus } = useGetComposeStatusQuery({
composeId: compose.id,
});
const options = compose.request.image_requests[0].upload_request.options;
if (!isAzureUploadRequestOptions(options)) {
throw TypeError(
`Error: options must be of type AzureUploadRequestOptions, not ${typeof options}.`
);
}
const sourceId = options.source_id;
const resourceGroup = options.resource_group;
const uploadStatus = composeStatus?.image_status.upload_status?.options;
if (uploadStatus && !isAzureUploadStatus(uploadStatus)) {
throw TypeError(
`Error: uploadStatus must be of type AzureUploadStatus, not ${typeof uploadStatus}.`
);
}
return (
<>
<div className="pf-u-font-weight-bold pf-u-pb-md">Build Information</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="azure-uuid"
>
{compose.id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{sourceId && (
<DescriptionListGroup>
<DescriptionListTerm>Source</DescriptionListTerm>
<DescriptionListDescription>
<AzureSourceName id={sourceId} />
</DescriptionListDescription>
</DescriptionListGroup>
)}
<DescriptionListGroup>
<DescriptionListTerm>Resource Group</DescriptionListTerm>
<DescriptionListDescription>
{resourceGroup}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
<br />
<div className="pf-u-font-weight-bold pf-u-pb-md">
Cloud Provider Identifiers
</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{composeStatus?.image_status.status === 'success' && (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{uploadStatus?.image_name}
</ClipboardCopy>
)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};
type GcpDetailsPropTypes = {
compose: ComposesResponseItem;
};
export const GcpDetails = ({ compose }: GcpDetailsPropTypes) => {
const { data: composeStatus } = useGetComposeStatusQuery({
composeId: compose.id,
});
const options = compose.request.image_requests[0].upload_request.options;
if (!isGcpUploadRequestOptions(options)) {
throw TypeError(
`Error: options must be of type GcpUploadRequestOptions, not ${typeof options}.`
);
}
const uploadStatus = composeStatus?.image_status.upload_status?.options;
if (uploadStatus && !isGcpUploadStatus(uploadStatus)) {
throw TypeError(
`Error: uploadStatus must be of type GcpUploadStatus, not ${typeof uploadStatus}.`
);
}
return (
<>
<div className="pf-u-font-weight-bold pf-u-pb-md">Build Information</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="gcp-uuid"
>
{compose.id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
{composeStatus?.image_status.status === 'success' && (
<DescriptionListGroup>
<DescriptionListTerm>Project ID</DescriptionListTerm>
<DescriptionListDescription>
{uploadStatus?.project_id}
</DescriptionListDescription>
</DescriptionListGroup>
)}
{options.share_with_accounts && (
<DescriptionListGroup>
<DescriptionListTerm>Shared with</DescriptionListTerm>
<DescriptionListDescription>
{parseGcpSharedWith(options.share_with_accounts)}
</DescriptionListDescription>
</DescriptionListGroup>
)}
</DescriptionList>
<br />
<div className="pf-u-font-weight-bold pf-u-pb-md">
Cloud Provider Identifiers
</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>Image name</DescriptionListTerm>
<DescriptionListDescription>
{composeStatus?.image_status.status === 'success' && (
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
>
{uploadStatus?.image_name}
</ClipboardCopy>
)}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};
type AwsS3DetailsPropTypes = {
compose: ComposesResponseItem;
};
export const AwsS3Details = ({ compose }: AwsS3DetailsPropTypes) => {
return (
<>
<div className="pf-u-font-weight-bold pf-u-pb-md">Build Information</div>
<DescriptionList isHorizontal isCompact className=" pf-u-pl-xl">
<DescriptionListGroup>
<DescriptionListTerm>UUID</DescriptionListTerm>
<DescriptionListDescription>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
variant="inline-compact"
ouiaId="other-targets-uuid"
>
{compose.id}
</ClipboardCopy>
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
</>
);
};

View file

@ -1,172 +0,0 @@
import React, { Suspense, useState, useMemo } from 'react';
import { Button, Modal, ModalVariant } from '@patternfly/react-core';
import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
import { useLoadModule, useScalprum } from '@scalprum/react-core';
import { useFlag } from '@unleash/proxy-client-react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import ImageLinkDirect from './ImageLinkDirect';
import { MODAL_ANCHOR } from '../../constants';
import { selectImageById } from '../../store/composesSlice';
import useProvisioningPermissions from '../../Utilities/useProvisioningPermissions';
const getImageProvider = ({ imageType }) => {
switch (imageType) {
case 'aws':
return 'aws';
case 'ami':
return 'aws';
case 'azure':
return 'azure';
case 'gcp':
return 'gcp';
default:
//TODO check with Provisioning: what if imageType is not 'aws', 'ami', or 'azure'?
return 'aws';
}
};
const ProvisioningLink = ({ imageId, isExpired, isInClonesTable }) => {
const image = useSelector((state) => selectImageById(state, imageId));
const [wizardOpen, openWizard] = useState(false);
const [{ default: ProvisioningWizard }, error] = useLoadModule(
{
appName: 'provisioning', // optional
scope: 'provisioning',
module: './ProvisioningWizard',
// processor: (val) => val, // optional
},
{},
{}
);
const appendTo = useMemo(() => document.querySelector(MODAL_ANCHOR), []);
const { permissions, isLoading: isLoadingPermission } =
useProvisioningPermissions();
const provider = getImageProvider(image);
if (!error) {
return (
<Suspense fallback="loading...">
<Button
spinnerAriaLabel="Loading launch"
isLoading={isLoadingPermission}
variant="link"
isInline
onClick={() => openWizard(true)}
>
Launch
</Button>
{wizardOpen && (
<Modal
isOpen
hasNoBodyWrapper
appendTo={appendTo}
showClose={false}
onClose={() => openWizard(false)}
variant={ModalVariant.large}
aria-label="Open launch wizard"
>
<ProvisioningWizard
hasAccess={permissions[provider]}
onClose={() => openWizard(false)}
image={{
name: image.imageName,
id: image.id,
architecture: image.architecture,
provider: provider,
sourceIDs: image.share_with_sources,
accountIDs: image.share_with_accounts,
uploadOptions: image.uploadOptions,
uploadStatus: image.uploadStatus,
// For backward compatibility only, remove once Provisioning ready (deploys):
// https://github.com/RHEnVision/provisioning-frontend/pull/238
sourceId: image.share_with_sources?.[0],
}}
/>
</Modal>
)}
</Suspense>
);
}
return (
<ImageLinkDirect
imageId={image.id}
isExpired={isExpired}
isInClonesTable={isInClonesTable}
/>
);
};
const ImageLink = ({ imageId, isExpired, isInClonesTable }) => {
const image = useSelector((state) => selectImageById(state, imageId));
const uploadStatus = image.uploadStatus;
const { initialized: chromeInitialized, getEnvironment } = useChrome();
const azureFeatureFlag = useFlag('provisioning.azure');
const gcpFeatureFlag = useFlag('provisioning.gcp');
const scalprum = useScalprum();
const hasProvisioning = chromeInitialized && scalprum.config?.provisioning;
if (!uploadStatus || image.status !== 'success') return null;
const provisioningLinkEnabled = (image) => {
switch (image.imageType) {
case 'aws':
case 'ami':
return true;
case 'azure':
if (getEnvironment() === 'qa') {
return true;
}
return !!azureFeatureFlag;
case 'gcp':
if (getEnvironment() === 'qa') {
return true;
}
return !!gcpFeatureFlag;
default:
return false;
}
};
if (hasProvisioning && provisioningLinkEnabled(image)) {
if (isInClonesTable) {
return null;
}
return (
<ProvisioningLink
imageId={image.id}
isExpired={isExpired}
isInClonesTable={isInClonesTable}
/>
);
}
return (
<ImageLinkDirect
imageId={image.id}
isExpired={isExpired}
isInClonesTable={isInClonesTable}
/>
);
};
ProvisioningLink.propTypes = {
imageId: PropTypes.string,
isExpired: PropTypes.bool,
isInClonesTable: PropTypes.bool,
};
ImageLink.propTypes = {
imageId: PropTypes.string.isRequired,
isExpired: PropTypes.bool,
isInClonesTable: PropTypes.bool,
};
export default ImageLink;

View file

@ -1,171 +0,0 @@
import React from 'react';
import {
Button,
ClipboardCopy,
ClipboardCopyVariant,
Popover,
Text,
TextContent,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { RegionsPopover } from './RegionsPopover';
import { selectImageById } from '../../store/composesSlice';
import { resolveRelPath } from '../../Utilities/path';
const launchInstanceCommand = (uploadStatus) => {
return `gcloud compute instances create ${uploadStatus.options.image_name}-instance --image-project ${uploadStatus.options.project_id} --image ${uploadStatus.options.image_name}`;
};
const saveCopyCommand = (uploadStatus) => {
return `gcloud compute images create ${uploadStatus.options.image_name}-copy --source-image-project ${uploadStatus.options.project_id} --source-image ${uploadStatus.options.image_name}`;
};
const ImageLinkDirect = ({ imageId, isExpired, isInClonesTable }) => {
const navigate = useNavigate();
const image = useSelector((state) => selectImageById(state, imageId));
if (!image) {
return null;
}
const uploadStatus = image.uploadStatus;
const fileExtensions = {
vsphere: '.vmdk',
'vsphere-ova': '.ova',
'guest-image': '.qcow2',
'image-installer': '.iso',
wsl: '.tar.gz',
};
if (uploadStatus.type === 'aws') {
const url =
'https://console.aws.amazon.com/ec2/v2/home?region=' +
uploadStatus.options.region +
'#LaunchInstanceWizard:ami=' +
uploadStatus.options.ami;
if (isInClonesTable) {
return (
<Button
component="a"
target="_blank"
variant="link"
isInline
href={url}
>
Launch
</Button>
);
} else {
return <RegionsPopover composeId={image.id} />;
}
} else if (uploadStatus.type === 'azure') {
const url =
'https://portal.azure.com/#@' +
image.uploadOptions.tenant_id +
'/resource/subscriptions/' +
image.uploadOptions.subscription_id +
'/resourceGroups/' +
image.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 commands"
maxWidth="30rem"
headerContent={'Image commands'}
bodyContent={
<TextContent>
<br />
<Text>
<strong>Launch an instance</strong>
</Text>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
ouiaId="gcp-launch-instance"
variant={ClipboardCopyVariant.expansion}
isReadOnly
isExpanded
>
{launchInstanceCommand(uploadStatus)}
</ClipboardCopy>
<br />
<Text>
<strong>Save a copy</strong>
</Text>
<ClipboardCopy
hoverTip="Copy"
clickTip="Copied"
ouiaId="gcp-save-copy"
variant={ClipboardCopyVariant.expansion}
isReadOnly
isExpanded
>
{saveCopyCommand(uploadStatus)}
</ClipboardCopy>
</TextContent>
}
>
<Button component="a" target="_blank" variant="link" isInline>
Image commands
</Button>
</Popover>
);
} else if (uploadStatus.type === 'aws.s3') {
if (!isExpired) {
return (
<Button
component="a"
target="_blank"
variant="link"
isInline
href={uploadStatus.options.url}
>
Download ({fileExtensions[image.imageType]})
</Button>
);
} else {
return (
<Button
component="a"
target="_blank"
variant="link"
onClick={() => navigate(resolveRelPath(`imagewizard/${imageId}`))}
isInline
>
Recreate image
</Button>
);
}
}
return null;
};
ImageLinkDirect.propTypes = {
imageId: PropTypes.string,
isExpired: PropTypes.bool,
isInClonesTable: PropTypes.bool,
};
export default ImageLinkDirect;

View file

@ -1,363 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateSecondaryActions,
EmptyStateVariant,
Pagination,
PaginationVariant,
Text,
Title,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, PlusCircleIcon } from '@patternfly/react-icons';
import {
ActionsColumn,
ExpandableRowContent,
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import './ImagesTable.scss';
import { ImageBuildStatus } from './ImageBuildStatus';
import ImageDetails from './ImageDetails';
import ImageLink from './ImageLink';
import Release from './Release';
import Target from './Target';
import { AWS_S3_EXPIRATION_TIME_IN_HOURS } from '../../constants';
import { fetchComposes, fetchComposeStatus } from '../../store/actions/actions';
import { resolveRelPath } from '../../Utilities/path';
import {
hoursToExpiration,
timestampToDisplayString,
} from '../../Utilities/time';
const ImagesTable = () => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
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 composes = useSelector((state) => state.composes);
const dispatch = useDispatch();
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;
}
dispatch(fetchComposeStatus(id));
});
};
/* Get all composes once on mount */
useEffect(() => {
dispatch(fetchComposes(perPage, 0));
}, []);
/* Reset the polling each time the composes in the store are updated */
useEffect(() => {
const intervalId = setInterval(() => pollComposeStatuses(), 8000);
// 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(fetchComposes(perPage, offset));
}
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(fetchComposes(perPage, 0));
}
// page should be reset to the first page when the page size is changed.
setPerPage(perPage);
setPage(1);
};
const actions = (compose) => [
{
title: 'Recreate image',
onClick: () => {
navigate(resolveRelPath(`imagewizard/${compose.id}`));
},
},
{
title: (
<a
className="ib-subdued-link"
href={`data:text/plain;charset=utf-8,${encodeURIComponent(
JSON.stringify(compose.request, null, ' ')
)}`}
download={`request-${compose.id}.json`}
>
Download compose request (.json)
</a>
),
},
];
const awsActions = (compose) => [
{
title: 'Share to new region',
onClick: () => navigate(resolveRelPath(`share/${compose.id}`)),
isDisabled: compose?.image_status?.status === 'success' ? false : true,
},
...actions(compose),
];
// 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 (
<React.Fragment>
{(composes.allIds.length === 0 && (
<EmptyState variant={EmptyStateVariant.large} data-testid="empty-state">
<EmptyStateIcon icon={PlusCircleIcon} />
<Title headingLevel="h4" size="lg">
Create an RPM-DNF image
</Title>
<EmptyStateBody>
<Text>
Image builder is a tool for creating deployment-ready customized
system images: installation disks, virtual machines, cloud
vendor-specific images, and others. By using image builder, you
can create these images faster than manual procedures because it
eliminates the specific configurations required for each output
type.
</Text>
<br />
<Text>
With RPM-DNF, you can manage the system software by using the DNF
package manager and updated RPM packages. This is a simple and
adaptive method of managing and modifying the system over its
lifecycle.
</Text>
<br />
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9/html-single/managing_software_with_the_dnf_tool/index'
}
>
Learn more about managing images with DNF
</Button>
</Text>
</EmptyStateBody>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
<EmptyStateSecondaryActions>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/creating_customized_rhel_images_using_the_image_builder_service'
}
className="pf-u-pt-md"
>
Image builder for RPM-DNF documentation
</Button>
</EmptyStateSecondaryActions>
</EmptyState>
)) || (
<React.Fragment>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
</ToolbarItem>
<ToolbarItem
variant="pagination"
align={{ default: 'alignRight' }}
>
<Pagination
itemCount={composes.count}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination-top"
data-testid="images-pagination-top"
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<TableComposable variant="compact" data-testid="images-table">
<Thead>
<Tr>
<Th />
<Th>Image name</Th>
<Th>Created/Updated</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 className="no-bottom-border">
<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 composeId={id} />
</Td>
<Td dataLabel="Status">
<ImageBuildStatus
imageId={id}
isImagesTableRow={true}
imageStatus={compose.image_status}
/>
</Td>
<Td dataLabel="Instance">
<ImageLink
imageId={id}
isExpired={
hoursToExpiration(compose.created_at) >=
AWS_S3_EXPIRATION_TIME_IN_HOURS
? true
: false
}
/>
</Td>
<Td>
{compose.request.image_requests[0].upload_request
.type === 'aws' ? (
<ActionsColumn items={awsActions(compose)} />
) : (
<ActionsColumn items={actions(compose)} />
)}
</Td>
</Tr>
<Tr isExpanded={isExpanded(compose)}>
<Td colSpan={8}>
<ExpandableRowContent>
<ImageDetails id={id} />
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
);
})}
</TableComposable>
<Toolbar className="pf-u-mb-xl">
<ToolbarContent>
<ToolbarItem
variant="pagination"
align={{ default: 'alignRight' }}
>
<Pagination
variant={PaginationVariant.bottom}
itemCount={composes.count}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination-bottom"
data-testid="images-pagination-bottom"
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
</React.Fragment>
)}
</React.Fragment>
);
};
ImagesTable.propTypes = {
composes: PropTypes.object,
composesGet: PropTypes.func,
composeGetStatus: PropTypes.func,
};
export default ImagesTable;

View file

@ -0,0 +1,486 @@
import React, { useEffect, useState } from 'react';
import {
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateSecondaryActions,
EmptyStateVariant,
OnSetPage,
Pagination,
PaginationVariant,
Text,
Title,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon, PlusCircleIcon } from '@patternfly/react-icons';
import {
ActionsColumn,
ExpandableRowContent,
TableComposable,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import { Link, NavigateFunction, useNavigate } from 'react-router-dom';
import './ImagesTable.scss';
import {
AwsDetails,
AwsS3Details,
AzureDetails,
GcpDetails,
} from './ImageDetails';
import { AwsS3Instance, CloudInstance } from './Instance';
import Release from './Release';
import { AwsS3Status, CloudStatus } from './Status';
import { AwsTarget, Target } from './Target';
import {
AWS_S3_EXPIRATION_TIME_IN_HOURS,
STATUS_POLLING_INTERVAL,
} from '../../constants';
import {
ComposesResponseItem,
ComposeStatus,
useGetComposesQuery,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
import {
computeHoursToExpiration,
timestampToDisplayString,
} from '../../Utilities/time';
const ImagesTable = () => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const onSetPage: OnSetPage = (_, page) => setPage(page);
const onPerPageSelect: OnSetPage = (_, perPage) => {
setPage(1);
setPerPage(perPage);
};
const { data, isSuccess } = useGetComposesQuery({
limit: perPage,
offset: perPage * (page - 1),
});
if (!isSuccess) {
return undefined;
}
const composes = data.data;
const itemCount = data.meta.count;
return (
<>
{data.meta.count === 0 && <EmptyImagesTable />}
{data.meta.count > 0 && (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
</ToolbarItem>
<ToolbarItem
variant="pagination"
alignment={{ default: 'alignRight' }}
>
<Pagination
itemCount={itemCount}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination-top"
data-testid="images-pagination-top"
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<TableComposable variant="compact" data-testid="images-table">
<Thead>
<Tr>
<Th />
<Th>Image name</Th>
<Th>Created/Updated</Th>
<Th>Release</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Instance</Th>
<Th />
</Tr>
</Thead>
{composes.map((compose, rowIndex) => {
return (
<ImagesTableRow
compose={compose}
rowIndex={rowIndex}
key={compose.id}
/>
);
})}
</TableComposable>
<Toolbar className="pf-u-mb-xl">
<ToolbarContent>
<ToolbarItem
variant="pagination"
alignment={{ default: 'alignRight' }}
>
<Pagination
variant={PaginationVariant.bottom}
itemCount={itemCount}
perPage={perPage}
page={page}
onSetPage={onSetPage}
onPerPageSelect={onPerPageSelect}
widgetId="compose-pagination-bottom"
data-testid="images-pagination-bottom"
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
</>
)}
</>
);
};
const EmptyImagesTable = () => {
return (
<EmptyState variant={EmptyStateVariant.large} data-testid="empty-state">
<EmptyStateIcon icon={PlusCircleIcon} />
<Title headingLevel="h4" size="lg">
Create an RPM-DNF image
</Title>
<EmptyStateBody>
<Text>
Image builder is a tool for creating deployment-ready customized
system images: installation disks, virtual machines, cloud
vendor-specific images, and others. By using image builder, you can
create these images faster than manual procedures because it
eliminates the specific configurations required for each output type.
</Text>
<br />
<Text>
With RPM-DNF, you can manage the system software by using the DNF
package manager and updated RPM packages. This is a simple and
adaptive method of managing and modifying the system over its
lifecycle.
</Text>
<br />
<Text>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/9/html-single/managing_software_with_the_dnf_tool/index'
}
>
Learn more about managing images with DNF
</Button>
</Text>
</EmptyStateBody>
<Link
to={resolveRelPath('imagewizard')}
className="pf-c-button pf-m-primary"
data-testid="create-image-action"
>
Create image
</Link>
<EmptyStateSecondaryActions>
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={
'https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/8/html/creating_customized_rhel_images_using_the_image_builder_service'
}
className="pf-u-pt-md"
>
Image builder for RPM-DNF documentation
</Button>
</EmptyStateSecondaryActions>
</EmptyState>
);
};
type ImagesTableRowPropTypes = {
compose: ComposesResponseItem;
rowIndex: number;
};
const ImagesTableRow = ({ compose, rowIndex }: ImagesTableRowPropTypes) => {
const [pollingInterval, setPollingInterval] = useState(
STATUS_POLLING_INTERVAL
);
const { data: composeStatus } = useGetComposeStatusQuery(
{
composeId: compose.id,
},
{ pollingInterval: pollingInterval }
);
useEffect(() => {
if (
composeStatus?.image_status.status === 'success' ||
composeStatus?.image_status.status === 'failure'
) {
setPollingInterval(0);
} else {
setPollingInterval(STATUS_POLLING_INTERVAL);
}
}, [setPollingInterval, composeStatus]);
const type = compose.request.image_requests[0].upload_request.type;
switch (type) {
case 'aws':
return (
<AwsRow
compose={compose}
composeStatus={composeStatus}
rowIndex={rowIndex}
/>
);
case 'gcp':
return <GcpRow compose={compose} rowIndex={rowIndex} />;
case 'azure':
return <AzureRow compose={compose} rowIndex={rowIndex} />;
case 'aws.s3':
return <AwsS3Row compose={compose} rowIndex={rowIndex} />;
}
};
type GcpRowPropTypes = {
compose: ComposesResponseItem;
rowIndex: number;
};
const GcpRow = ({ compose, rowIndex }: GcpRowPropTypes) => {
const details = <GcpDetails compose={compose} />;
const instance = <CloudInstance compose={compose} />;
const status = <CloudStatus compose={compose} />;
return (
<Row
compose={compose}
rowIndex={rowIndex}
details={details}
status={status}
instance={instance}
/>
);
};
type AzureRowPropTypes = {
compose: ComposesResponseItem;
rowIndex: number;
};
const AzureRow = ({ compose, rowIndex }: AzureRowPropTypes) => {
const details = <AzureDetails compose={compose} />;
const instance = <CloudInstance compose={compose} />;
const status = <CloudStatus compose={compose} />;
return (
<Row
compose={compose}
rowIndex={rowIndex}
details={details}
instance={instance}
status={status}
/>
);
};
type AwsS3RowPropTypes = {
compose: ComposesResponseItem;
rowIndex: number;
};
const AwsS3Row = ({ compose, rowIndex }: AwsS3RowPropTypes) => {
const hoursToExpiration = computeHoursToExpiration(compose.created_at);
const isExpired = hoursToExpiration >= AWS_S3_EXPIRATION_TIME_IN_HOURS;
const details = <AwsS3Details compose={compose} />;
const instance = <AwsS3Instance compose={compose} isExpired={isExpired} />;
const status = (
<AwsS3Status
compose={compose}
isExpired={isExpired}
hoursToExpiration={hoursToExpiration}
/>
);
return (
<Row
compose={compose}
rowIndex={rowIndex}
details={details}
instance={instance}
status={status}
/>
);
};
type AwsRowPropTypes = {
compose: ComposesResponseItem;
composeStatus: ComposeStatus | undefined;
rowIndex: number;
};
const AwsRow = ({ compose, composeStatus, rowIndex }: AwsRowPropTypes) => {
const navigate = useNavigate();
const target = <AwsTarget compose={compose} />;
const status = <CloudStatus compose={compose} />;
const instance = <CloudInstance compose={compose} />;
const details = <AwsDetails compose={compose} />;
const actions = (
<ActionsColumn items={awsActions(compose, composeStatus, navigate)} />
);
return (
<Row
compose={compose}
rowIndex={rowIndex}
status={status}
target={target}
actions={actions}
instance={instance}
details={details}
/>
);
};
type RowPropTypes = {
compose: ComposesResponseItem;
rowIndex: any;
status: JSX.Element;
target?: JSX.Element;
actions?: JSX.Element;
instance: JSX.Element;
details: JSX.Element;
};
const Row = ({
compose,
rowIndex,
status,
target,
actions,
details,
instance,
}: RowPropTypes) => {
const [isExpanded, setIsExpanded] = useState(false);
const handleToggle = () => setIsExpanded(!isExpanded);
const navigate = useNavigate();
return (
<Tbody key={compose.id} isExpanded={isExpanded}>
<Tr className="no-bottom-border">
<Td
expand={{
rowIndex: rowIndex,
isExpanded: isExpanded,
onToggle: () => handleToggle(),
}}
/>
<Td dataLabel="Image name">{compose.image_name || compose.id}</Td>
<Td dataLabel="Created">
{timestampToDisplayString(compose.created_at)}
</Td>
<Td dataLabel="Release">
<Release release={compose.request.distribution} />
</Td>
<Td dataLabel="Target">
{target ? target : <Target compose={compose} />}
</Td>
<Td dataLabel="Status">{status}</Td>
<Td dataLabel="Instance">{instance}</Td>
<Td>
{actions ? (
actions
) : (
<ActionsColumn items={defaultActions(compose, navigate)} />
)}
</Td>
</Tr>
<Tr isExpanded={isExpanded}>
<Td colSpan={8}>
<ExpandableRowContent>{details}</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
);
};
const defaultActions = (
compose: ComposesResponseItem,
navigate: NavigateFunction
) => [
{
title: 'Recreate image',
onClick: () => {
navigate(resolveRelPath(`imagewizard/${compose.id}`));
},
},
{
title: (
<a
className="ib-subdued-link"
href={`data:text/plain;charset=utf-8,${encodeURIComponent(
JSON.stringify(compose.request, null, ' ')
)}`}
download={`request-${compose.id}.json`}
>
Download compose request (.json)
</a>
),
},
];
const awsActions = (
compose: ComposesResponseItem,
status: ComposeStatus | undefined,
navigate: NavigateFunction
) => [
{
title: 'Share to new region',
onClick: () => navigate(resolveRelPath(`share/${compose.id}`)),
isDisabled: status?.image_status.status === 'success' ? false : true,
},
...defaultActions(compose, navigate),
];
export default ImagesTable;

View file

@ -0,0 +1,228 @@
import React, { useState } from 'react';
import { Button, Modal, ModalVariant, Skeleton } from '@patternfly/react-core';
import { useChrome } from '@redhat-cloud-services/frontend-components/useChrome';
import { useLoadModule, useScalprum } from '@scalprum/react-core';
import { useNavigate } from 'react-router-dom';
import { MODAL_ANCHOR } from '../../constants';
import {
ComposesResponseItem,
ComposeStatus,
ImageTypes,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import {
isAwsUploadRequestOptions,
isAwss3UploadStatus,
isGcpUploadRequestOptions,
} from '../../store/typeGuards';
import { resolveRelPath } from '../../Utilities/path';
type CloudInstancePropTypes = {
compose: ComposesResponseItem;
};
export const CloudInstance = ({ compose }: CloudInstancePropTypes) => {
const { initialized: chromeInitialized } = useChrome();
const scalprum = useScalprum();
const hasProvisioning = chromeInitialized && scalprum.config?.provisioning;
const { data, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <Skeleton />;
}
if (hasProvisioning) {
return <ProvisioningLink compose={compose} composeStatus={data} />;
} else {
return <DisabledProvisioningLink />;
}
};
const DisabledProvisioningLink = () => {
return (
<Button variant="link" isInline isDisabled>
Launch
</Button>
);
};
type ProvisioningLinkPropTypes = {
compose: ComposesResponseItem;
composeStatus: ComposeStatus;
};
const ProvisioningLink = ({
compose,
composeStatus,
}: ProvisioningLinkPropTypes) => {
const [wizardOpen, openWizard] = useState(false);
const [exposedScalprumModule, error] = useLoadModule(
{
scope: 'provisioning',
module: './ProvisioningWizard',
},
{}
);
if (
error ||
!exposedScalprumModule ||
composeStatus.image_status.status !== 'success'
) {
return <DisabledProvisioningLink />;
} else {
const appendTo = () => document.querySelector(MODAL_ANCHOR) as HTMLElement;
const ProvisioningWizard = exposedScalprumModule.default;
const provider = getImageProvider(compose);
const options = compose.request.image_requests[0].upload_request.options;
let sourceIds = undefined;
let accountIds = undefined;
if (isGcpUploadRequestOptions(options)) {
accountIds = options.share_with_accounts;
}
if (isAwsUploadRequestOptions(options)) {
accountIds = options.share_with_accounts;
sourceIds = options.share_with_sources;
}
return (
<>
<Button variant="link" isInline onClick={() => openWizard(true)}>
Launch
</Button>
{wizardOpen && (
<Modal
isOpen
hasNoBodyWrapper
appendTo={appendTo}
showClose={false}
variant={ModalVariant.large}
aria-label="Open launch wizard"
>
<ProvisioningWizard
onClose={() => openWizard(false)}
image={{
name: compose.image_name || compose.id,
id: compose.id,
architecture:
compose.request.image_requests[0].upload_request.options,
provider: provider,
sourceIDs: sourceIds,
accountIDs: accountIds,
uploadOptions:
compose.request.image_requests[0].upload_request.options,
uploadStatus: composeStatus.image_status.upload_status,
}}
/>
</Modal>
)}
</>
);
}
};
const getImageProvider = (compose: ComposesResponseItem) => {
const imageType = compose.request.image_requests[0].image_type;
switch (imageType) {
case 'aws':
return 'aws';
case 'ami':
return 'aws';
case 'azure':
return 'azure';
case 'gcp':
return 'gcp';
default:
//TODO check with Provisioning: what if imageType is not 'aws', 'ami', or 'azure'?
return 'aws';
}
};
type AwsS3InstancePropTypes = {
compose: ComposesResponseItem;
isExpired: boolean;
};
export const AwsS3Instance = ({
compose,
isExpired,
}: AwsS3InstancePropTypes) => {
const { data: composeStatus, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
const navigate = useNavigate();
if (!isSuccess) {
return <Skeleton />;
}
const fileExtensions: { [key in ImageTypes]: string } = {
aws: '',
azure: '',
'edge-commit': '',
'edge-installer': '',
gcp: '',
'guest-image': '.qcow2',
'image-installer': '.iso',
vsphere: '.vmdk',
'vsphere-ova': '.ova',
wsl: '.tar.gz',
ami: '',
'rhel-edge-commit': '',
'rhel-edge-installer': '',
vhd: '',
};
const status = composeStatus.image_status.status;
const options = composeStatus.image_status.upload_status?.options;
if (options && !isAwss3UploadStatus(options)) {
throw TypeError(
`Error: options must be of type Awss3UploadStatus, not ${typeof options}.`
);
}
if (status !== 'success') {
return (
<Button isDisabled variant="link" isInline>
Download ({fileExtensions[compose.request.image_requests[0].image_type]}
)
</Button>
);
} else if (!isExpired) {
return (
<Button
component="a"
target="_blank"
variant="link"
isInline
href={options?.url}
>
Download ({fileExtensions[compose.request.image_requests[0].image_type]}
)
</Button>
);
} else if (isExpired) {
return (
<Button
component="a"
target="_blank"
variant="link"
onClick={() => navigate(resolveRelPath(`imagewizard/${compose.id}`))}
isInline
>
Recreate image
</Button>
);
}
};

View file

@ -1,101 +0,0 @@
import React, { useMemo } from 'react';
import { Button, Popover } from '@patternfly/react-core';
import { createSelector } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { selectComposeById, selectImagesById } from '../../store/composesSlice';
export const selectRegions = createSelector(
[selectComposeById, selectImagesById],
(compose, images) => {
const filteredImages = images.filter(
(image) =>
compose.share_with_accounts &&
compose.share_with_accounts[0] === image.share_with_accounts[0]
);
const regions = {};
filteredImages.forEach((image) => {
if (image.region && image.status === 'success') {
if (regions[image.region]) {
if (
new Date(image.created_at) >
new Date(regions[image.region].created_at)
) {
regions[image.region] = {
ami: image.ami,
created_at: image.created_at,
};
}
} else {
regions[image.region] = {
ami: image.ami,
created_at: image.created_at,
};
}
}
});
return regions;
}
);
const ImageLinkRegion = ({ region, ami }) => {
const url =
'https://console.aws.amazon.com/ec2/v2/home?region=' +
region +
'#LaunchInstanceWizard:ami=' +
ami;
return (
<Button component="a" target="_blank" variant="link" isInline href={url}>
{region}
</Button>
);
};
export const RegionsPopover = ({ composeId }) => {
const regions = useSelector((state) => selectRegions(state, composeId));
const listItems = useMemo(() => {
const listItems = [];
for (const [key, value] of Object.entries(regions).sort()) {
listItems.push(
<li key={key}>
<ImageLinkRegion region={key} ami={value.ami} />
</li>
);
}
return listItems;
}, [regions]);
return (
<Popover
/* popovers aren't rendered inside of the main page section, make sure our prefixed css still
* applies */
className="imageBuilder"
aria-label="Launch instance"
headerContent={<div>Launch instance</div>}
bodyContent={
<>
<ul>{listItems}</ul>
</>
}
>
<Button variant="link" isInline>
Launch
</Button>
</Popover>
);
};
ImageLinkRegion.propTypes = {
region: PropTypes.string,
ami: PropTypes.string,
};
RegionsPopover.propTypes = {
composeId: PropTypes.string,
};

View file

@ -1,3 +1,5 @@
import React from 'react';
import { Distributions } from '../../store/imageBuilderApi';
type ReleaseProps = {
@ -27,7 +29,7 @@ const Release = ({ release }: ReleaseProps) => {
'fedora-39': 'Fedora 39',
};
return releaseDisplayValue[release];
return <p>{releaseDisplayValue[release]}</p>;
};
export default Release;

View file

@ -0,0 +1,298 @@
import React from 'react';
import './ImageBuildStatus.scss';
import {
Alert,
Button,
Flex,
Panel,
PanelMain,
Popover,
Skeleton,
} from '@patternfly/react-core';
import {
CheckCircleIcon,
CopyIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InProgressIcon,
OffIcon,
PendingIcon,
} from '@patternfly/react-icons';
import { AWS_S3_EXPIRATION_TIME_IN_HOURS } from '../../constants';
import {
ClonesResponseItem,
ComposeStatus,
ComposesResponseItem,
UploadStatus,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
type StatusClonePropTypes = {
clone: ClonesResponseItem;
status: UploadStatus | undefined;
};
export const StatusClone = ({ clone, status }: StatusClonePropTypes) => {
switch (status?.status) {
case 'failure':
return (
<ErrorStatus
icon={statuses.failureSharing.icon}
text={statuses.failureSharing.text}
reason={`Failed to share image to ${clone.request.region}`}
/>
);
case 'success':
case 'running':
case 'pending':
return (
<Status
icon={statuses[status.status].icon}
text={statuses[status.status].text}
/>
);
default:
return <></>;
}
};
type ComposeStatusPropTypes = {
compose: ComposesResponseItem;
};
export const AwsDetailsStatus = ({ compose }: ComposeStatusPropTypes) => {
const { data, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <></>;
}
switch (data.image_status.status) {
case 'failure':
return (
<ErrorStatus
icon={statuses[data.image_status.status].icon}
text={statuses[data.image_status.status].text}
reason={data?.image_status?.error?.details?.reason || ''}
/>
);
default:
return (
<Status
icon={statuses[data.image_status.status].icon}
text={statuses[data.image_status.status].text}
/>
);
}
};
type CloudStatusPropTypes = {
compose: ComposesResponseItem;
};
export const CloudStatus = ({ compose }: CloudStatusPropTypes) => {
const { data, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <Skeleton />;
}
switch (data.image_status.status) {
case 'failure':
return (
<ErrorStatus
icon={statuses['failure'].icon}
text={statuses['failure'].text}
reason={data.image_status.error?.details?.reason || ''}
/>
);
default:
return (
<Status
icon={statuses[data.image_status.status].icon}
text={statuses[data.image_status.status].text}
/>
);
}
};
type AzureStatusPropTypes = {
status: ComposeStatus;
};
export const AzureStatus = ({ status }: AzureStatusPropTypes) => {
switch (status.image_status.status) {
case 'failure':
return (
<ErrorStatus
icon={statuses[status.image_status.status].icon}
text={statuses[status.image_status.status].text}
reason={status.image_status.error?.reason || ''}
/>
);
default:
return (
<Status
icon={statuses[status.image_status.status].icon}
text={statuses[status.image_status.status].text}
/>
);
}
};
type AwsS3StatusPropTypes = {
compose: ComposesResponseItem;
isExpired: boolean;
hoursToExpiration: number;
};
export const AwsS3Status = ({
compose,
isExpired,
hoursToExpiration,
}: AwsS3StatusPropTypes) => {
const { data: composeStatus, isSuccess } = useGetComposeStatusQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <Skeleton />;
}
const status = composeStatus.image_status.status;
const remainingTime = AWS_S3_EXPIRATION_TIME_IN_HOURS - hoursToExpiration;
if (isExpired) {
return (
<Status icon={statuses['expired'].icon} text={statuses['expired'].text} />
);
} else if (status === 'success') {
return (
<Status
icon={statuses['expiring'].icon}
text={`Expires in ${remainingTime} ${
remainingTime > 1 ? 'hours' : 'hour'
}`}
/>
);
} else {
return <Status icon={statuses[status].icon} text={statuses[status].text} />;
}
};
const statuses = {
failure: {
icon: <ExclamationCircleIcon className="error" />,
text: 'Image build failed',
},
pending: {
icon: <PendingIcon />,
text: 'Image build is pending',
},
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',
},
running: {
icon: <InProgressIcon className="pending" />,
text: 'Running',
},
success: {
icon: <CheckCircleIcon className="success" />,
text: 'Ready',
},
expired: {
icon: <OffIcon />,
text: 'Expired',
},
expiring: {
icon: <ExclamationTriangleIcon className="expiring" />,
},
failureSharing: {
icon: <ExclamationCircleIcon className="error" />,
text: 'Sharing image failed',
},
failedClone: {
icon: <ExclamationCircleIcon className="error" />,
text: 'Failure sharing',
},
};
type StatusPropTypes = {
icon: JSX.Element;
text: string;
};
const Status = ({ icon, text }: StatusPropTypes) => {
return (
<Flex className="pf-u-align-items-baseline pf-m-nowrap">
<div className="pf-u-mr-sm">{icon}</div>
<p>{text}</p>
</Flex>
);
};
type ErrorStatusPropTypes = {
icon: JSX.Element;
text: string;
reason: string;
};
const ErrorStatus = ({ icon, text, reason }: ErrorStatusPropTypes) => {
return (
<Flex className="pf-u-align-items-baseline pf-m-nowrap">
<div className="pf-u-mr-sm">{icon}</div>
<Popover
position="bottom"
minWidth="30rem"
bodyContent={
<>
<Alert variant="danger" title={text} isInline isPlain />
<Panel isScrollable>
<PanelMain maxHeight="25rem">
<div className="pf-u-mt-sm">
<p>{reason}</p>
<Button
variant="link"
onClick={() => navigator.clipboard.writeText(reason)}
className="pf-u-pl-0 pf-u-mt-md"
>
Copy error text to clipboard <CopyIcon />
</Button>
</div>
</PanelMain>
</Panel>
</>
}
>
<Button variant="link" className="pf-u-p-0 pf-u-font-size-sm">
<div className="failure-button">{text}</div>
</Button>
</Popover>
</Flex>
);
};

View file

@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { selectComposeById } from '../../store/composesSlice';
const Target = ({ composeId }) => {
const compose = useSelector((state) => selectComposeById(state, composeId));
const targetOptions = {
aws: 'Amazon Web Services',
azure: 'Microsoft Azure',
gcp: 'Google Cloud Platform',
vsphere: 'VMWare vSphere',
'vsphere-ova': 'VMWare vSphere',
'guest-image': 'Virtualization - Guest image',
'image-installer': 'Bare metal - Installer',
wsl: 'Windows Subsystem for Linux',
};
let target;
if (compose.uploadType === 'aws.s3') {
target = targetOptions[compose.imageType];
} else if (compose.uploadType === 'aws') {
target =
targetOptions[compose.uploadType] +
` (${compose.clones.length !== 0 ? compose.clones.length + 1 : 1})`;
} else {
target = targetOptions[compose.uploadType];
}
return <>{target ? target : compose.imageType}</>;
};
Target.propTypes = {
composeId: PropTypes.string,
};
export default Target;

View file

@ -0,0 +1,51 @@
import React from 'react';
import { Skeleton } from '@patternfly/react-core';
import {
ImageTypes,
useGetComposeClonesQuery,
} from '../../store/imageBuilderApi';
import { ComposesResponseItem } from '../../store/imageBuilderApi';
const targetOptions: { [key in ImageTypes]: string } = {
aws: 'Amazon Web Services',
azure: 'Microsoft Azure',
'edge-commit': 'Edge Commit',
'edge-installer': 'Edge Installer',
gcp: 'Google Cloud Platform',
'guest-image': 'Virtualization - Guest image',
'image-installer': 'Bare metal - Installer',
vsphere: 'VMWare vSphere',
'vsphere-ova': 'VMWare vSphere',
wsl: 'Windows Subsystem for Linux',
ami: 'Amazon Web Services',
'rhel-edge-commit': 'RHEL Edge Commit',
'rhel-edge-installer': 'RHEL Edge Installer',
vhd: '',
};
type TargetPropTypes = {
compose: ComposesResponseItem;
};
export const Target = ({ compose }: TargetPropTypes) => {
return <p>{targetOptions[compose.request.image_requests[0].image_type]}</p>;
};
type AwsTargetPropTypes = {
compose: ComposesResponseItem;
};
export const AwsTarget = ({ compose }: AwsTargetPropTypes) => {
const { data, isSuccess } = useGetComposeClonesQuery({
composeId: compose.id,
});
if (!isSuccess) {
return <Skeleton />;
}
const text = `Amazon Web Services (${data.data.length + 1})`;
return <>{text}</>;
};

View file

@ -42,7 +42,7 @@ export const LandingPage = () => {
const [activeTabKey, setActiveTabKey] = useState(initialActiveTabKey);
useEffect(() => {
setActiveTabKey(initialActiveTabKey);
}, [pathname]);
}, [initialActiveTabKey]);
const handleTabClick = (_event: React.MouseEvent, tabIndex: number) => {
const tabPath = tabsPath[tabIndex];
if (tabPath !== undefined) {
@ -60,7 +60,8 @@ export const LandingPage = () => {
</section>
);
return (
<React.Fragment>
<>
{/*@ts-ignore*/}
<PageHeader>
<PageHeaderTitle className="title" title="Image Builder" />
<Popover
@ -212,7 +213,7 @@ export const LandingPage = () => {
traditionalImageList
)}
<Outlet />
</React.Fragment>
</>
);
};

View file

@ -9,45 +9,52 @@ import {
Select,
SelectOption,
SelectVariant,
ValidatedOptions,
} from '@patternfly/react-core';
import { ExclamationCircleIcon, HelpIcon } from '@patternfly/react-icons';
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
import { createSelector } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import api from '../../api';
import { AWS_REGIONS } from '../../constants';
import { cloneAdded } from '../../store/clonesSlice';
import { selectClonesById, selectComposeById } from '../../store/composesSlice';
import {
ComposeStatus,
useCloneComposeMutation,
useGetComposeStatusQuery,
} from '../../store/imageBuilderApi';
import { resolveRelPath } from '../../Utilities/path';
export const selectRegionsToDisable = createSelector(
[selectComposeById, selectClonesById],
(compose, clones) => {
const regions = new Set();
regions.add(compose.region);
clones.map((clone) => {
clone.region &&
clone.share_with_accounts?.[0] === compose.share_with_accounts?.[0] &&
clone.share_with_sources?.[0] === compose.share_with_sources?.[0] &&
clone.status !== 'failure' &&
regions.add(clone.region);
});
const generateRequests = (
composeId: string,
composeStatus: ComposeStatus,
regions: string[]
) => {
return regions.map((region) => {
const options =
composeStatus.request.image_requests[0].upload_request.options;
return {
composeId: composeId,
cloneRequest: {
region: region,
share_with_sources:
'share_with_sources' in options
? options.share_with_sources
: undefined,
share_with_accounts:
'share_with_accounts' in options
? options.share_with_accounts
: undefined,
},
};
});
};
return regions;
}
);
const prepareRegions = (regionsToDisable) => {
const regions = AWS_REGIONS.map((region) => ({
...region,
disabled:
regionsToDisable.has(region.value) || region?.disableRegion === true,
}));
return regions;
type RegionsSelectPropTypes = {
composeId: string;
handleClose: any;
handleToggle: any;
isOpen: boolean;
setIsOpen: any;
};
const RegionsSelect = ({
@ -56,25 +63,34 @@ const RegionsSelect = ({
handleToggle,
isOpen,
setIsOpen,
}) => {
}: RegionsSelectPropTypes) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const [isSaving, setIsSaving] = useState(false);
const [selected, setSelected] = useState([]);
const [selected, setSelected] = useState<string[]>([]);
const titleId = 'Clone this image';
const [validated, setValidated] = useState('default');
const [validated, setValidated] = useState<ValidatedOptions>(
ValidatedOptions.default
);
const [helperTextInvalid] = useState(
'Select at least one region to share to.'
);
const [cloneCompose] = useCloneComposeMutation();
const compose = useSelector((state) => selectComposeById(state, composeId));
const { data: composeStatus, isSuccess } = useGetComposeStatusQuery({
composeId,
});
const regionsToDisable = useSelector((state) =>
selectRegionsToDisable(state, composeId)
);
const [options] = useState(prepareRegions(regionsToDisable));
if (!isSuccess) {
return undefined;
}
const handleSelect = (event, selection) => {
const options = AWS_REGIONS;
const handleSelect = (
event: React.MouseEvent<Element, MouseEvent> | React.ChangeEvent<Element>,
selection: string
): void => {
let nextSelected;
if (selected.includes(selection)) {
nextSelected = selected.filter((region) => region !== selection);
@ -83,48 +99,27 @@ const RegionsSelect = ({
nextSelected = [...selected, selection];
setSelected(nextSelected);
}
nextSelected.length === 0 ? setValidated('error') : setValidated('default');
nextSelected.length === 0
? setValidated(ValidatedOptions.error)
: setValidated(ValidatedOptions.default);
};
const handleClear = () => {
setSelected([]);
setIsOpen(false);
setValidated('error');
setValidated(ValidatedOptions.error);
};
const generateRequests = () => {
const requests = selected.map((region) => {
const request = { region: region };
if (compose.share_with_sources?.[0]) {
request.share_with_sources = [compose.share_with_sources[0]];
} else {
request.share_with_accounts = [compose.share_with_accounts[0]];
}
return request;
});
return requests;
};
const handleSubmit = () => {
const handleSubmit = async () => {
setIsSaving(true);
const requests = generateRequests();
Promise.all(
requests.map((request) =>
api.cloneImage(composeId, request).then((response) => {
dispatch(
cloneAdded({
clone: {
...response,
request,
image_status: { status: 'pending' },
},
parent: composeId,
})
);
})
)
)
const requests = generateRequests(composeId, composeStatus, selected);
// https://redux-toolkit.js.org/rtk-query/usage/mutations#frequently-used-mutation-hook-return-values
// If you want to immediately access the result of a mutation, you need to chain `.unwrap()`
// if you actually want the payload or to catch the error.
// We do this so we can dispatch the appropriate notification (success or failure).
await Promise.all(requests.map((request) => cloneCompose(request).unwrap()))
.then(() => {
setIsSaving(false);
navigate(resolveRelPath(''));
dispatch(
addNotification({
@ -132,16 +127,15 @@ const RegionsSelect = ({
title: 'Your image is being shared',
})
);
setIsSaving(false);
})
.catch((err) => {
navigate(resolveRelPath(''));
// TODO The error should be typed.
dispatch(
addNotification({
variant: 'danger',
title: 'Your image could not be created',
description: `Status code ${err.response.status}: ${err.response.statusText}`,
title: 'Your image could not be shared',
description: `Status code ${err.status}: ${err.data.errors[0].detail}`,
})
);
});
@ -198,7 +192,7 @@ const RegionsSelect = ({
>
{options.map((option, index) => (
<SelectOption
isDisabled={option.disabled}
isDisabled={option.disableRegion}
key={index}
value={option.value}
{...(option.description && { description: option.description })}
@ -224,12 +218,4 @@ const RegionsSelect = ({
);
};
RegionsSelect.propTypes = {
composeId: PropTypes.string,
handleClose: PropTypes.func,
handleToggle: PropTypes.func,
isOpen: PropTypes.bool,
setIsOpen: PropTypes.func,
};
export default RegionsSelect;

View file

@ -15,7 +15,7 @@ const ShareToRegionsModal = () => {
const { composeId } = useParams();
const handleToggle = (isOpen) => setIsOpen(isOpen);
const handleToggle = (isOpen: boolean) => setIsOpen(isOpen);
const handleEscapePress = () => {
if (isOpen) {
@ -25,7 +25,15 @@ const ShareToRegionsModal = () => {
}
};
const appendTo = useMemo(() => document.querySelector(MODAL_ANCHOR), []);
const appendTo = useMemo(() => {
const modalAnchor = document.querySelector(MODAL_ANCHOR);
return modalAnchor === null ? undefined : (modalAnchor as HTMLElement);
}, []);
if (!composeId) {
handleClose();
return undefined;
}
return (
<Modal

View file

@ -23,7 +23,7 @@ export const convertStringToDate = (createdAtAsString) => {
}
};
export const hoursToExpiration = (imageCreatedAt) => {
export const computeHoursToExpiration = (imageCreatedAt) => {
if (imageCreatedAt) {
const currentTime = Date.now();
// miliseconds in hour - needed for calculating the difference

View file

@ -27,7 +27,8 @@ export const AWS_REGIONS = [
{
description: 'US East (N. Virginia)',
value: 'us-east-1',
disableRegion: false,
// disable default region
disableRegion: true,
},
{
description: 'US West (N. California)',
@ -120,3 +121,5 @@ export const AWS_S3_EXPIRATION_TIME_IN_HOURS = 6;
// Anchor element for all modals that we display so that they play nice with top-most components like Quickstarts
export const MODAL_ANCHOR = '.pf-c-page.chr-c-page';
export const STATUS_POLLING_INTERVAL = 8000;

View file

@ -0,0 +1,29 @@
import { imageBuilderApi } from './imageBuilderApi';
const enhancedApi = imageBuilderApi.enhanceEndpoints({
addTagTypes: ['Clone', 'Compose'],
endpoints: {
getComposes: {
providesTags: () => {
return [{ type: 'Compose' }];
},
},
getComposeClones: {
providesTags: (_request, _error, arg) => {
return [{ type: 'Clone', id: arg.composeId }];
},
},
cloneCompose: {
invalidatesTags: (_request, _error, arg) => {
return [{ type: 'Clone', id: arg.composeId }];
},
},
composeImage: {
invalidatesTags: () => {
return [{ type: 'Compose' }];
},
},
},
});
export { enhancedApi as imageBuilderApi };

View file

@ -6,7 +6,7 @@ import clonesSlice from './clonesSlice';
import composesSlice from './composesSlice';
import { contentSourcesApi } from './contentSourcesApi';
import { edgeApi } from './edgeApi';
import { imageBuilderApi } from './imageBuilderApi';
import { imageBuilderApi } from './enhancedImageBuilderApi';
import { provisioningApi } from './provisioningApi';
import { rhsmApi } from './rhsmApi';
@ -21,13 +21,13 @@ export const reducer = {
notifications: notificationsReducer,
};
export const middleware = (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(promiseMiddleware)
.concat(contentSourcesApi.middleware)
.concat(edgeApi.middleware)
.concat(imageBuilderApi.middleware)
.concat(rhsmApi.middleware)
.concat(provisioningApi.middleware);
export const middleware = (getDefaultMiddleware: Function) =>
getDefaultMiddleware().concat(
promiseMiddleware,
contentSourcesApi.middleware,
imageBuilderApi.middleware,
rhsmApi.middleware,
provisioningApi.middleware
);
export const store = configureStore({ reducer, middleware });

46
src/store/typeGuards.ts Normal file
View file

@ -0,0 +1,46 @@
import {
AwsUploadRequestOptions,
Awss3UploadStatus,
AzureUploadRequestOptions,
AzureUploadStatus,
GcpUploadRequestOptions,
GcpUploadStatus,
UploadRequest,
UploadStatus,
} from './imageBuilderApi';
export const isGcpUploadRequestOptions = (
options: UploadRequest['options']
): options is GcpUploadRequestOptions => {
return (options as GcpUploadRequestOptions).share_with_accounts !== undefined;
};
export const isAwsUploadRequestOptions = (
options: UploadRequest['options']
): options is AwsUploadRequestOptions => {
return true;
};
export const isAzureUploadRequestOptions = (
options: UploadRequest['options']
): options is AzureUploadRequestOptions => {
return (options as AzureUploadRequestOptions).resource_group !== undefined;
};
export const isGcpUploadStatus = (
status: UploadStatus['options']
): status is GcpUploadStatus => {
return (status as GcpUploadStatus).project_id !== undefined;
};
export const isAwss3UploadStatus = (
status: UploadStatus['options']
): status is Awss3UploadStatus => {
return (status as Awss3UploadStatus).url !== undefined;
};
export const isAzureUploadStatus = (
status: UploadStatus['options']
): status is AzureUploadStatus => {
return (status as AzureUploadStatus).image_name !== undefined;
};

View file

@ -75,7 +75,11 @@ const getSourceDropdown = async () => {
describe('Step Upload to Azure', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select Azure as upload destination
await user.click(await screen.findByTestId('upload-azure'));

View file

@ -8,11 +8,6 @@ import userEvent from '@testing-library/user-event';
import api from '../../../api.js';
import CreateImageWizard from '../../../Components/CreateImageWizard/CreateImageWizard';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import { store } from '../../../store/index.js';
import {
mockComposesEmpty,
mockStateRecreateImage,
} from '../../fixtures/composes';
import {
mockPkgResultAlpha,
mockPkgResultAlphaContentSources,
@ -63,14 +58,6 @@ jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
}),
}));
// Mocking getComposes is necessary because in many tests we call navigate()
// to navigate to the images table (via useNavigate hook), which will in turn
// result in a call to getComposes. If it is not mocked, tests fail due to MSW
// being unable to resolve that endpoint.
jest
.spyOn(api, 'getComposes')
.mockImplementation(() => Promise.resolve(mockComposesEmpty));
const searchForAvailablePackages = async (searchbox, searchTerm) => {
const user = userEvent.setup();
await user.type(searchbox, searchTerm);
@ -117,7 +104,7 @@ describe('Step Packages', () => {
const setUp = async () => {
mockContentSourcesEnabled = false;
({ router } = renderCustomRoutesWithReduxRouter(
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
@ -877,35 +864,21 @@ describe('Step Custom repositories', () => {
describe('On Recreate', () => {
const user = userEvent.setup();
const setUp = async () => {
jest.mock('../../../store/index.js');
const state = mockStateRecreateImage;
store.getState = () => state;
({ router } = renderWithReduxRouter(
'imagewizard/hyk93673-8dcc-4a61-ac30-e9f4940d8346',
state
'imagewizard/hyk93673-8dcc-4a61-ac30-e9f4940d8346'
));
};
const setUpUnavailableRepo = async () => {
jest.mock('../../../store/index.js');
const state = mockStateRecreateImage;
store.getState = () => state;
({ router } = renderWithReduxRouter(
'imagewizard/b7193673-8dcc-4a5f-ac30-e9f4940d8346',
state
'imagewizard/b7193673-8dcc-4a5f-ac30-e9f4940d8346'
));
};
test('with valid repositories', async () => {
await setUp();
screen.getByRole('heading', { name: /review/i });
await screen.findByRole('heading', { name: /review/i });
expect(
screen.queryByText('Previously added custom repository unavailable')
).not.toBeInTheDocument();
@ -943,7 +916,7 @@ describe('On Recreate', () => {
test('with repositories that are no longer available', async () => {
await setUpUnavailableRepo();
screen.getByRole('heading', { name: /review/i });
await screen.findByRole('heading', { name: /review/i });
await screen.findByText('Previously added custom repository unavailable');
const createImageButton = await screen.findByRole('button', {

View file

@ -11,12 +11,9 @@ import {
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import api from '../../../api.js';
import CreateImageWizard from '../../../Components/CreateImageWizard/CreateImageWizard';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import { RHEL_8, RHEL_9, PROVISIONING_API } from '../../../constants.js';
import { mockComposesEmpty } from '../../fixtures/composes';
import { customizations } from '../../fixtures/customizations';
import { PROVISIONING_API } from '../../../constants.js';
import { server } from '../../mocks/server.js';
import {
clickBack,
@ -48,9 +45,9 @@ let router = undefined;
// to navigate to the images table (via useNavigate hook), which will in turn
// result in a call to getComposes. If it is not mocked, tests fail due to MSW
// being unable to resolve that endpoint.
jest
.spyOn(api, 'getComposes')
.mockImplementation(() => Promise.resolve(mockComposesEmpty));
// jest
// .spyOn(api, 'getComposes')
// .mockImplementation(() => Promise.resolve(mockComposesEmpty));
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
@ -137,7 +134,11 @@ describe('Create Image Wizard', () => {
describe('Step Image output', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select aws as upload destination
await user.click(await screen.findByTestId('upload-aws'));
@ -270,7 +271,7 @@ describe('Step Image output', () => {
describe('Step Upload to AWS', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router, store } = renderCustomRoutesWithReduxRouter(
({ router, store } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
@ -399,44 +400,38 @@ describe('Step Upload to AWS', () => {
await clickNext();
await clickNext();
const composeImage = jest
.spyOn(api, 'composeImage')
.mockImplementation((body) => {
expect(body).toEqual({
distribution: RHEL_9,
image_name: undefined,
customizations: {
packages: undefined,
},
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_sources: ['123'],
},
},
},
],
});
const id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5a';
return Promise.resolve({ id });
});
// const composeImage = jest
// .spyOn(api, 'composeImage')
// .mockImplementation((body) => {
// expect(body).toEqual({
// distribution: RHEL_9,
// image_name: undefined,
// customizations: {
// packages: undefined,
// },
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'aws',
// upload_request: {
// type: 'aws',
// options: {
// share_with_sources: ['123'],
// },
// },
// },
// ],
// });
// const id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5a';
// return Promise.resolve({ id });
// });
user.click(screen.getByRole('button', { name: /Create/ }));
// API request sent to backend
await waitFor(() => expect(composeImage).toHaveBeenCalledTimes(1));
// returns back to the landing page
await waitFor(() =>
expect(router.state.location.pathname).toBe('/insights/image-builder')
);
expect(store.getState().composes.allIds).toEqual([
'edbae1c2-62bc-42c1-ae0c-3110ab718f5a',
]);
// set test timeout of 10 seconds
}, 10000);
});
@ -444,7 +439,11 @@ describe('Step Upload to AWS', () => {
describe('Step Upload to Google', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select gcp as upload destination
user.click(screen.getByTestId('upload-google'));
@ -507,7 +506,11 @@ describe('Step Upload to Google', () => {
describe('Step Registration', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select aws as upload destination
user.click(screen.getByTestId('upload-aws'));
@ -693,7 +696,11 @@ describe('Step Registration', () => {
describe('Step File system configuration', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select aws as upload destination
user.click(screen.getByTestId('upload-aws'));
@ -768,7 +775,11 @@ describe('Step File system configuration', () => {
describe('Step Details', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select aws as upload destination
user.click(screen.getByTestId('upload-aws'));
@ -837,7 +848,11 @@ describe('Step Details', () => {
describe('Step Review', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
// select aws as upload destination
user.click(screen.getByTestId('upload-aws'));
@ -984,7 +999,7 @@ describe('Step Review', () => {
describe('Click through all steps', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router, store } = renderCustomRoutesWithReduxRouter(
({ router, store } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
@ -1051,7 +1066,10 @@ describe('Click through all steps', () => {
await clickNext();
// fsc
(await screen.findByTestId('file-system-config-radio-manual')).click();
const fscToggle = await screen.findByTestId(
'file-system-config-radio-manual'
);
await user.click(fscToggle);
const addPartition = await screen.findByTestId('file-system-add-partition');
await user.click(addPartition);
await user.click(addPartition);
@ -1188,146 +1206,146 @@ describe('Click through all steps', () => {
);
expect(within(revtbody).getAllByRole('row')).toHaveLength(3);
// mock the backend API
const ids = [];
const composeImage = jest
.spyOn(api, 'composeImage')
.mockImplementation((body) => {
let id;
let expectedbody = {};
if (body.image_requests[0].upload_request.type === 'aws') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['012345678901'],
},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f56';
} else if (body.image_requests[0].upload_request.type === 'gcp') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['user:test@test.com'],
},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f57';
} else if (body.image_requests[0].upload_request.type === 'azure') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
tenant_id: 'b8f86d22-4371-46ce-95e7-65c415f3b1e2',
subscription_id: '60631143-a7dc-4d15-988b-ba83f3c99711',
resource_group: 'testResourceGroup',
},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f58';
} else if (body.image_requests[0].image_type === 'vsphere-ova') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere-ova',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f59';
} else if (body.image_requests[0].image_type === 'guest-image') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'guest-image',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5a';
} else if (body.image_requests[0].image_type === 'image-installer') {
expectedbody = {
distribution: RHEL_8,
image_name: 'my-image-name',
image_description: 'this is a perfect description for image',
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
type: 'aws.s3',
options: {},
},
},
],
customizations: customizations,
};
id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5b';
}
expect(body).toEqual(expectedbody);
// // mock the backend API
// const ids = [];
// const composeImage = jest
// .spyOn(api, 'composeImage')
// .mockImplementation((body) => {
// let id;
// let expectedbody = {};
// if (body.image_requests[0].upload_request.type === 'aws') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'aws',
// upload_request: {
// type: 'aws',
// options: {
// share_with_accounts: ['012345678901'],
// },
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f56';
// } else if (body.image_requests[0].upload_request.type === 'gcp') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'gcp',
// upload_request: {
// type: 'gcp',
// options: {
// share_with_accounts: ['user:test@test.com'],
// },
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f57';
// } else if (body.image_requests[0].upload_request.type === 'azure') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'azure',
// upload_request: {
// type: 'azure',
// options: {
// tenant_id: 'b8f86d22-4371-46ce-95e7-65c415f3b1e2',
// subscription_id: '60631143-a7dc-4d15-988b-ba83f3c99711',
// resource_group: 'testResourceGroup',
// },
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f58';
// } else if (body.image_requests[0].image_type === 'vsphere-ova') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'vsphere-ova',
// upload_request: {
// type: 'aws.s3',
// options: {},
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f59';
// } else if (body.image_requests[0].image_type === 'guest-image') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'guest-image',
// upload_request: {
// type: 'aws.s3',
// options: {},
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5a';
// } else if (body.image_requests[0].image_type === 'image-installer') {
// expectedbody = {
// distribution: RHEL_8,
// image_name: 'my-image-name',
// image_description: 'this is a perfect description for image',
// image_requests: [
// {
// architecture: 'x86_64',
// image_type: 'image-installer',
// upload_request: {
// type: 'aws.s3',
// options: {},
// },
// },
// ],
// customizations: customizations,
// };
// id = 'edbae1c2-62bc-42c1-ae0c-3110ab718f5b';
// }
// expect(body).toEqual(expectedbody);
ids.unshift(id);
return Promise.resolve({ id });
});
// ids.unshift(id);
// return Promise.resolve({ id });
// });
await user.click(screen.getByRole('button', { name: /Create/ }));
// API request sent to backend
expect(composeImage).toHaveBeenCalledTimes(6);
// expect(composeImage).toHaveBeenCalledTimes(6);
// returns back to the landing page
await waitFor(() =>
expect(router.state.location.pathname).toBe('/insights/image-builder')
);
expect(store.getState().composes.allIds).toEqual(ids);
// expect(store.getState().composes.allIds).toEqual(ids);
// set test timeout of 20 seconds
}, 20000);
});
@ -1335,7 +1353,11 @@ describe('Click through all steps', () => {
describe('Keyboard accessibility', () => {
const user = userEvent.setup();
const setUp = async () => {
({ router } = renderCustomRoutesWithReduxRouter('imagewizard', {}, routes));
({ router } = await renderCustomRoutesWithReduxRouter(
'imagewizard',
{},
routes
));
await clickNext();
};

View file

@ -2,21 +2,15 @@ import React from 'react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter } from 'react-router-dom';
import { act } from 'react-dom/test-utils';
import api from '../../../api.js';
import { ImageBuildStatus } from '../../../Components/ImagesTable/ImageBuildStatus';
import ImageLink from '../../../Components/ImagesTable/ImageLink';
import Target from '../../../Components/ImagesTable/Target';
import '@testing-library/jest-dom';
import {
mockComposes,
mockStatus,
mockClones,
mockCloneStatus,
mockNoClones,
} from '../../fixtures/composes';
import { renderWithProvider, renderWithReduxRouter } from '../../testUtils';
import { renderWithReduxRouter } from '../../testUtils';
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
useChrome: () => ({
@ -31,24 +25,6 @@ jest.mock('@unleash/proxy-client-react', () => ({
useFlag: jest.fn((flag) => (flag === 'edgeParity.image-list' ? false : true)),
}));
jest
.spyOn(api, 'getComposes')
.mockImplementation(() => Promise.resolve(mockComposes));
jest.spyOn(api, 'getComposeStatus').mockImplementation((id) => {
return Promise.resolve(mockStatus(id));
});
jest.spyOn(api, 'getClones').mockImplementation((id) => {
return id === '1579d95b-8f1d-4982-8c53-8c2afa4ab04c'
? Promise.resolve(mockClones(id))
: Promise.resolve(mockNoClones);
});
jest.spyOn(api, 'getCloneStatus').mockImplementation((id) => {
return Promise.resolve(mockCloneStatus(id));
});
beforeAll(() => {
// scrollTo is not defined in jsdom
window.HTMLElement.prototype.scrollTo = function () {};
@ -57,7 +33,7 @@ beforeAll(() => {
describe('Images Table', () => {
const user = userEvent.setup();
test('render ImagesTable', async () => {
const view = renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
const table = await screen.findByTestId('images-table');
@ -65,8 +41,6 @@ describe('Images Table', () => {
const emptyState = screen.queryByTestId('empty-state');
expect(emptyState).not.toBeInTheDocument();
const state = view.store.getState();
// check table
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
@ -80,103 +54,107 @@ describe('Images Table', () => {
expect(header.cells[5]).toHaveTextContent('Status');
expect(header.cells[6]).toHaveTextContent('Instance');
const imageNameValues = mockComposes.map((compose) =>
compose.image_name ? compose.image_name : compose.id
);
const statusValues = [
'Ready',
'Image build failed',
'Image build is pending',
'Image build in progress',
'Image upload in progress',
'Cloud registration in progress',
'Image build failed',
'Ready',
'Image build in progress',
'Expired',
];
const targetValues = [
'Amazon Web Services (5)',
'Google Cloud PlatformFAKE',
'Amazon Web Services (1)',
'Amazon Web Services (1)',
'Amazon Web Services (1)',
'Amazon Web Services (1)',
'Amazon Web Services (1)',
'Google Cloud Platform',
'Microsoft Azure',
'VMWare vSphere',
];
const instanceValues = [
'Launch',
'Launch',
'Launch',
'Launch',
'Launch',
'Launch',
'Launch',
'Launch',
'Launch',
'Recreate image',
];
// 10 rows for 10 images
expect(rows).toHaveLength(10);
for (const row of rows) {
const col1 = row.cells[1].textContent;
// find compose with either the user defined image name or the uuid
const compose = mockComposes.data.find(
(compose) => compose?.image_name === col1 || compose.id === col1
);
expect(compose).toBeTruthy();
// date should match the month day and year of the timestamp.
rows.forEach(async (row, index) => {
expect(row.cells[1]).toHaveTextContent(imageNameValues[index]);
expect(row.cells[2]).toHaveTextContent('Apr 27, 2021');
expect(row.cells[3]).toHaveTextContent('RHEL 8.8');
});
// render the expected <ImageBuildStatus /> and compare the text content
const testElement = document.createElement('testElement');
// render(<Target composeId={compose.id} />, { container: testElement });
renderWithProvider(<Target composeId={compose.id} />, testElement, state);
expect(row.cells[4]).toHaveTextContent(testElement.textContent);
let toTest = expect(row.cells[5]);
// render the expected <ImageBuildStatus /> and compare the text content
if (
compose.created_at === '2021-04-27T12:31:12Z' &&
compose.request.image_requests[0].upload_request.type === 'aws.s3'
) {
toTest.toHaveTextContent('Expired');
} else {
renderWithProvider(
<ImageBuildStatus imageId={compose.id} isImagesTableRow={true} />,
testElement,
state
);
toTest.toHaveTextContent(testElement.textContent);
}
toTest = expect(row.cells[6]);
// render the expected <ImageLink /> and compare the text content for a link
if (
compose.created_at === '2021-04-27T12:31:12Z' &&
compose.request.image_requests[0].upload_request.type === 'aws.s3'
) {
toTest.toHaveTextContent('Recreate image');
} else {
renderWithProvider(
<BrowserRouter>
<ImageLink imageId={compose.id} isInClonesTable={false} />
</BrowserRouter>,
testElement,
state
);
toTest.toHaveTextContent(testElement.textContent);
}
}
// TODO Test remaining table content.
});
test('check recreate action', async () => {
const { router } = renderWithReduxRouter('', {});
const { router } = await renderWithReduxRouter('', {});
// get rows
const table = await screen.findByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const { findAllByRole } = within(table);
const rows = await findAllByRole('row');
// first row is header so look at index 1
const imageId = rows[1].cells[1].textContent;
const actionsButton = within(rows[1]).getByRole('button', {
const actionsButton = await within(rows[1]).findByRole('button', {
name: 'Actions',
});
await waitFor(() => {
expect(actionsButton).toBeEnabled();
});
user.click(actionsButton);
const recreateButton = await screen.findByRole('menuitem', {
name: 'Recreate image',
});
user.click(recreateButton);
act(() => {
user.click(recreateButton);
});
await waitFor(() =>
expect(router.state.location.pathname).toBe(
`/insights/image-builder/imagewizard/${imageId}`
'/insights/image-builder/imagewizard/1579d95b-8f1d-4982-8c53-8c2afa4ab04c'
)
);
});
test('check download compose request action', async () => {
renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
// get rows
const table = await screen.findByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const { findAllByRole } = within(table);
const rows = await findAllByRole('row');
const expectedRequest = mockComposes[0].request;
// first row is header so look at index 1
const imageId = rows[1].cells[1].textContent;
const expectedRequest = mockComposes.data.filter((c) => c.id === imageId)[0]
.request;
const actionsButton = within(rows[1]).getByRole('button', {
const actionsButton = await within(rows[1]).findByRole('button', {
name: 'Actions',
});
user.click(actionsButton);
@ -202,31 +180,33 @@ describe('Images Table', () => {
});
test('check expandable row toggle', async () => {
renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
const table = await screen.findByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const { findAllByRole } = within(table);
const rows = await findAllByRole('row');
const toggleButton = within(rows[1]).getByRole('button', {
const toggleButton = await within(rows[1]).findByRole('button', {
name: /details/i,
});
expect(screen.getByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
expect(await screen.findByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
await user.click(toggleButton);
expect(screen.getByText(/ami-0e778053cd490ad21/i)).toBeVisible();
expect(await screen.findByText(/ami-0e778053cd490ad21/i)).toBeVisible();
await user.click(toggleButton);
expect(screen.getByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
expect(await screen.findByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
});
test('check error details', async () => {
renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
const table = await screen.findByTestId('images-table');
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
const { findAllByRole } = within(table);
const rows = await findAllByRole('row');
const errorPopover = within(rows[2]).getByText(/image build failed/i);
const errorPopover = await within(rows[2]).findByText(
/image build failed/i
);
expect(
screen.getAllByText(/c1cfa347-4c37-49b5-8e73-6aa1d1746cfa/i)[1]
@ -242,7 +222,7 @@ describe('Images Table', () => {
describe('Images Table Toolbar', () => {
test('render toolbar', async () => {
renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
await screen.findByTestId('images-table');
// check create image button
@ -257,7 +237,7 @@ describe('Images Table Toolbar', () => {
describe('Clones table', () => {
const user = userEvent.setup();
test('renders clones table', async () => {
renderWithReduxRouter('', {});
await renderWithReduxRouter('', {});
const table = await screen.findByTestId('images-table');
@ -330,7 +310,7 @@ describe('Clones table', () => {
toTest.toHaveTextContent('Ready');
break;
case 2:
toTest.toHaveTextContent('Image build failed');
toTest.toHaveTextContent('Sharing image failed');
break;
// no default
}

View file

@ -1,6 +1,10 @@
import { screen } from '@testing-library/react';
import { rest } from 'msw';
import api from '../../../api.js';
import { IMAGE_BUILDER_API } from '../../../constants.js';
import { mockComposesEmpty } from '../../fixtures/composes';
import { server } from '../../mocks/server.js';
import { renderWithReduxRouter } from '../../testUtils';
jest.mock('../../../store/actions/actions', () => {
@ -34,11 +38,17 @@ describe('Landing Page', () => {
});
test('renders EmptyState child component', async () => {
server.use(
rest.get(`${IMAGE_BUILDER_API}/composes`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockComposesEmpty));
})
);
renderWithReduxRouter('', {});
// check action loads
screen.getByTestId('create-image-action');
await screen.findByTestId('create-image-action');
// check table loads
screen.getByTestId('empty-state');
await screen.findByTestId('empty-state');
});
});

View file

@ -4,9 +4,7 @@ import '@testing-library/jest-dom';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import api from '../../../api.js';
import ShareImageModal from '../../../Components/ShareImageModal/ShareImageModal';
import { mockState } from '../../fixtures/composes';
import { renderCustomRoutesWithReduxRouter } from '../../testUtils';
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({
@ -37,9 +35,9 @@ const routes = [
describe('Create Share To Regions Modal', () => {
const user = userEvent.setup();
test('validation', async () => {
renderCustomRoutesWithReduxRouter(`share/${composeId}`, mockState, routes);
await renderCustomRoutesWithReduxRouter(`share/${composeId}`, {}, routes);
const shareButton = screen.getByRole('button', { name: /share/i });
const shareButton = await screen.findByRole('button', { name: /share/i });
expect(shareButton).toBeDisabled();
let invalidAlert = screen.queryByText(
@ -69,14 +67,14 @@ describe('Create Share To Regions Modal', () => {
});
test('cancel button redirects to landing page', async () => {
const { router } = renderCustomRoutesWithReduxRouter(
const { router } = await renderCustomRoutesWithReduxRouter(
`share/${composeId}`,
mockState,
{},
routes
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
cancelButton.click();
const cancelButton = await screen.findByRole('button', { name: /cancel/i });
user.click(cancelButton);
// returns back to the landing page
await waitFor(() =>
@ -85,14 +83,14 @@ describe('Create Share To Regions Modal', () => {
});
test('close button redirects to landing page', async () => {
const { router } = renderCustomRoutesWithReduxRouter(
const { router } = await renderCustomRoutesWithReduxRouter(
`share/${composeId}`,
mockState,
{},
routes
);
const closeButton = screen.getByRole('button', { name: /close/i });
closeButton.click();
const closeButton = await screen.findByRole('button', { name: /close/i });
user.click(closeButton);
// returns back to the landing page
await waitFor(() =>
@ -101,11 +99,13 @@ describe('Create Share To Regions Modal', () => {
});
test('select options disabled correctly based on status and region', async () => {
renderCustomRoutesWithReduxRouter(`share/${composeId}`, mockState, routes);
renderCustomRoutesWithReduxRouter(`share/${composeId}`, {}, routes);
const selectToggle = screen.getByRole('button', { name: /options menu/i });
const selectToggle = await screen.findByRole('button', {
name: /options menu/i,
});
// eslint-disable-next-line testing-library/no-unnecessary-act
userEvent.click(selectToggle);
user.click(selectToggle);
// parent region disabled
const usEast1 = await screen.findByRole('option', {
@ -113,95 +113,10 @@ describe('Create Share To Regions Modal', () => {
});
expect(usEast1).toHaveClass('pf-m-disabled');
// successful clone disabled
const usWest1 = screen.getByRole('option', {
name: /us-west-1 us west \(n. california\)/i,
});
expect(usWest1).toHaveClass('pf-m-disabled');
// unsuccessful clone enabled
const usWest2 = screen.getByRole('option', {
name: /us-west-2 us west \(oregon\)/i,
});
expect(usWest2).not.toHaveClass('pf-m-disabled');
// successful clone with different share_with_accounts than its parent enabled
const euCentral1 = screen.getByRole('option', {
name: /eu-central-1 europe \(frankfurt\)/i,
});
expect(euCentral1).not.toHaveClass('pf-m-disabled');
// close the select again to avoid state update
// eslint-disable-next-line testing-library/no-unnecessary-act
await userEvent.click(selectToggle);
await user.click(selectToggle);
});
test('cloning an image results in successful store updates', async () => {
const { router, store } = renderCustomRoutesWithReduxRouter(
`share/${composeId}`,
mockState,
routes
);
const selectToggle = screen.getByRole('button', { name: /options menu/i });
user.click(selectToggle);
const usEast2 = await screen.findByRole('option', {
name: /us-east-2 us east \(ohio\)/i,
});
expect(usEast2).not.toHaveClass('pf-m-disabled');
user.click(usEast2);
const mockResponse = {
id: '123e4567-e89b-12d3-a456-426655440000',
};
const cloneImage = jest.spyOn(api, 'cloneImage').mockImplementation(() => {
return Promise.resolve(mockResponse);
});
const shareButton = await screen.findByRole('button', { name: /share/i });
await waitFor(() => expect(shareButton).toBeEnabled());
user.click(shareButton);
await waitFor(() => expect(cloneImage).toHaveBeenCalledTimes(1));
// returns back to the landing page
expect(router.state.location.pathname).toBe('/insights/image-builder');
// Clone has been added to its parent's list of clones
expect(
store.getState().composes.byId['1579d95b-8f1d-4982-8c53-8c2afa4ab04c']
.clones
).toEqual([
'f9133ec4-7a9e-4fd9-9a9f-9636b82b0a5d',
'48fce414-0cc0-4a16-8645-e3f0edec3212',
'0169538e-515c-477e-b934-f12783939313',
'4a851db1-919f-43ca-a7ef-dd209877a77e',
'123e4567-e89b-12d3-a456-426655440000',
]);
// Clone has been added to state.clones.allIds
expect(store.getState().clones.allIds).toEqual([
'f9133ec4-7a9e-4fd9-9a9f-9636b82b0a5d',
'48fce414-0cc0-4a16-8645-e3f0edec3212',
'0169538e-515c-477e-b934-f12783939313',
'4a851db1-919f-43ca-a7ef-dd209877a77e',
'123e4567-e89b-12d3-a456-426655440000',
]);
// Clone has been added to state.clones.byId
expect(
store.getState().clones.byId['123e4567-e89b-12d3-a456-426655440000']
).toEqual({
id: '123e4567-e89b-12d3-a456-426655440000',
image_status: {
status: 'pending',
},
parent: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
request: {
region: 'us-east-2',
share_with_accounts: ['123123123123'],
},
});
});
// TODO Verify that sharing clones works once msw/data is incorporated.
});

View file

@ -1,8 +1,11 @@
import { PathParams, RestRequest } from 'msw';
import { RHEL_8 } from '../../constants';
import {
ClonesResponse,
ComposeStatus,
ComposesResponse,
ComposesResponseItem,
UploadStatus,
} from '../../store/imageBuilderApi';
@ -22,331 +25,258 @@ export const mockComposesEmpty: ComposesResponse = {
const currentDate = new Date();
const currentDateInString = currentDate.toISOString();
export const mockComposes: ComposesResponse = {
meta: {
count: 13,
},
links: {
first: '',
last: '',
},
data: [
{
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
image_name: 'testImageName',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['123123123123'],
},
},
},
],
},
export const composesEndpoint = (
req: RestRequest<never, PathParams<string>>
) => {
const params = req.url.searchParams;
const limit = Number(params.get('limit')) || 100;
const offset = Number(params.get('offset')) || 0;
return {
meta: {
count: mockComposes.length,
},
// kept "running" for backward compatibility
{
id: 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['serviceAccount:test@email.com'],
},
},
},
],
},
links: {
first: '',
last: '',
},
{
id: 'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '42ad0826-30b5-4f64-a24e-957df26fd564',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '955944a2-e149-4058-8ac1-35b514cb5a16',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: 'f7a60094-b376-4b58-a102-5c8c82dfd18b',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '61b0effa-c901-4ee5-86b9-2010b47f1b22',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: 'ca03f120-9840-4959-871e-94a5cb49d1f2',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['serviceAccount:test@email.com'],
},
},
},
],
},
},
{
id: '551de6f6-1533-4b46-a69f-7924051f9bc6',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {},
},
},
],
},
},
{
id: 'b7193673-8dcc-4a5f-ac30-e9f4940d8346',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
customizations: {
custom_repositories: [
{
baseurl: ['http://unreachable.link.to.repo.org/x86_64/'],
check_gpg: true,
check_repo_gpg: false,
gpgkey: [
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
],
id: 'd4b6d3db-bd15-4750-98c0-667f42995566',
name: '03-test-unavailable-repo',
},
{
baseurl: ['http://yum.theforeman.org/releases/3.4/el8/x86_64/'],
check_gpg: true,
check_repo_gpg: false,
gpgkey: [
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
],
id: 'dbad4dfc-1547-45f8-b5af-1d7fec0476c6',
name: '13lk3',
},
],
payload_repositories: [
{
baseurl: 'http://unreachable.link.to.repo.org/x86_64/',
check_gpg: true,
check_repo_gpg: false,
gpgkey:
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
rhsm: false,
},
{
baseurl: 'http://yum.theforeman.org/releases/3.4/el8/x86_64/',
check_gpg: true,
check_repo_gpg: false,
gpgkey:
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
rhsm: false,
},
],
},
},
},
{
created_at: '2021-04-27T12:31:12Z',
id: 'hyk93673-8dcc-4a61-ac30-e9f4940d8346',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere-ova',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
customizations: {
custom_repositories: [
{
baseurl: ['http://yum.theforeman.org/releases/3.4/el8/x86_64/'],
check_gpg: true,
check_repo_gpg: false,
gpgkey: [
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
],
id: 'dbad4dfc-1547-45f8-b5af-1d7fec0476c6',
name: '13lk3',
},
{
baseurl: [
'http://mirror.stream.centos.org/SIGs/8/kmods/x86_64/packages-main/',
],
check_gpg: false,
check_repo_gpg: false,
gpgkey: [''],
id: '9cf1d45d-aa06-46fe-87ea-121845cc6bbb',
name: '2lmdtj',
},
],
payload_repositories: [
{
baseurl: 'http://yum.theforeman.org/releases/3.4/el8/x86_64/',
check_gpg: true,
check_repo_gpg: false,
gpgkey:
'-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGN9300BEAC1FLODu0cL6saMMHa7yJY1JZUc+jQUI/HdECQrrsTaPXlcc7nM\nykYMMv6amPqbnhH/R5BW2Ano+OMse+PXtUr0NXU4OcvxbnnXkrVBVUf8mXI9DzLZ\njw8KoD+4/s0BuzO78zAJF5uhuyHMAK0ll9v0r92kK45Fas9iZTfRFcqFAzvgjScf\n5jeBnbRs5U3UTz9mtDy802mk357o1A8BD0qlu3kANDpjLbORGWdAj21A6sMJDYXy\nHS9FBNV54daNcr+weky2L9gaF2yFjeu2rSEHCSfkbWfpSiVUx/bDTj7XS6XDOuJT\nJqvGS8jHqjHAIFBirhCA4cY/jLKxWyMr5N6IbXpPAYgt8/YYz2aOYVvdyB8tZ1u1\nkVsMYSGcvTBexZCn1cDkbO6I+waIlsc0uxGqUGBKF83AVYCQqOkBjF1uNnu9qefE\nkEc9obr4JZsAgnisboU25ss5ZJddKlmFMKSi66g4S5ChLEPFq7MB06PhLFioaD3L\nEXza7XitoW5VBwr0BSVKAHMC0T2xbm70zY06a6gQRlvr9a10lPmv4Tptc7xgQReg\nu1TlFPbrkGJ0d8O6vHQRAd3zdsNaVr4gX0Tg7UYiqT9ZUkP7hOc8PYXQ28hHrHTB\nA63MTq0aiPlJ/ivTuX8M6+Bi25dIV6N6IOUi/NQKIYxgovJCDSdCAAM0fQARAQAB\ntCFMdWNhcyBHYXJmaWVsZCA8bHVjYXNAcmVkaGF0LmNvbT6JAlcEEwEIAEEWIQTO\nQZeiHnXqdjmfUURc6PeuecS2PAUCY33fTQIbAwUJA8JnAAULCQgHAgIiAgYVCgkI\nCwIEFgIDAQIeBwIXgAAKCRBc6PeuecS2PCk3D/9jW7xrBB/2MQFKd5l+mNMFyKwc\nL9M/M5RFI9GaQRo55CwnPb0nnxOJR1V5GzZ/YGii53H2ose65CfBOE2L/F/RvKF0\nH9S9MInixlahzzKtV3TpDoZGk5oZIHEMuPmPS4XaHggolrzExY0ib0mQuBBE/uEV\n/HlyHEunBKPhTkAe+6Q+2dl22SUuVfWr4Uzlp65+DkdN3M37WI1a3Suhnef3rOSM\nV6puUzWRR7qcYs5C2In87AcYPn92P5ur1y/C32r8Ftg3fRWnEzI9QfRG52ojNOLK\nyGQ8ZC9PGe0q7VFcF7ridT/uzRU+NVKldbJg+rvBnszb1MjNuR7rUQHyvGmbsUVQ\nRCsgdovkee3lP4gfZHzk2SSLVSo0+NJRNaM90EmPk14Pgi/yfRSDGBVvLBbEanYI\nv1ZtdIPRyKi+/IaMOu/l7nayM/8RzghdU+0f1FAif5qf9nXuI13P8fqcqfu67gNd\nkh0UUF1XyR5UHHEZQQDqCuKEkZJ/+27jYlsG1ZiLb1odlIWoR44RP6k5OJl0raZb\nyLXbAfpITsXiJJBpCam9P9+XR5VSfgkqp5hIa7J8piN3DoMpoExg4PPQr6PbLAJy\nOUCOnuB7yYVbj0wYuMXTuyrcBHh/UymQnS8AMpQoEkCLWS/A/Hze/pD23LgiBoLY\nXIn5A2EOAf7t2IMSlA==\n=OanT\n-----END PGP PUBLIC KEY BLOCK-----',
rhsm: false,
},
{
baseurl:
'http://mirror.stream.centos.org/SIGs/8/kmods/x86_64/packages-main/',
check_gpg: false,
check_repo_gpg: false,
gpgkey: '',
rhsm: false,
},
],
},
},
},
{
created_at: '2021-04-27T12:31:12Z',
id: '4873fd0f-1851-4b9f-b4fe-4639fce90794',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
},
{
created_at: currentDateInString,
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',
},
},
],
},
},
],
data: mockComposes.slice(offset, offset + limit),
} as ComposesResponse;
};
export const mockComposes: ComposesResponseItem[] = [
{
id: '1579d95b-8f1d-4982-8c53-8c2afa4ab04c',
image_name: 'testImageName',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {
share_with_accounts: ['123123123123'],
},
},
},
],
},
},
{
id: 'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['serviceAccount:test@email.com'],
},
},
},
],
},
},
{
id: 'edbae1c2-62bc-42c1-ae0c-3110ab718f58',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '42ad0826-30b5-4f64-a24e-957df26fd564',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '955944a2-e149-4058-8ac1-35b514cb5a16',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: 'f7a60094-b376-4b58-a102-5c8c82dfd18b',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: '61b0effa-c901-4ee5-86b9-2010b47f1b22',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'aws',
upload_request: {
type: 'aws',
options: {},
},
},
],
},
},
{
id: 'ca03f120-9840-4959-871e-94a5cb49d1f2',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'gcp',
upload_request: {
type: 'gcp',
options: {
share_with_accounts: ['serviceAccount:test@email.com'],
},
},
},
],
},
},
{
id: '551de6f6-1533-4b46-a69f-7924051f9bc6',
created_at: '2021-04-27T12:31:12Z',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'azure',
upload_request: {
type: 'azure',
options: {
resource_group: 'my_resource_group',
},
},
},
],
},
},
{
created_at: '2021-04-27T12:31:12Z',
id: 'b7193673-8dcc-4a5f-ac30-e9f4940d8346',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
},
{
created_at: '2021-04-27T12:31:12Z',
id: 'hyk93673-8dcc-4a61-ac30-e9f4940d8346',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'vsphere-ova',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
},
{
created_at: '2021-04-27T12:31:12Z',
id: '4873fd0f-1851-4b9f-b4fe-4639fce90794',
request: {
distribution: RHEL_8,
image_requests: [
{
architecture: 'x86_64',
image_type: 'image-installer',
upload_request: {
options: {},
type: 'aws.s3',
},
},
],
},
},
{
created_at: currentDateInString,
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',
},
},
],
},
},
];
export const mockStatus = (composeId: string): ComposeStatus => {
const mockComposes: { [key: string]: ComposeStatus } = {
'1579d95b-8f1d-4982-8c53-8c2afa4ab04c': {
@ -378,7 +308,6 @@ export const mockStatus = (composeId: string): ComposeStatus => {
},
},
'c1cfa347-4c37-49b5-8e73-6aa1d1746cfa': {
// kept "running" for backward compatibility
image_status: {
status: 'failure',
error: {
@ -542,7 +471,9 @@ export const mockStatus = (composeId: string): ComposeStatus => {
image_type: 'azure',
upload_request: {
type: 'azure',
options: {},
options: {
resource_group: 'my_resource_group',
},
},
},
],

View file

@ -12,6 +12,7 @@ import {
} from '../fixtures/activationKeys';
import { mockArchitecturesByDistro } from '../fixtures/architectures';
import {
composesEndpoint,
mockClones,
mockCloneStatus,
mockComposes,
@ -70,7 +71,7 @@ export const handlers = [
return res(ctx.status(200), ctx.json(mockRepositoryResults(args)));
}),
rest.get(`${IMAGE_BUILDER_API}/composes`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockComposes));
return res(ctx.status(200), ctx.json(composesEndpoint(req)));
}),
rest.get(`${IMAGE_BUILDER_API}/composes/:composeId`, (req, res, ctx) => {
const { composeId } = req.params;
@ -87,4 +88,7 @@ export const handlers = [
const { cloneId } = req.params;
return res(ctx.status(200), ctx.json(mockCloneStatus(cloneId)));
}),
rest.post(`${IMAGE_BUILDER_API}/compose`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json({}));
}),
];

View file

@ -12,7 +12,7 @@ import ShareImageModal from '../Components/ShareImageModal/ShareImageModal';
import { middleware, reducer } from '../store';
import { resolveRelPath } from '../Utilities/path';
export const renderCustomRoutesWithReduxRouter = (
export const renderCustomRoutesWithReduxRouter = async (
route = '/',
preloadedState = {},
routes
@ -32,7 +32,10 @@ export const renderCustomRoutesWithReduxRouter = (
return { router, store };
};
export const renderWithReduxRouter = (route = '/', preloadedState = {}) => {
export const renderWithReduxRouter = async (
route = '/',
preloadedState = {}
) => {
const store = configureStore({ reducer, middleware, preloadedState });
const routes = [