References tab
Signed-off-by: Raul Kele <raulkeleblk@gmail.com> Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
parent
9eeb00e801
commit
cea2fb605e
103
src/__tests__/TagPage/ReferredBy.test.js
Normal file
103
src/__tests__/TagPage/ReferredBy.test.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { api } from 'api';
|
||||
import ReferredBy from 'components/ReferredBy';
|
||||
import React from 'react';
|
||||
|
||||
const mockReferrersList = {
|
||||
data: {
|
||||
Referrers: [
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c',
|
||||
Annotations: [
|
||||
{
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
MediaType: 'application/vnd.oci.artifact.manifest.v1+json',
|
||||
ArtifactType: 'application/vnd.example.icecream.v1',
|
||||
Size: 466,
|
||||
Digest: 'sha256:d9ad22f41d9cb9797c134401416eee2a70446cee1a8eb76fc6b191f4320dade2',
|
||||
Annotations: [
|
||||
{
|
||||
Key: 'demo',
|
||||
Value: 'true'
|
||||
},
|
||||
{
|
||||
Key: 'format',
|
||||
Value: 'oci'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// useNavigate mock
|
||||
const mockedUsedNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedUsedNavigate
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
// restore the spy created with spyOn
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Referred by tab', () => {
|
||||
it('should render referrers if there are any', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
expect(await screen.findAllByText('Media type: application/vnd.oci.artifact.manifest.v1+json')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders no referrers if there aren't any", async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Referrers: [] } } });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should log an error if the request fails', async () => {
|
||||
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
|
||||
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
await waitFor(() => expect(error).toBeCalledTimes(1));
|
||||
});
|
||||
|
||||
it('should display the digest when clicking the dropdowns', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
const firstDigest = (await screen.findAllByText(/digest/i))[0];
|
||||
expect(firstDigest).toBeInTheDocument();
|
||||
await userEvent.click(firstDigest);
|
||||
expect(
|
||||
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
|
||||
).toBeInTheDocument();
|
||||
await userEvent.click(firstDigest);
|
||||
expect(
|
||||
await screen.findByText(/sha256:be7a3d01c35a2cf53c502e9dc50cdf36b15d9361c81c63bf319f1d5cbe44ab7c/i)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display the annotations when clicking the dropdown', async () => {
|
||||
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockReferrersList });
|
||||
render(<ReferredBy repoName="golang" digest="test" />);
|
||||
const firstAnnotations = (await screen.findAllByText(/ANNOTATIONS/i))[0];
|
||||
expect(firstAnnotations).toBeInTheDocument();
|
||||
await userEvent.click(firstAnnotations);
|
||||
expect(await screen.findByText(/demo: true/i)).toBeInTheDocument();
|
||||
await userEvent.click(firstAnnotations);
|
||||
expect(await screen.findByText(/demo: true/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -235,7 +235,7 @@ describe('Tags details', () => {
|
||||
const dependenciesTab = await screen.findByTestId('dependencies-tab');
|
||||
fireEvent.click(dependenciesTab);
|
||||
expect(await screen.findByTestId('depends-on-container')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(4));
|
||||
await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(5));
|
||||
});
|
||||
|
||||
it("should log an error when data can't be fetched", async () => {
|
||||
|
@ -105,7 +105,9 @@ const endpoints = {
|
||||
const searchParam = searchQuery !== '' ? `query:"${searchQuery}"` : `query:""`;
|
||||
const paginationParam = `requestedPage: {limit:${pageSize} offset:${(pageNumber - 1) * pageSize} sortBy:RELEVANCE}`;
|
||||
return `/v2/_zot/ext/search?query={GlobalSearch(${searchParam}, ${paginationParam}) {Images {RepoName Tag Logo}}}`;
|
||||
}
|
||||
},
|
||||
referrers: ({ repo, digest, type = '' }) =>
|
||||
`/v2/_zot/ext/search?query={Referrers(repo: "${repo}" digest: "${digest}" type: "${type}"){MediaType ArtifactType Size Digest Annotations{Key Value}}}`
|
||||
};
|
||||
|
||||
export { api, endpoints };
|
||||
|
@ -89,13 +89,15 @@ function DependsOn(props) {
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let imagesData = response.data.data.BaseImageList?.Results?.map((img) => mapToImage(img));
|
||||
const newImageList = [...images, ...imagesData];
|
||||
setImages(newImageList);
|
||||
setIsEndOfList(
|
||||
response.data.data.BaseImageList.Page?.ItemCount < EXPLORE_PAGE_SIZE ||
|
||||
newImageList.length >= response.data.data.BaseImageList?.Page?.TotalCount
|
||||
);
|
||||
if (!isEmpty(response.data.data.BaseImageList?.Results)) {
|
||||
let imagesData = response.data.data.BaseImageList?.Results?.map((img) => mapToImage(img));
|
||||
const newImageList = [...images, ...imagesData];
|
||||
setImages(newImageList);
|
||||
setIsEndOfList(
|
||||
response.data.data.BaseImageList.Page?.ItemCount < EXPLORE_PAGE_SIZE ||
|
||||
newImageList.length >= response.data.data.BaseImageList?.Page?.TotalCount
|
||||
);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
|
@ -119,13 +119,6 @@ function Header() {
|
||||
<Grid container className={classes.grid}>
|
||||
<Grid item xs={2} sx={{ display: 'flex', justifyContent: 'start' }}>
|
||||
<Link to="/home" className={classes.grid}>
|
||||
{/* <img
|
||||
alt="zot"
|
||||
src={logo}
|
||||
srcSet={`${logoxs} 192w, ${logo} 489w`}
|
||||
sizes="(max-width: 480px) 192px, 489px"
|
||||
className={classes.logo}
|
||||
/> */}
|
||||
<picture>
|
||||
<source media="(min-width:600px)" srcSet={logo} />
|
||||
<img alt="zot" src={logoxs} className={classes.logo} />
|
||||
|
106
src/components/ReferredBy.jsx
Normal file
106
src/components/ReferredBy.jsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Divider, Typography, Stack } from '@mui/material';
|
||||
import ReferrerCard from './ReferrerCard';
|
||||
import Loading from './Loading';
|
||||
import { api, endpoints } from 'api';
|
||||
import { host } from '../host';
|
||||
import { mapReferrer } from 'utilities/objectModels';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
none: {
|
||||
color: '#52637A',
|
||||
fontSize: '1.4rem',
|
||||
fontWeight: '600'
|
||||
}
|
||||
}));
|
||||
|
||||
function ReferredBy(props) {
|
||||
const { repoName, digest } = props;
|
||||
const [referrers, setReferrers] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const classes = useStyles();
|
||||
const abortController = useMemo(() => new AbortController(), []);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get(`${host()}${endpoints.referrers({ repo: repoName, digest })}`, abortController.signal)
|
||||
.then((response) => {
|
||||
if (response.data && response.data.data) {
|
||||
let referrersData = response.data.data.Referrers?.map((referrer) => mapReferrer(referrer));
|
||||
if (!isEmpty(referrersData)) {
|
||||
setReferrers(referrersData);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderReferrers = () => {
|
||||
return !isEmpty(referrers) ? (
|
||||
referrers.map((referrer, index) => {
|
||||
return (
|
||||
<ReferrerCard
|
||||
key={index}
|
||||
artifactType={referrer.artifactType}
|
||||
mediaType={referrer.mediaType}
|
||||
size={referrer.size}
|
||||
digest={referrer.digest}
|
||||
annotations={referrer.annotations}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div>{!isLoading && <Typography className={classes.none}> Nothing found </Typography>}</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListBottom = () => {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (!isLoading) {
|
||||
return <div />;
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="referred-by-container">
|
||||
<Typography
|
||||
variant="h4"
|
||||
gutterBottom
|
||||
component="div"
|
||||
align="left"
|
||||
style={{
|
||||
color: 'rgba(0, 0, 0, 0.87)',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
paddingTop: '0.5rem'
|
||||
}}
|
||||
>
|
||||
Referred By
|
||||
</Typography>
|
||||
<Divider
|
||||
variant="fullWidth"
|
||||
sx={{ margin: '5% 0% 5% 0%', background: 'rgba(0, 0, 0, 0.38)', height: '0.00625rem', width: '100%' }}
|
||||
/>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
{renderReferrers()}
|
||||
{renderListBottom()}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReferredBy;
|
146
src/components/ReferrerCard.jsx
Normal file
146
src/components/ReferrerCard.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@mui/styles';
|
||||
import { Card, CardContent, Stack, Tooltip, Typography, Collapse, Box, Grid } from '@mui/material';
|
||||
import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material';
|
||||
import { useState } from 'react';
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
refCard: {
|
||||
marginBottom: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: 'none!important',
|
||||
borderRadius: '1.875rem',
|
||||
flex: 'none',
|
||||
alignSelf: 'stretch',
|
||||
flexGrow: 0,
|
||||
order: 0,
|
||||
width: '100%'
|
||||
},
|
||||
card: {
|
||||
marginBottom: '2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
|
||||
borderRadius: '1.875rem',
|
||||
flex: 'none',
|
||||
alignSelf: 'stretch',
|
||||
flexGrow: 0,
|
||||
order: 0,
|
||||
width: '100%'
|
||||
},
|
||||
content: {
|
||||
textAlign: 'left',
|
||||
color: '#606060',
|
||||
padding: '2% 3% 2% 3%',
|
||||
width: '100%'
|
||||
},
|
||||
clickCursor: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
cardText: {
|
||||
color: '#000000',
|
||||
fontSize: '1rem',
|
||||
paddingBottom: '0.5rem',
|
||||
paddingTop: '0.5rem',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
dropdown: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
dropdownText: {
|
||||
color: '#1479FF',
|
||||
paddingTop: '1rem',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
}
|
||||
}));
|
||||
|
||||
export default function ReferrerCard(props) {
|
||||
const { artifactType, mediaType, size, digest, annotations } = props;
|
||||
const [digestDropdownOpen, setDigestDropdownOpen] = useState(false);
|
||||
const [annotationDropdownOpen, setAnnotationDropdownOpen] = useState(false);
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Card className={classes.card} raised>
|
||||
<CardContent className={classes.content}>
|
||||
<Typography variant="body1" align="left" className={classes.cardText}>
|
||||
Type: {artifactType && `${artifactType}`}
|
||||
</Typography>
|
||||
<Typography variant="body1" align="left" className={classes.cardText}>
|
||||
Media type: {mediaType && `${mediaType}`}
|
||||
</Typography>
|
||||
<Typography variant="body1" align="left" className={classes.cardText}>
|
||||
Size: {size && `${size}`}
|
||||
</Typography>
|
||||
<Stack direction="row" onClick={() => setDigestDropdownOpen(!digestDropdownOpen)}>
|
||||
{!digestDropdownOpen ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#1479FF',
|
||||
paddingTop: '1rem',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
DIGEST
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Collapse in={digestDropdownOpen} timeout="auto" unmountOnExit>
|
||||
<Box>
|
||||
<Grid container item xs={12} direction={'row'}>
|
||||
<Tooltip title={digest || ''} placement="top">
|
||||
<Typography variant="body1">{digest}</Typography>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
<Stack direction="row" onClick={() => setAnnotationDropdownOpen(!annotationDropdownOpen)}>
|
||||
{!annotationDropdownOpen ? (
|
||||
<KeyboardArrowRight className={classes.dropdownText} />
|
||||
) : (
|
||||
<KeyboardArrowDown className={classes.dropdownText} />
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
color: '#1479FF',
|
||||
paddingTop: '1rem',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
ANNOTATIONS
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Collapse in={annotationDropdownOpen} timeout="auto" unmountOnExit>
|
||||
<Box>
|
||||
<Grid container item xs={12} direction={'row'}>
|
||||
<ul>
|
||||
{annotations?.map((annotation) => (
|
||||
<li key={annotation.key}>
|
||||
<Typography variant="body1">{`${annotation?.key}: ${annotation?.value}`}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
@ -41,6 +41,7 @@ import { isEmpty } from 'lodash';
|
||||
import Loading from './Loading';
|
||||
import { dockerPull, podmanPull, skopeoPull } from 'utilities/pullStrings';
|
||||
import { VulnerabilityIconCheck, SignatureIconCheck } from 'utilities/vulnerabilityAndSignatureCheck';
|
||||
import ReferredBy from './ReferredBy';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
pageWrapper: {
|
||||
@ -511,6 +512,7 @@ function TagDetails() {
|
||||
/>
|
||||
<Tab value="IsDependentOn" label="Used by" className={classes.tabContent} />
|
||||
<Tab value="Vulnerabilities" label="Vulnerabilities" className={classes.tabContent} />
|
||||
<Tab value="ReferredBy" label="Referred By" className={classes.tabContent} />
|
||||
</TabList>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
@ -526,6 +528,9 @@ function TagDetails() {
|
||||
<TabPanel value="Vulnerabilities" className={classes.tabPanel}>
|
||||
<VulnerabilitiesDetails name={reponame} tag={tag} />
|
||||
</TabPanel>
|
||||
<TabPanel value="ReferredBy" className={classes.tabPanel}>
|
||||
<ReferredBy repoName={reponame} digest={imageDetailData?.digest} />
|
||||
</TabPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
@ -73,4 +73,12 @@ const mapCVEInfo = (cveInfo) => {
|
||||
};
|
||||
};
|
||||
|
||||
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo };
|
||||
const mapReferrer = (referrer) => ({
|
||||
mediaType: referrer.MediaType,
|
||||
artifactType: referrer.ArtifactType,
|
||||
size: referrer.Size,
|
||||
digest: referrer.Digest,
|
||||
annotations: referrer.Annotations?.map((annotation) => ({ key: annotation.Key, value: annotation.Value }))
|
||||
});
|
||||
|
||||
export { mapToRepo, mapToImage, mapToRepoFromRepoInfo, mapCVEInfo, mapReferrer };
|
||||
|
Loading…
Reference in New Issue
Block a user