Dependencies tabs (#99)

feat: Added the isDependentOn and Depends on tabs on tag detail page

Signed-off-by: Amelia-Maria Breda <ameliamaria.breda@dxc.com>
Signed-off-by: Raul Kele <raulkeleblk@gmail.com>
Co-authored-by: Raul Kele <raulkeleblk@gmail.com>
This commit is contained in:
Amelia-Maria Breda 2022-10-06 14:19:58 +03:00 committed by GitHub
parent e18279a32c
commit ca1c9b00cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 483 additions and 41 deletions

View File

@ -36,7 +36,7 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --detectOpenHandles",
"test:coverage": "react-scripts test --coverage",
"test:coverage": "react-scripts test --detectOpenHandles --coverage",
"lint": "eslint -c .eslintrc.json --ext .js,.jsx .",
"lint:fix": "npm run lint -- --fix",
"format": "prettier --write ./**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc",

View File

@ -3,6 +3,14 @@ import ExplorePage from 'pages/ExplorePage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
jest.mock(
'components/Explore',
() =>
function Explore() {
return <div />;
}
);
it('renders the explore page component', () => {
render(
<BrowserRouter>

View File

@ -3,6 +3,14 @@ import HomePage from 'pages/HomePage';
import React from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
jest.mock(
'components/Home',
() =>
function Home() {
return <div />;
}
);
it('renders the homepage component', () => {
render(
<BrowserRouter>

View File

@ -12,6 +12,14 @@ jest.mock('react-router-dom', () => ({
})
}));
jest.mock(
'components/RepoDetails',
() =>
function RepoDetails() {
return <div />;
}
);
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
@ -21,7 +29,7 @@ it('renders the repository page component', () => {
render(
<BrowserRouter>
<Routes>
<Route path="*" element={<RepoPage />} />
<Route path="*" element={<RepoPage updateData={() => {}} />} />
</Routes>
</BrowserRouter>
);

View File

@ -0,0 +1,74 @@
import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import DependsOn from 'components/DependsOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const mockDependenciesList = {
data: {
BaseImageList: [
{
RepoName: 'project-stacker/c3/static-ubuntu-amd64'
},
{
RepoName: 'tag2'
},
{
RepoName: 'tag3'
},
{
RepoName: 'tag4'
}
]
}
};
const RouterDependsWrapper = () => {
return (
<BrowserRouter>
<Routes>
<Route path="*" element={<DependsOn name="alpine:latest" />} />
</Routes>
</BrowserRouter>
);
};
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
// @ts-ignore
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate
}));
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
describe('Dependencies tab', () => {
it('should render the dependencies if there are any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockDependenciesList });
render(<RouterDependsWrapper />);
expect(await screen.findAllByRole('link')).toHaveLength(4);
});
it('renders no dependencies if there are not any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
data: { data: { BaseImageList: [] } }
});
render(<RouterDependsWrapper />);
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
});
it("should log an error when data can't be fetched", async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<RouterDependsWrapper />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -0,0 +1,59 @@
import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import HistoryLayers from 'components/HistoryLayers';
import React from 'react';
const mockLayersList = [
{
Layer: { Size: '2806054', Digest: '213ec9aee27d8be045c6a92b7eac22c9a64b44558193775a1a7f626352392b49', Score: null },
HistoryDescription: {
Created: '2022-08-09T17:19:53.274069586Z',
CreatedBy: '/bin/sh -c #(nop) ADD file:2a949686d9886ac7c10582a6c29116fd29d3077d02755e87e111870d63607725 in / ',
Author: '',
Comment: '',
EmptyLayer: false
}
},
{
Layer: null,
HistoryDescription: {
Created: '2022-08-09T17:19:53.47374331Z',
CreatedBy: '/bin/sh -c #(nop) CMD ["/bin/sh"]',
Author: '',
Comment: '',
EmptyLayer: true
}
}
];
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
describe('Layers page', () => {
it('renders the layers if there are any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: { Image: { History: mockLayersList } } } });
render(<HistoryLayers name="alpine:latest" />);
expect(await screen.findAllByTestId('layer-card-container')).toHaveLength(2);
});
it('renders no layers if there are not any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
data: { data: { History: { Tag: '', mockLayersList: [] } } }
});
render(<HistoryLayers name="alpine:latest" />);
await waitFor(() => expect(screen.getAllByText('No Layers')).toHaveLength(1));
});
it("should log an error when data can't be fetched", async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<HistoryLayers name="alpine:latest" />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -0,0 +1,74 @@
import { render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import IsDependentOn from 'components/IsDependentOn';
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const mockDependentsList = {
data: {
DerivedImageList: [
{
RepoName: 'project-stacker/c3/static-ubuntu-amd64'
},
{
RepoName: 'tag2'
},
{
RepoName: 'tag3'
},
{
RepoName: 'tag4'
}
]
}
};
const RouterDependsWrapper = () => {
return (
<BrowserRouter>
<Routes>
<Route path="*" element={<IsDependentOn name="alpine:latest" />} />
</Routes>
</BrowserRouter>
);
};
// useNavigate mock
const mockedUsedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
// @ts-ignore
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedUsedNavigate
}));
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
describe('Dependents tab', () => {
it('should render the dependents if there are any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: mockDependentsList });
render(<RouterDependsWrapper />);
expect(await screen.findAllByRole('link')).toHaveLength(4);
});
it('renders no dependents if there are not any', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({
status: 200,
data: { data: { DerivedImageList: [] } }
});
render(<RouterDependsWrapper />);
expect(await screen.findByText(/Nothing found/i)).toBeInTheDocument();
});
it("should log an error when data can't be fetched", async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockRejectedValue({ status: 500, data: {} });
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<RouterDependsWrapper />);
await waitFor(() => expect(error).toBeCalledTimes(1));
});
});

