■ ■ ■ ■ ■ ■
packages/web/src/components/ui/Package/Package.tsx
1 | | - | /* eslint-disable react/button-has-type */ |
2 | | - | import clsx from 'clsx'; |
3 | | - | import React from 'react'; |
4 | | - | import semver from 'semver'; |
5 | | - | import styles from './Package.module.scss'; |
6 | | - | import Dropdown from '../Dropdown/Dropdown'; |
7 | | - | import Vulnerability from '../Vulnerability/Vulnerability'; |
8 | | - | import TagBadge from '../TagBadge/TagBadge'; |
9 | | - | import { trackCustomEvent } from '../../../services/analytics'; |
10 | | - | import { ClientApi } from '../../../services/apiClient'; |
11 | | - | import { Icon } from '../Icon/Icon'; |
12 | | - | |
13 | | - | export type Props = { |
14 | | - | className?: string; |
15 | | - | variant?: 'grid' | 'lines'; |
16 | | - | pkg: ClientApi.ScanResultPackageResponse; |
17 | | - | vulnerabilities: ClientApi.PackageVulnerabilityResponse[]; |
18 | | - | }; |
19 | | - | |
20 | | - | export default function Package({ className, variant = 'grid', pkg, vulnerabilities }: Props) { |
21 | | - | const repositoryUrl = pkg.registryMetadata?.repositoryUrl; |
22 | | - | const homepageUrl = pkg.registryMetadata?.homepageUrl; |
23 | | - | const isOutdated = |
24 | | - | pkg.registryMetadata && semver.gtr(pkg.registryMetadata.latestVersion, pkg.versionRange); |
25 | | - | const isVulnerable = !!vulnerabilities?.length; |
26 | | - | |
27 | | - | return ( |
28 | | - | <div className={clsx(styles.container, styles[variant], className)}> |
29 | | - | <div className={styles.registryMeta}> |
30 | | - | <div className={styles.packageTags}> |
31 | | - | {isVulnerable && ( |
32 | | - | <Dropdown |
33 | | - | TriggerComponent={(props) => ( |
34 | | - | <span className={styles.tagContainer}> |
35 | | - | <TagBadge color='red' {...props}> |
36 | | - | Vulnerable |
37 | | - | </TagBadge> |
38 | | - | </span> |
39 | | - | )} |
40 | | - | triggerType='hover' |
41 | | - | position='bottomleft' |
42 | | - | onOpen={() => trackCustomEvent('Package', 'ShowVulnerabilitiesTooltip')} |
43 | | - | > |
44 | | - | <div className={styles.vulnerabilityTooltip}> |
45 | | - | {vulnerabilities.map((it) => ( |
46 | | - | <Vulnerability key={it.osvId} vulnerability={it} /> |
47 | | - | ))} |
48 | | - | </div> |
49 | | - | </Dropdown> |
50 | | - | )} |
51 | | - | {isOutdated && ( |
52 | | - | <span className={styles.tagContainer}> |
53 | | - | <TagBadge color='yellow'>Outdated</TagBadge> |
54 | | - | </span> |
55 | | - | )} |
56 | | - | </div> |
57 | | - | <div className={styles.externalLinks}> |
58 | | - | {repositoryUrl && ( |
59 | | - | <a |
60 | | - | href={repositoryUrl} |
61 | | - | target='_blank' |
62 | | - | rel='noopener noreferrer' |
63 | | - | onClick={() => trackCustomEvent('Package', 'ClickRepoUrl')} |
64 | | - | className={styles.externalLink} |
65 | | - | > |
66 | | - | <Icon kind='githubLogo' width={19} height={19} /> |
67 | | - | </a> |
68 | | - | )} |
69 | | - | {homepageUrl && homepageUrl !== repositoryUrl && ( |
70 | | - | <a |
71 | | - | href={homepageUrl} |
72 | | - | target='_blank' |
73 | | - | rel='noopener noreferrer' |
74 | | - | onClick={() => trackCustomEvent('Package', 'ClickHomepageUrl')} |
75 | | - | className={styles.externalLink} |
76 | | - | > |
77 | | - | <Icon kind='external' width={19} height={19} /> |
78 | | - | </a> |
79 | | - | )} |
80 | | - | </div> |
81 | | - | </div> |
82 | | - | <a |
83 | | - | className={styles.name} |
84 | | - | href={`https://www.npmjs.com/package/${pkg.name}`} |
85 | | - | target='_blank' |
86 | | - | rel='noopener noreferrer' |
87 | | - | aria-label={pkg.name} |
88 | | - | // Browser won't break a line for '/' symbol, so we add the <wbr> specificaly |
89 | | - | // eslint-disable-next-line react/no-danger |
90 | | - | dangerouslySetInnerHTML={{ __html: pkg.name.replace('/', '/<wbr>') }} |
91 | | - | onClick={() => trackCustomEvent('Package', 'ClickPackageUrl')} |
92 | | - | /> |
93 | | - | <div className={styles.meta}> |
94 | | - | <div className={styles.version}>{toReadableVersion(pkg.versionRange)}</div> |
95 | | - | {!!pkg.approximateByteSize && ( |
96 | | - | <span className={styles.size}>{toReadableSize(pkg.approximateByteSize)}</span> |
97 | | - | )} |
98 | | - | </div> |
99 | | - | </div> |
100 | | - | ); |
101 | | - | } |
102 | | - | |
103 | | - | const byteUnits = ['B', 'KB', 'MB']; |
104 | | - | function toReadableSize(size: number) { |
105 | | - | let byteUnitIndex = 0; |
106 | | - | let sizeInUnits = size; |
107 | | - | |
108 | | - | while (sizeInUnits > 1024) { |
109 | | - | sizeInUnits /= 1024; |
110 | | - | byteUnitIndex += 1; |
111 | | - | } |
112 | | - | |
113 | | - | // parseFloat(X.toFixed(1)) removes a zero fraction |
114 | | - | return `${parseFloat(sizeInUnits.toFixed(1))}${byteUnits[byteUnitIndex]}`; |
115 | | - | } |
116 | | - | |
117 | | - | function toReadableVersion(version: string) { |
118 | | - | if (version === '*') { |
119 | | - | return 'Unknown version'; |
120 | | - | } |
121 | | - | |
122 | | - | return version; |
123 | | - | } |
124 | | - | |