ImagesTable: Add image details and update ClonesTable

This adds expandable rows details for each image and updates ClonesTable according to the new mocks.
This commit is contained in:
regexowl 2023-04-25 12:25:39 +02:00 committed by Klara Simickova
parent f332dff5b9
commit 5c8a08f45c
4 changed files with 461 additions and 76 deletions

View file

@ -1,5 +1,6 @@
import React from 'react';
import { ClipboardCopy } from '@patternfly/react-core';
import {
TableComposable,
Tbody,
@ -13,7 +14,6 @@ import { useSelector } from 'react-redux';
import { ImageBuildStatus } from './ImageBuildStatus';
import { useGetAWSSourcesQuery } from '../../store/apiSlice';
import {
selectClonesById,
selectComposeById,
@ -22,26 +22,21 @@ import {
const Row = ({ imageId }) => {
const image = useSelector((state) => selectImageById(state, imageId));
const { data: awsSources, isSuccess } = useGetAWSSourcesQuery();
const getAccount = (image) => {
if (image.share_with_sources?.[0]) {
if (isSuccess) {
const accountId = awsSources.find(
(source) => source.id === image.share_with_sources[0]
)?.account_id;
return accountId;
}
return null;
}
return image.share_with_accounts?.[0];
};
return (
<Tbody>
<Tr>
<Td dataLabel="UUID">{image.id}</Td>
<Td dataLabel="Account">{getAccount(image)}</Td>
<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} />
@ -58,15 +53,10 @@ const ClonesTable = ({ composeId }) => {
const clones = useSelector((state) => selectClonesById(state, composeId));
return (
<TableComposable
variant="compact"
className="pf-u-mb-md"
data-testid="clones-table"
>
<TableComposable variant="compact" data-testid="clones-table">
<Thead>
<Tr className="no-bottom-border">
<Th className="pf-m-width-40">UUID</Th>
<Th className="pf-m-width-20">Account</Th>
<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>

View file

@ -0,0 +1,410 @@
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 {
useGetAWSSourcesQuery,
useGetAzureSourcesQuery,
} from '../../store/apiSlice';
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 getAzureSourceName = (id) => {
const { data: sources, isSuccess } = useGetAzureSourcesQuery();
if (isSuccess) {
const sourcename = sources.find((source) => source.id === id);
if (sourcename) {
return sourcename.name;
} else {
return sourceNotFoundPopover();
}
} else {
return <Spinner isSVG size="md" />;
}
};
const getAWSSourceName = (id) => {
const { data: sources, isSuccess } = useGetAWSSourcesQuery();
if (isSuccess) {
const sourcename = sources.find((source) => source.id === id);
if (sourcename) {
return sourcename.name;
} 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>
{getAWSSourceName(
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>
{getAzureSourceName(
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>
) : (
<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 === 'vsphere' ||
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,
};
export default ImageDetails;

View file

@ -29,8 +29,8 @@ import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import './ImagesTable.scss';
import ClonesTable from './ClonesTable';
import { ImageBuildStatus } from './ImageBuildStatus';
import ImageDetails from './ImageDetails';
import ImageLink from './ImageLink';
import Release from './Release';
import Target from './Target';
@ -277,15 +277,9 @@ const ImagesTable = () => {
</Tr>
<Tr isExpanded={isExpanded(compose)}>
<Td colSpan={8}>
{compose.request.image_requests[0].upload_request
.type === 'aws' ? (
<ClonesTable composeId={compose.id} />
) : (
<ExpandableRowContent>
<strong>UUID</strong>
<div>{id}</div>
</ExpandableRowContent>
)}
<ExpandableRowContent>
<ImageDetails id={id} />
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>

View file

@ -198,17 +198,11 @@ describe('Images Table', () => {
name: /details/i,
});
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).not.toBeVisible();
expect(screen.getByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
await user.click(toggleButton);
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).toBeVisible();
expect(screen.getByText(/ami-0e778053cd490ad21/i)).toBeVisible();
await user.click(toggleButton);
expect(
screen.getAllByText(/1579d95b-8f1d-4982-8c53-8c2afa4ab04c/i)[1]
).not.toBeVisible();
expect(screen.getByText(/ami-0e778053cd490ad21/i)).not.toBeVisible();
});
test('check error details', async () => {
@ -245,7 +239,7 @@ describe('Images Table Toolbar', () => {
describe('Clones table', () => {
test('renders clones table', async () => {
const view = renderWithReduxRouter('', {});
renderWithReduxRouter('', {});
const table = await screen.findByTestId('images-table');
@ -253,8 +247,6 @@ describe('Clones table', () => {
const emptyState = screen.queryByTestId('empty-state');
expect(emptyState).not.toBeInTheDocument();
const state = view.store.getState();
// get rows
const { getAllByRole } = within(table);
const rows = getAllByRole('row');
@ -272,29 +264,25 @@ describe('Clones table', () => {
// remove first row from list since it is just header labels
const header = cloneRows.shift();
// test the header has correct labels
expect(header.cells[0]).toHaveTextContent('UUID');
expect(header.cells[1]).toHaveTextContent('Account');
expect(header.cells[2]).toHaveTextContent('Region');
expect(header.cells[3]).toHaveTextContent('Status');
expect(header.cells[0]).toHaveTextContent('AMI');
expect(header.cells[1]).toHaveTextContent('Region');
expect(header.cells[2]).toHaveTextContent('Status');
expect(cloneRows).toHaveLength(5);
// shift by a parent compose as the row has a different format
cloneRows.shift();
expect(cloneRows).toHaveLength(4);
// prepend parent data
const composeId = '1579d95b-8f1d-4982-8c53-8c2afa4ab04c';
const clonesTableData = {
uuid: [composeId, ...mockClones(composeId).data.map((clone) => clone.id)],
created: [
'2021-04-27 12:31:12.794809 +0000 UTC',
...mockClones(composeId).data.map((clone) => clone.created_at),
],
account: [
'123123123123',
ami: [
...mockClones(composeId).data.map(
(clone) => clone.request.share_with_accounts[0]
(clone) => mockCloneStatus[clone.id].options.ami
),
],
created: [...mockClones(composeId).data.map((clone) => clone.created_at)],
region: [
'us-east-1',
...mockClones(composeId).data.map(
(clone) => mockCloneStatus[clone.id].options.region
),
@ -302,25 +290,28 @@ describe('Clones table', () => {
};
for (const [index, row] of cloneRows.entries()) {
// render UUIDs in correct order
expect(row.cells[0]).toHaveTextContent(clonesTableData.uuid[index]);
// account cell
expect(row.cells[1]).toHaveTextContent(clonesTableData.account[index]);
// render AMIs in correct order
switch (index) {
case (0, 1, 3):
expect(row.cells[0]).toHaveTextContent(clonesTableData.ami[index]);
break;
case 2:
expect(row.cells[0]).toHaveTextContent('');
break;
}
// region cell
expect(row.cells[2]).toHaveTextContent(clonesTableData.region[index]);
const testElement = document.createElement('testElement');
const imageId = clonesTableData.uuid[index];
expect(row.cells[1]).toHaveTextContent(clonesTableData.region[index]);
// status cell
renderWithProvider(
<ImageBuildStatus imageId={imageId} />,
testElement,
state
);
expect(row.cells[3]).toHaveTextContent(testElement.textContent);
switch (index) {
case (0, 1, 3):
expect(row.cells[2]).toHaveTextContent('Ready');
break;
case 2:
expect(row.cells[2]).toHaveTextContent('Image build failed');
break;
}
}
});
});