View File

@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { api } from 'api';
import TagDetails from 'components/TagDetails';
import React from 'react';
@ -79,10 +79,13 @@ afterEach(() => {
});
describe('Tags details', () => {
it('should show vulnerability tab', async () => {
it('should show tabs and allow nagivation between them', async () => {
// @ts-ignore
jest.spyOn(api, 'get').mockResolvedValue({ status: 200, data: { data: mockImage } });
render(<TagDetails />);
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));
});

View File

@ -70,6 +70,7 @@ const endpoints = {
layersDetailsForImage: (name) =>
`/v2/_zot/ext/search?query={Image(image: "${name}"){History {Layer {Size Digest Score} HistoryDescription {Created CreatedBy Author Comment EmptyLayer} }}}`,
dependsOnForImage: (name) => `/v2/_zot/ext/search?query={BaseImageList(image: "${name}"){RepoName}}`,
isDependentOnForImage: (name) => `/v2/_zot/ext/search?query={DerivedImageList(image: "${name}"){RepoName}}`,
globalSearchPaginated: (searchQuery, pageNumber, pageSize) =>
`/v2/_zot/ext/search?query={GlobalSearch(query:"${searchQuery}", requestedPage: {limit:${pageSize} offset:${
(pageNumber - 1) * pageSize

BIN
src/assets/Monitor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -4,15 +4,14 @@ import React, { useEffect, useState } from 'react';
import { api, endpoints } from '../api';
// components
import { Divider, Typography } from '@mui/material';
import { Divider, Typography, Card, CardContent } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { Link } from 'react-router-dom';
import { host } from '../host';
import Monitor from '../assets/Monitor.png';
const useStyles = makeStyles(() => ({
card: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
background: '#FFFFFF',
boxShadow: '0rem 0.3125rem 0.625rem rgba(131, 131, 131, 0.08)',
borderRadius: '1.875rem',
@ -22,7 +21,10 @@ const useStyles = makeStyles(() => ({
order: 0,
width: '100%',
marginTop: '2rem',
marginBottom: '2rem'
marginBottom: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
},
content: {
textAlign: 'left',
@ -43,6 +45,23 @@ const useStyles = makeStyles(() => ({
fontWeight: '600',
paddingBottom: '0.5rem',
paddingTop: '0.5rem'
},
link: {
color: '#52637A',
fontSize: '1rem',
letterSpacing: '0.009375rem',
paddingRight: '1rem',
textDecorationLine: 'underline'
},
monitor: {
width: '27.25rem',
height: '24.625rem',
paddingTop: '2rem'
},
none: {
color: '#52637A',
fontSize: '1.4rem',
fontWeight: '600'
}
}));
@ -50,6 +69,7 @@ function DependsOn(props) {
const [images, setImages] = useState([]);
const { name } = props;
const classes = useStyles();
// const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
api
@ -57,20 +77,16 @@ function DependsOn(props) {
.then((response) => {
if (response.data && response.data.data) {
let images = response.data.data.BaseImageList;
// let cveListData = {
// cveList: cveInfo?.CVEList
// }
setImages(images);
}
})
.catch((e) => {
console.error(e);
setImages([]);
});
}, []);
return (
<div>
<div data-testid="depends-on-container">
<Typography
variant="h4"
gutterBottom
@ -85,7 +101,26 @@ function DependsOn(props) {
variant="fullWidth"
sx={{ margin: '5% 0% 5% 0%', background: 'rgba(0, 0, 0, 0.38)', height: '0.00625rem', width: '100%' }}
/>
{console.log(JSON.stringify(images))}
{images?.length ? (
<Card className={classes.card} raised>
<CardContent>
<Typography className={classes.content}>
{images.map((dependence, index) => {
return (
<Link key={index} className={classes.link} to={`/image/${encodeURIComponent(dependence.RepoName)}`}>
{dependence.RepoName}
</Link>
);
})}
</Typography>
</CardContent>
</Card>
) : (
<div>
<img src={Monitor} alt="Monitor" className={classes.monitor}></img>
<Typography className={classes.none}> Nothing found </Typography>
</div>
)}
</div>
);
}

View File

@ -18,7 +18,8 @@ import { useSearchParams } from 'react-router-dom';
const useStyles = makeStyles(() => ({
gridWrapper: {
paddingTop: '2rem'
paddingTop: '2rem',
paddingBottom: '2rem'
},
nodataWrapper: {
backgroundColor: '#fff',

View File

@ -12,15 +12,15 @@ import {
ClickAwayListener,
Paper,
Grow,
Stack,
IconButton
Stack
//IconButton
} from '@mui/material';
import Avatar from '@mui/material/Avatar';
// styling
import makeStyles from '@mui/styles/makeStyles';
import logo from '../assets/Zot-white-text.svg';
import placeholderProfileButton from '../assets/Profile_button_placeholder.svg';
//import placeholderProfileButton from '../assets/Profile_button_placeholder.svg';
import { useState, useRef } from 'react';
import SearchSuggestion from './SearchSuggestion';
@ -98,7 +98,8 @@ function Header({ updateData }) {
<Avatar alt="zot" src={logo} className={classes.logo} variant="square" />
</Link>
{path !== '/' && <SearchSuggestion updateData={updateData} />}
<IconButton
<div></div>
{/* <IconButton
ref={anchorRef}
id="composition-button"
aria-controls={open ? 'composition-menu' : undefined}
@ -107,7 +108,7 @@ function Header({ updateData }) {
onClick={handleToggle}
>
<Avatar alt="profile" src={placeholderProfileButton} className={classes.userAvatar} variant="rounded" />
</IconButton>
</IconButton> */}
<Popper
open={open}
anchorEl={anchorRef.current}

View File

@ -8,6 +8,7 @@ import { api, endpoints } from '../api';
import { Card, CardContent, Divider, Grid, Stack, Typography } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { host } from '../host';
import Monitor from '../assets/Monitor.png';
const useStyles = makeStyles(() => ({
card: {
@ -59,6 +60,16 @@ const useStyles = makeStyles(() => ({
fontWeight: '400',
paddingBottom: '0.5rem',
paddingTop: '0.5rem'
},
monitor: {
width: '27.25rem',
height: '24.625rem',
paddingTop: '2rem'
},
none: {
color: '#52637A',
fontSize: '1.4rem',
fontWeight: '600'
}
}));
@ -77,7 +88,7 @@ function LayerCard(props) {
}, []);
return (
<Grid sx={isSelected ? { backgroundColor: '#F7F7F7' } : null} container>
<Grid sx={isSelected ? { backgroundColor: '#F7F7F7' } : null} container data-testid="layer-card-container">
<Grid item xs={10} container>
<Grid item xs={1}>
<Typography variant="body1" align="left" className={classes.title}>
@ -139,10 +150,10 @@ function HistoryLayers(props) {
variant="fullWidth"
sx={{ margin: '5% 0% 0% 0%', background: 'rgba(0, 0, 0, 0.38)', height: '0.00625rem', width: '100%' }}
/>
<Card className={classes.card} raised>
<CardContent className={classes.content}>
{historyData &&
historyData.map((layer, index) => {
{historyData ? (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
{historyData.map((layer, index) => {
return (
<div key={`${layer?.Layer?.Size}${index}`} onClick={() => setSelectedIndex(index)}>
<LayerCard
@ -155,26 +166,30 @@ function HistoryLayers(props) {
</div>
);
})}
</CardContent>
</Card>
</CardContent>
</Card>
) : (
<div>
<img src={Monitor} alt="Monitor" className={classes.monitor}></img>
<Typography className={classes.none}> No Layers </Typography>
</div>
)}
{isLoaded && historyData && (
<Card className={classes.card} raised>
<CardContent className={classes.content}>
<Grid item xs={11}>
<Stack sx={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-between' }}>
<Typography variant="body1" align="left" className={classes.title}>
{' '}
Command{' '}
Command
</Typography>
<Typography variant="body1" align="left" className={classes.values}>
{' '}
{transform.formatBytes(historyData[selectedIndex].Layer?.Size)}{' '}
{transform.formatBytes(historyData[selectedIndex].Layer?.Size)}
</Typography>
</Stack>
</Grid>
<Typography variant="body1" align="left" className={classes.title} sx={{ backgroundColor: '#F7F7F7' }}>
{' '}
{historyData[selectedIndex].HistoryDescription?.CreatedBy}{' '}
{historyData[selectedIndex].HistoryDescription?.CreatedBy}
</Typography>
</CardContent>
</Card>

View File

@ -9,7 +9,8 @@ import { mapToRepo } from 'utilities/objectModels';
const useStyles = makeStyles(() => ({
gridWrapper: {
marginTop: 10
marginTop: 10,
marginBottom: '5rem'
},
nodataWrapper: {
backgroundColor: '#fff',

View File

@ -0,0 +1,131 @@
import React, { useEffect, useState } from 'react';
// utility
import { api, endpoints } from '../api';
// components
import { Divider, Typography, Card, CardContent } from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import { Link } from 'react-router-dom';
import { host } from '../host';
import Monitor from '../assets/Monitor.png';
const useStyles = makeStyles(() => ({
card: {
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%',
marginTop: '2rem',
marginBottom: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
},
content: {
textAlign: 'left',
color: '#606060',
padding: '2% 3% 2% 3%',
width: '100%'
},
title: {
color: '#828282',
fontSize: '1rem',
paddingRight: '0.5rem',
paddingBottom: '0.5rem',
paddingTop: '0.5rem'
},
values: {
color: '#000000',
fontSize: '1rem',
fontWeight: '600',
paddingBottom: '0.5rem',
paddingTop: '0.5rem'
},
link: {
color: '#52637A',
fontSize: '1rem',
letterSpacing: '0.009375rem',
paddingRight: '1rem',
textDecorationLine: 'underline'
},
monitor: {
width: '27.25rem',
height: '24.625rem',
paddingTop: '2rem'
},
none: {
color: '#52637A',
fontSize: '1.4rem',
fontWeight: '600'
}
}));
function IsDependentOn(props) {
const [images, setImages] = useState([]);
const { name } = props;
const classes = useStyles();
//const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
api
.get(`${host()}${endpoints.isDependentOnForImage(name)}`)
.then((response) => {
if (response.data && response.data.data) {
let images = response.data.data.DerivedImageList;
setImages(images);
}
})
.catch((e) => {
console.error(e);
//setImages([]);
});
//setIsLoaded(true);
}, []);
return (
<div>
<Typography
variant="h4"
gutterBottom
component="div"
align="left"
className={classes.title}
style={{ color: 'rgba(0, 0, 0, 0.87)', fontSize: '1.5rem', fontWeight: '600', paddingTop: '0.5rem' }}
>
Is Dependent On
</Typography>
<Divider
variant="fullWidth"
sx={{ margin: '5% 0% 5% 0%', background: 'rgba(0, 0, 0, 0.38)', height: '0.00625rem', width: '100%' }}
/>
{images?.length ? (
<Card className={classes.card} raised>
<CardContent>
<Typography className={classes.content}>
{images.map((dependence, index) => {
return (
<Link key={index} to={`/image/${encodeURIComponent(dependence.RepoName)}`} className={classes.link}>
{dependence.RepoName}
</Link>
);
})}
</Typography>
</CardContent>
</Card>
) : (
<div>
<img src={Monitor} alt="Monitor" className={classes.monitor}></img>
<Typography className={classes.none}> Nothing found </Typography>
</div>
)}
</div>
);
}
export default IsDependentOn;

View File

@ -20,6 +20,7 @@ import TagDetailsMetadata from './TagDetailsMetadata';
import VulnerabilitiesDetails from './VulnerabilitiesDetails';
import HistoryLayers from './HistoryLayers';
import DependsOn from './DependsOn';
import IsDependentOn from './IsDependentOn';
// @ts-ignore
const useStyles = makeStyles(() => ({
@ -136,14 +137,14 @@ function TagDetails() {
let repoInfo = response.data.data.ExpandedRepoInfo;
let imageData = {
name: name,
tags: repoInfo.Images[0].Tag,
tags: repoInfo.Images[0]?.Tag,
lastUpdated: repoInfo.Summary?.LastUpdated,
size: repoInfo.Summary?.Size,
latestDigest: repoInfo.Images[0].Digest,
layers: repoInfo.Images[0].Layers,
platforms: repoInfo.Summary?.Platforms,
vendors: repoInfo.Summary?.Vendors,
newestTag: repoInfo.Summary?.NewestImage.Tag
newestTag: repoInfo.Summary?.NewestImage?.Tag
};
setRepoDetailData(imageData);
setTagName(imageData.name + ':' + imageData.newestTag);
@ -267,7 +268,12 @@ function TagDetails() {
sx={{ '& button.Mui-selected': { color: '#14191F', fontWeight: '600' } }}
>
<Tab value="Layers" label="Layers" className={classes.tabContent} />
<Tab value="DependsOn" label="Depends on" className={classes.tabContent} />
<Tab
value="DependsOn"
label="Depends on"
className={classes.tabContent}
data-testid="dependencies-tab"
/>
<Tab value="IsDependentOn" label="Is dependent on" className={classes.tabContent} />
<Tab value="Vulnerabilities" label="Vulnerabilities" className={classes.tabContent} />
</TabList>
@ -280,7 +286,7 @@ function TagDetails() {
<DependsOn name={tagName} />
</TabPanel>
<TabPanel value="IsDependentOn" className={classes.tabPanel}>
<Typography> Is Dependent On </Typography>
<IsDependentOn name={tagName} />
</TabPanel>
<TabPanel value="Vulnerabilities" className={classes.tabPanel}>
<VulnerabilitiesDetails name={name} />

View File

@ -10,6 +10,7 @@ import makeStyles from '@mui/styles/makeStyles';
import { host } from '../host';
import PestControlOutlinedIcon from '@mui/icons-material/PestControlOutlined';
import PestControlIcon from '@mui/icons-material/PestControl';
import Monitor from '../assets/Monitor.png';
const useStyles = makeStyles(() => ({
card: {
@ -46,6 +47,16 @@ const useStyles = makeStyles(() => ({
fontWeight: '600',
paddingBottom: '0.5rem',
paddingTop: '0.5rem'
},
monitor: {
width: '27.25rem',
height: '24.625rem',
paddingTop: '2rem'
},
none: {
color: '#52637A',
fontSize: '1.4rem',
fontWeight: '600'
}
}));
@ -194,6 +205,7 @@ function VulnerabilitiyCard(props) {
}
function VulnerabilitiesDetails(props) {
const classes = useStyles();
const [cveData, setCveData] = useState({});
// const [isLoading, setIsLoading] = useState(true);
const { name } = props;
@ -226,7 +238,12 @@ function VulnerabilitiesDetails(props) {
})
);
} else {
return <Typography> No Vulnerabilities </Typography>;
return (
<div>
<img src={Monitor} alt="Monitor" className={classes.monitor}></img>
<Typography className={classes.none}> No Vulnerabilities </Typography>{' '}
</div>
);
}
};

View File

@ -16,5 +16,5 @@ code {
}
a {
text-decoration: none !important;
text-decoration: none;
}